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
« 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
4"""File system implementation of NodeRepo for node file operations."""
6from pathlib import Path
7from typing import Any, ClassVar
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
25class NodeRepoFs(NodeRepo):
26 """File system implementation of NodeRepo for managing node files.
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
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 ```
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 """
54 VALID_PARTS: ClassVar[set[str]] = {'draft', 'notes', 'synopsis'}
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
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.
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
74 """
75 self.project_path = project_path
76 self.editor = editor
77 self.clock = clock
78 self.frontmatter_codec = FrontmatterCodec()
80 def create(self, node_id: 'NodeId', title: str | None, synopsis: str | None) -> None:
81 """Create new node files with initial frontmatter.
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
88 Raises:
89 NodeAlreadyExistsError: If node with this ID already exists
90 FileSystemError: If files cannot be created
92 """
93 draft_file = self.project_path / f'{node_id}.md'
94 notes_file = self.project_path / f'{node_id}.notes.md'
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)
101 try:
102 # Create timestamp
103 now = self.clock.now_iso()
105 # Prepare frontmatter
106 frontmatter = {
107 'id': str(node_id),
108 'title': title,
109 'synopsis': synopsis,
110 'created': now,
111 'updated': now,
112 }
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')
118 # Create notes file (minimal content, no frontmatter needed)
119 notes_content = '# Notes\n'
120 notes_file.write_text(notes_content, encoding='utf-8')
122 except OSError as exc:
123 msg = f'Cannot create node files: {exc}'
124 raise FileSystemError(msg) from exc
126 def read_frontmatter(self, node_id: 'NodeId') -> dict[str, Any]:
127 """Read frontmatter from node draft file.
129 Args:
130 node_id: NodeId to read frontmatter for
132 Returns:
133 Dictionary containing frontmatter fields
135 Raises:
136 NodeNotFoundError: If node file doesn't exist
137 FileSystemError: If file cannot be read
138 FrontmatterFormatError: If frontmatter format is invalid
140 """
141 draft_file = self.project_path / f'{node_id}.md'
143 if not draft_file.exists():
144 msg = f'Node file not found: {draft_file}'
145 raise NodeNotFoundError(msg)
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
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
161 def write_frontmatter(self, node_id: 'NodeId', frontmatter: dict[str, Any]) -> None:
162 """Update frontmatter in node draft file.
164 Args:
165 node_id: NodeId to update frontmatter for
166 frontmatter: Dictionary containing frontmatter fields to write
168 Raises:
169 NodeNotFoundError: If node file doesn't exist
170 FileSystemError: If file cannot be written
172 """
173 draft_file = self.project_path / f'{node_id}.md'
175 if not draft_file.exists():
176 msg = f'Node file not found: {draft_file}'
177 raise NodeNotFoundError(msg)
179 try:
180 # Read existing content
181 content = draft_file.read_text(encoding='utf-8')
183 # Update timestamp
184 updated_frontmatter = frontmatter.copy()
185 updated_frontmatter['updated'] = self.clock.now_iso()
187 # Update frontmatter
188 updated_content = self.frontmatter_codec.update_frontmatter(content, updated_frontmatter)
190 # Write back
191 draft_file.write_text(updated_content, encoding='utf-8')
193 except OSError as exc:
194 msg = f'Cannot write node file: {exc}'
195 raise FileSystemError(msg) from exc
197 def open_in_editor(self, node_id: 'NodeId', part: str) -> None:
198 """Open specified node part in editor.
200 Args:
201 node_id: NodeId to open in editor
202 part: Which part to open ('draft', 'notes', 'synopsis')
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
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)
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'
221 if not file_path.exists():
222 msg = f'Node file not found: {file_path}'
223 raise NodeNotFoundError(msg)
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)
230 except Exception as exc:
231 msg = f'Failed to open editor for {file_path}'
232 raise EditorError(msg) from exc
234 def delete(self, node_id: 'NodeId', *, delete_files: bool = True) -> None:
235 """Remove node from system.
237 Args:
238 node_id: NodeId to delete
239 delete_files: If True, delete actual files from filesystem
241 Raises:
242 FileSystemError: If files cannot be deleted (when delete_files=True)
244 """
245 if not delete_files:
246 # No-op for file system implementation when not deleting files
247 return
249 draft_file = self.project_path / f'{node_id}.md'
250 notes_file = self.project_path / f'{node_id}.notes.md'
252 try:
253 # Delete files if they exist
254 if draft_file.exists():
255 draft_file.unlink()
257 if notes_file.exists():
258 notes_file.unlink()
260 except OSError as exc:
261 msg = f'Cannot delete node files: {exc}'
262 raise FileSystemError(msg) from exc
264 def get_existing_files(self) -> set['NodeId']:
265 """Get all existing node files from the filesystem.
267 Scans the project directory for node files ({id}.md) and returns
268 the set of NodeIds that have existing files.
270 Returns:
271 Set of NodeIds for files that exist on disk
273 Raises:
274 FileSystemError: If directory cannot be scanned
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
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
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
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
302 def _is_valid_node_id(self, filename: str) -> bool:
303 """Check if a filename looks like a valid NodeId.
305 A valid NodeId should be a UUID7 format string, but we'll also
306 accept any reasonable identifier for audit purposes.
308 Args:
309 filename: The filename (without extension) to check
311 Returns:
312 True if the filename appears to be a valid NodeId
314 """
315 # Skip empty filenames
316 if not filename:
317 return False
319 # Check for UUID7 format first
320 if self._is_uuid7_format(filename):
321 return True
323 # Also accept other reasonable node IDs for audit purposes
324 return self._is_reasonable_node_id(filename)
326 def _is_uuid7_format(self, filename: str) -> bool:
327 """Check if filename matches UUID7 format (8-4-4-4-12).
329 Returns:
330 True if filename matches UUID7 format, False otherwise
332 """
333 if len(filename) != self.UUID7_LENGTH:
334 return False
336 parts = filename.split('-')
337 if len(parts) != self.UUID7_PARTS_COUNT:
338 return False
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
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
355 def _is_reasonable_node_id(self, filename: str) -> bool:
356 """Check if filename is a reasonable node ID for audit purposes.
358 Returns:
359 True if filename appears to be a reasonable node ID
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
366 # Must not be a reserved name
367 reserved_names = {'readme', 'license', 'changelog', 'todo', 'notes'}
368 return filename.lower() not in reserved_names
370 def file_exists(self, node_id: 'NodeId', file_type: str) -> bool:
371 """Check if a specific node file exists.
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)
377 Returns:
378 True if the file exists, False otherwise
380 Raises:
381 ValueError: If file_type is not valid
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)
392 return file_path.exists()