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 {
29 self.workspace_dir = Some(workspace_dir);
30 self
31 }
32
33 fn to_relative_path<P: AsRef<Path>>(&self, absolute_path: P) -> String {
36 if let Some(workspace_dir) = self.workspace_dir {
37 if let Ok(relative) = absolute_path.as_ref().strip_prefix(workspace_dir) {
38 return relative.to_string_lossy().to_string();
39 }
40 }
41 absolute_path.as_ref().to_string_lossy().to_string()
43 }
44
45 fn to_absolute_path(&self, relative_path: &str) -> std::path::PathBuf {
48 if let Some(workspace_dir) = self.workspace_dir {
49 workspace_dir.join(relative_path)
50 } else {
51 std::path::PathBuf::from(relative_path)
53 }
54 }
55
56 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
59 let path_str = self.to_relative_path(&file_path);
61
62 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
64 MetisError::ValidationFailed {
65 message: format!("Failed to parse document: {}", e),
66 }
67 })?;
68
69 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
71 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
72 let content = FilesystemService::read_file(&file_path)?;
73
74 let new_doc = self.domain_to_database_model(
76 document_obj.as_ref(),
77 &path_str,
78 file_hash,
79 updated_at,
80 content,
81 )?;
82
83 self.db_service.create_document(new_doc)
85 }
86
87 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
90 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
92 MetisError::DocumentNotFound {
93 id: filepath.to_string(),
94 }
95 })?;
96
97 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
99 message: "Document has no content".to_string(),
100 })?;
101
102 let absolute_path = self.to_absolute_path(filepath);
104
105 FilesystemService::write_file(absolute_path, &content)?;
107
108 Ok(())
109 }
110
111 fn domain_to_database_model(
113 &self,
114 document_obj: &dyn DocumentTrait,
115 filepath: &str,
116 file_hash: String,
117 updated_at: f64,
118 content: String,
119 ) -> Result<NewDocument> {
120 let core = document_obj.core();
121 let phase = document_obj
122 .phase()
123 .map_err(|e| MetisError::ValidationFailed {
124 message: format!("Failed to get document phase: {}", e),
125 })?
126 .to_string();
127
128 let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
130 Self::extract_lineage_from_path(filepath, workspace_dir)
131 } else {
132 (None, None)
133 };
134
135 let final_strategy_id = fs_strategy_id
137 .or_else(|| core.strategy_id.clone())
138 .map(|id| id.to_string());
139 let final_initiative_id = fs_initiative_id
140 .or_else(|| core.initiative_id.clone())
141 .map(|id| id.to_string());
142
143 Ok(NewDocument {
144 filepath: filepath.to_string(),
145 id: document_obj.id().to_string(),
146 title: core.title.clone(),
147 document_type: document_obj.document_type().to_string(),
148 created_at: core.metadata.created_at.timestamp() as f64,
149 updated_at,
150 archived: core.archived,
151 exit_criteria_met: document_obj.exit_criteria_met(),
152 file_hash,
153 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
154 content: Some(content),
155 phase,
156 strategy_id: final_strategy_id,
157 initiative_id: final_initiative_id,
158 short_code: core.metadata.short_code.clone(),
159 })
160 }
161
162 fn extract_lineage_from_path<P: AsRef<Path>>(
165 file_path: P,
166 workspace_dir: &Path,
167 ) -> (Option<DocumentId>, Option<DocumentId>) {
168 let path = file_path.as_ref();
169
170 let relative_path = match path.strip_prefix(workspace_dir) {
172 Ok(rel) => rel,
173 Err(_) => return (None, None),
174 };
175
176 let path_parts: Vec<&str> = relative_path
177 .components()
178 .filter_map(|c| c.as_os_str().to_str())
179 .collect();
180
181 match path_parts.as_slice() {
183 ["strategies", strategy_id, "strategy.md"] => {
185 if strategy_id == &"NULL" {
186 (None, None)
187 } else {
188 (Some(DocumentId::from(*strategy_id)), None)
189 }
190 }
191 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
193 let strat_id = if strategy_id == &"NULL" {
194 None
195 } else {
196 Some(DocumentId::from(*strategy_id))
197 };
198 let init_id = if initiative_id == &"NULL" {
199 None
200 } else {
201 Some(DocumentId::from(*initiative_id))
202 };
203 (strat_id, init_id)
204 }
205 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
207 let strat_id = if strategy_id == &"NULL" {
208 None
209 } else {
210 Some(DocumentId::from(*strategy_id))
211 };
212 let init_id = if initiative_id == &"NULL" {
213 None
214 } else {
215 Some(DocumentId::from(*initiative_id))
216 };
217 (strat_id, init_id)
218 }
219 ["backlog", _] => (None, None),
221 ["adrs", _] => (None, None),
223 ["vision.md"] => (None, None),
225 _ => (None, None),
227 }
228 }
229
230 fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
232 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
234 MetisError::ValidationFailed {
235 message: format!("Failed to read file: {}", e),
236 }
237 })?;
238
239 use gray_matter::{engine::YAML, Matter};
241 let matter = Matter::<YAML>::new();
242 let result = matter.parse(&raw_content);
243
244 if let Some(frontmatter) = result.data {
246 let fm_map = match frontmatter {
247 gray_matter::Pod::Hash(map) => map,
248 _ => {
249 return Err(MetisError::ValidationFailed {
250 message: "Frontmatter must be a hash/map".to_string(),
251 });
252 }
253 };
254
255 if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
256 return Ok(short_code_str.clone());
257 }
258 }
259
260 Err(MetisError::ValidationFailed {
261 message: "Document missing short_code in frontmatter".to_string(),
262 })
263 }
264
265 async fn update_moved_document<P: AsRef<Path>>(
267 &mut self,
268 existing_doc: &Document,
269 new_file_path: P,
270 ) -> Result<()> {
271 self.db_service.delete_document(&existing_doc.filepath)?;
273
274 self.import_from_file(&new_file_path).await?;
276
277 Ok(())
278 }
279
280 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
282 let relative_path_str = self.to_relative_path(&file_path);
284
285 let file_exists = FilesystemService::file_exists(&file_path);
287
288 let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
290
291 match (file_exists, db_doc_by_path) {
292 (true, None) => {
294 let short_code = Self::extract_document_short_code(&file_path)?;
296
297 if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
299 let old_path = existing_doc.filepath.clone();
301 self.update_moved_document(&existing_doc, &file_path)
302 .await?;
303 Ok(SyncResult::Moved {
304 from: old_path,
305 to: relative_path_str,
306 })
307 } else {
308 self.import_from_file(&file_path).await?;
310 Ok(SyncResult::Imported {
311 filepath: relative_path_str,
312 })
313 }
314 }
315
316 (false, Some(_)) => {
318 self.db_service.delete_document(&relative_path_str)?;
319 Ok(SyncResult::Deleted {
320 filepath: relative_path_str,
321 })
322 }
323
324 (true, Some(db_doc)) => {
326 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
327
328 if db_doc.file_hash != current_hash {
329 self.db_service.delete_document(&relative_path_str)?;
331 self.import_from_file(&file_path).await?;
332 Ok(SyncResult::Updated {
333 filepath: relative_path_str,
334 })
335 } else {
336 Ok(SyncResult::UpToDate {
337 filepath: relative_path_str,
338 })
339 }
340 }
341
342 (false, None) => Ok(SyncResult::NotFound {
344 filepath: relative_path_str,
345 }),
346 }
347 }
348
349 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
351 let mut results = Vec::new();
352
353 let files = FilesystemService::find_markdown_files(&dir_path)?;
355
356 for file_path in files {
358 match self.sync_file(&file_path).await {
359 Ok(result) => results.push(result),
360 Err(e) => results.push(SyncResult::Error {
361 filepath: file_path,
362 error: e.to_string(),
363 }),
364 }
365 }
366
367 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
369 for (_, relative_filepath) in db_pairs {
370 let absolute_path = self.to_absolute_path(&relative_filepath);
372 if !FilesystemService::file_exists(&absolute_path) {
373 match self.db_service.delete_document(&relative_filepath) {
375 Ok(_) => results.push(SyncResult::Deleted {
376 filepath: relative_filepath,
377 }),
378 Err(e) => results.push(SyncResult::Error {
379 filepath: relative_filepath,
380 error: e.to_string(),
381 }),
382 }
383 }
384 }
385
386 Ok(results)
387 }
388
389 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
391 let mut issues = Vec::new();
392
393 let files = FilesystemService::find_markdown_files(&dir_path)?;
395
396 for file_path in &files {
398 let relative_path = self.to_relative_path(file_path);
400
401 if let Some(db_doc) = self.db_service.find_by_filepath(&relative_path)? {
402 let current_hash = FilesystemService::compute_file_hash(file_path)?;
403 if db_doc.file_hash != current_hash {
404 issues.push(SyncIssue::OutOfSync {
405 filepath: relative_path,
406 reason: "File hash mismatch".to_string(),
407 });
408 }
409 } else {
410 issues.push(SyncIssue::MissingFromDatabase {
411 filepath: relative_path,
412 });
413 }
414 }
415
416 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
418 for (_, relative_filepath) in db_pairs {
419 let absolute_path = self.to_absolute_path(&relative_filepath);
421 let absolute_str = absolute_path.to_string_lossy().to_string();
422 if !files.contains(&absolute_str) && !FilesystemService::file_exists(&absolute_path) {
423 issues.push(SyncIssue::MissingFromFilesystem {
424 filepath: relative_filepath,
425 });
426 }
427 }
428
429 Ok(issues)
430 }
431
432 pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
440 &self,
441 dir_path: P,
442 ) -> Result<std::collections::HashMap<String, u32>> {
443 use gray_matter::{engine::YAML, Matter};
444 use std::collections::HashMap;
445
446 let mut counters: HashMap<String, u32> = HashMap::new();
447 let mut skipped_files = 0;
448 let mut invalid_short_codes = 0;
449
450 let dir_path = dir_path.as_ref();
451
452 if !dir_path.exists() {
454 tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
455 return Ok(counters);
456 }
457
458 let files = FilesystemService::find_markdown_files(&dir_path)?;
460 tracing::info!("Counter recovery: scanning {} markdown files", files.len());
461
462 for file_path in files {
463 let content = match std::fs::read_to_string(&file_path) {
465 Ok(c) => c,
466 Err(e) => {
467 tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
468 skipped_files += 1;
469 continue;
470 }
471 };
472
473 let matter = Matter::<YAML>::new();
475 let result = matter.parse(&content);
476
477 if let Some(frontmatter) = result.data {
478 let fm_map = match frontmatter {
479 gray_matter::Pod::Hash(map) => map,
480 _ => continue,
481 };
482
483 if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
485 if !Self::is_valid_short_code_format(short_code) {
487 tracing::warn!(
488 "Counter recovery: invalid short code '{}' in {}",
489 short_code,
490 file_path
491 );
492 invalid_short_codes += 1;
493 continue;
494 }
495
496 if let Some((_, type_and_num)) = short_code.split_once('-') {
498 if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
499 let doc_type = match type_letter {
500 "V" => "vision",
501 "S" => "strategy",
502 "I" => "initiative",
503 "T" => "task",
504 "A" => "adr",
505 _ => continue,
506 };
507
508 match num_str.parse::<u32>() {
510 Ok(num) if num <= 1_000_000 => {
511 counters
512 .entry(doc_type.to_string())
513 .and_modify(|max| {
514 if num > *max {
515 *max = num;
516 }
517 })
518 .or_insert(num);
519 }
520 Ok(num) => {
521 tracing::warn!(
522 "Counter recovery: suspiciously large counter {} in {}, skipping",
523 num,
524 file_path
525 );
526 }
527 Err(e) => {
528 tracing::warn!(
529 "Counter recovery: invalid number '{}' in {}: {}",
530 num_str,
531 file_path,
532 e
533 );
534 invalid_short_codes += 1;
535 }
536 }
537 }
538 }
539 }
540 }
541 }
542
543 if skipped_files > 0 || invalid_short_codes > 0 {
544 tracing::warn!(
545 "Counter recovery: {} files skipped, {} invalid short codes",
546 skipped_files,
547 invalid_short_codes
548 );
549 }
550
551 tracing::info!("Recovered counters: {:?}", counters);
552 Ok(counters)
553 }
554
555 fn is_valid_short_code_format(short_code: &str) -> bool {
557 let parts: Vec<&str> = short_code.split('-').collect();
558 if parts.len() != 3 {
559 return false;
560 }
561
562 let prefix = parts[0];
563 let type_letter = parts[1];
564 let number = parts[2];
565
566 if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
568 return false;
569 }
570
571 if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
573 return false;
574 }
575
576 number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
578 }
579}
580
581#[derive(Debug, Clone, PartialEq)]
583pub enum SyncResult {
584 Imported { filepath: String },
585 Updated { filepath: String },
586 Deleted { filepath: String },
587 UpToDate { filepath: String },
588 NotFound { filepath: String },
589 Error { filepath: String, error: String },
590 Moved { from: String, to: String },
591}
592
593impl SyncResult {
594 pub fn filepath(&self) -> &str {
596 match self {
597 SyncResult::Imported { filepath }
598 | SyncResult::Updated { filepath }
599 | SyncResult::Deleted { filepath }
600 | SyncResult::UpToDate { filepath }
601 | SyncResult::NotFound { filepath }
602 | SyncResult::Error { filepath, .. } => filepath,
603 SyncResult::Moved { to, .. } => to,
604 }
605 }
606
607 pub fn is_change(&self) -> bool {
609 matches!(
610 self,
611 SyncResult::Imported { .. }
612 | SyncResult::Updated { .. }
613 | SyncResult::Deleted { .. }
614 | SyncResult::Moved { .. }
615 )
616 }
617
618 pub fn is_error(&self) -> bool {
620 matches!(self, SyncResult::Error { .. })
621 }
622}
623
624#[derive(Debug, Clone)]
626pub enum SyncIssue {
627 MissingFromDatabase { filepath: String },
628 MissingFromFilesystem { filepath: String },
629 OutOfSync { filepath: String, reason: String },
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::dal::Database;
636 use tempfile::tempdir;
637
638 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
639 let temp_dir = tempdir().expect("Failed to create temp dir");
640 let db = Database::new(":memory:").expect("Failed to create test database");
641 let db_service = DatabaseService::new(db.into_repository());
642 (temp_dir, db_service)
643 }
644
645 fn create_test_document_content() -> String {
646 "---\n".to_string()
647 + "id: test-document\n"
648 + "title: Test Document\n"
649 + "level: vision\n"
650 + "created_at: \"2021-01-01T00:00:00Z\"\n"
651 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
652 + "archived: false\n"
653 + "short_code: TEST-V-9003\n"
654 + "exit_criteria_met: false\n"
655 + "tags:\n"
656 + " - \"#phase/draft\"\n"
657 + "---\n\n"
658 + "# Test Document\n\n"
659 + "Test content.\n"
660 }
661
662 #[tokio::test]
663 async fn test_import_from_file() {
664 let (temp_dir, mut db_service) = setup_services();
665 let mut sync_service = SyncService::new(&mut db_service);
666
667 let file_path = temp_dir.path().join("test.md");
668 FilesystemService::write_file(&file_path, &create_test_document_content())
669 .expect("Failed to write file");
670
671 let doc = sync_service
672 .import_from_file(&file_path)
673 .await
674 .expect("Failed to import");
675 assert_eq!(doc.title, "Test Document");
676 assert_eq!(doc.document_type, "vision");
677
678 assert!(db_service
680 .document_exists(&file_path.to_string_lossy())
681 .expect("Failed to check"));
682 }
683
684 #[tokio::test]
685 async fn test_sync_file_operations() {
686 let (temp_dir, mut db_service) = setup_services();
687 let mut sync_service = SyncService::new(&mut db_service);
688
689 let file_path = temp_dir.path().join("test.md");
690 let path_str = file_path.to_string_lossy().to_string();
691
692 let result = sync_service
694 .sync_file(&file_path)
695 .await
696 .expect("Failed to sync");
697 assert_eq!(
698 result,
699 SyncResult::NotFound {
700 filepath: path_str.clone()
701 }
702 );
703
704 FilesystemService::write_file(&file_path, &create_test_document_content())
706 .expect("Failed to write file");
707
708 let result = sync_service
709 .sync_file(&file_path)
710 .await
711 .expect("Failed to sync");
712 assert_eq!(
713 result,
714 SyncResult::Imported {
715 filepath: path_str.clone()
716 }
717 );
718
719 let result = sync_service
721 .sync_file(&file_path)
722 .await
723 .expect("Failed to sync");
724 assert_eq!(
725 result,
726 SyncResult::UpToDate {
727 filepath: path_str.clone()
728 }
729 );
730
731 let modified_content =
733 &create_test_document_content().replace("Test content.", "Modified content.");
734 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
735
736 let result = sync_service
737 .sync_file(&file_path)
738 .await
739 .expect("Failed to sync");
740 assert_eq!(
741 result,
742 SyncResult::Updated {
743 filepath: path_str.clone()
744 }
745 );
746
747 FilesystemService::delete_file(&file_path).expect("Failed to delete");
749
750 let result = sync_service
751 .sync_file(&file_path)
752 .await
753 .expect("Failed to sync");
754 assert_eq!(
755 result,
756 SyncResult::Deleted {
757 filepath: path_str.clone()
758 }
759 );
760
761 assert!(!db_service
763 .document_exists(&path_str)
764 .expect("Failed to check"));
765 }
766
767 #[tokio::test]
768 async fn test_sync_directory() {
769 let (temp_dir, mut db_service) = setup_services();
770 let mut sync_service = SyncService::new(&mut db_service);
771
772 let files = vec![
774 ("doc1.md", "test-1"),
775 ("subdir/doc2.md", "test-2"),
776 ("subdir/nested/doc3.md", "test-3"),
777 ];
778
779 for (i, (file_path, id)) in files.iter().enumerate() {
780 let full_path = temp_dir.path().join(file_path);
781 let content = &create_test_document_content()
782 .replace("Test Document", &format!("Test Document {}", id))
783 .replace("test-document", id)
784 .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
785 FilesystemService::write_file(&full_path, content).expect("Failed to write");
786 }
787
788 let results = sync_service
790 .sync_directory(temp_dir.path())
791 .await
792 .expect("Failed to sync directory");
793
794 let imports = results
796 .iter()
797 .filter(|r| matches!(r, SyncResult::Imported { .. }))
798 .count();
799 assert_eq!(imports, 3);
800
801 let results = sync_service
803 .sync_directory(temp_dir.path())
804 .await
805 .expect("Failed to sync directory");
806 let up_to_date = results
807 .iter()
808 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
809 .count();
810 assert_eq!(up_to_date, 3);
811
812 for (file_path, _) in &files {
814 let full_path = temp_dir
815 .path()
816 .join(file_path)
817 .to_string_lossy()
818 .to_string();
819 assert!(results.iter().any(|r| r.filepath() == full_path));
820 }
821 }
822}