Coverage for src/prosemark/adapters/node_repo_fs.py: 100%

142 statements  

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

1# Copyright (c) 2024 Prosemark Contributors 

2# This software is licensed under the MIT License 

3 

4"""File system implementation of NodeRepo for node file operations.""" 

5 

6from pathlib import Path 

7from typing import Any, ClassVar 

8 

9from prosemark.adapters.frontmatter_codec import FrontmatterCodec 

10from prosemark.domain.models import NodeId 

11from prosemark.exceptions import ( 

12 EditorError, 

13 FileSystemError, 

14 FrontmatterFormatError, 

15 InvalidPartError, 

16 NodeAlreadyExistsError, 

17 NodeIdentityError, 

18 NodeNotFoundError, 

19) 

20from prosemark.ports.clock import Clock 

21from prosemark.ports.editor_port import EditorPort 

22from prosemark.ports.node_repo import NodeRepo 

23 

24 

25class NodeRepoFs(NodeRepo): 

26 """File system implementation of NodeRepo for managing node files. 

27 

28 This adapter manages the persistence of individual node files with: 

29 - {id}.md files for draft content with YAML frontmatter 

30 - {id}.notes.md files for notes (optional frontmatter) 

31 - Frontmatter parsing and generation using FrontmatterCodec 

32 - Editor integration for opening specific node parts 

33 - Proper error handling for file system operations 

34 

35 Frontmatter structure in {id}.md: 

36 ```yaml 

37 --- 

38 id: "0192f0c1-2345-7123-8abc-def012345678" 

39 title: "Node Title" 

40 synopsis: "Brief description" 

41 created: "2025-09-20T15:30:00Z" 

42 updated: "2025-09-20T16:00:00Z" 

43 --- 

44 # Node Content 

45 ``` 

46 

47 The adapter ensures: 

48 - Consistent frontmatter format across all node files 

49 - Automatic timestamp management (created on create, updated on modify) 

50 - Editor integration with proper part handling 

51 - Robust file system error handling 

52 """ 

53 

54 VALID_PARTS: ClassVar[set[str]] = {'draft', 'notes', 'synopsis'} 

55 

56 # UUID7 format constants 

57 UUID7_LENGTH = 36 # Total length of UUID7 string (8-4-4-4-12) 

58 UUID7_PARTS_COUNT = 5 # Number of hyphen-separated parts 

59 MIN_NODE_ID_LENGTH = 3 # Minimum length for reasonable node ID 

60 

61 def __init__( 

62 self, 

63 project_path: Path, 

64 editor: EditorPort, 

65 clock: Clock, 

66 ) -> None: 

67 """Initialize repository with project path and dependencies. 

68 

69 Args: 

70 project_path: Root directory containing node files 

71 editor: Editor port for launching external editor 

72 clock: Clock port for timestamp generation 

73 

74 """ 

75 self.project_path = project_path 

76 self.editor = editor 

77 self.clock = clock 

78 self.frontmatter_codec = FrontmatterCodec() 

79 

80 def create(self, node_id: 'NodeId', title: str | None, synopsis: str | None) -> None: 

81 """Create new node files with initial frontmatter. 

82 

83 Args: 

84 node_id: Unique identifier for the node 

85 title: Optional title for the node 

86 synopsis: Optional synopsis/summary for the node 

87 

88 Raises: 

89 NodeAlreadyExistsError: If node with this ID already exists 

90 FileSystemError: If files cannot be created 

91 

92 """ 

93 draft_file = self.project_path / f'{node_id}.md' 

94 notes_file = self.project_path / f'{node_id}.notes.md' 

95 

96 # Check if files already exist 

97 if draft_file.exists() or notes_file.exists(): 

98 msg = f'Node files already exist for {node_id}' 

99 raise NodeAlreadyExistsError(msg) 

100 

101 try: 

102 # Create timestamp 

103 now = self.clock.now_iso() 

104 

105 # Prepare frontmatter 

106 frontmatter = { 

107 'id': str(node_id), 

108 'title': title, 

109 'synopsis': synopsis, 

110 'created': now, 

111 'updated': now, 

112 } 

113 

114 # Create draft file with frontmatter 

115 draft_content = self.frontmatter_codec.generate(frontmatter, '\n# Draft Content\n') 

116 draft_file.write_text(draft_content, encoding='utf-8') 

117 

118 # Create notes file (minimal content, no frontmatter needed) 

119 notes_content = '# Notes\n' 

120 notes_file.write_text(notes_content, encoding='utf-8') 

121 

122 except OSError as exc: 

123 msg = f'Cannot create node files: {exc}' 

124 raise FileSystemError(msg) from exc 

125 

126 def read_frontmatter(self, node_id: 'NodeId') -> dict[str, Any]: 

127 """Read frontmatter from node draft file. 

128 

129 Args: 

130 node_id: NodeId to read frontmatter for 

131 

132 Returns: 

133 Dictionary containing frontmatter fields 

134 

135 Raises: 

136 NodeNotFoundError: If node file doesn't exist 

137 FileSystemError: If file cannot be read 

138 FrontmatterFormatError: If frontmatter format is invalid 

139 

140 """ 

141 draft_file = self.project_path / f'{node_id}.md' 

142 

143 if not draft_file.exists(): 

144 msg = f'Node file not found: {draft_file}' 

145 raise NodeNotFoundError(msg) 

146 

147 try: 

148 content = draft_file.read_text(encoding='utf-8') 

149 except OSError as exc: 

150 msg = f'Cannot read node file: {exc}' 

151 raise FileSystemError(msg) from exc 

152 

153 try: 

154 frontmatter, _ = self.frontmatter_codec.parse(content) 

155 except Exception as exc: 

156 msg = f'Invalid frontmatter in {draft_file}' 

157 raise FrontmatterFormatError(msg) from exc 

158 else: 

159 return frontmatter 

160 

161 def write_frontmatter(self, node_id: 'NodeId', frontmatter: dict[str, Any]) -> None: 

162 """Update frontmatter in node draft file. 

163 

164 Args: 

165 node_id: NodeId to update frontmatter for 

166 frontmatter: Dictionary containing frontmatter fields to write 

167 

168 Raises: 

169 NodeNotFoundError: If node file doesn't exist 

170 FileSystemError: If file cannot be written 

171 

172 """ 

173 draft_file = self.project_path / f'{node_id}.md' 

174 

175 if not draft_file.exists(): 

176 msg = f'Node file not found: {draft_file}' 

177 raise NodeNotFoundError(msg) 

178 

179 try: 

180 # Read existing content 

181 content = draft_file.read_text(encoding='utf-8') 

182 

183 # Update timestamp 

184 updated_frontmatter = frontmatter.copy() 

185 updated_frontmatter['updated'] = self.clock.now_iso() 

186 

187 # Update frontmatter 

188 updated_content = self.frontmatter_codec.update_frontmatter(content, updated_frontmatter) 

189 

190 # Write back 

191 draft_file.write_text(updated_content, encoding='utf-8') 

192 

193 except OSError as exc: 

194 msg = f'Cannot write node file: {exc}' 

195 raise FileSystemError(msg) from exc 

196 

197 def open_in_editor(self, node_id: 'NodeId', part: str) -> None: 

198 """Open specified node part in editor. 

199 

200 Args: 

201 node_id: NodeId to open in editor 

202 part: Which part to open ('draft', 'notes', 'synopsis') 

203 

204 Raises: 

205 NodeNotFoundError: If node file doesn't exist 

206 InvalidPartError: If part is not a valid option 

207 EditorError: If editor cannot be launched 

208 

209 """ 

210 if part not in self.VALID_PARTS: 

211 msg = f'Invalid part: {part}. Must be one of {self.VALID_PARTS}' 

212 raise InvalidPartError(msg) 

213 

214 # Determine which file to open 

215 if part == 'notes': 

216 file_path = self.project_path / f'{node_id}.notes.md' 

217 else: 

218 # Both 'draft' and 'synopsis' open the main draft file 

219 file_path = self.project_path / f'{node_id}.md' 

220 

221 if not file_path.exists(): 

222 msg = f'Node file not found: {file_path}' 

223 raise NodeNotFoundError(msg) 

224 

225 try: 

226 # For synopsis, provide cursor hint to focus on frontmatter area 

227 cursor_hint = '1' if part == 'synopsis' else None 

228 self.editor.open(str(file_path), cursor_hint=cursor_hint) 

229 

230 except Exception as exc: 

231 msg = f'Failed to open editor for {file_path}' 

232 raise EditorError(msg) from exc 

233 

234 def delete(self, node_id: 'NodeId', *, delete_files: bool = True) -> None: 

235 """Remove node from system. 

236 

237 Args: 

238 node_id: NodeId to delete 

239 delete_files: If True, delete actual files from filesystem 

240 

241 Raises: 

242 FileSystemError: If files cannot be deleted (when delete_files=True) 

243 

244 """ 

245 if not delete_files: 

246 # No-op for file system implementation when not deleting files 

247 return 

248 

249 draft_file = self.project_path / f'{node_id}.md' 

250 notes_file = self.project_path / f'{node_id}.notes.md' 

251 

252 try: 

253 # Delete files if they exist 

254 if draft_file.exists(): 

255 draft_file.unlink() 

256 

257 if notes_file.exists(): 

258 notes_file.unlink() 

259 

260 except OSError as exc: 

261 msg = f'Cannot delete node files: {exc}' 

262 raise FileSystemError(msg) from exc 

263 

264 def get_existing_files(self) -> set['NodeId']: 

265 """Get all existing node files from the filesystem. 

266 

267 Scans the project directory for node files ({id}.md) and returns 

268 the set of NodeIds that have existing files. 

269 

270 Returns: 

271 Set of NodeIds for files that exist on disk 

272 

273 Raises: 

274 FileSystemError: If directory cannot be scanned 

275 

276 """ 

277 try: 

278 existing_files = set() 

279 for md_file in self.project_path.glob('*.md'): 

280 # Skip non-node files like _binder.md and README.md 

281 if md_file.stem.startswith('_') or not self._is_valid_node_id(md_file.stem): 

282 continue 

283 

284 # Skip .notes.md files as they are secondary files 

285 if md_file.stem.endswith('.notes'): # pragma: no cover 

286 continue # pragma: no cover 

287 

288 # The filename should be the NodeId 

289 try: 

290 node_id = NodeId(md_file.stem) 

291 existing_files.add(node_id) 

292 except NodeIdentityError: 

293 # Skip files that aren't valid NodeIds 

294 continue 

295 

296 except OSError as exc: 

297 msg = f'Cannot scan directory for node files: {exc}' 

298 raise FileSystemError(msg) from exc 

299 else: 

300 return existing_files 

301 

302 def _is_valid_node_id(self, filename: str) -> bool: 

303 """Check if a filename looks like a valid NodeId. 

304 

305 A valid NodeId should be a UUID7 format string, but we'll also 

306 accept any reasonable identifier for audit purposes. 

307 

308 Args: 

309 filename: The filename (without extension) to check 

310 

311 Returns: 

312 True if the filename appears to be a valid NodeId 

313 

314 """ 

315 # Skip empty filenames 

316 if not filename: 

317 return False 

318 

319 # Check for UUID7 format first 

320 if self._is_uuid7_format(filename): 

321 return True 

322 

323 # Also accept other reasonable node IDs for audit purposes 

324 return self._is_reasonable_node_id(filename) 

325 

326 def _is_uuid7_format(self, filename: str) -> bool: 

327 """Check if filename matches UUID7 format (8-4-4-4-12). 

328 

329 Returns: 

330 True if filename matches UUID7 format, False otherwise 

331 

332 """ 

333 if len(filename) != self.UUID7_LENGTH: 

334 return False 

335 

336 parts = filename.split('-') 

337 if len(parts) != self.UUID7_PARTS_COUNT: 

338 return False 

339 

340 expected_lengths = [8, 4, 4, 4, 12] 

341 if not all( 

342 len(part) == expected_length for part, expected_length in zip(parts, expected_lengths, strict=False) 

343 ): 

344 return False 

345 

346 # Check if all characters are valid hex 

347 try: 

348 for part in parts: 

349 int(part, 16) 

350 except ValueError: 

351 return False 

352 else: 

353 return True 

354 

355 def _is_reasonable_node_id(self, filename: str) -> bool: 

356 """Check if filename is a reasonable node ID for audit purposes. 

357 

358 Returns: 

359 True if filename appears to be a reasonable node ID 

360 

361 """ 

362 # Must be at least 3 characters, alphanumeric plus hyphens/underscores 

363 if len(filename) < self.MIN_NODE_ID_LENGTH or not all(c.isalnum() or c in '-_' for c in filename): 

364 return False 

365 

366 # Must not be a reserved name 

367 reserved_names = {'readme', 'license', 'changelog', 'todo', 'notes'} 

368 return filename.lower() not in reserved_names 

369 

370 def file_exists(self, node_id: 'NodeId', file_type: str) -> bool: 

371 """Check if a specific node file exists. 

372 

373 Args: 

374 node_id: NodeId to check 

375 file_type: Type of file to check ('draft' for {id}.md, 'notes' for {id}.notes.md) 

376 

377 Returns: 

378 True if the file exists, False otherwise 

379 

380 Raises: 

381 ValueError: If file_type is not valid 

382 

383 """ 

384 if file_type == 'draft': 

385 file_path = self.project_path / f'{node_id}.md' 

386 elif file_type == 'notes': 

387 file_path = self.project_path / f'{node_id}.notes.md' 

388 else: 

389 msg = f'Invalid file_type: {file_type}. Must be "draft" or "notes"' 

390 raise ValueError(msg) 

391 

392 return file_path.exists()