1use crate::application::services::{DatabaseService, FilesystemService};
2use crate::dal::database::models::{Document, NewDocument};
3use crate::domain::documents::{
4 factory::DocumentFactory, traits::Document as DocumentTrait, types::DocumentId,
5};
6use crate::{MetisError, Result};
7use serde_json;
8use std::path::Path;
9
10pub struct SyncService<'a> {
12 db_service: &'a mut DatabaseService,
13 workspace_dir: Option<&'a Path>,
14}
15
16impl<'a> SyncService<'a> {
17 pub fn new(db_service: &'a mut DatabaseService) -> Self {
18 Self {
19 db_service,
20 workspace_dir: None,
21 }
22 }
23
24 pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
26 self.workspace_dir = Some(workspace_dir);
27 self
28 }
29
30 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
33 let path_str = file_path.as_ref().to_string_lossy().to_string();
34
35 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
37 MetisError::ValidationFailed {
38 message: format!("Failed to parse document: {}", e),
39 }
40 })?;
41
42 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
44 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
45 let content = FilesystemService::read_file(&file_path)?;
46
47 let new_doc = self.domain_to_database_model(
49 document_obj.as_ref(),
50 &path_str,
51 file_hash,
52 updated_at,
53 content,
54 )?;
55
56 self.db_service.create_document(new_doc)
58 }
59
60 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
63 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
65 MetisError::DocumentNotFound {
66 id: filepath.to_string(),
67 }
68 })?;
69
70 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
72 message: "Document has no content".to_string(),
73 })?;
74
75 FilesystemService::write_file(filepath, &content)?;
77
78 Ok(())
79 }
80
81 fn domain_to_database_model(
83 &self,
84 document_obj: &dyn DocumentTrait,
85 filepath: &str,
86 file_hash: String,
87 updated_at: f64,
88 content: String,
89 ) -> Result<NewDocument> {
90 let core = document_obj.core();
91 let phase = document_obj
92 .phase()
93 .map_err(|e| MetisError::ValidationFailed {
94 message: format!("Failed to get document phase: {}", e),
95 })?
96 .to_string();
97
98 let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
100 Self::extract_lineage_from_path(filepath, workspace_dir)
101 } else {
102 (None, None)
103 };
104
105 let final_strategy_id = fs_strategy_id
107 .or_else(|| core.strategy_id.clone())
108 .map(|id| id.to_string());
109 let final_initiative_id = fs_initiative_id
110 .or_else(|| core.initiative_id.clone())
111 .map(|id| id.to_string());
112
113 Ok(NewDocument {
114 filepath: filepath.to_string(),
115 id: document_obj.id().to_string(),
116 title: core.title.clone(),
117 document_type: document_obj.document_type().to_string(),
118 created_at: core.metadata.created_at.timestamp() as f64,
119 updated_at,
120 archived: core.archived,
121 exit_criteria_met: document_obj.exit_criteria_met(),
122 file_hash,
123 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
124 content: Some(content),
125 phase,
126 strategy_id: final_strategy_id,
127 initiative_id: final_initiative_id,
128 short_code: core.metadata.short_code.clone(),
129 })
130 }
131
132 fn extract_lineage_from_path<P: AsRef<Path>>(
135 file_path: P,
136 workspace_dir: &Path,
137 ) -> (Option<DocumentId>, Option<DocumentId>) {
138 let path = file_path.as_ref();
139
140 let relative_path = match path.strip_prefix(workspace_dir) {
142 Ok(rel) => rel,
143 Err(_) => return (None, None),
144 };
145
146 let path_parts: Vec<&str> = relative_path
147 .components()
148 .filter_map(|c| c.as_os_str().to_str())
149 .collect();
150
151 match path_parts.as_slice() {
153 ["strategies", strategy_id, "strategy.md"] => {
155 if strategy_id == &"NULL" {
156 (None, None)
157 } else {
158 (Some(DocumentId::from(*strategy_id)), None)
159 }
160 }
161 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
163 let strat_id = if strategy_id == &"NULL" {
164 None
165 } else {
166 Some(DocumentId::from(*strategy_id))
167 };
168 let init_id = if initiative_id == &"NULL" {
169 None
170 } else {
171 Some(DocumentId::from(*initiative_id))
172 };
173 (strat_id, init_id)
174 }
175 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
177 let strat_id = if strategy_id == &"NULL" {
178 None
179 } else {
180 Some(DocumentId::from(*strategy_id))
181 };
182 let init_id = if initiative_id == &"NULL" {
183 None
184 } else {
185 Some(DocumentId::from(*initiative_id))
186 };
187 (strat_id, init_id)
188 }
189 ["backlog", _] => (None, None),
191 ["adrs", _] => (None, None),
193 ["vision.md"] => (None, None),
195 _ => (None, None),
197 }
198 }
199
200 fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
202 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
204 MetisError::ValidationFailed {
205 message: format!("Failed to read file: {}", e),
206 }
207 })?;
208
209 use gray_matter::{engine::YAML, Matter};
211 let matter = Matter::<YAML>::new();
212 let result = matter.parse(&raw_content);
213
214 if let Some(frontmatter) = result.data {
216 let fm_map = match frontmatter {
217 gray_matter::Pod::Hash(map) => map,
218 _ => {
219 return Err(MetisError::ValidationFailed {
220 message: "Frontmatter must be a hash/map".to_string(),
221 });
222 }
223 };
224
225 if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
226 return Ok(short_code_str.clone());
227 }
228 }
229
230 Err(MetisError::ValidationFailed {
231 message: "Document missing short_code in frontmatter".to_string(),
232 })
233 }
234
235 async fn update_moved_document<P: AsRef<Path>>(
237 &mut self,
238 existing_doc: &Document,
239 new_file_path: P,
240 ) -> Result<()> {
241 self.db_service.delete_document(&existing_doc.filepath)?;
243
244 self.import_from_file(&new_file_path).await?;
246
247 Ok(())
248 }
249
250 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
252 let path_str = file_path.as_ref().to_string_lossy().to_string();
253
254 let file_exists = FilesystemService::file_exists(&file_path);
256
257 let db_doc_by_path = self.db_service.find_by_filepath(&path_str)?;
259
260 match (file_exists, db_doc_by_path) {
261 (true, None) => {
263 let short_code = Self::extract_document_short_code(&file_path)?;
265
266 if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
268 let old_path = existing_doc.filepath.clone();
270 self.update_moved_document(&existing_doc, &file_path)
271 .await?;
272 Ok(SyncResult::Moved {
273 from: old_path,
274 to: path_str,
275 })
276 } else {
277 self.import_from_file(&file_path).await?;
279 Ok(SyncResult::Imported { filepath: path_str })
280 }
281 }
282
283 (false, Some(_)) => {
285 self.db_service.delete_document(&path_str)?;
286 Ok(SyncResult::Deleted { filepath: path_str })
287 }
288
289 (true, Some(db_doc)) => {
291 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
292
293 if db_doc.file_hash != current_hash {
294 self.db_service.delete_document(&path_str)?;
296 self.import_from_file(&file_path).await?;
297 Ok(SyncResult::Updated { filepath: path_str })
298 } else {
299 Ok(SyncResult::UpToDate { filepath: path_str })
300 }
301 }
302
303 (false, None) => Ok(SyncResult::NotFound { filepath: path_str }),
305 }
306 }
307
308 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
310 let mut results = Vec::new();
311
312 let files = FilesystemService::find_markdown_files(&dir_path)?;
314
315 for file_path in files {
317 match self.sync_file(&file_path).await {
318 Ok(result) => results.push(result),
319 Err(e) => results.push(SyncResult::Error {
320 filepath: file_path,
321 error: e.to_string(),
322 }),
323 }
324 }
325
326 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
328 for (_, filepath) in db_pairs {
329 if !FilesystemService::file_exists(&filepath) {
330 match self.db_service.delete_document(&filepath) {
332 Ok(_) => results.push(SyncResult::Deleted { filepath }),
333 Err(e) => results.push(SyncResult::Error {
334 filepath,
335 error: e.to_string(),
336 }),
337 }
338 }
339 }
340
341 Ok(results)
342 }
343
344 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
346 let mut issues = Vec::new();
347
348 let files = FilesystemService::find_markdown_files(&dir_path)?;
350
351 for file_path in &files {
353 let path_str = file_path.to_string();
354
355 if let Some(db_doc) = self.db_service.find_by_filepath(&path_str)? {
356 let current_hash = FilesystemService::compute_file_hash(file_path)?;
357 if db_doc.file_hash != current_hash {
358 issues.push(SyncIssue::OutOfSync {
359 filepath: path_str,
360 reason: "File hash mismatch".to_string(),
361 });
362 }
363 } else {
364 issues.push(SyncIssue::MissingFromDatabase { filepath: path_str });
365 }
366 }
367
368 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
370 for (_, filepath) in db_pairs {
371 if !files.contains(&filepath) && !FilesystemService::file_exists(&filepath) {
372 issues.push(SyncIssue::MissingFromFilesystem {
373 filepath: filepath.clone(),
374 });
375 }
376 }
377
378 Ok(issues)
379 }
380
381 pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
389 &self,
390 dir_path: P,
391 ) -> Result<std::collections::HashMap<String, u32>> {
392 use gray_matter::{engine::YAML, Matter};
393 use std::collections::HashMap;
394
395 let mut counters: HashMap<String, u32> = HashMap::new();
396 let mut skipped_files = 0;
397 let mut invalid_short_codes = 0;
398
399 let dir_path = dir_path.as_ref();
400
401 if !dir_path.exists() {
403 tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
404 return Ok(counters);
405 }
406
407 let files = FilesystemService::find_markdown_files(&dir_path)?;
409 tracing::info!("Counter recovery: scanning {} markdown files", files.len());
410
411 for file_path in files {
412 let content = match std::fs::read_to_string(&file_path) {
414 Ok(c) => c,
415 Err(e) => {
416 tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
417 skipped_files += 1;
418 continue;
419 }
420 };
421
422 let matter = Matter::<YAML>::new();
424 let result = matter.parse(&content);
425
426 if let Some(frontmatter) = result.data {
427 let fm_map = match frontmatter {
428 gray_matter::Pod::Hash(map) => map,
429 _ => continue,
430 };
431
432 if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
434 if !Self::is_valid_short_code_format(short_code) {
436 tracing::warn!(
437 "Counter recovery: invalid short code '{}' in {}",
438 short_code,
439 file_path
440 );
441 invalid_short_codes += 1;
442 continue;
443 }
444
445 if let Some((_, type_and_num)) = short_code.split_once('-') {
447 if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
448 let doc_type = match type_letter {
449 "V" => "vision",
450 "S" => "strategy",
451 "I" => "initiative",
452 "T" => "task",
453 "A" => "adr",
454 _ => continue,
455 };
456
457 match num_str.parse::<u32>() {
459 Ok(num) if num <= 1_000_000 => {
460 counters
461 .entry(doc_type.to_string())
462 .and_modify(|max| {
463 if num > *max {
464 *max = num;
465 }
466 })
467 .or_insert(num);
468 }
469 Ok(num) => {
470 tracing::warn!(
471 "Counter recovery: suspiciously large counter {} in {}, skipping",
472 num,
473 file_path
474 );
475 }
476 Err(e) => {
477 tracing::warn!(
478 "Counter recovery: invalid number '{}' in {}: {}",
479 num_str,
480 file_path,
481 e
482 );
483 invalid_short_codes += 1;
484 }
485 }
486 }
487 }
488 }
489 }
490 }
491
492 if skipped_files > 0 || invalid_short_codes > 0 {
493 tracing::warn!(
494 "Counter recovery: {} files skipped, {} invalid short codes",
495 skipped_files,
496 invalid_short_codes
497 );
498 }
499
500 tracing::info!("Recovered counters: {:?}", counters);
501 Ok(counters)
502 }
503
504 fn is_valid_short_code_format(short_code: &str) -> bool {
506 let parts: Vec<&str> = short_code.split('-').collect();
507 if parts.len() != 3 {
508 return false;
509 }
510
511 let prefix = parts[0];
512 let type_letter = parts[1];
513 let number = parts[2];
514
515 if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
517 return false;
518 }
519
520 if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
522 return false;
523 }
524
525 number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
527 }
528}
529
530#[derive(Debug, Clone, PartialEq)]
532pub enum SyncResult {
533 Imported { filepath: String },
534 Updated { filepath: String },
535 Deleted { filepath: String },
536 UpToDate { filepath: String },
537 NotFound { filepath: String },
538 Error { filepath: String, error: String },
539 Moved { from: String, to: String },
540}
541
542impl SyncResult {
543 pub fn filepath(&self) -> &str {
545 match self {
546 SyncResult::Imported { filepath }
547 | SyncResult::Updated { filepath }
548 | SyncResult::Deleted { filepath }
549 | SyncResult::UpToDate { filepath }
550 | SyncResult::NotFound { filepath }
551 | SyncResult::Error { filepath, .. } => filepath,
552 SyncResult::Moved { to, .. } => to,
553 }
554 }
555
556 pub fn is_change(&self) -> bool {
558 matches!(
559 self,
560 SyncResult::Imported { .. }
561 | SyncResult::Updated { .. }
562 | SyncResult::Deleted { .. }
563 | SyncResult::Moved { .. }
564 )
565 }
566
567 pub fn is_error(&self) -> bool {
569 matches!(self, SyncResult::Error { .. })
570 }
571}
572
573#[derive(Debug, Clone)]
575pub enum SyncIssue {
576 MissingFromDatabase { filepath: String },
577 MissingFromFilesystem { filepath: String },
578 OutOfSync { filepath: String, reason: String },
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use crate::dal::Database;
585 use tempfile::tempdir;
586
587 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
588 let temp_dir = tempdir().expect("Failed to create temp dir");
589 let db = Database::new(":memory:").expect("Failed to create test database");
590 let db_service = DatabaseService::new(db.into_repository());
591 (temp_dir, db_service)
592 }
593
594 fn create_test_document_content() -> String {
595 "---\n".to_string()
596 + "id: test-document\n"
597 + "title: Test Document\n"
598 + "level: vision\n"
599 + "created_at: \"2021-01-01T00:00:00Z\"\n"
600 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
601 + "archived: false\n"
602 + "short_code: TEST-V-9003\n"
603 + "exit_criteria_met: false\n"
604 + "tags:\n"
605 + " - \"#phase/draft\"\n"
606 + "---\n\n"
607 + "# Test Document\n\n"
608 + "Test content.\n"
609 }
610
611 #[tokio::test]
612 async fn test_import_from_file() {
613 let (temp_dir, mut db_service) = setup_services();
614 let mut sync_service = SyncService::new(&mut db_service);
615
616 let file_path = temp_dir.path().join("test.md");
617 FilesystemService::write_file(&file_path, &create_test_document_content())
618 .expect("Failed to write file");
619
620 let doc = sync_service
621 .import_from_file(&file_path)
622 .await
623 .expect("Failed to import");
624 assert_eq!(doc.title, "Test Document");
625 assert_eq!(doc.document_type, "vision");
626
627 assert!(db_service
629 .document_exists(&file_path.to_string_lossy())
630 .expect("Failed to check"));
631 }
632
633 #[tokio::test]
634 async fn test_sync_file_operations() {
635 let (temp_dir, mut db_service) = setup_services();
636 let mut sync_service = SyncService::new(&mut db_service);
637
638 let file_path = temp_dir.path().join("test.md");
639 let path_str = file_path.to_string_lossy().to_string();
640
641 let result = sync_service
643 .sync_file(&file_path)
644 .await
645 .expect("Failed to sync");
646 assert_eq!(
647 result,
648 SyncResult::NotFound {
649 filepath: path_str.clone()
650 }
651 );
652
653 FilesystemService::write_file(&file_path, &create_test_document_content())
655 .expect("Failed to write file");
656
657 let result = sync_service
658 .sync_file(&file_path)
659 .await
660 .expect("Failed to sync");
661 assert_eq!(
662 result,
663 SyncResult::Imported {
664 filepath: path_str.clone()
665 }
666 );
667
668 let result = sync_service
670 .sync_file(&file_path)
671 .await
672 .expect("Failed to sync");
673 assert_eq!(
674 result,
675 SyncResult::UpToDate {
676 filepath: path_str.clone()
677 }
678 );
679
680 let modified_content =
682 &create_test_document_content().replace("Test content.", "Modified content.");
683 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
684
685 let result = sync_service
686 .sync_file(&file_path)
687 .await
688 .expect("Failed to sync");
689 assert_eq!(
690 result,
691 SyncResult::Updated {
692 filepath: path_str.clone()
693 }
694 );
695
696 FilesystemService::delete_file(&file_path).expect("Failed to delete");
698
699 let result = sync_service
700 .sync_file(&file_path)
701 .await
702 .expect("Failed to sync");
703 assert_eq!(
704 result,
705 SyncResult::Deleted {
706 filepath: path_str.clone()
707 }
708 );
709
710 assert!(!db_service
712 .document_exists(&path_str)
713 .expect("Failed to check"));
714 }
715
716 #[tokio::test]
717 async fn test_sync_directory() {
718 let (temp_dir, mut db_service) = setup_services();
719 let mut sync_service = SyncService::new(&mut db_service);
720
721 let files = vec![
723 ("doc1.md", "test-1"),
724 ("subdir/doc2.md", "test-2"),
725 ("subdir/nested/doc3.md", "test-3"),
726 ];
727
728 for (i, (file_path, id)) in files.iter().enumerate() {
729 let full_path = temp_dir.path().join(file_path);
730 let content = &create_test_document_content()
731 .replace("Test Document", &format!("Test Document {}", id))
732 .replace("test-document", id)
733 .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
734 FilesystemService::write_file(&full_path, content).expect("Failed to write");
735 }
736
737 let results = sync_service
739 .sync_directory(temp_dir.path())
740 .await
741 .expect("Failed to sync directory");
742
743 let imports = results
745 .iter()
746 .filter(|r| matches!(r, SyncResult::Imported { .. }))
747 .count();
748 assert_eq!(imports, 3);
749
750 let results = sync_service
752 .sync_directory(temp_dir.path())
753 .await
754 .expect("Failed to sync directory");
755 let up_to_date = results
756 .iter()
757 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
758 .count();
759 assert_eq!(up_to_date, 3);
760
761 for (file_path, _) in &files {
763 let full_path = temp_dir
764 .path()
765 .join(file_path)
766 .to_string_lossy()
767 .to_string();
768 assert!(results.iter().any(|r| r.filepath() == full_path));
769 }
770 }
771}