Coverage for src/prosemark/freewriting/adapters/freewrite_service_adapter.py: 100%
115 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"""Freewrite service adapter implementation.
3This module provides the concrete implementation of the FreewriteServicePort
4that orchestrates all freewriting operations.
5"""
7from __future__ import annotations
9from datetime import UTC, datetime
10from pathlib import Path
11from typing import TYPE_CHECKING, Any
12from uuid import uuid4
14from prosemark.freewriting.domain.exceptions import FileSystemError, ValidationError
15from prosemark.freewriting.domain.models import FreewriteSession, SessionConfig, SessionState
16from prosemark.freewriting.ports.freewrite_service import FreewriteServicePort
18if TYPE_CHECKING: # pragma: no cover
19 from prosemark.freewriting.ports.file_system import FileSystemPort
20 from prosemark.freewriting.ports.node_service import NodeServicePort
21from prosemark.freewriting.adapters.node_service_adapter import NodeServiceAdapter
24class FreewriteServiceAdapter(FreewriteServicePort):
25 """Concrete implementation of FreewriteServicePort.
27 This adapter orchestrates freewriting operations by coordinating
28 between the file system, node service, and domain models.
29 """
31 def __init__(
32 self,
33 file_system: FileSystemPort,
34 node_service: NodeServicePort,
35 ) -> None:
36 """Initialize the freewrite service adapter.
38 Args:
39 file_system: File system port for file operations.
40 node_service: Node service port for node operations.
42 """
43 self.file_system = file_system
44 self.node_service = node_service
46 def create_session(self, config: SessionConfig) -> FreewriteSession:
47 """Create a new freewriting session with given configuration.
49 Args:
50 config: Session configuration from CLI.
52 Returns:
53 Initialized FreewriteSession.
55 Raises:
56 ValidationError: If configuration is invalid.
57 FileSystemError: If target directory is not writable.
59 """
60 # Validate the configuration
61 self._validate_session_config(config)
63 # Generate session ID
64 session_id = str(uuid4())
66 # Determine output file path
67 output_file_path = self._determine_output_path(config)
69 # Ensure we can write to the target location
70 self._ensure_writable_target(output_file_path, config)
72 # Create and return the session
73 session = FreewriteSession(
74 session_id=session_id,
75 target_node=config.target_node,
76 title=config.title,
77 start_time=datetime.now(tz=UTC),
78 word_count_goal=config.word_count_goal,
79 time_limit=config.time_limit,
80 current_word_count=0,
81 elapsed_time=0,
82 output_file_path=output_file_path,
83 content_lines=[],
84 state=SessionState.INITIALIZING,
85 )
87 # Initialize the output file
88 self._initialize_output_file(session, config)
90 return session.change_state(SessionState.ACTIVE)
92 def append_content(self, session: FreewriteSession, content: str) -> FreewriteSession:
93 """Append content line to the session and persist immediately.
95 Args:
96 session: Current session state.
97 content: Content line to append.
99 Returns:
100 Updated session with new content and word count.
102 Raises:
103 FileSystemError: If write operation fails.
104 ValidationError: If content is invalid.
106 """
107 if not content.strip():
108 # Allow empty lines - they're part of freewriting
109 pass
111 # Add content to session
112 updated_session = session.add_content_line(content)
114 try:
115 # Persist to file immediately
116 if session.target_node:
117 self._append_to_node_file(updated_session, content)
118 else:
119 self._append_to_daily_file(updated_session, content)
120 except Exception as e:
121 raise FileSystemError('append', session.output_file_path, str(e)) from e
123 return updated_session
125 @staticmethod
126 def validate_node_uuid(node_uuid: str) -> bool:
127 """Validate that a node UUID is properly formatted.
129 Args:
130 node_uuid: UUID string to validate.
132 Returns:
133 True if valid UUID format, False otherwise.
135 """
136 # Use the standard UUID validation logic directly
137 try:
138 from uuid import UUID
140 UUID(node_uuid)
141 except ValueError:
142 return False
143 return True
145 @staticmethod
146 def create_daily_filename(timestamp: datetime) -> str:
147 """Generate filename for daily freewrite file.
149 Args:
150 timestamp: When the session started.
152 Returns:
153 Filename in YYYY-MM-DD-HHmm.md format.
155 """
156 return timestamp.strftime('%Y-%m-%d-%H%M.md')
158 @staticmethod
159 def get_session_stats(session: FreewriteSession) -> dict[str, int | float | bool]:
160 """Calculate current session statistics.
162 Args:
163 session: Current session.
165 Returns:
166 Dictionary with word_count, elapsed_time, progress metrics.
168 """
169 stats: dict[str, int | float | bool] = {
170 'word_count': session.current_word_count,
171 'elapsed_time': session.elapsed_time,
172 'line_count': len(session.content_lines),
173 }
175 # Add goal progress if goals are set
176 goals_met = session.is_goal_reached()
177 if goals_met:
178 stats.update(goals_met)
180 # Calculate progress percentages
181 if session.word_count_goal:
182 word_progress = min(100.0, (session.current_word_count / session.word_count_goal) * 100)
183 stats['word_progress_percent'] = word_progress
185 if session.time_limit:
186 time_progress = min(100.0, (session.elapsed_time / session.time_limit) * 100)
187 stats['time_progress_percent'] = time_progress
188 stats['time_remaining'] = max(0, session.time_limit - session.elapsed_time)
190 return stats
192 def _validate_session_config(self, config: SessionConfig) -> None:
193 """Validate session configuration.
195 Args:
196 config: Configuration to validate.
198 Raises:
199 ValidationError: If configuration is invalid.
201 """
202 if config.target_node and not NodeServiceAdapter.validate_node_uuid(config.target_node):
203 msg = 'Invalid UUID format'
204 raise ValidationError('target_node', config.target_node, msg)
206 if not self.file_system.is_writable(config.current_directory):
207 msg = 'Directory is not writable'
208 raise ValidationError('current_directory', config.current_directory, msg)
210 def _determine_output_path(self, config: SessionConfig) -> str:
211 """Determine the output file path based on configuration.
213 Args:
214 config: Session configuration.
216 Returns:
217 Absolute path to output file.
219 """
220 if config.target_node:
221 # For node-targeted sessions, use node service to get path
222 return self.node_service.get_node_path(config.target_node)
223 # For daily files, create timestamped file in current directory
224 filename = FreewriteServiceAdapter.create_daily_filename(datetime.now(tz=UTC))
225 return self.file_system.get_absolute_path(self.file_system.join_paths(config.current_directory, filename))
227 def _ensure_writable_target(self, output_file_path: str, config: SessionConfig) -> None:
228 """Ensure we can write to the target location.
230 Args:
231 output_file_path: Path to output file.
232 config: Session configuration.
234 Raises:
235 FileSystemError: If target is not writable.
237 """
238 if config.target_node:
239 # For nodes, ensure the node exists or can be created
240 if not self.node_service.node_exists(config.target_node):
241 try:
242 self.node_service.create_node(config.target_node, config.title)
243 except Exception as e:
244 msg = f'Cannot create node: {e}'
245 raise FileSystemError('create_node', config.target_node, str(e)) from e
246 else:
247 # For daily files, ensure parent directory is writable
248 parent_dir = str(Path(output_file_path).parent)
249 if not self.file_system.is_writable(parent_dir):
250 msg = 'Parent directory is not writable'
251 raise FileSystemError('check_writable', parent_dir, msg)
253 def _initialize_output_file(self, session: FreewriteSession, config: SessionConfig) -> None:
254 """Initialize the output file with proper structure.
256 Args:
257 session: The session being initialized.
258 config: Session configuration.
260 Raises:
261 FileSystemError: If file initialization fails.
263 """
264 if config.target_node:
265 # For node files, we don't initialize - content gets appended
266 return
268 # For daily files, create with YAML frontmatter
269 frontmatter_data: dict[str, Any] = {
270 'type': 'freewrite',
271 'session_id': session.session_id,
272 'created': session.start_time.isoformat(),
273 }
275 if session.title:
276 frontmatter_data['title'] = session.title
278 if session.word_count_goal:
279 frontmatter_data['word_count_goal'] = session.word_count_goal
281 if session.time_limit:
282 frontmatter_data['time_limit'] = session.time_limit
284 # Create YAML frontmatter
285 frontmatter_lines = ['---']
286 for key, value in frontmatter_data.items():
287 if isinstance(value, str):
288 frontmatter_lines.append(f'{key}: "{value}"')
289 else:
290 frontmatter_lines.append(f'{key}: {value}')
291 frontmatter_lines.extend(['---', '', '# Freewrite Session', ''])
293 initial_content = '\n'.join(frontmatter_lines)
295 # Write initial content
296 self.file_system.write_file(session.output_file_path, initial_content, append=False)
298 def _verify_file_created() -> None:
299 """Verify that the file was written successfully."""
300 if not Path(session.output_file_path).exists():
301 raise OSError('File not created')
303 try:
304 # Verify file was written successfully
305 _verify_file_created()
306 except Exception as e:
307 raise FileSystemError('initialize', session.output_file_path, str(e)) from e
309 def _append_to_daily_file(self, session: FreewriteSession, content: str) -> None:
310 """Append content to daily freewrite file.
312 Args:
313 session: Current session.
314 content: Content line to append.
316 Raises:
317 FileSystemError: If append operation fails.
319 """
320 # Add newline and append to file
321 content_with_newline = content + '\n'
322 self.file_system.write_file(session.output_file_path, content_with_newline, append=True)
324 def _append_to_node_file(self, session: FreewriteSession, content: str) -> None:
325 """Append content to node file via node service.
327 Args:
328 session: Current session.
329 content: Content line to append.
331 Raises:
332 FileSystemError: If append operation fails.
334 """
335 if not session.target_node:
336 msg = 'No target node specified for node append operation'
337 raise FileSystemError('append_node', session.output_file_path, msg)
339 # Prepare session metadata
340 session_metadata = {
341 'timestamp': datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M'),
342 'word_count': str(session.current_word_count),
343 'session_id': session.session_id,
344 }
346 # Use node service to append content
347 self.node_service.append_to_node(session.target_node, [content], session_metadata)