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

1# Copyright (c) 2024 Prosemark Contributors 

2# This software is licensed under the MIT License 

3 

4"""File system implementation of BinderRepo for _binder.md persistence.""" 

5 

6from pathlib import Path 

7from typing import TYPE_CHECKING 

8 

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 

13 

14if TYPE_CHECKING: # pragma: no cover 

15 from prosemark.domain.models import Binder 

16 

17 

18class BinderRepoFs(BinderRepo): 

19 """File system implementation of BinderRepo using _binder.md files. 

20 

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 

27 

28 File format (_binder.md): 

29 ``` 

30 # Custom Project Notes 

31 Any content here is preserved outside managed blocks. 

32 

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 --> 

38 

39 More custom content is preserved here too. 

40 ``` 

41 

42 The managed block contains the actual binder hierarchy that is parsed 

43 into domain objects, while preserving all other content. 

44 """ 

45 

46 MANAGED_BLOCK_START = '<!-- BEGIN_MANAGED_BLOCK -->' 

47 MANAGED_BLOCK_END = '<!-- END_MANAGED_BLOCK -->' 

48 

49 def __init__(self, project_path: Path) -> None: 

50 """Initialize repository with project path. 

51 

52 Args: 

53 project_path: Root directory containing _binder.md file 

54 

55 """ 

56 self.project_path = project_path 

57 self.binder_file = project_path / '_binder.md' 

58 self.parser = MarkdownBinderParser() 

59 self.frontmatter_codec = FrontmatterCodec() 

60 

61 def load(self) -> 'Binder': 

62 """Load binder from storage. 

63 

64 Returns: 

65 The loaded Binder aggregate 

66 

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 

71 

72 """ 

73 if not self.binder_file.exists(): 

74 msg = 'Binder file not found' 

75 raise BinderNotFoundError(msg, str(self.binder_file)) 

76 

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 

82 

83 try: 

84 # Extract managed block content 

85 managed_content = self._extract_managed_block(content) 

86 

87 # Parse binder from managed content 

88 binder = self.parser.parse_to_binder(managed_content) 

89 

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 

98 

99 def save(self, binder: 'Binder') -> None: 

100 """Save binder to storage. 

101 

102 Args: 

103 binder: The Binder aggregate to persist 

104 

105 Raises: 

106 FileSystemError: If file cannot be written 

107 

108 """ 

109 try: 

110 # Generate managed block content from binder 

111 managed_content = self.parser.render_from_binder(binder) 

112 

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) 

120 

121 # Ensure parent directory exists 

122 self.binder_file.parent.mkdir(parents=True, exist_ok=True) 

123 

124 # Write to file 

125 self.binder_file.write_text(updated_content, encoding='utf-8') 

126 

127 except OSError as exc: 

128 msg = f'Cannot write binder file: {exc}' 

129 raise FileSystemError(msg) from exc 

130 

131 def _extract_managed_block(self, content: str) -> str: 

132 """Extract content from managed block markers. 

133 

134 Args: 

135 content: Full file content 

136 

137 Returns: 

138 Content between managed block markers, or empty string if not found 

139 

140 Raises: 

141 BinderFormatError: If managed block start found but no end marker 

142 

143 """ 

144 start_pos = content.find(self.MANAGED_BLOCK_START) 

145 if start_pos == -1: 

146 return '' 

147 

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) 

152 

153 # Extract content between markers 

154 start_pos += len(self.MANAGED_BLOCK_START) 

155 return content[start_pos:end_pos].strip() 

156 

157 def _update_managed_block(self, original_content: str, new_managed_content: str) -> str: 

158 """Update managed block content while preserving other content. 

159 

160 Args: 

161 original_content: Original file content 

162 new_managed_content: New content for managed block 

163 

164 Returns: 

165 Updated file content with new managed block 

166 

167 """ 

168 start_pos = original_content.find(self.MANAGED_BLOCK_START) 

169 end_pos = original_content.find(self.MANAGED_BLOCK_END) 

170 

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) 

174 

175 # Replace content between markers 

176 before = original_content[: start_pos + len(self.MANAGED_BLOCK_START)] 

177 after = original_content[end_pos:] 

178 

179 return f'{before}\n{new_managed_content}\n{after}' 

180 

181 def _create_new_content(self, managed_content: str) -> str: 

182 """Create new file content with managed block. 

183 

184 Args: 

185 managed_content: Content for the managed block 

186 

187 Returns: 

188 Complete file content with managed block structure 

189 

190 """ 

191 return f"""# Project Structure 

192 

193{self.MANAGED_BLOCK_START} 

194{managed_content} 

195{self.MANAGED_BLOCK_END} 

196"""