Coverage for src/prosemark/freewriting/adapters/node_service_adapter.py: 95%

105 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-24 18:08 +0000

1"""Node service adapter implementation for freewriting feature. 

2 

3This module provides concrete implementation of the NodeServicePort 

4using the existing prosemark node infrastructure. 

5""" 

6 

7from __future__ import annotations 

8 

9from contextlib import suppress 

10from typing import TYPE_CHECKING 

11from uuid import UUID 

12 

13from prosemark.adapters.frontmatter_codec import FrontmatterCodec 

14from prosemark.domain.models import NodeId 

15from prosemark.freewriting.domain.exceptions import FileSystemError, NodeError, ValidationError 

16from prosemark.freewriting.ports.node_service import NodeServicePort 

17 

18if TYPE_CHECKING: # pragma: no cover 

19 from pathlib import Path 

20 

21 from prosemark.ports.binder_repo import BinderRepo 

22 from prosemark.ports.clock import Clock 

23 from prosemark.ports.node_repo import NodeRepo 

24 

25 

26class NodeServiceAdapter(NodeServicePort): 

27 """Concrete implementation of NodeServicePort using prosemark infrastructure. 

28 

29 This adapter integrates freewriting functionality with the existing 

30 prosemark node system, using the NodeRepo and BinderRepo for 

31 node management operations. 

32 """ 

33 

34 def __init__( 

35 self, 

36 project_path: Path, 

37 node_repo: NodeRepo, 

38 binder_repo: BinderRepo, 

39 clock: Clock, 

40 ) -> None: 

41 """Initialize the node service adapter. 

42 

43 Args: 

44 project_path: Root directory containing node files. 

45 node_repo: Repository for node operations. 

46 binder_repo: Repository for binder operations. 

47 clock: Clock port for timestamps. 

48 

49 """ 

50 self.project_path = project_path 

51 self.node_repo = node_repo 

52 self.binder_repo = binder_repo 

53 self.clock = clock 

54 self.frontmatter_codec = FrontmatterCodec() 

55 

56 def node_exists(self, node_uuid: str) -> bool: 

57 """Check if a node file exists. 

58 

59 Args: 

60 node_uuid: UUID of the node to check. 

61 

62 Returns: 

63 True if node exists, False otherwise. 

64 

65 """ 

66 try: 

67 # Validate UUID format first 

68 if not NodeServiceAdapter.validate_node_uuid(node_uuid): 

69 return False 

70 

71 draft_file = self.project_path / f'{node_uuid}.md' 

72 return draft_file.exists() 

73 

74 except (OSError, ValueError): 

75 return False 

76 

77 def create_node(self, node_uuid: str, title: str | None = None) -> str: 

78 """Create a new node file and add to binder. 

79 

80 Args: 

81 node_uuid: UUID for the new node. 

82 title: Optional title for the node. 

83 

84 Returns: 

85 Path to created node file. 

86 

87 Raises: 

88 ValidationError: If UUID is invalid. 

89 FileSystemError: If creation fails. 

90 NodeError: If node creation fails. 

91 

92 """ 

93 # Validate UUID format first (outside try block to avoid TRY301) 

94 if not NodeServiceAdapter.validate_node_uuid(node_uuid): 

95 raise ValidationError('node_uuid', node_uuid, 'must be valid UUID format') 

96 

97 try: 

98 # Convert to NodeId 

99 node_id = NodeId(node_uuid) 

100 

101 # Create the node using prosemark infrastructure 

102 self.node_repo.create(node_id, title, None) # No synopsis for freewriting nodes 

103 

104 # Add to binder if not already present 

105 # If binder addition fails, we continue - the node still exists 

106 # This is because freewriting should be resilient to binder issues 

107 with suppress(NodeError): 

108 self.add_to_binder(node_uuid, title) 

109 

110 # Return path to the created file 

111 return str(self.project_path / f'{node_uuid}.md') 

112 

113 except Exception as e: 

114 # Convert various exception types to our domain exceptions 

115 if isinstance(e, ValidationError): 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true

116 raise 

117 if 'already exists' in str(e).lower(): 

118 msg = f'Node {node_uuid} already exists' 

119 raise NodeError(node_uuid, 'create', msg) from e 

120 msg = f'Failed to create node: {e}' 

121 raise FileSystemError('create_node', node_uuid, str(e)) from e 

122 

123 def append_to_node(self, node_uuid: str, content: list[str], session_metadata: dict[str, str]) -> None: 

124 """Append freewriting content to existing node. 

125 

126 Args: 

127 node_uuid: Target node UUID. 

128 content: Lines of content to append. 

129 session_metadata: Session info for context. 

130 

131 Raises: 

132 FileSystemError: If write fails. 

133 ValidationError: If node doesn't exist. 

134 NodeError: If node operations fail. 

135 

136 """ 

137 # Validate UUID format first (outside try block to avoid TRY301) 

138 if not NodeServiceAdapter.validate_node_uuid(node_uuid): 

139 raise ValidationError('node_uuid', node_uuid, 'must be valid UUID format') 

140 

141 # Check if node exists (outside try block to avoid TRY301) 

142 if not self.node_exists(node_uuid): 

143 raise ValidationError('node_uuid', node_uuid, 'node must exist') 

144 

145 try: 

146 node_file = self.project_path / f'{node_uuid}.md' 

147 

148 # Read existing content 

149 try: 

150 existing_content = node_file.read_text(encoding='utf-8') 

151 frontmatter, body = self.frontmatter_codec.parse(existing_content) 

152 except Exception as e: 

153 msg = f'Failed to read existing node content: {e}' 

154 raise FileSystemError('read', str(node_file), str(e)) from e 

155 

156 # Create session header 

157 timestamp = session_metadata.get('timestamp', self.clock.now_iso()) 

158 word_count = session_metadata.get('word_count', '0') 

159 

160 session_header = f'\n\n## Freewrite Session - {timestamp}\n\n' 

161 session_footer = f'\n\n*Session completed: {word_count} words*\n' 

162 

163 # Append content with session context 

164 new_content_lines = [session_header, *content, session_footer] 

165 new_body = body + '\n'.join(new_content_lines) 

166 

167 # Update the updated timestamp in frontmatter 

168 frontmatter['updated'] = self.clock.now_iso() 

169 

170 # Encode and write back 

171 try: 

172 updated_content = self.frontmatter_codec.generate(frontmatter, new_body) 

173 node_file.write_text(updated_content, encoding='utf-8') 

174 except Exception as e: 

175 msg = f'Failed to write updated node content: {e}' 

176 raise FileSystemError('write', str(node_file), str(e)) from e 

177 

178 except (ValidationError, FileSystemError, NodeError): 

179 raise 

180 except Exception as e: 

181 msg = f'Failed to append to node: {e}' 

182 raise NodeError(node_uuid, 'append', msg) from e 

183 

184 def get_node_path(self, node_uuid: str) -> str: 

185 """Get file path for a node UUID. 

186 

187 Args: 

188 node_uuid: UUID of the node. 

189 

190 Returns: 

191 Absolute path to the node file. 

192 

193 Raises: 

194 ValidationError: If UUID format is invalid. 

195 

196 """ 

197 

198 def _validate_node_format() -> None: 

199 if not NodeServiceAdapter.validate_node_uuid(node_uuid): 

200 raise ValidationError('node_uuid', node_uuid, 'must be valid UUID format') 

201 

202 _validate_node_format() 

203 

204 return str((self.project_path / f'{node_uuid}.md').resolve()) 

205 

206 @staticmethod 

207 def validate_node_uuid(node_uuid: str) -> bool: 

208 """Validate that a node UUID is properly formatted. 

209 

210 Args: 

211 node_uuid: UUID string to validate. 

212 

213 Returns: 

214 True if valid UUID format, False otherwise. 

215 

216 """ 

217 

218 def _validate() -> bool: 

219 # Use Python's UUID class for validation 

220 UUID(node_uuid) 

221 return True 

222 

223 try: 

224 return _validate() 

225 except (ValueError, TypeError): 

226 return False 

227 

228 def add_to_binder(self, node_uuid: str, _title: str | None = None) -> None: 

229 """Add node to the project binder. 

230 

231 Args: 

232 node_uuid: UUID of the node to add. 

233 title: Optional title for the binder entry. 

234 

235 Raises: 

236 FileSystemError: If binder update fails. 

237 NodeError: If node addition fails. 

238 

239 """ 

240 try: 

241 # Convert to NodeId 

242 node_id = NodeId(node_uuid) 

243 

244 # Check if node is already in binder 

245 with suppress(Exception): 

246 binder = self.binder_repo.load() 

247 # If node is already in the structure, don't add it again 

248 existing_node_ids = binder.get_all_node_ids() 

249 for node_id in existing_node_ids: 

250 if str(node_id) == node_uuid: 

251 return # Node already in binder 

252 

253 # Add to binder - this will handle the binder structure updates 

254 # For freewriting, we add nodes to the root level with optional title 

255 try: 

256 # The existing binder system should handle adding nodes 

257 # For now, we'll create a simple entry 

258 # This may need to be adjusted based on the actual BinderRepo implementation 

259 pass # The actual binder integration would depend on the BinderRepo interface 

260 except Exception as e: 

261 # Don't let binder failures stop freewriting 

262 msg = f'Warning: Could not add node to binder: {e}' 

263 raise NodeError(node_uuid, 'add_to_binder', msg) from e 

264 

265 except (ValidationError, NodeError): 

266 raise 

267 except Exception as e: 

268 msg = f'Failed to add node to binder: {e}' 

269 raise NodeError(node_uuid, 'add_to_binder', msg) from e