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::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11pub struct SyncService<'a> {
13 db_service: &'a mut DatabaseService,
14 workspace_dir: Option<&'a Path>,
15 db_path: Option<std::path::PathBuf>,
16}
17
18impl<'a> SyncService<'a> {
19 pub fn new(db_service: &'a mut DatabaseService) -> Self {
20 Self {
21 db_service,
22 workspace_dir: None,
23 db_path: None,
24 }
25 }
26
27 pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
32 self.workspace_dir = Some(workspace_dir);
33 self.db_path = Some(workspace_dir.join("metis.db"));
35 self
36 }
37
38 fn to_relative_path<P: AsRef<Path>>(&self, absolute_path: P) -> String {
41 if let Some(workspace_dir) = self.workspace_dir {
42 if let Ok(relative) = absolute_path.as_ref().strip_prefix(workspace_dir) {
43 return relative.to_string_lossy().to_string();
44 }
45 }
46 absolute_path.as_ref().to_string_lossy().to_string()
48 }
49
50 fn to_absolute_path(&self, relative_path: &str) -> std::path::PathBuf {
53 if let Some(workspace_dir) = self.workspace_dir {
54 workspace_dir.join(relative_path)
55 } else {
56 std::path::PathBuf::from(relative_path)
58 }
59 }
60
61 pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
64 let path_str = self.to_relative_path(&file_path);
66
67 let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
69 MetisError::ValidationFailed {
70 message: format!("Failed to parse document: {}", e),
71 }
72 })?;
73
74 let file_hash = FilesystemService::compute_file_hash(&file_path)?;
76 let updated_at = FilesystemService::get_file_mtime(&file_path)?;
77 let content = FilesystemService::read_file(&file_path)?;
78
79 let new_doc = self.domain_to_database_model(
81 document_obj.as_ref(),
82 &path_str,
83 file_hash,
84 updated_at,
85 content,
86 )?;
87
88 self.db_service.create_document(new_doc)
90 }
91
92 pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
95 let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
97 MetisError::DocumentNotFound {
98 id: filepath.to_string(),
99 }
100 })?;
101
102 let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
104 message: "Document has no content".to_string(),
105 })?;
106
107 let absolute_path = self.to_absolute_path(filepath);
109
110 FilesystemService::write_file(absolute_path, &content)?;
112
113 Ok(())
114 }
115
116 fn domain_to_database_model(
118 &self,
119 document_obj: &dyn DocumentTrait,
120 filepath: &str,
121 file_hash: String,
122 updated_at: f64,
123 content: String,
124 ) -> Result<NewDocument> {
125 let core = document_obj.core();
126 let phase = document_obj
127 .phase()
128 .map_err(|e| MetisError::ValidationFailed {
129 message: format!("Failed to get document phase: {}", e),
130 })?
131 .to_string();
132
133 let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
135 Self::extract_lineage_from_path(filepath, workspace_dir)
136 } else {
137 (None, None)
138 };
139
140 let final_strategy_id = fs_strategy_id
142 .or_else(|| core.strategy_id.clone())
143 .map(|id| id.to_string());
144 let final_initiative_id = fs_initiative_id
145 .or_else(|| core.initiative_id.clone())
146 .map(|id| id.to_string());
147
148 Ok(NewDocument {
149 filepath: filepath.to_string(),
150 id: document_obj.id().to_string(),
151 title: core.title.clone(),
152 document_type: document_obj.document_type().to_string(),
153 created_at: core.metadata.created_at.timestamp() as f64,
154 updated_at,
155 archived: core.archived,
156 exit_criteria_met: document_obj.exit_criteria_met(),
157 file_hash,
158 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
159 content: Some(content),
160 phase,
161 strategy_id: final_strategy_id,
162 initiative_id: final_initiative_id,
163 short_code: core.metadata.short_code.clone(),
164 })
165 }
166
167 fn extract_lineage_from_path<P: AsRef<Path>>(
170 file_path: P,
171 workspace_dir: &Path,
172 ) -> (Option<DocumentId>, Option<DocumentId>) {
173 let path = file_path.as_ref();
174
175 let relative_path = match path.strip_prefix(workspace_dir) {
177 Ok(rel) => rel,
178 Err(_) => return (None, None),
179 };
180
181 let path_parts: Vec<&str> = relative_path
182 .components()
183 .filter_map(|c| c.as_os_str().to_str())
184 .collect();
185
186 match path_parts.as_slice() {
188 ["strategies", strategy_id, "strategy.md"] => {
190 if strategy_id == &"NULL" {
191 (None, None)
192 } else {
193 (Some(DocumentId::from(*strategy_id)), None)
194 }
195 }
196 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
198 let strat_id = if strategy_id == &"NULL" {
199 None
200 } else {
201 Some(DocumentId::from(*strategy_id))
202 };
203 let init_id = if initiative_id == &"NULL" {
204 None
205 } else {
206 Some(DocumentId::from(*initiative_id))
207 };
208 (strat_id, init_id)
209 }
210 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
212 let strat_id = if strategy_id == &"NULL" {
213 None
214 } else {
215 Some(DocumentId::from(*strategy_id))
216 };
217 let init_id = if initiative_id == &"NULL" {
218 None
219 } else {
220 Some(DocumentId::from(*initiative_id))
221 };
222 (strat_id, init_id)
223 }
224 ["backlog", _] => (None, None),
226 ["adrs", _] => (None, None),
228 ["vision.md"] => (None, None),
230 _ => (None, None),
232 }
233 }
234
235 fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
237 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
239 MetisError::ValidationFailed {
240 message: format!("Failed to read file: {}", e),
241 }
242 })?;
243
244 use gray_matter::{engine::YAML, Matter};
246 let matter = Matter::<YAML>::new();
247 let result = matter.parse(&raw_content);
248
249 if let Some(frontmatter) = result.data {
251 let fm_map = match frontmatter {
252 gray_matter::Pod::Hash(map) => map,
253 _ => {
254 return Err(MetisError::ValidationFailed {
255 message: "Frontmatter must be a hash/map".to_string(),
256 });
257 }
258 };
259
260 if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
261 return Ok(short_code_str.clone());
262 }
263 }
264
265 Err(MetisError::ValidationFailed {
266 message: "Document missing short_code in frontmatter".to_string(),
267 })
268 }
269
270 async fn update_moved_document<P: AsRef<Path>>(
272 &mut self,
273 existing_doc: &Document,
274 new_file_path: P,
275 ) -> Result<()> {
276 self.db_service.delete_document(&existing_doc.filepath)?;
278
279 self.import_from_file(&new_file_path).await?;
281
282 Ok(())
283 }
284
285 async fn resolve_short_code_collisions<P: AsRef<Path>>(
288 &mut self,
289 dir_path: P,
290 ) -> Result<Vec<SyncResult>> {
291 let mut results = Vec::new();
292
293 self.update_counters_from_filesystem(&dir_path)?;
296
297 let files = FilesystemService::find_markdown_files(&dir_path)?;
299 let mut short_code_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
300
301 for file_path in files {
302 match Self::extract_document_short_code(&file_path) {
303 Ok(short_code) => {
304 short_code_map
305 .entry(short_code)
306 .or_default()
307 .push(PathBuf::from(&file_path));
308 }
309 Err(e) => {
310 tracing::warn!("Failed to extract short code from {}: {}", file_path, e);
311 }
312 }
313 }
314
315 let mut collision_groups: Vec<(String, Vec<PathBuf>)> = short_code_map
317 .into_iter()
318 .filter(|(_, paths)| paths.len() > 1)
319 .collect();
320
321 if collision_groups.is_empty() {
322 return Ok(results);
323 }
324
325 for (_, paths) in &mut collision_groups {
327 paths.sort_by(|a, b| {
328 let depth_a = a.components().count();
329 let depth_b = b.components().count();
330 depth_a.cmp(&depth_b).then_with(|| a.cmp(b))
331 });
332 }
333
334 for (old_short_code, mut paths) in collision_groups {
336 tracing::info!(
337 "Detected short code collision for {}: {} files",
338 old_short_code,
339 paths.len()
340 );
341
342 let _keeper = paths.remove(0);
344
345 for path in paths {
346 match self.renumber_document(&path, &old_short_code).await {
347 Ok(new_short_code) => {
348 let relative_path = self.to_relative_path(&path);
349 results.push(SyncResult::Renumbered {
350 filepath: relative_path,
351 old_short_code: old_short_code.clone(),
352 new_short_code,
353 });
354 }
355 Err(e) => {
356 let relative_path = self.to_relative_path(&path);
357 results.push(SyncResult::Error {
358 filepath: relative_path,
359 error: format!("Failed to renumber: {}", e),
360 });
361 }
362 }
363 }
364 }
365
366 Ok(results)
367 }
368
369 async fn renumber_document<P: AsRef<Path>>(
372 &mut self,
373 file_path: P,
374 old_short_code: &str,
375 ) -> Result<String> {
376 let file_path = file_path.as_ref();
377
378 let content = FilesystemService::read_file(file_path)?;
380
381 use gray_matter::{engine::YAML, Matter};
383 let matter = Matter::<YAML>::new();
384 let parsed = matter.parse(&content);
385
386 let doc_type = if let Some(frontmatter) = &parsed.data {
388 if let gray_matter::Pod::Hash(map) = frontmatter {
389 if let Some(gray_matter::Pod::String(level_str)) = map.get("level") {
390 level_str.as_str()
391 } else {
392 return Err(MetisError::ValidationFailed {
393 message: "Document missing 'level' in frontmatter".to_string(),
394 });
395 }
396 } else {
397 return Err(MetisError::ValidationFailed {
398 message: "Frontmatter must be a hash/map".to_string(),
399 });
400 }
401 } else {
402 return Err(MetisError::ValidationFailed {
403 message: "Document missing frontmatter".to_string(),
404 });
405 };
406
407 let db_path_str = self
409 .db_path
410 .as_ref()
411 .ok_or_else(|| MetisError::ValidationFailed {
412 message: "Database path not set".to_string(),
413 })?
414 .to_string_lossy()
415 .to_string();
416
417 use crate::dal::database::configuration_repository::ConfigurationRepository;
418 use diesel::sqlite::SqliteConnection;
419 use diesel::Connection;
420
421 let mut config_repo = ConfigurationRepository::new(
422 SqliteConnection::establish(&db_path_str).map_err(|e| {
423 MetisError::ConfigurationError(
424 crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
425 )
426 })?,
427 );
428
429 let new_short_code = config_repo.generate_short_code(doc_type)?;
430
431 let short_code_pattern = regex::Regex::new(r#"(?m)^short_code:\s*['"]?([^'"]+)['"]?$"#)
433 .map_err(|e| MetisError::ValidationFailed {
434 message: format!("Failed to compile regex: {}", e),
435 })?;
436
437 let updated_content = short_code_pattern.replace(
438 &content,
439 format!("short_code: \"{}\"", new_short_code)
440 ).to_string();
441
442 self.update_sibling_references(file_path, old_short_code, &new_short_code)
444 .await?;
445
446 FilesystemService::write_file(file_path, &updated_content)?;
448
449 let old_suffix = old_short_code.rsplit('-').take(2).collect::<Vec<_>>();
452 let old_suffix = format!("{}-{}", old_suffix[1], old_suffix[0]);
453 let new_suffix = new_short_code.rsplit('-').take(2).collect::<Vec<_>>();
454 let new_suffix = format!("{}-{}", new_suffix[1], new_suffix[0]);
455
456 let file_name = file_path
457 .file_name()
458 .and_then(|n| n.to_str())
459 .ok_or_else(|| MetisError::ValidationFailed {
460 message: "Invalid file path".to_string(),
461 })?;
462
463 if file_name.contains(&old_suffix) {
464 let new_file_name = file_name.replace(&old_suffix, &new_suffix);
465 let new_path = file_path.with_file_name(new_file_name);
466 std::fs::rename(file_path, &new_path)?;
467
468 tracing::info!(
469 "Renumbered {} from {} to {}",
470 file_path.display(),
471 old_short_code,
472 new_short_code
473 );
474 }
475
476 Ok(new_short_code)
477 }
478
479 async fn update_sibling_references<P: AsRef<Path>>(
481 &mut self,
482 file_path: P,
483 old_short_code: &str,
484 new_short_code: &str,
485 ) -> Result<()> {
486 let file_path = file_path.as_ref();
487
488 let parent_dir = file_path.parent().ok_or_else(|| MetisError::ValidationFailed {
490 message: "File has no parent directory".to_string(),
491 })?;
492
493 let siblings = FilesystemService::find_markdown_files(parent_dir)?;
495
496 let pattern_str = format!(r"\b{}\b", regex::escape(old_short_code));
498 let pattern = regex::Regex::new(&pattern_str)
499 .map_err(|e| MetisError::ValidationFailed {
500 message: format!("Failed to compile regex: {}", e),
501 })?;
502
503 for sibling_path in siblings {
505 let sibling_path_buf = PathBuf::from(&sibling_path);
506 if sibling_path_buf == file_path {
507 continue; }
509
510 match FilesystemService::read_file(&sibling_path) {
511 Ok(content) => {
512 if pattern.is_match(&content) {
513 let updated_content = pattern.replace_all(&content, new_short_code);
514 if let Err(e) = FilesystemService::write_file(&sibling_path, &updated_content) {
515 tracing::warn!(
516 "Failed to update references in {}: {}",
517 sibling_path,
518 e
519 );
520 } else {
521 tracing::info!(
522 "Updated references in {} from {} to {}",
523 sibling_path,
524 old_short_code,
525 new_short_code
526 );
527 }
528 }
529 }
530 Err(e) => {
531 tracing::warn!("Failed to read sibling file {}: {}", sibling_path, e);
532 }
533 }
534 }
535
536 Ok(())
537 }
538
539 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
541 let relative_path_str = self.to_relative_path(&file_path);
543
544 let file_exists = FilesystemService::file_exists(&file_path);
546
547 let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
549
550 match (file_exists, db_doc_by_path) {
551 (true, None) => {
553 let short_code = Self::extract_document_short_code(&file_path)?;
555
556 if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
558 let old_path = existing_doc.filepath.clone();
560 self.update_moved_document(&existing_doc, &file_path)
561 .await?;
562 Ok(SyncResult::Moved {
563 from: old_path,
564 to: relative_path_str,
565 })
566 } else {
567 self.import_from_file(&file_path).await?;
569 Ok(SyncResult::Imported {
570 filepath: relative_path_str,
571 })
572 }
573 }
574
575 (false, Some(_)) => {
577 self.db_service.delete_document(&relative_path_str)?;
578 Ok(SyncResult::Deleted {
579 filepath: relative_path_str,
580 })
581 }
582
583 (true, Some(db_doc)) => {
585 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
586
587 if db_doc.file_hash != current_hash {
588 self.db_service.delete_document(&relative_path_str)?;
590 self.import_from_file(&file_path).await?;
591 Ok(SyncResult::Updated {
592 filepath: relative_path_str,
593 })
594 } else {
595 Ok(SyncResult::UpToDate {
596 filepath: relative_path_str,
597 })
598 }
599 }
600
601 (false, None) => Ok(SyncResult::NotFound {
603 filepath: relative_path_str,
604 }),
605 }
606 }
607
608 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
610 let mut results = Vec::new();
611
612 let collision_results = self.resolve_short_code_collisions(&dir_path).await?;
615 results.extend(collision_results);
616
617 let files = FilesystemService::find_markdown_files(&dir_path)?;
620
621 for file_path in files {
623 match self.sync_file(&file_path).await {
624 Ok(result) => results.push(result),
625 Err(e) => results.push(SyncResult::Error {
626 filepath: file_path,
627 error: e.to_string(),
628 }),
629 }
630 }
631
632 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
634 for (_, relative_filepath) in db_pairs {
635 let absolute_path = self.to_absolute_path(&relative_filepath);
637 if !FilesystemService::file_exists(&absolute_path) {
638 match self.db_service.delete_document(&relative_filepath) {
640 Ok(_) => results.push(SyncResult::Deleted {
641 filepath: relative_filepath,
642 }),
643 Err(e) => results.push(SyncResult::Error {
644 filepath: relative_filepath,
645 error: e.to_string(),
646 }),
647 }
648 }
649 }
650
651 self.update_counters_from_filesystem(&dir_path)?;
653
654 Ok(results)
655 }
656
657 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
659 let mut issues = Vec::new();
660
661 let files = FilesystemService::find_markdown_files(&dir_path)?;
663
664 for file_path in &files {
666 let relative_path = self.to_relative_path(file_path);
668
669 if let Some(db_doc) = self.db_service.find_by_filepath(&relative_path)? {
670 let current_hash = FilesystemService::compute_file_hash(file_path)?;
671 if db_doc.file_hash != current_hash {
672 issues.push(SyncIssue::OutOfSync {
673 filepath: relative_path,
674 reason: "File hash mismatch".to_string(),
675 });
676 }
677 } else {
678 issues.push(SyncIssue::MissingFromDatabase {
679 filepath: relative_path,
680 });
681 }
682 }
683
684 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
686 for (_, relative_filepath) in db_pairs {
687 let absolute_path = self.to_absolute_path(&relative_filepath);
689 let absolute_str = absolute_path.to_string_lossy().to_string();
690 if !files.contains(&absolute_str) && !FilesystemService::file_exists(&absolute_path) {
691 issues.push(SyncIssue::MissingFromFilesystem {
692 filepath: relative_filepath,
693 });
694 }
695 }
696
697 Ok(issues)
698 }
699
700 fn update_counters_from_filesystem<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<()> {
703 let counters = self.recover_counters_from_filesystem(dir_path)?;
704
705 let db_path_str = self
706 .db_path
707 .as_ref()
708 .ok_or_else(|| MetisError::ValidationFailed {
709 message: "Database path not set".to_string(),
710 })?
711 .to_string_lossy()
712 .to_string();
713
714 use crate::dal::database::configuration_repository::ConfigurationRepository;
715 use diesel::sqlite::SqliteConnection;
716 use diesel::Connection;
717
718 let mut config_repo = ConfigurationRepository::new(
719 SqliteConnection::establish(&db_path_str).map_err(|e| {
720 MetisError::ConfigurationError(
721 crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
722 )
723 })?,
724 );
725
726 for (doc_type, max_counter) in counters {
727 config_repo.set_counter_if_lower(&doc_type, max_counter)?;
729 }
730
731 Ok(())
732 }
733
734 pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
742 &self,
743 dir_path: P,
744 ) -> Result<std::collections::HashMap<String, u32>> {
745 use gray_matter::{engine::YAML, Matter};
746 use std::collections::HashMap;
747
748 let mut counters: HashMap<String, u32> = HashMap::new();
749 let mut skipped_files = 0;
750 let mut invalid_short_codes = 0;
751
752 let dir_path = dir_path.as_ref();
753
754 if !dir_path.exists() {
756 tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
757 return Ok(counters);
758 }
759
760 let files = FilesystemService::find_markdown_files(&dir_path)?;
762 tracing::info!("Counter recovery: scanning {} markdown files", files.len());
763
764 for file_path in files {
765 let content = match std::fs::read_to_string(&file_path) {
767 Ok(c) => c,
768 Err(e) => {
769 tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
770 skipped_files += 1;
771 continue;
772 }
773 };
774
775 let matter = Matter::<YAML>::new();
777 let result = matter.parse(&content);
778
779 if let Some(frontmatter) = result.data {
780 let fm_map = match frontmatter {
781 gray_matter::Pod::Hash(map) => map,
782 _ => continue,
783 };
784
785 if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
787 if !Self::is_valid_short_code_format(short_code) {
789 tracing::warn!(
790 "Counter recovery: invalid short code '{}' in {}",
791 short_code,
792 file_path
793 );
794 invalid_short_codes += 1;
795 continue;
796 }
797
798 if let Some((_, type_and_num)) = short_code.split_once('-') {
800 if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
801 let doc_type = match type_letter {
802 "V" => "vision",
803 "S" => "strategy",
804 "I" => "initiative",
805 "T" => "task",
806 "A" => "adr",
807 _ => continue,
808 };
809
810 match num_str.parse::<u32>() {
812 Ok(num) if num <= 1_000_000 => {
813 counters
814 .entry(doc_type.to_string())
815 .and_modify(|max| {
816 if num > *max {
817 *max = num;
818 }
819 })
820 .or_insert(num);
821 }
822 Ok(num) => {
823 tracing::warn!(
824 "Counter recovery: suspiciously large counter {} in {}, skipping",
825 num,
826 file_path
827 );
828 }
829 Err(e) => {
830 tracing::warn!(
831 "Counter recovery: invalid number '{}' in {}: {}",
832 num_str,
833 file_path,
834 e
835 );
836 invalid_short_codes += 1;
837 }
838 }
839 }
840 }
841 }
842 }
843 }
844
845 if skipped_files > 0 || invalid_short_codes > 0 {
846 tracing::warn!(
847 "Counter recovery: {} files skipped, {} invalid short codes",
848 skipped_files,
849 invalid_short_codes
850 );
851 }
852
853 tracing::info!("Recovered counters: {:?}", counters);
854 Ok(counters)
855 }
856
857 fn is_valid_short_code_format(short_code: &str) -> bool {
859 let parts: Vec<&str> = short_code.split('-').collect();
860 if parts.len() != 3 {
861 return false;
862 }
863
864 let prefix = parts[0];
865 let type_letter = parts[1];
866 let number = parts[2];
867
868 if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
870 return false;
871 }
872
873 if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
875 return false;
876 }
877
878 number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
880 }
881}
882
883#[derive(Debug, Clone, PartialEq)]
885pub enum SyncResult {
886 Imported { filepath: String },
887 Updated { filepath: String },
888 Deleted { filepath: String },
889 UpToDate { filepath: String },
890 NotFound { filepath: String },
891 Error { filepath: String, error: String },
892 Moved { from: String, to: String },
893 Renumbered {
894 filepath: String,
895 old_short_code: String,
896 new_short_code: String
897 },
898}
899
900impl SyncResult {
901 pub fn filepath(&self) -> &str {
903 match self {
904 SyncResult::Imported { filepath }
905 | SyncResult::Updated { filepath }
906 | SyncResult::Deleted { filepath }
907 | SyncResult::UpToDate { filepath }
908 | SyncResult::NotFound { filepath }
909 | SyncResult::Renumbered { filepath, .. }
910 | SyncResult::Error { filepath, .. } => filepath,
911 SyncResult::Moved { to, .. } => to,
912 }
913 }
914
915 pub fn is_change(&self) -> bool {
917 matches!(
918 self,
919 SyncResult::Imported { .. }
920 | SyncResult::Updated { .. }
921 | SyncResult::Deleted { .. }
922 | SyncResult::Moved { .. }
923 | SyncResult::Renumbered { .. }
924 )
925 }
926
927 pub fn is_error(&self) -> bool {
929 matches!(self, SyncResult::Error { .. })
930 }
931}
932
933#[derive(Debug, Clone)]
935pub enum SyncIssue {
936 MissingFromDatabase { filepath: String },
937 MissingFromFilesystem { filepath: String },
938 OutOfSync { filepath: String, reason: String },
939}
940
941#[cfg(test)]
942mod tests {
943 use super::*;
944 use crate::dal::Database;
945 use tempfile::tempdir;
946
947 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
948 let temp_dir = tempdir().expect("Failed to create temp dir");
949 let db = Database::new(":memory:").expect("Failed to create test database");
950 let db_service = DatabaseService::new(db.into_repository());
951 (temp_dir, db_service)
952 }
953
954 fn create_test_document_content() -> String {
955 "---\n".to_string()
956 + "id: test-document\n"
957 + "title: Test Document\n"
958 + "level: vision\n"
959 + "created_at: \"2021-01-01T00:00:00Z\"\n"
960 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
961 + "archived: false\n"
962 + "short_code: TEST-V-9003\n"
963 + "exit_criteria_met: false\n"
964 + "tags:\n"
965 + " - \"#phase/draft\"\n"
966 + "---\n\n"
967 + "# Test Document\n\n"
968 + "Test content.\n"
969 }
970
971 #[tokio::test]
972 async fn test_import_from_file() {
973 let (temp_dir, mut db_service) = setup_services();
974 let mut sync_service = SyncService::new(&mut db_service);
975
976 let file_path = temp_dir.path().join("test.md");
977 FilesystemService::write_file(&file_path, &create_test_document_content())
978 .expect("Failed to write file");
979
980 let doc = sync_service
981 .import_from_file(&file_path)
982 .await
983 .expect("Failed to import");
984 assert_eq!(doc.title, "Test Document");
985 assert_eq!(doc.document_type, "vision");
986
987 assert!(db_service
989 .document_exists(&file_path.to_string_lossy())
990 .expect("Failed to check"));
991 }
992
993 #[tokio::test]
994 async fn test_sync_file_operations() {
995 let (temp_dir, mut db_service) = setup_services();
996 let mut sync_service = SyncService::new(&mut db_service);
997
998 let file_path = temp_dir.path().join("test.md");
999 let path_str = file_path.to_string_lossy().to_string();
1000
1001 let result = sync_service
1003 .sync_file(&file_path)
1004 .await
1005 .expect("Failed to sync");
1006 assert_eq!(
1007 result,
1008 SyncResult::NotFound {
1009 filepath: path_str.clone()
1010 }
1011 );
1012
1013 FilesystemService::write_file(&file_path, &create_test_document_content())
1015 .expect("Failed to write file");
1016
1017 let result = sync_service
1018 .sync_file(&file_path)
1019 .await
1020 .expect("Failed to sync");
1021 assert_eq!(
1022 result,
1023 SyncResult::Imported {
1024 filepath: path_str.clone()
1025 }
1026 );
1027
1028 let result = sync_service
1030 .sync_file(&file_path)
1031 .await
1032 .expect("Failed to sync");
1033 assert_eq!(
1034 result,
1035 SyncResult::UpToDate {
1036 filepath: path_str.clone()
1037 }
1038 );
1039
1040 let modified_content =
1042 &create_test_document_content().replace("Test content.", "Modified content.");
1043 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
1044
1045 let result = sync_service
1046 .sync_file(&file_path)
1047 .await
1048 .expect("Failed to sync");
1049 assert_eq!(
1050 result,
1051 SyncResult::Updated {
1052 filepath: path_str.clone()
1053 }
1054 );
1055
1056 FilesystemService::delete_file(&file_path).expect("Failed to delete");
1058
1059 let result = sync_service
1060 .sync_file(&file_path)
1061 .await
1062 .expect("Failed to sync");
1063 assert_eq!(
1064 result,
1065 SyncResult::Deleted {
1066 filepath: path_str.clone()
1067 }
1068 );
1069
1070 assert!(!db_service
1072 .document_exists(&path_str)
1073 .expect("Failed to check"));
1074 }
1075
1076 #[tokio::test]
1077 async fn test_sync_directory() {
1078 let (temp_dir, mut db_service) = setup_services();
1079 let mut sync_service = SyncService::new(&mut db_service);
1080
1081 let files = vec![
1083 ("doc1.md", "test-1"),
1084 ("subdir/doc2.md", "test-2"),
1085 ("subdir/nested/doc3.md", "test-3"),
1086 ];
1087
1088 for (i, (file_path, id)) in files.iter().enumerate() {
1089 let full_path = temp_dir.path().join(file_path);
1090 let content = &create_test_document_content()
1091 .replace("Test Document", &format!("Test Document {}", id))
1092 .replace("test-document", id)
1093 .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
1094 FilesystemService::write_file(&full_path, content).expect("Failed to write");
1095 }
1096
1097 let results = sync_service
1099 .sync_directory(temp_dir.path())
1100 .await
1101 .expect("Failed to sync directory");
1102
1103 let imports = results
1105 .iter()
1106 .filter(|r| matches!(r, SyncResult::Imported { .. }))
1107 .count();
1108 assert_eq!(imports, 3);
1109
1110 let results = sync_service
1112 .sync_directory(temp_dir.path())
1113 .await
1114 .expect("Failed to sync directory");
1115 let up_to_date = results
1116 .iter()
1117 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
1118 .count();
1119 assert_eq!(up_to_date, 3);
1120
1121 for (file_path, _) in &files {
1123 let full_path = temp_dir
1124 .path()
1125 .join(file_path)
1126 .to_string_lossy()
1127 .to_string();
1128 assert!(results.iter().any(|r| r.filepath() == full_path));
1129 }
1130 }
1131}