Coverage for src/prosemark/freewriting/adapters/freewrite_service_adapter.py: 100%

160 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-09-28 19:17 +0000

1"""Freewrite service adapter implementation. 

2 

3This module provides the concrete implementation of the FreewriteServicePort 

4that orchestrates all freewriting operations. 

5""" 

6 

7from __future__ import annotations 

8 

9from datetime import UTC, datetime 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any 

12from uuid import uuid4 

13 

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 

17 

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 

22 

23 

24class FreewriteServiceAdapter(FreewriteServicePort): 

25 """Concrete implementation of FreewriteServicePort. 

26 

27 This adapter orchestrates freewriting operations by coordinating 

28 between the file system, node service, and domain models. 

29 """ 

30 

31 def __init__( 

32 self, 

33 file_system: FileSystemPort, 

34 node_service: NodeServicePort, 

35 ) -> None: 

36 """Initialize the freewrite service adapter. 

37 

38 Args: 

39 file_system: File system port for file operations. 

40 node_service: Node service port for node operations. 

41 

42 """ 

43 self.file_system = file_system 

44 self.node_service = node_service 

45 

46 def create_session(self, config: SessionConfig) -> FreewriteSession: 

47 """Create a new freewriting session with given configuration. 

48 

49 Args: 

50 config: Session configuration from CLI. 

51 

52 Returns: 

53 Initialized FreewriteSession. 

54 

55 Raises: 

56 ValidationError: If configuration is invalid. 

57 FileSystemError: If target directory is not writable. 

58 

59 """ 

60 # Validate the configuration 

61 self._validate_session_config(config) 

62 

63 # Generate session ID 

64 session_id = str(uuid4()) 

65 

66 # Determine output file path 

67 output_file_path = self._determine_output_path(config) 

68 

69 # Ensure we can write to the target location 

70 self._ensure_writable_target(output_file_path, config) 

71 

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 ) 

86 

87 # Initialize the output file 

88 self._initialize_output_file(session, config) 

89 

90 # Load existing content if file exists 

91 session = self._load_existing_content(session, config) 

92 

93 return session.change_state(SessionState.ACTIVE) 

94 

95 def append_content(self, session: FreewriteSession, content: str) -> FreewriteSession: 

96 """Append content line to the session and persist immediately. 

97 

98 Args: 

99 session: Current session state. 

100 content: Content line to append. 

101 

102 Returns: 

103 Updated session with new content and word count. 

104 

105 Raises: 

106 FileSystemError: If write operation fails. 

107 ValidationError: If content is invalid. 

108 

109 """ 

110 if not content.strip(): 

111 # Allow empty lines - they're part of freewriting 

112 pass 

113 

114 # Add content to session 

115 updated_session = session.add_content_line(content) 

116 

117 try: 

118 # Persist to file immediately 

119 if session.target_node: 

120 self._append_to_node_file(updated_session, content) 

121 else: 

122 self._append_to_daily_file(updated_session, content) 

123 except Exception as e: 

124 raise FileSystemError('append', session.output_file_path, str(e)) from e 

125 

126 return updated_session 

127 

128 @staticmethod 

129 def validate_node_uuid(node_uuid: str) -> bool: 

130 """Validate that a node UUID is properly formatted. 

131 

132 Args: 

133 node_uuid: UUID string to validate. 

134 

135 Returns: 

136 True if valid UUID format, False otherwise. 

137 

138 """ 

139 # Use the standard UUID validation logic directly 

140 try: 

141 from uuid import UUID 

142 

143 UUID(node_uuid) 

144 except ValueError: 

145 return False 

146 return True 

147 

148 @staticmethod 

149 def create_daily_filename(timestamp: datetime) -> str: 

150 """Generate filename for daily freewrite file. 

151 

152 Args: 

153 timestamp: When the session started. 

154 

155 Returns: 

156 Filename in YYYY-MM-DD-HHmm.md format. 

157 

158 """ 

159 return timestamp.strftime('%Y-%m-%d-%H%M.md') 

160 

161 @staticmethod 

162 def get_session_stats(session: FreewriteSession) -> dict[str, int | float | bool]: 

163 """Calculate current session statistics. 

164 

165 Args: 

166 session: Current session. 

167 

168 Returns: 

169 Dictionary with word_count, elapsed_time, progress metrics. 

170 

171 """ 

172 stats: dict[str, int | float | bool] = { 

173 'word_count': session.current_word_count, 

174 'elapsed_time': session.elapsed_time, 

175 'line_count': len(session.content_lines), 

176 } 

177 

178 # Add goal progress if goals are set 

179 goals_met = session.is_goal_reached() 

180 if goals_met: 

181 stats.update(goals_met) 

182 

183 # Calculate progress percentages 

184 if session.word_count_goal: 

185 word_progress = min(100.0, (session.current_word_count / session.word_count_goal) * 100) 

186 stats['word_progress_percent'] = word_progress 

187 

188 if session.time_limit: 

189 time_progress = min(100.0, (session.elapsed_time / session.time_limit) * 100) 

190 stats['time_progress_percent'] = time_progress 

191 stats['time_remaining'] = max(0, session.time_limit - session.elapsed_time) 

192 

193 return stats 

194 

195 def _validate_session_config(self, config: SessionConfig) -> None: 

196 """Validate session configuration. 

197 

198 Args: 

199 config: Configuration to validate. 

200 

201 Raises: 

202 ValidationError: If configuration is invalid. 

203 

204 """ 

205 if config.target_node and not NodeServiceAdapter.validate_node_uuid(config.target_node): 

206 msg = 'Invalid UUID format' 

207 raise ValidationError('target_node', config.target_node, msg) 

208 

209 if not self.file_system.is_writable(config.current_directory): 

210 msg = 'Directory is not writable' 

211 raise ValidationError('current_directory', config.current_directory, msg) 

212 

213 def _determine_output_path(self, config: SessionConfig) -> str: 

214 """Determine the output file path based on configuration. 

215 

216 Args: 

217 config: Session configuration. 

218 

219 Returns: 

220 Absolute path to output file. 

221 

222 """ 

223 if config.target_node: 

224 # For node-targeted sessions, use node service to get path 

225 return self.node_service.get_node_path(config.target_node) 

226 # For daily files, create timestamped file in current directory 

227 filename = FreewriteServiceAdapter.create_daily_filename(datetime.now(tz=UTC)) 

228 return self.file_system.get_absolute_path(self.file_system.join_paths(config.current_directory, filename)) 

229 

230 def _ensure_writable_target(self, output_file_path: str, config: SessionConfig) -> None: 

231 """Ensure we can write to the target location. 

232 

233 Args: 

234 output_file_path: Path to output file. 

235 config: Session configuration. 

236 

237 Raises: 

238 FileSystemError: If target is not writable. 

239 

240 """ 

241 if config.target_node: 

242 # For nodes, ensure the node exists or can be created 

243 if not self.node_service.node_exists(config.target_node): 

244 try: 

245 self.node_service.create_node(config.target_node, config.title) 

246 except Exception as e: 

247 msg = f'Cannot create node: {e}' 

248 raise FileSystemError('create_node', config.target_node, str(e)) from e 

249 else: 

250 # For daily files, ensure parent directory is writable 

251 parent_dir = str(Path(output_file_path).parent) 

252 if not self.file_system.is_writable(parent_dir): 

253 msg = 'Parent directory is not writable' 

254 raise FileSystemError('check_writable', parent_dir, msg) 

255 

256 def _initialize_output_file(self, session: FreewriteSession, config: SessionConfig) -> None: 

257 """Initialize the output file with proper structure. 

258 

259 Args: 

260 session: The session being initialized. 

261 config: Session configuration. 

262 

263 Raises: 

264 FileSystemError: If file initialization fails. 

265 

266 """ 

267 if config.target_node: 

268 # For node files, we don't initialize - content gets appended 

269 return 

270 

271 # Check if file already exists 

272 if self.file_system.file_exists(session.output_file_path): 

273 # File exists, no initialization needed - content will be loaded into session separately 

274 return 

275 

276 # Create initial content for daily file 

277 initial_content = FreewriteServiceAdapter._create_daily_file_initial_content(session) 

278 

279 # Write initial content 

280 self.file_system.write_file(session.output_file_path, initial_content, append=False) 

281 

282 try: 

283 # Verify file was written successfully 

284 self._verify_file_created(session.output_file_path) 

285 except Exception as e: 

286 raise FileSystemError('initialize', session.output_file_path, str(e)) from e 

287 

288 @staticmethod 

289 def _create_daily_file_initial_content(session: FreewriteSession) -> str: 

290 """Create the initial content for a daily freewrite file. 

291 

292 Args: 

293 session: The session being initialized. 

294 

295 Returns: 

296 Initial file content with YAML frontmatter and header. 

297 

298 """ 

299 # For daily files, create with YAML frontmatter 

300 frontmatter_data: dict[str, Any] = { 

301 'type': 'freewrite', 

302 'session_id': session.session_id, 

303 'created': session.start_time.isoformat(), 

304 } 

305 

306 if session.title: 

307 frontmatter_data['title'] = session.title 

308 

309 if session.word_count_goal: 

310 frontmatter_data['word_count_goal'] = session.word_count_goal 

311 

312 if session.time_limit: 

313 frontmatter_data['time_limit'] = session.time_limit 

314 

315 # Create YAML frontmatter 

316 frontmatter_lines = ['---'] 

317 for key, value in frontmatter_data.items(): 

318 if isinstance(value, str): 

319 frontmatter_lines.append(f'{key}: "{value}"') 

320 else: 

321 frontmatter_lines.append(f'{key}: {value}') 

322 frontmatter_lines.extend(['---', '', '# Freewrite Session', '']) 

323 

324 return '\n'.join(frontmatter_lines) 

325 

326 @staticmethod 

327 def _verify_file_created(file_path: str) -> None: 

328 """Verify that the file was written successfully. 

329 

330 Args: 

331 file_path: Path to the file to verify. 

332 

333 Raises: 

334 OSError: If file was not created. 

335 

336 """ 

337 if not Path(file_path).exists(): 

338 raise OSError('File not created') 

339 

340 def _load_existing_content(self, session: FreewriteSession, config: SessionConfig) -> FreewriteSession: 

341 """Load existing content from file if it exists. 

342 

343 Args: 

344 session: The session being initialized. 

345 config: Session configuration. 

346 

347 Returns: 

348 Updated session with existing content loaded. 

349 

350 Raises: 

351 FileSystemError: If file reading fails. 

352 

353 """ 

354 # Check if file exists 

355 if not self.file_system.file_exists(session.output_file_path): 

356 # No existing file, return session as-is 

357 return session 

358 

359 try: 

360 # Read existing file content 

361 existing_content = self.file_system.read_file(session.output_file_path) 

362 

363 # Split into lines and filter out empty lines at the end 

364 content_lines = existing_content.splitlines() 

365 

366 # For daily files, skip YAML frontmatter and header if present 

367 if not config.target_node: 

368 content_lines = FreewriteServiceAdapter._filter_frontmatter_and_header(content_lines) 

369 

370 # Update session with existing content 

371 updated_session = session 

372 for line in content_lines: 

373 updated_session = updated_session.add_content_line(line) 

374 

375 except Exception as e: 

376 raise FileSystemError('read_existing', session.output_file_path, str(e)) from e 

377 else: 

378 return updated_session 

379 

380 @staticmethod 

381 def _filter_frontmatter_and_header(content_lines: list[str]) -> list[str]: 

382 """Filter out YAML frontmatter and header from daily freewrite files. 

383 

384 Args: 

385 content_lines: Raw content lines from file. 

386 

387 Returns: 

388 Content lines without frontmatter and header. 

389 

390 """ 

391 filtered_lines = [] 

392 in_frontmatter = False 

393 frontmatter_closed = False 

394 skip_empty_lines = True 

395 

396 for line in content_lines: 

397 # Check for YAML frontmatter start/end 

398 if line.strip() == '---': 

399 if not in_frontmatter and not frontmatter_closed: 

400 in_frontmatter = True 

401 continue 

402 if in_frontmatter: 

403 in_frontmatter = False 

404 frontmatter_closed = True 

405 continue 

406 

407 # Skip frontmatter lines 

408 if in_frontmatter: 

409 continue 

410 

411 # Skip header line (starts with #) 

412 if frontmatter_closed and line.strip().startswith('# '): 

413 continue 

414 

415 # Skip leading empty lines after header 

416 if skip_empty_lines and not line.strip(): 

417 continue 

418 

419 # Found content, stop skipping empty lines 

420 skip_empty_lines = False 

421 filtered_lines.append(line) 

422 

423 return filtered_lines 

424 

425 def _append_to_daily_file(self, session: FreewriteSession, content: str) -> None: 

426 """Append content to daily freewrite file. 

427 

428 Args: 

429 session: Current session. 

430 content: Content line to append. 

431 

432 Raises: 

433 FileSystemError: If append operation fails. 

434 

435 """ 

436 # Add newline and append to file 

437 content_with_newline = content + '\n' 

438 self.file_system.write_file(session.output_file_path, content_with_newline, append=True) 

439 

440 def _append_to_node_file(self, session: FreewriteSession, content: str) -> None: 

441 """Append content to node file via node service. 

442 

443 Args: 

444 session: Current session. 

445 content: Content line to append. 

446 

447 Raises: 

448 FileSystemError: If append operation fails. 

449 

450 """ 

451 if not session.target_node: 

452 msg = 'No target node specified for node append operation' 

453 raise FileSystemError('append_node', session.output_file_path, msg) 

454 

455 # Prepare session metadata 

456 session_metadata = { 

457 'timestamp': datetime.now(tz=UTC).strftime('%Y-%m-%d %H:%M'), 

458 'word_count': str(session.current_word_count), 

459 'session_id': session.session_id, 

460 } 

461 

462 # Use node service to append content 

463 self.node_service.append_to_node(session.target_node, [content], session_metadata)