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

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 return session.change_state(SessionState.ACTIVE) 

91 

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

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

94 

95 Args: 

96 session: Current session state. 

97 content: Content line to append. 

98 

99 Returns: 

100 Updated session with new content and word count. 

101 

102 Raises: 

103 FileSystemError: If write operation fails. 

104 ValidationError: If content is invalid. 

105 

106 """ 

107 if not content.strip(): 

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

109 pass 

110 

111 # Add content to session 

112 updated_session = session.add_content_line(content) 

113 

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 

122 

123 return updated_session 

124 

125 @staticmethod 

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

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

128 

129 Args: 

130 node_uuid: UUID string to validate. 

131 

132 Returns: 

133 True if valid UUID format, False otherwise. 

134 

135 """ 

136 # Use the standard UUID validation logic directly 

137 try: 

138 from uuid import UUID 

139 

140 UUID(node_uuid) 

141 except ValueError: 

142 return False 

143 return True 

144 

145 @staticmethod 

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

147 """Generate filename for daily freewrite file. 

148 

149 Args: 

150 timestamp: When the session started. 

151 

152 Returns: 

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

154 

155 """ 

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

157 

158 @staticmethod 

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

160 """Calculate current session statistics. 

161 

162 Args: 

163 session: Current session. 

164 

165 Returns: 

166 Dictionary with word_count, elapsed_time, progress metrics. 

167 

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 } 

174 

175 # Add goal progress if goals are set 

176 goals_met = session.is_goal_reached() 

177 if goals_met: 

178 stats.update(goals_met) 

179 

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 

184 

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) 

189 

190 return stats 

191 

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

193 """Validate session configuration. 

194 

195 Args: 

196 config: Configuration to validate. 

197 

198 Raises: 

199 ValidationError: If configuration is invalid. 

200 

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) 

205 

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) 

209 

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

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

212 

213 Args: 

214 config: Session configuration. 

215 

216 Returns: 

217 Absolute path to output file. 

218 

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

226 

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

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

229 

230 Args: 

231 output_file_path: Path to output file. 

232 config: Session configuration. 

233 

234 Raises: 

235 FileSystemError: If target is not writable. 

236 

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) 

252 

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

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

255 

256 Args: 

257 session: The session being initialized. 

258 config: Session configuration. 

259 

260 Raises: 

261 FileSystemError: If file initialization fails. 

262 

263 """ 

264 if config.target_node: 

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

266 return 

267 

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 } 

274 

275 if session.title: 

276 frontmatter_data['title'] = session.title 

277 

278 if session.word_count_goal: 

279 frontmatter_data['word_count_goal'] = session.word_count_goal 

280 

281 if session.time_limit: 

282 frontmatter_data['time_limit'] = session.time_limit 

283 

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', '']) 

292 

293 initial_content = '\n'.join(frontmatter_lines) 

294 

295 # Write initial content 

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

297 

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

302 

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 

308 

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

310 """Append content to daily freewrite file. 

311 

312 Args: 

313 session: Current session. 

314 content: Content line to append. 

315 

316 Raises: 

317 FileSystemError: If append operation fails. 

318 

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) 

323 

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

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

326 

327 Args: 

328 session: Current session. 

329 content: Content line to append. 

330 

331 Raises: 

332 FileSystemError: If append operation fails. 

333 

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) 

338 

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 } 

345 

346 # Use node service to append content 

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