1use crate::application::services::{DatabaseService, FilesystemService};
2use crate::dal::database::models::{Document, NewDocument};
3use crate::domain::documents::{factory::DocumentFactory, traits::Document as DocumentTrait, types::DocumentId};
4use crate::{MetisError, Result};
5use serde_json;
6use std::path::Path;
7
8pub struct SyncService<'a> {
10 db_service: &'a mut DatabaseService,
11 workspace_dir: Option<&'a Path>,
12}
13
14impl<'a> SyncService<'a> {
15 pub fn new(db_service: &'a mut DatabaseService) -> Self {
16 Self {
17 db_service,
18 workspace_dir: None,
19 }
20 }
21
22 pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
24 self.workspace_dir = Some(workspace_dir);
25 self
26 }
27
28 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
31 let path_str = file_path.as_ref().to_string_lossy().to_string();
32
33 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
35 MetisError::ValidationFailed {
36 message: format!("Failed to parse document: {}", e),
37 }
38 })?;
39
40 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
42 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
43 let content = FilesystemService::read_file(&file_path)?;
44
45 let new_doc = self.domain_to_database_model(
47 document_obj.as_ref(),
48 &path_str,
49 file_hash,
50 updated_at,
51 content,
52 )?;
53
54 self.db_service.create_document(new_doc)
56 }
57
58 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
61 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
63 MetisError::DocumentNotFound {
64 id: filepath.to_string(),
65 }
66 })?;
67
68 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
70 message: "Document has no content".to_string(),
71 })?;
72
73 FilesystemService::write_file(filepath, &content)?;
75
76 Ok(())
77 }
78
79 fn domain_to_database_model(
81 &self,
82 document_obj: &dyn DocumentTrait,
83 filepath: &str,
84 file_hash: String,
85 updated_at: f64,
86 content: String,
87 ) -> Result<NewDocument> {
88 let core = document_obj.core();
89 let phase = document_obj
90 .phase()
91 .map_err(|e| MetisError::ValidationFailed {
92 message: format!("Failed to get document phase: {}", e),
93 })?
94 .to_string();
95
96 let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
98 Self::extract_lineage_from_path(filepath, workspace_dir)
99 } else {
100 (None, None)
101 };
102
103 let final_strategy_id = fs_strategy_id
105 .or_else(|| core.strategy_id.clone())
106 .map(|id| id.to_string());
107 let final_initiative_id = fs_initiative_id
108 .or_else(|| core.initiative_id.clone())
109 .map(|id| id.to_string());
110
111 Ok(NewDocument {
112 filepath: filepath.to_string(),
113 id: document_obj.id().to_string(),
114 title: core.title.clone(),
115 document_type: document_obj.document_type().to_string(),
116 created_at: core.metadata.created_at.timestamp() as f64,
117 updated_at,
118 archived: core.archived,
119 exit_criteria_met: document_obj.exit_criteria_met(),
120 file_hash,
121 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
122 content: Some(content),
123 phase,
124 strategy_id: final_strategy_id,
125 initiative_id: final_initiative_id,
126 })
127 }
128
129 fn extract_lineage_from_path<P: AsRef<Path>>(
132 file_path: P,
133 workspace_dir: &Path,
134 ) -> (Option<DocumentId>, Option<DocumentId>) {
135 let path = file_path.as_ref();
136
137 let relative_path = match path.strip_prefix(workspace_dir) {
139 Ok(rel) => rel,
140 Err(_) => return (None, None),
141 };
142
143 let path_parts: Vec<&str> = relative_path
144 .components()
145 .filter_map(|c| c.as_os_str().to_str())
146 .collect();
147
148 match path_parts.as_slice() {
150 ["strategies", strategy_id, "strategy.md"] => {
152 if strategy_id == &"NULL" {
153 (None, None)
154 } else {
155 (Some(DocumentId::from(*strategy_id)), None)
156 }
157 }
158 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
160 let strat_id = if strategy_id == &"NULL" {
161 None
162 } else {
163 Some(DocumentId::from(*strategy_id))
164 };
165 let init_id = if initiative_id == &"NULL" {
166 None
167 } else {
168 Some(DocumentId::from(*initiative_id))
169 };
170 (strat_id, init_id)
171 }
172 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
174 let strat_id = if strategy_id == &"NULL" {
175 None
176 } else {
177 Some(DocumentId::from(*strategy_id))
178 };
179 let init_id = if initiative_id == &"NULL" {
180 None
181 } else {
182 Some(DocumentId::from(*initiative_id))
183 };
184 (strat_id, init_id)
185 }
186 ["backlog", _] => (None, None),
188 ["adrs", _] => (None, None),
190 ["vision.md"] => (None, None),
192 _ => (None, None),
194 }
195 }
196
197 fn extract_document_id<P: AsRef<Path>>(file_path: P) -> Result<String> {
199 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
201 MetisError::ValidationFailed {
202 message: format!("Failed to read file: {}", e),
203 }
204 })?;
205
206 use gray_matter::{engine::YAML, Matter};
208 let matter = Matter::<YAML>::new();
209 let result = matter.parse(&raw_content);
210
211 if let Some(frontmatter) = result.data {
213 let fm_map = match frontmatter {
214 gray_matter::Pod::Hash(map) => map,
215 _ => {
216 return Err(MetisError::ValidationFailed {
217 message: "Frontmatter must be a hash/map".to_string(),
218 });
219 }
220 };
221
222 if let Some(gray_matter::Pod::String(id_str)) = fm_map.get("id") {
223 return Ok(id_str.clone());
224 }
225 }
226
227 Err(MetisError::ValidationFailed {
228 message: "Document missing ID in frontmatter".to_string(),
229 })
230 }
231
232 async fn update_moved_document<P: AsRef<Path>>(
234 &mut self,
235 existing_doc: &Document,
236 new_file_path: P,
237 ) -> Result<()> {
238 self.db_service.delete_document(&existing_doc.filepath)?;
240
241 self.import_from_file(&new_file_path).await?;
243
244 Ok(())
245 }
246
247 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
249 let path_str = file_path.as_ref().to_string_lossy().to_string();
250
251 let file_exists = FilesystemService::file_exists(&file_path);
253
254 let db_doc_by_path = self.db_service.find_by_filepath(&path_str)?;
256
257 match (file_exists, db_doc_by_path) {
258 (true, None) => {
260 let document_id = Self::extract_document_id(&file_path)?;
262
263 if let Some(existing_doc) = self.db_service.find_by_id(&document_id)? {
265 let old_path = existing_doc.filepath.clone();
267 self.update_moved_document(&existing_doc, &file_path)
268 .await?;
269 Ok(SyncResult::Moved {
270 from: old_path,
271 to: path_str,
272 })
273 } else {
274 self.import_from_file(&file_path).await?;
276 Ok(SyncResult::Imported { filepath: path_str })
277 }
278 }
279
280 (false, Some(_)) => {
282 self.db_service.delete_document(&path_str)?;
283 Ok(SyncResult::Deleted { filepath: path_str })
284 }
285
286 (true, Some(db_doc)) => {
288 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
289
290 if db_doc.file_hash != current_hash {
291 self.db_service.delete_document(&path_str)?;
293 self.import_from_file(&file_path).await?;
294 Ok(SyncResult::Updated { filepath: path_str })
295 } else {
296 Ok(SyncResult::UpToDate { filepath: path_str })
297 }
298 }
299
300 (false, None) => Ok(SyncResult::NotFound { filepath: path_str }),
302 }
303 }
304
305 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
307 let mut results = Vec::new();
308
309 let files = FilesystemService::find_markdown_files(&dir_path)?;
311
312 for file_path in files {
314 match self.sync_file(&file_path).await {
315 Ok(result) => results.push(result),
316 Err(e) => results.push(SyncResult::Error {
317 filepath: file_path,
318 error: e.to_string(),
319 }),
320 }
321 }
322
323 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
325 for (_, filepath) in db_pairs {
326 if !FilesystemService::file_exists(&filepath) {
327 match self.db_service.delete_document(&filepath) {
329 Ok(_) => results.push(SyncResult::Deleted { filepath }),
330 Err(e) => results.push(SyncResult::Error {
331 filepath,
332 error: e.to_string(),
333 }),
334 }
335 }
336 }
337
338 Ok(results)
339 }
340
341 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
343 let mut issues = Vec::new();
344
345 let files = FilesystemService::find_markdown_files(&dir_path)?;
347
348 for file_path in &files {
350 let path_str = file_path.to_string();
351
352 if let Some(db_doc) = self.db_service.find_by_filepath(&path_str)? {
353 let current_hash = FilesystemService::compute_file_hash(file_path)?;
354 if db_doc.file_hash != current_hash {
355 issues.push(SyncIssue::OutOfSync {
356 filepath: path_str,
357 reason: "File hash mismatch".to_string(),
358 });
359 }
360 } else {
361 issues.push(SyncIssue::MissingFromDatabase { filepath: path_str });
362 }
363 }
364
365 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
367 for (_, filepath) in db_pairs {
368 if !files.contains(&filepath) && !FilesystemService::file_exists(&filepath) {
369 issues.push(SyncIssue::MissingFromFilesystem {
370 filepath: filepath.clone(),
371 });
372 }
373 }
374
375 Ok(issues)
376 }
377}
378
379#[derive(Debug, Clone, PartialEq)]
381pub enum SyncResult {
382 Imported { filepath: String },
383 Updated { filepath: String },
384 Deleted { filepath: String },
385 UpToDate { filepath: String },
386 NotFound { filepath: String },
387 Error { filepath: String, error: String },
388 Moved { from: String, to: String },
389}
390
391impl SyncResult {
392 pub fn filepath(&self) -> &str {
394 match self {
395 SyncResult::Imported { filepath }
396 | SyncResult::Updated { filepath }
397 | SyncResult::Deleted { filepath }
398 | SyncResult::UpToDate { filepath }
399 | SyncResult::NotFound { filepath }
400 | SyncResult::Error { filepath, .. } => filepath,
401 SyncResult::Moved { to, .. } => to,
402 }
403 }
404
405 pub fn is_change(&self) -> bool {
407 matches!(
408 self,
409 SyncResult::Imported { .. }
410 | SyncResult::Updated { .. }
411 | SyncResult::Deleted { .. }
412 | SyncResult::Moved { .. }
413 )
414 }
415
416 pub fn is_error(&self) -> bool {
418 matches!(self, SyncResult::Error { .. })
419 }
420}
421
422#[derive(Debug, Clone)]
424pub enum SyncIssue {
425 MissingFromDatabase { filepath: String },
426 MissingFromFilesystem { filepath: String },
427 OutOfSync { filepath: String, reason: String },
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::dal::Database;
434 use tempfile::tempdir;
435
436 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
437 let temp_dir = tempdir().expect("Failed to create temp dir");
438 let db = Database::new(":memory:").expect("Failed to create test database");
439 let db_service = DatabaseService::new(db.into_repository());
440 (temp_dir, db_service)
441 }
442
443 fn create_test_document_content() -> String {
444 "---\n".to_string()
445 + "id: test-document\n"
446 + "title: Test Document\n"
447 + "level: vision\n"
448 + "created_at: \"2021-01-01T00:00:00Z\"\n"
449 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
450 + "archived: false\n"
451 + "exit_criteria_met: false\n"
452 + "tags:\n"
453 + " - \"#phase/draft\"\n"
454 + "---\n\n"
455 + "# Test Document\n\n"
456 + "Test content.\n"
457 }
458
459 #[tokio::test]
460 async fn test_import_from_file() {
461 let (temp_dir, mut db_service) = setup_services();
462 let mut sync_service = SyncService::new(&mut db_service);
463
464 let file_path = temp_dir.path().join("test.md");
465 FilesystemService::write_file(&file_path, &create_test_document_content())
466 .expect("Failed to write file");
467
468 let doc = sync_service
469 .import_from_file(&file_path)
470 .await
471 .expect("Failed to import");
472 assert_eq!(doc.title, "Test Document");
473 assert_eq!(doc.document_type, "vision");
474
475 assert!(db_service
477 .document_exists(&file_path.to_string_lossy())
478 .expect("Failed to check"));
479 }
480
481 #[tokio::test]
482 async fn test_sync_file_operations() {
483 let (temp_dir, mut db_service) = setup_services();
484 let mut sync_service = SyncService::new(&mut db_service);
485
486 let file_path = temp_dir.path().join("test.md");
487 let path_str = file_path.to_string_lossy().to_string();
488
489 let result = sync_service
491 .sync_file(&file_path)
492 .await
493 .expect("Failed to sync");
494 assert_eq!(
495 result,
496 SyncResult::NotFound {
497 filepath: path_str.clone()
498 }
499 );
500
501 FilesystemService::write_file(&file_path, &create_test_document_content())
503 .expect("Failed to write file");
504
505 let result = sync_service
506 .sync_file(&file_path)
507 .await
508 .expect("Failed to sync");
509 assert_eq!(
510 result,
511 SyncResult::Imported {
512 filepath: path_str.clone()
513 }
514 );
515
516 let result = sync_service
518 .sync_file(&file_path)
519 .await
520 .expect("Failed to sync");
521 assert_eq!(
522 result,
523 SyncResult::UpToDate {
524 filepath: path_str.clone()
525 }
526 );
527
528 let modified_content =
530 &create_test_document_content().replace("Test content.", "Modified content.");
531 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
532
533 let result = sync_service
534 .sync_file(&file_path)
535 .await
536 .expect("Failed to sync");
537 assert_eq!(
538 result,
539 SyncResult::Updated {
540 filepath: path_str.clone()
541 }
542 );
543
544 FilesystemService::delete_file(&file_path).expect("Failed to delete");
546
547 let result = sync_service
548 .sync_file(&file_path)
549 .await
550 .expect("Failed to sync");
551 assert_eq!(
552 result,
553 SyncResult::Deleted {
554 filepath: path_str.clone()
555 }
556 );
557
558 assert!(!db_service
560 .document_exists(&path_str)
561 .expect("Failed to check"));
562 }
563
564 #[tokio::test]
565 async fn test_sync_directory() {
566 let (temp_dir, mut db_service) = setup_services();
567 let mut sync_service = SyncService::new(&mut db_service);
568
569 let files = vec![
571 ("doc1.md", "test-1"),
572 ("subdir/doc2.md", "test-2"),
573 ("subdir/nested/doc3.md", "test-3"),
574 ];
575
576 for (file_path, id) in &files {
577 let full_path = temp_dir.path().join(file_path);
578 let content = &create_test_document_content()
579 .replace("Test Document", &format!("Test Document {}", id))
580 .replace("test-document", id);
581 FilesystemService::write_file(&full_path, content).expect("Failed to write");
582 }
583
584 let results = sync_service
586 .sync_directory(temp_dir.path())
587 .await
588 .expect("Failed to sync directory");
589
590 let imports = results
592 .iter()
593 .filter(|r| matches!(r, SyncResult::Imported { .. }))
594 .count();
595 assert_eq!(imports, 3);
596
597 let results = sync_service
599 .sync_directory(temp_dir.path())
600 .await
601 .expect("Failed to sync directory");
602 let up_to_date = results
603 .iter()
604 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
605 .count();
606 assert_eq!(up_to_date, 3);
607
608 for (file_path, _) in &files {
610 let full_path = temp_dir
611 .path()
612 .join(file_path)
613 .to_string_lossy()
614 .to_string();
615 assert!(results.iter().any(|r| r.filepath() == full_path));
616 }
617 }
618}