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, is_backlog) =
135 if let Some(workspace_dir) = self.workspace_dir {
136 let (strat, init) = Self::extract_lineage_from_path(filepath, workspace_dir);
137 let is_backlog = Self::is_backlog_path(filepath, workspace_dir);
138 (strat, init, is_backlog)
139 } else {
140 (None, None, false)
141 };
142
143 let final_strategy_id = fs_strategy_id
146 .or_else(|| core.strategy_id.clone())
147 .map(|id| id.to_string());
148 let final_initiative_id = if is_backlog {
149 None } else {
151 fs_initiative_id
152 .or_else(|| core.initiative_id.clone())
153 .map(|id| id.to_string())
154 };
155
156 Ok(NewDocument {
157 filepath: filepath.to_string(),
158 id: document_obj.id().to_string(),
159 title: core.title.clone(),
160 document_type: document_obj.document_type().to_string(),
161 created_at: core.metadata.created_at.timestamp() as f64,
162 updated_at,
163 archived: core.archived,
164 exit_criteria_met: document_obj.exit_criteria_met(),
165 file_hash,
166 frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
167 content: Some(content),
168 phase,
169 strategy_id: final_strategy_id,
170 initiative_id: final_initiative_id,
171 short_code: core.metadata.short_code.clone(),
172 })
173 }
174
175 fn extract_lineage_from_path<P: AsRef<Path>>(
178 file_path: P,
179 workspace_dir: &Path,
180 ) -> (Option<DocumentId>, Option<DocumentId>) {
181 let path = file_path.as_ref();
182
183 let relative_path = match path.strip_prefix(workspace_dir) {
185 Ok(rel) => rel,
186 Err(_) => return (None, None),
187 };
188
189 let path_parts: Vec<&str> = relative_path
190 .components()
191 .filter_map(|c| c.as_os_str().to_str())
192 .collect();
193
194 match path_parts.as_slice() {
196 ["strategies", strategy_id, "strategy.md"] => {
198 if strategy_id == &"NULL" {
199 (None, None)
200 } else {
201 (Some(DocumentId::from(*strategy_id)), None)
202 }
203 }
204 ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
206 let strat_id = if strategy_id == &"NULL" {
207 None
208 } else {
209 Some(DocumentId::from(*strategy_id))
210 };
211 let init_id = if initiative_id == &"NULL" {
212 None
213 } else {
214 Some(DocumentId::from(*initiative_id))
215 };
216 (strat_id, init_id)
217 }
218 ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
220 let strat_id = if strategy_id == &"NULL" {
221 None
222 } else {
223 Some(DocumentId::from(*strategy_id))
224 };
225 let init_id = if initiative_id == &"NULL" {
226 None
227 } else {
228 Some(DocumentId::from(*initiative_id))
229 };
230 (strat_id, init_id)
231 }
232 ["backlog", _] => (None, None),
234 ["backlog", _, _] => (None, None),
236 ["adrs", _] => (None, None),
238 ["vision.md"] => (None, None),
240 _ => (None, None),
242 }
243 }
244
245 fn is_backlog_path<P: AsRef<Path>>(file_path: P, workspace_dir: &Path) -> bool {
248 let path = file_path.as_ref();
249
250 let relative_path = match path.strip_prefix(workspace_dir) {
252 Ok(rel) => rel,
253 Err(_) => return false,
254 };
255
256 let components: Vec<&str> = relative_path
258 .components()
259 .filter_map(|c| c.as_os_str().to_str())
260 .collect();
261
262 matches!(components.first(), Some(&"backlog"))
264 }
265
266 fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
268 let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
270 MetisError::ValidationFailed {
271 message: format!("Failed to read file: {}", e),
272 }
273 })?;
274
275 use gray_matter::{engine::YAML, Matter};
277 let matter = Matter::<YAML>::new();
278 let result = matter.parse(&raw_content);
279
280 if let Some(frontmatter) = result.data {
282 let fm_map = match frontmatter {
283 gray_matter::Pod::Hash(map) => map,
284 _ => {
285 return Err(MetisError::ValidationFailed {
286 message: "Frontmatter must be a hash/map".to_string(),
287 });
288 }
289 };
290
291 if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
292 return Ok(short_code_str.clone());
293 }
294 }
295
296 Err(MetisError::ValidationFailed {
297 message: "Document missing short_code in frontmatter".to_string(),
298 })
299 }
300
301 async fn update_moved_document<P: AsRef<Path>>(
303 &mut self,
304 existing_doc: &Document,
305 new_file_path: P,
306 ) -> Result<()> {
307 self.db_service.delete_document(&existing_doc.filepath)?;
309
310 self.import_from_file(&new_file_path).await?;
312
313 Ok(())
314 }
315
316 async fn resolve_short_code_collisions<P: AsRef<Path>>(
319 &mut self,
320 dir_path: P,
321 ) -> Result<Vec<SyncResult>> {
322 let mut results = Vec::new();
323
324 self.update_counters_from_filesystem(&dir_path)?;
327
328 let files = FilesystemService::find_markdown_files(&dir_path)?;
330 let mut short_code_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
331
332 for file_path in files {
333 match Self::extract_document_short_code(&file_path) {
334 Ok(short_code) => {
335 short_code_map
336 .entry(short_code)
337 .or_default()
338 .push(PathBuf::from(&file_path));
339 }
340 Err(e) => {
341 tracing::warn!("Failed to extract short code from {}: {}", file_path, e);
342 }
343 }
344 }
345
346 let mut collision_groups: Vec<(String, Vec<PathBuf>)> = short_code_map
348 .into_iter()
349 .filter(|(_, paths)| paths.len() > 1)
350 .collect();
351
352 if collision_groups.is_empty() {
353 return Ok(results);
354 }
355
356 for (_, paths) in &mut collision_groups {
358 paths.sort_by(|a, b| {
359 let depth_a = a.components().count();
360 let depth_b = b.components().count();
361 depth_a.cmp(&depth_b).then_with(|| a.cmp(b))
362 });
363 }
364
365 for (old_short_code, mut paths) in collision_groups {
367 tracing::info!(
368 "Detected short code collision for {}: {} files",
369 old_short_code,
370 paths.len()
371 );
372
373 let _keeper = paths.remove(0);
375
376 for path in paths {
377 match self.renumber_document(&path, &old_short_code).await {
378 Ok(new_short_code) => {
379 let relative_path = self.to_relative_path(&path);
380 results.push(SyncResult::Renumbered {
381 filepath: relative_path,
382 old_short_code: old_short_code.clone(),
383 new_short_code,
384 });
385 }
386 Err(e) => {
387 let relative_path = self.to_relative_path(&path);
388 results.push(SyncResult::Error {
389 filepath: relative_path,
390 error: format!("Failed to renumber: {}", e),
391 });
392 }
393 }
394 }
395 }
396
397 Ok(results)
398 }
399
400 async fn renumber_document<P: AsRef<Path>>(
403 &mut self,
404 file_path: P,
405 old_short_code: &str,
406 ) -> Result<String> {
407 let file_path = file_path.as_ref();
408
409 let content = FilesystemService::read_file(file_path)?;
411
412 use gray_matter::{engine::YAML, Matter};
414 let matter = Matter::<YAML>::new();
415 let parsed = matter.parse(&content);
416
417 let doc_type = if let Some(frontmatter) = &parsed.data {
419 if let gray_matter::Pod::Hash(map) = frontmatter {
420 if let Some(gray_matter::Pod::String(level_str)) = map.get("level") {
421 level_str.as_str()
422 } else {
423 return Err(MetisError::ValidationFailed {
424 message: "Document missing 'level' in frontmatter".to_string(),
425 });
426 }
427 } else {
428 return Err(MetisError::ValidationFailed {
429 message: "Frontmatter must be a hash/map".to_string(),
430 });
431 }
432 } else {
433 return Err(MetisError::ValidationFailed {
434 message: "Document missing frontmatter".to_string(),
435 });
436 };
437
438 let db_path_str = self
440 .db_path
441 .as_ref()
442 .ok_or_else(|| MetisError::ValidationFailed {
443 message: "Database path not set".to_string(),
444 })?
445 .to_string_lossy()
446 .to_string();
447
448 use crate::dal::database::configuration_repository::ConfigurationRepository;
449 use diesel::sqlite::SqliteConnection;
450 use diesel::Connection;
451
452 let mut config_repo = ConfigurationRepository::new(
453 SqliteConnection::establish(&db_path_str).map_err(|e| {
454 MetisError::ConfigurationError(
455 crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
456 )
457 })?,
458 );
459
460 let new_short_code = config_repo.generate_short_code(doc_type)?;
461
462 let short_code_pattern = regex::Regex::new(r#"(?m)^short_code:\s*['"]?([^'"]+)['"]?$"#)
464 .map_err(|e| MetisError::ValidationFailed {
465 message: format!("Failed to compile regex: {}", e),
466 })?;
467
468 let updated_content = short_code_pattern.replace(
469 &content,
470 format!("short_code: \"{}\"", new_short_code)
471 ).to_string();
472
473 self.update_sibling_references(file_path, old_short_code, &new_short_code)
475 .await?;
476
477 FilesystemService::write_file(file_path, &updated_content)?;
479
480 let old_suffix = old_short_code.rsplit('-').take(2).collect::<Vec<_>>();
483 let old_suffix = format!("{}-{}", old_suffix[1], old_suffix[0]);
484 let new_suffix = new_short_code.rsplit('-').take(2).collect::<Vec<_>>();
485 let new_suffix = format!("{}-{}", new_suffix[1], new_suffix[0]);
486
487 let file_name = file_path
488 .file_name()
489 .and_then(|n| n.to_str())
490 .ok_or_else(|| MetisError::ValidationFailed {
491 message: "Invalid file path".to_string(),
492 })?;
493
494 if file_name.contains(&old_suffix) {
495 let new_file_name = file_name.replace(&old_suffix, &new_suffix);
496 let new_path = file_path.with_file_name(new_file_name);
497 std::fs::rename(file_path, &new_path)?;
498
499 tracing::info!(
500 "Renumbered {} from {} to {}",
501 file_path.display(),
502 old_short_code,
503 new_short_code
504 );
505 }
506
507 Ok(new_short_code)
508 }
509
510 async fn update_sibling_references<P: AsRef<Path>>(
512 &mut self,
513 file_path: P,
514 old_short_code: &str,
515 new_short_code: &str,
516 ) -> Result<()> {
517 let file_path = file_path.as_ref();
518
519 let parent_dir = file_path.parent().ok_or_else(|| MetisError::ValidationFailed {
521 message: "File has no parent directory".to_string(),
522 })?;
523
524 let siblings = FilesystemService::find_markdown_files(parent_dir)?;
526
527 let pattern_str = format!(r"\b{}\b", regex::escape(old_short_code));
529 let pattern = regex::Regex::new(&pattern_str)
530 .map_err(|e| MetisError::ValidationFailed {
531 message: format!("Failed to compile regex: {}", e),
532 })?;
533
534 for sibling_path in siblings {
536 let sibling_path_buf = PathBuf::from(&sibling_path);
537 if sibling_path_buf == file_path {
538 continue; }
540
541 match FilesystemService::read_file(&sibling_path) {
542 Ok(content) => {
543 if pattern.is_match(&content) {
544 let updated_content = pattern.replace_all(&content, new_short_code);
545 if let Err(e) = FilesystemService::write_file(&sibling_path, &updated_content) {
546 tracing::warn!(
547 "Failed to update references in {}: {}",
548 sibling_path,
549 e
550 );
551 } else {
552 tracing::info!(
553 "Updated references in {} from {} to {}",
554 sibling_path,
555 old_short_code,
556 new_short_code
557 );
558 }
559 }
560 }
561 Err(e) => {
562 tracing::warn!("Failed to read sibling file {}: {}", sibling_path, e);
563 }
564 }
565 }
566
567 Ok(())
568 }
569
570 pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
572 let relative_path_str = self.to_relative_path(&file_path);
574
575 let file_exists = FilesystemService::file_exists(&file_path);
577
578 let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
580
581 match (file_exists, db_doc_by_path) {
582 (true, None) => {
584 let short_code = Self::extract_document_short_code(&file_path)?;
586
587 if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
589 let old_path = existing_doc.filepath.clone();
591 self.update_moved_document(&existing_doc, &file_path)
592 .await?;
593 Ok(SyncResult::Moved {
594 from: old_path,
595 to: relative_path_str,
596 })
597 } else {
598 self.import_from_file(&file_path).await?;
600 Ok(SyncResult::Imported {
601 filepath: relative_path_str,
602 })
603 }
604 }
605
606 (false, Some(_)) => {
608 self.db_service.delete_document(&relative_path_str)?;
609 Ok(SyncResult::Deleted {
610 filepath: relative_path_str,
611 })
612 }
613
614 (true, Some(db_doc)) => {
616 let current_hash = FilesystemService::compute_file_hash(&file_path)?;
617
618 if db_doc.file_hash != current_hash {
619 self.db_service.delete_document(&relative_path_str)?;
621 self.import_from_file(&file_path).await?;
622 Ok(SyncResult::Updated {
623 filepath: relative_path_str,
624 })
625 } else {
626 Ok(SyncResult::UpToDate {
627 filepath: relative_path_str,
628 })
629 }
630 }
631
632 (false, None) => Ok(SyncResult::NotFound {
634 filepath: relative_path_str,
635 }),
636 }
637 }
638
639 pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
641 let mut results = Vec::new();
642
643 let collision_results = self.resolve_short_code_collisions(&dir_path).await?;
646 results.extend(collision_results);
647
648 let files = FilesystemService::find_markdown_files(&dir_path)?;
651
652 for file_path in files {
654 match self.sync_file(&file_path).await {
655 Ok(result) => results.push(result),
656 Err(e) => results.push(SyncResult::Error {
657 filepath: file_path,
658 error: e.to_string(),
659 }),
660 }
661 }
662
663 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
665 for (_, relative_filepath) in db_pairs {
666 let absolute_path = self.to_absolute_path(&relative_filepath);
668 if !FilesystemService::file_exists(&absolute_path) {
669 match self.db_service.delete_document(&relative_filepath) {
671 Ok(_) => results.push(SyncResult::Deleted {
672 filepath: relative_filepath,
673 }),
674 Err(e) => results.push(SyncResult::Error {
675 filepath: relative_filepath,
676 error: e.to_string(),
677 }),
678 }
679 }
680 }
681
682 self.update_counters_from_filesystem(&dir_path)?;
684
685 Ok(results)
686 }
687
688 pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
690 let mut issues = Vec::new();
691
692 let files = FilesystemService::find_markdown_files(&dir_path)?;
694
695 for file_path in &files {
697 let relative_path = self.to_relative_path(file_path);
699
700 if let Some(db_doc) = self.db_service.find_by_filepath(&relative_path)? {
701 let current_hash = FilesystemService::compute_file_hash(file_path)?;
702 if db_doc.file_hash != current_hash {
703 issues.push(SyncIssue::OutOfSync {
704 filepath: relative_path,
705 reason: "File hash mismatch".to_string(),
706 });
707 }
708 } else {
709 issues.push(SyncIssue::MissingFromDatabase {
710 filepath: relative_path,
711 });
712 }
713 }
714
715 let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
717 for (_, relative_filepath) in db_pairs {
718 let absolute_path = self.to_absolute_path(&relative_filepath);
720 let absolute_str = absolute_path.to_string_lossy().to_string();
721 if !files.contains(&absolute_str) && !FilesystemService::file_exists(&absolute_path) {
722 issues.push(SyncIssue::MissingFromFilesystem {
723 filepath: relative_filepath,
724 });
725 }
726 }
727
728 Ok(issues)
729 }
730
731 fn update_counters_from_filesystem<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<()> {
734 let counters = self.recover_counters_from_filesystem(dir_path)?;
735
736 let db_path_str = self
737 .db_path
738 .as_ref()
739 .ok_or_else(|| MetisError::ValidationFailed {
740 message: "Database path not set".to_string(),
741 })?
742 .to_string_lossy()
743 .to_string();
744
745 use crate::dal::database::configuration_repository::ConfigurationRepository;
746 use diesel::sqlite::SqliteConnection;
747 use diesel::Connection;
748
749 let mut config_repo = ConfigurationRepository::new(
750 SqliteConnection::establish(&db_path_str).map_err(|e| {
751 MetisError::ConfigurationError(
752 crate::domain::configuration::ConfigurationError::InvalidValue(e.to_string()),
753 )
754 })?,
755 );
756
757 for (doc_type, max_counter) in counters {
758 config_repo.set_counter_if_lower(&doc_type, max_counter)?;
760 }
761
762 Ok(())
763 }
764
765 pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
773 &self,
774 dir_path: P,
775 ) -> Result<std::collections::HashMap<String, u32>> {
776 use gray_matter::{engine::YAML, Matter};
777 use std::collections::HashMap;
778
779 let mut counters: HashMap<String, u32> = HashMap::new();
780 let mut skipped_files = 0;
781 let mut invalid_short_codes = 0;
782
783 let dir_path = dir_path.as_ref();
784
785 if !dir_path.exists() {
787 tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
788 return Ok(counters);
789 }
790
791 let files = FilesystemService::find_markdown_files(&dir_path)?;
793 tracing::info!("Counter recovery: scanning {} markdown files", files.len());
794
795 for file_path in files {
796 let content = match std::fs::read_to_string(&file_path) {
798 Ok(c) => c,
799 Err(e) => {
800 tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
801 skipped_files += 1;
802 continue;
803 }
804 };
805
806 let matter = Matter::<YAML>::new();
808 let result = matter.parse(&content);
809
810 if let Some(frontmatter) = result.data {
811 let fm_map = match frontmatter {
812 gray_matter::Pod::Hash(map) => map,
813 _ => continue,
814 };
815
816 if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
818 if !Self::is_valid_short_code_format(short_code) {
820 tracing::warn!(
821 "Counter recovery: invalid short code '{}' in {}",
822 short_code,
823 file_path
824 );
825 invalid_short_codes += 1;
826 continue;
827 }
828
829 if let Some((_, type_and_num)) = short_code.split_once('-') {
831 if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
832 let doc_type = match type_letter {
833 "V" => "vision",
834 "S" => "strategy",
835 "I" => "initiative",
836 "T" => "task",
837 "A" => "adr",
838 _ => continue,
839 };
840
841 match num_str.parse::<u32>() {
843 Ok(num) if num <= 1_000_000 => {
844 counters
845 .entry(doc_type.to_string())
846 .and_modify(|max| {
847 if num > *max {
848 *max = num;
849 }
850 })
851 .or_insert(num);
852 }
853 Ok(num) => {
854 tracing::warn!(
855 "Counter recovery: suspiciously large counter {} in {}, skipping",
856 num,
857 file_path
858 );
859 }
860 Err(e) => {
861 tracing::warn!(
862 "Counter recovery: invalid number '{}' in {}: {}",
863 num_str,
864 file_path,
865 e
866 );
867 invalid_short_codes += 1;
868 }
869 }
870 }
871 }
872 }
873 }
874 }
875
876 if skipped_files > 0 || invalid_short_codes > 0 {
877 tracing::warn!(
878 "Counter recovery: {} files skipped, {} invalid short codes",
879 skipped_files,
880 invalid_short_codes
881 );
882 }
883
884 tracing::info!("Recovered counters: {:?}", counters);
885 Ok(counters)
886 }
887
888 fn is_valid_short_code_format(short_code: &str) -> bool {
890 let parts: Vec<&str> = short_code.split('-').collect();
891 if parts.len() != 3 {
892 return false;
893 }
894
895 let prefix = parts[0];
896 let type_letter = parts[1];
897 let number = parts[2];
898
899 if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
901 return false;
902 }
903
904 if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
906 return false;
907 }
908
909 number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
911 }
912}
913
914#[derive(Debug, Clone, PartialEq)]
916pub enum SyncResult {
917 Imported { filepath: String },
918 Updated { filepath: String },
919 Deleted { filepath: String },
920 UpToDate { filepath: String },
921 NotFound { filepath: String },
922 Error { filepath: String, error: String },
923 Moved { from: String, to: String },
924 Renumbered {
925 filepath: String,
926 old_short_code: String,
927 new_short_code: String
928 },
929}
930
931impl SyncResult {
932 pub fn filepath(&self) -> &str {
934 match self {
935 SyncResult::Imported { filepath }
936 | SyncResult::Updated { filepath }
937 | SyncResult::Deleted { filepath }
938 | SyncResult::UpToDate { filepath }
939 | SyncResult::NotFound { filepath }
940 | SyncResult::Renumbered { filepath, .. }
941 | SyncResult::Error { filepath, .. } => filepath,
942 SyncResult::Moved { to, .. } => to,
943 }
944 }
945
946 pub fn is_change(&self) -> bool {
948 matches!(
949 self,
950 SyncResult::Imported { .. }
951 | SyncResult::Updated { .. }
952 | SyncResult::Deleted { .. }
953 | SyncResult::Moved { .. }
954 | SyncResult::Renumbered { .. }
955 )
956 }
957
958 pub fn is_error(&self) -> bool {
960 matches!(self, SyncResult::Error { .. })
961 }
962}
963
964#[derive(Debug, Clone)]
966pub enum SyncIssue {
967 MissingFromDatabase { filepath: String },
968 MissingFromFilesystem { filepath: String },
969 OutOfSync { filepath: String, reason: String },
970}
971
972#[cfg(test)]
973mod tests {
974 use super::*;
975 use crate::dal::Database;
976 use tempfile::tempdir;
977
978 fn setup_services() -> (tempfile::TempDir, DatabaseService) {
979 let temp_dir = tempdir().expect("Failed to create temp dir");
980 let db_path = temp_dir.path().join("metis.db");
982 let db = Database::new(db_path.to_str().unwrap()).expect("Failed to create test database");
983 let mut config_repo = db.configuration_repository().expect("Failed to create config repo");
985 config_repo.set_project_prefix("TEST").expect("Failed to set prefix");
986 let db_service = DatabaseService::new(db.into_repository());
987 (temp_dir, db_service)
988 }
989
990 fn create_test_document_content() -> String {
991 "---\n".to_string()
992 + "id: test-document\n"
993 + "title: Test Document\n"
994 + "level: vision\n"
995 + "created_at: \"2021-01-01T00:00:00Z\"\n"
996 + "updated_at: \"2021-01-01T00:00:00Z\"\n"
997 + "archived: false\n"
998 + "short_code: TEST-V-9003\n"
999 + "exit_criteria_met: false\n"
1000 + "tags:\n"
1001 + " - \"#phase/draft\"\n"
1002 + "---\n\n"
1003 + "# Test Document\n\n"
1004 + "Test content.\n"
1005 }
1006
1007 #[tokio::test]
1008 async fn test_import_from_file() {
1009 let (temp_dir, mut db_service) = setup_services();
1010 let mut sync_service = SyncService::new(&mut db_service);
1011
1012 let file_path = temp_dir.path().join("test.md");
1013 FilesystemService::write_file(&file_path, &create_test_document_content())
1014 .expect("Failed to write file");
1015
1016 let doc = sync_service
1017 .import_from_file(&file_path)
1018 .await
1019 .expect("Failed to import");
1020 assert_eq!(doc.title, "Test Document");
1021 assert_eq!(doc.document_type, "vision");
1022
1023 assert!(db_service
1025 .document_exists(&file_path.to_string_lossy())
1026 .expect("Failed to check"));
1027 }
1028
1029 #[tokio::test]
1030 async fn test_sync_file_operations() {
1031 let (temp_dir, mut db_service) = setup_services();
1032 let mut sync_service = SyncService::new(&mut db_service);
1033
1034 let file_path = temp_dir.path().join("test.md");
1035 let path_str = file_path.to_string_lossy().to_string();
1036
1037 let result = sync_service
1039 .sync_file(&file_path)
1040 .await
1041 .expect("Failed to sync");
1042 assert_eq!(
1043 result,
1044 SyncResult::NotFound {
1045 filepath: path_str.clone()
1046 }
1047 );
1048
1049 FilesystemService::write_file(&file_path, &create_test_document_content())
1051 .expect("Failed to write file");
1052
1053 let result = sync_service
1054 .sync_file(&file_path)
1055 .await
1056 .expect("Failed to sync");
1057 assert_eq!(
1058 result,
1059 SyncResult::Imported {
1060 filepath: path_str.clone()
1061 }
1062 );
1063
1064 let result = sync_service
1066 .sync_file(&file_path)
1067 .await
1068 .expect("Failed to sync");
1069 assert_eq!(
1070 result,
1071 SyncResult::UpToDate {
1072 filepath: path_str.clone()
1073 }
1074 );
1075
1076 let modified_content =
1078 &create_test_document_content().replace("Test content.", "Modified content.");
1079 FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
1080
1081 let result = sync_service
1082 .sync_file(&file_path)
1083 .await
1084 .expect("Failed to sync");
1085 assert_eq!(
1086 result,
1087 SyncResult::Updated {
1088 filepath: path_str.clone()
1089 }
1090 );
1091
1092 FilesystemService::delete_file(&file_path).expect("Failed to delete");
1094
1095 let result = sync_service
1096 .sync_file(&file_path)
1097 .await
1098 .expect("Failed to sync");
1099 assert_eq!(
1100 result,
1101 SyncResult::Deleted {
1102 filepath: path_str.clone()
1103 }
1104 );
1105
1106 assert!(!db_service
1108 .document_exists(&path_str)
1109 .expect("Failed to check"));
1110 }
1111
1112 #[tokio::test]
1113 async fn test_sync_directory() {
1114 let (temp_dir, mut db_service) = setup_services();
1115 let mut sync_service = SyncService::new(&mut db_service).with_workspace_dir(temp_dir.path());
1116
1117 let files = vec![
1119 ("doc1.md", "test-1"),
1120 ("subdir/doc2.md", "test-2"),
1121 ("subdir/nested/doc3.md", "test-3"),
1122 ];
1123
1124 for (i, (file_path, id)) in files.iter().enumerate() {
1125 let full_path = temp_dir.path().join(file_path);
1126 let content = &create_test_document_content()
1127 .replace("Test Document", &format!("Test Document {}", id))
1128 .replace("test-document", id)
1129 .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
1130 FilesystemService::write_file(&full_path, content).expect("Failed to write");
1131 }
1132
1133 let results = sync_service
1135 .sync_directory(temp_dir.path())
1136 .await
1137 .expect("Failed to sync directory");
1138
1139 let imports = results
1141 .iter()
1142 .filter(|r| matches!(r, SyncResult::Imported { .. }))
1143 .count();
1144 assert_eq!(imports, 3);
1145
1146 let results = sync_service
1148 .sync_directory(temp_dir.path())
1149 .await
1150 .expect("Failed to sync directory");
1151 let up_to_date = results
1152 .iter()
1153 .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
1154 .count();
1155 assert_eq!(up_to_date, 3);
1156
1157 for (file_path, _) in &files {
1160 assert!(
1161 results.iter().any(|r| r.filepath() == *file_path),
1162 "Expected to find result for {}, but results were: {:?}",
1163 file_path,
1164 results.iter().map(|r| r.filepath()).collect::<Vec<_>>()
1165 );
1166 }
1167 }
1168
1169 #[test]
1170 fn test_is_backlog_path() {
1171 let workspace = Path::new("/workspace");
1172
1173 assert!(SyncService::is_backlog_path(
1175 "/workspace/backlog/task.md",
1176 workspace
1177 ));
1178 assert!(SyncService::is_backlog_path(
1179 "/workspace/backlog/bug/task.md",
1180 workspace
1181 ));
1182 assert!(SyncService::is_backlog_path(
1183 "/workspace/backlog/feature/task.md",
1184 workspace
1185 ));
1186 assert!(SyncService::is_backlog_path(
1187 "/workspace/backlog/tech-debt/task.md",
1188 workspace
1189 ));
1190
1191 assert!(!SyncService::is_backlog_path(
1193 "/workspace/strategies/strat-1/initiatives/init-1/tasks/task.md",
1194 workspace
1195 ));
1196 assert!(!SyncService::is_backlog_path(
1197 "/workspace/initiatives/init-1/tasks/task.md",
1198 workspace
1199 ));
1200 assert!(!SyncService::is_backlog_path(
1201 "/workspace/vision.md",
1202 workspace
1203 ));
1204 assert!(!SyncService::is_backlog_path(
1205 "/workspace/adrs/adr-001.md",
1206 workspace
1207 ));
1208
1209 assert!(!SyncService::is_backlog_path(
1211 "/other/backlog/task.md",
1212 workspace
1213 ));
1214 }
1215}