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
« prev ^ index » next coverage.py v7.8.0, created at 2025-09-24 18:08 +0000
1"""Node service adapter implementation for freewriting feature.
3This module provides concrete implementation of the NodeServicePort
4using the existing prosemark node infrastructure.
5"""
7from __future__ import annotations
9from contextlib import suppress
10from typing import TYPE_CHECKING
11from uuid import UUID
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
18if TYPE_CHECKING: # pragma: no cover
19 from pathlib import Path
21 from prosemark.ports.binder_repo import BinderRepo
22 from prosemark.ports.clock import Clock
23 from prosemark.ports.node_repo import NodeRepo
26class NodeServiceAdapter(NodeServicePort):
27 """Concrete implementation of NodeServicePort using prosemark infrastructure.
29 This adapter integrates freewriting functionality with the existing
30 prosemark node system, using the NodeRepo and BinderRepo for
31 node management operations.
32 """
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.
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.
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()
56 def node_exists(self, node_uuid: str) -> bool:
57 """Check if a node file exists.
59 Args:
60 node_uuid: UUID of the node to check.
62 Returns:
63 True if node exists, False otherwise.
65 """
66 try:
67 # Validate UUID format first
68 if not NodeServiceAdapter.validate_node_uuid(node_uuid):
69 return False
71 draft_file = self.project_path / f'{node_uuid}.md'
72 return draft_file.exists()
74 except (OSError, ValueError):
75 return False
77 def create_node(self, node_uuid: str, title: str | None = None) -> str:
78 """Create a new node file and add to binder.
80 Args:
81 node_uuid: UUID for the new node.
82 title: Optional title for the node.
84 Returns:
85 Path to created node file.
87 Raises:
88 ValidationError: If UUID is invalid.
89 FileSystemError: If creation fails.
90 NodeError: If node creation fails.
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')
97 try:
98 # Convert to NodeId
99 node_id = NodeId(node_uuid)
101 # Create the node using prosemark infrastructure
102 self.node_repo.create(node_id, title, None) # No synopsis for freewriting nodes
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)
110 # Return path to the created file
111 return str(self.project_path / f'{node_uuid}.md')
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
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.
126 Args:
127 node_uuid: Target node UUID.
128 content: Lines of content to append.
129 session_metadata: Session info for context.
131 Raises:
132 FileSystemError: If write fails.
133 ValidationError: If node doesn't exist.
134 NodeError: If node operations fail.
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')
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')
145 try:
146 node_file = self.project_path / f'{node_uuid}.md'
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
156 # Create session header
157 timestamp = session_metadata.get('timestamp', self.clock.now_iso())
158 word_count = session_metadata.get('word_count', '0')
160 session_header = f'\n\n## Freewrite Session - {timestamp}\n\n'
161 session_footer = f'\n\n*Session completed: {word_count} words*\n'
163 # Append content with session context
164 new_content_lines = [session_header, *content, session_footer]
165 new_body = body + '\n'.join(new_content_lines)
167 # Update the updated timestamp in frontmatter
168 frontmatter['updated'] = self.clock.now_iso()
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
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
184 def get_node_path(self, node_uuid: str) -> str:
185 """Get file path for a node UUID.
187 Args:
188 node_uuid: UUID of the node.
190 Returns:
191 Absolute path to the node file.
193 Raises:
194 ValidationError: If UUID format is invalid.
196 """
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')
202 _validate_node_format()
204 return str((self.project_path / f'{node_uuid}.md').resolve())
206 @staticmethod
207 def validate_node_uuid(node_uuid: str) -> bool:
208 """Validate that a node UUID is properly formatted.
210 Args:
211 node_uuid: UUID string to validate.
213 Returns:
214 True if valid UUID format, False otherwise.
216 """
218 def _validate() -> bool:
219 # Use Python's UUID class for validation
220 UUID(node_uuid)
221 return True
223 try:
224 return _validate()
225 except (ValueError, TypeError):
226 return False
228 def add_to_binder(self, node_uuid: str, _title: str | None = None) -> None:
229 """Add node to the project binder.
231 Args:
232 node_uuid: UUID of the node to add.
233 title: Optional title for the binder entry.
235 Raises:
236 FileSystemError: If binder update fails.
237 NodeError: If node addition fails.
239 """
240 try:
241 # Convert to NodeId
242 node_id = NodeId(node_uuid)
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
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
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