Coverage for src/prosemark/adapters/binder_repo_fs.py: 100%
63 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 BinderRepo for _binder.md persistence."""
6from pathlib import Path
7from typing import TYPE_CHECKING
9from prosemark.adapters.frontmatter_codec import FrontmatterCodec
10from prosemark.adapters.markdown_binder_parser import MarkdownBinderParser
11from prosemark.exceptions import BinderFormatError, BinderNotFoundError, FileSystemError
12from prosemark.ports.binder_repo import BinderRepo
14if TYPE_CHECKING: # pragma: no cover
15 from prosemark.domain.models import Binder
18class BinderRepoFs(BinderRepo):
19 """File system implementation of BinderRepo using _binder.md files.
21 This adapter manages the persistence of Binder objects in markdown files
22 with managed content blocks. It provides:
23 - Round-trip preservation of content outside managed blocks
24 - Proper parsing and generation of binder hierarchy from markdown lists
25 - Robust error handling for file system operations
26 - Integration with frontmatter and markdown parsing codecs
28 File format (_binder.md):
29 ```
30 # Custom Project Notes
31 Any content here is preserved outside managed blocks.
33 <!-- BEGIN_MANAGED_BLOCK -->
34 - [Chapter 1](0192f0c1-2345-7123-8abc-def012345678.md)
35 - [Section 1.1](0192f0c1-2345-7456-8abc-def012345678.md)
36 - [Chapter 2]() # Placeholder
37 <!-- END_MANAGED_BLOCK -->
39 More custom content is preserved here too.
40 ```
42 The managed block contains the actual binder hierarchy that is parsed
43 into domain objects, while preserving all other content.
44 """
46 MANAGED_BLOCK_START = '<!-- BEGIN_MANAGED_BLOCK -->'
47 MANAGED_BLOCK_END = '<!-- END_MANAGED_BLOCK -->'
49 def __init__(self, project_path: Path) -> None:
50 """Initialize repository with project path.
52 Args:
53 project_path: Root directory containing _binder.md file
55 """
56 self.project_path = project_path
57 self.binder_file = project_path / '_binder.md'
58 self.parser = MarkdownBinderParser()
59 self.frontmatter_codec = FrontmatterCodec()
61 def load(self) -> 'Binder':
62 """Load binder from storage.
64 Returns:
65 The loaded Binder aggregate
67 Raises:
68 BinderNotFoundError: If binder file doesn't exist
69 FileSystemError: If file cannot be read
70 BinderFormatError: If binder content cannot be parsed
72 """
73 if not self.binder_file.exists():
74 msg = 'Binder file not found'
75 raise BinderNotFoundError(msg, str(self.binder_file))
77 try:
78 content = self.binder_file.read_text(encoding='utf-8')
79 except OSError as exc:
80 msg = f'Cannot read binder file: {exc}'
81 raise FileSystemError(msg) from exc
83 try:
84 # Extract managed block content
85 managed_content = self._extract_managed_block(content)
87 # Parse binder from managed content
88 binder = self.parser.parse_to_binder(managed_content)
90 # Store the complete original content for round-trip preservation
91 binder.original_content = content # Store for later saving
92 binder.managed_content = managed_content
93 except Exception as exc:
94 msg = f'Failed to parse binder content: {exc}'
95 raise BinderFormatError(msg) from exc
96 else:
97 return binder
99 def save(self, binder: 'Binder') -> None:
100 """Save binder to storage.
102 Args:
103 binder: The Binder aggregate to persist
105 Raises:
106 FileSystemError: If file cannot be written
108 """
109 try:
110 # Generate managed block content from binder
111 managed_content = self.parser.render_from_binder(binder)
113 # Preserve existing content or create new structure
114 if binder.original_content is not None:
115 # Update existing file with preserved content
116 updated_content = self._update_managed_block(binder.original_content, managed_content)
117 else:
118 # Create new file with managed block
119 updated_content = self._create_new_content(managed_content)
121 # Ensure parent directory exists
122 self.binder_file.parent.mkdir(parents=True, exist_ok=True)
124 # Write to file
125 self.binder_file.write_text(updated_content, encoding='utf-8')
127 except OSError as exc:
128 msg = f'Cannot write binder file: {exc}'
129 raise FileSystemError(msg) from exc
131 def _extract_managed_block(self, content: str) -> str:
132 """Extract content from managed block markers.
134 Args:
135 content: Full file content
137 Returns:
138 Content between managed block markers, or empty string if not found
140 Raises:
141 BinderFormatError: If managed block start found but no end marker
143 """
144 start_pos = content.find(self.MANAGED_BLOCK_START)
145 if start_pos == -1:
146 return ''
148 end_pos = content.find(self.MANAGED_BLOCK_END, start_pos)
149 if end_pos == -1:
150 msg = 'Managed block start found but no end marker'
151 raise BinderFormatError(msg)
153 # Extract content between markers
154 start_pos += len(self.MANAGED_BLOCK_START)
155 return content[start_pos:end_pos].strip()
157 def _update_managed_block(self, original_content: str, new_managed_content: str) -> str:
158 """Update managed block content while preserving other content.
160 Args:
161 original_content: Original file content
162 new_managed_content: New content for managed block
164 Returns:
165 Updated file content with new managed block
167 """
168 start_pos = original_content.find(self.MANAGED_BLOCK_START)
169 end_pos = original_content.find(self.MANAGED_BLOCK_END)
171 if start_pos == -1 or end_pos == -1:
172 # No existing managed block, append one
173 return self._create_new_content(new_managed_content)
175 # Replace content between markers
176 before = original_content[: start_pos + len(self.MANAGED_BLOCK_START)]
177 after = original_content[end_pos:]
179 return f'{before}\n{new_managed_content}\n{after}'
181 def _create_new_content(self, managed_content: str) -> str:
182 """Create new file content with managed block.
184 Args:
185 managed_content: Content for the managed block
187 Returns:
188 Complete file content with managed block structure
190 """
191 return f"""# Project Structure
193{self.MANAGED_BLOCK_START}
194{managed_content}
195{self.MANAGED_BLOCK_END}
196"""