metis_core/application/services/
synchronization.rs

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
11/// Synchronization service - bridges filesystem and database
12pub 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    /// Set the workspace directory for lineage extraction
28    /// Note: We store the original path reference without canonicalizing here
29    /// because canonicalization requires owned PathBuf. The caller should ensure
30    /// paths are properly resolved when needed.
31    pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
32        self.workspace_dir = Some(workspace_dir);
33        // Infer db_path from workspace_dir
34        self.db_path = Some(workspace_dir.join("metis.db"));
35        self
36    }
37
38    /// Convert absolute path to relative path (relative to workspace directory)
39    /// Returns the path as-is if workspace_dir is not set or if stripping fails
40    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        // Fallback to absolute path if no workspace or stripping fails
47        absolute_path.as_ref().to_string_lossy().to_string()
48    }
49
50    /// Convert relative path to absolute path (prepends workspace directory)
51    /// Returns the path as-is if workspace_dir is not set
52    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            // Fallback: assume it's already absolute
57            std::path::PathBuf::from(relative_path)
58        }
59    }
60
61    /// Direction 1: File → DocumentObject → Database
62    /// Load a document from filesystem and store in database
63    pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
64        // Convert absolute path to relative path for database storage
65        let path_str = self.to_relative_path(&file_path);
66
67        // Use DocumentFactory to parse file into domain object
68        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        // Get file metadata
75        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        // Convert domain object to database model
80        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        // Store in database
89        self.db_service.create_document(new_doc)
90    }
91
92    /// Direction 2: Database → DocumentObject → File
93    /// Export a document from database to filesystem
94    pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
95        // Get document from database (filepath in DB is relative)
96        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        // Get content from database
103        let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
104            message: "Document has no content".to_string(),
105        })?;
106
107        // Convert relative path to absolute for filesystem access
108        let absolute_path = self.to_absolute_path(filepath);
109
110        // Write to filesystem
111        FilesystemService::write_file(absolute_path, &content)?;
112
113        Ok(())
114    }
115
116    /// Convert domain object to database model
117    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        // Extract lineage from filesystem path if workspace directory is available
134        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        // Use filesystem lineage if available, otherwise use document lineage
141        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    /// Extract lineage information from file path
168    /// Returns (strategy_id, initiative_id) based on filesystem structure
169    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        // Get relative path from workspace
176        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 the path structure
187        match path_parts.as_slice() {
188            // strategies/{strategy-id}/strategy.md
189            ["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
197            ["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/{task-id}.md
211            ["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/{task-id}.md (no lineage)
225            ["backlog", _] => (None, None),
226            // adrs/{adr-id}.md (no lineage)
227            ["adrs", _] => (None, None),
228            // vision.md (no lineage)
229            ["vision.md"] => (None, None),
230            // Default: no lineage
231            _ => (None, None),
232        }
233    }
234
235    /// Extract document short code from file without keeping the document object around
236    fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
237        // Read file content to extract frontmatter and get document short code
238        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        // Parse frontmatter to get document short code
245        use gray_matter::{engine::YAML, Matter};
246        let matter = Matter::<YAML>::new();
247        let result = matter.parse(&raw_content);
248
249        // Extract short_code from frontmatter
250        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    /// Update a document that has been moved to a new path
271    async fn update_moved_document<P: AsRef<Path>>(
272        &mut self,
273        existing_doc: &Document,
274        new_file_path: P,
275    ) -> Result<()> {
276        // Delete the old database entry first (to handle foreign key constraints)
277        self.db_service.delete_document(&existing_doc.filepath)?;
278
279        // Import the document at the new path
280        self.import_from_file(&new_file_path).await?;
281
282        Ok(())
283    }
284
285    /// Detect and resolve short code collisions across all markdown files
286    /// Returns list of renumbering results
287    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        // Step 0: Update counters from filesystem FIRST
294        // This ensures the counter knows about all existing short codes before we generate new ones
295        self.update_counters_from_filesystem(&dir_path)?;
296
297        // Step 1: Scan all markdown files and group by short code
298        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        // Step 2: Find collisions (short codes with multiple files)
316        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        // Step 3: Sort collision groups by path depth (resolve parents first)
326        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        // Step 4: Resolve each collision group
335        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            // First path keeps original short code, rest get renumbered
343            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    /// Renumber a single document to resolve short code collision
370    /// Returns the new short code
371    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        // Step 1: Read current document content
379        let content = FilesystemService::read_file(file_path)?;
380
381        // Step 2: Parse frontmatter
382        use gray_matter::{engine::YAML, Matter};
383        let matter = Matter::<YAML>::new();
384        let parsed = matter.parse(&content);
385
386        // Step 3: Extract document type from frontmatter to generate new short code
387        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        // Step 4: Generate new short code
408        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        // Step 5: Update frontmatter with new short code using regex
432        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        // Step 6: Update cross-references in sibling documents
443        self.update_sibling_references(file_path, old_short_code, &new_short_code)
444            .await?;
445
446        // Step 7: Write updated content back to file
447        FilesystemService::write_file(file_path, &updated_content)?;
448
449        // Step 8: Rename file if filename contains the short code
450        // Extract just the suffix (e.g., "T-0001" from "TEST-T-0001")
451        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    /// Update cross-references in sibling documents (same directory)
480    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        // Get parent directory (sibling group)
489        let parent_dir = file_path.parent().ok_or_else(|| MetisError::ValidationFailed {
490            message: "File has no parent directory".to_string(),
491        })?;
492
493        // Find all markdown files in same directory
494        let siblings = FilesystemService::find_markdown_files(parent_dir)?;
495
496        // Create regex pattern to match short code as whole word
497        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        // Update each sibling file
504        for sibling_path in siblings {
505            let sibling_path_buf = PathBuf::from(&sibling_path);
506            if sibling_path_buf == file_path {
507                continue; // Skip the document we just renumbered
508            }
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    /// Synchronize a single file between filesystem and database using directional methods
540    pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
541        // Convert absolute path to relative for database queries
542        let relative_path_str = self.to_relative_path(&file_path);
543
544        // Check if file exists on filesystem
545        let file_exists = FilesystemService::file_exists(&file_path);
546
547        // Check if document exists in database at this filepath (DB stores relative paths)
548        let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
549
550        match (file_exists, db_doc_by_path) {
551            // File exists, not in database at this path - need to check if it's a moved document
552            (true, None) => {
553                // Extract the document short code without creating full document object
554                let short_code = Self::extract_document_short_code(&file_path)?;
555
556                // Check if a document with this short code exists at a different path
557                if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
558                    // Document moved - update the existing record
559                    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                    // Truly new document - import it
568                    self.import_from_file(&file_path).await?;
569                    Ok(SyncResult::Imported {
570                        filepath: relative_path_str,
571                    })
572                }
573            }
574
575            // File doesn't exist, but in database - remove from database
576            (false, Some(_)) => {
577                self.db_service.delete_document(&relative_path_str)?;
578                Ok(SyncResult::Deleted {
579                    filepath: relative_path_str,
580                })
581            }
582
583            // Both exist - check if file changed
584            (true, Some(db_doc)) => {
585                let current_hash = FilesystemService::compute_file_hash(&file_path)?;
586
587                if db_doc.file_hash != current_hash {
588                    // File changed, reimport (file is source of truth)
589                    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            // Neither exists
602            (false, None) => Ok(SyncResult::NotFound {
603                filepath: relative_path_str,
604            }),
605        }
606    }
607
608    /// Sync all markdown files in a directory
609    pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
610        let mut results = Vec::new();
611
612        // Step 1: Detect and resolve short code collisions BEFORE syncing to database
613        // This ensures we don't try to import duplicate short codes
614        let collision_results = self.resolve_short_code_collisions(&dir_path).await?;
615        results.extend(collision_results);
616
617        // Step 2: Re-scan all markdown files AFTER renumbering
618        // This picks up renamed files with new short codes
619        let files = FilesystemService::find_markdown_files(&dir_path)?;
620
621        // Step 3: Sync each file
622        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        // Step 4: Check for orphaned database entries (files that were deleted)
633        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
634        for (_, relative_filepath) in db_pairs {
635            // Convert relative path from DB to absolute for filesystem check
636            let absolute_path = self.to_absolute_path(&relative_filepath);
637            if !FilesystemService::file_exists(&absolute_path) {
638                // File no longer exists, delete from database
639                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        // Step 5: Update counters based on max seen values
652        self.update_counters_from_filesystem(&dir_path)?;
653
654        Ok(results)
655    }
656
657    /// Verify database and filesystem are in sync
658    pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
659        let mut issues = Vec::new();
660
661        // Find all markdown files (returns absolute paths)
662        let files = FilesystemService::find_markdown_files(&dir_path)?;
663
664        // Check each file
665        for file_path in &files {
666            // Convert absolute path to relative for DB query
667            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        // Check for orphaned database entries
685        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
686        for (_, relative_filepath) in db_pairs {
687            // Convert relative path from DB to absolute for filesystem check
688            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    /// Update counters in database based on max values seen in filesystem
701    /// Called after collision resolution to ensure counters are up to date
702    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            // Set counter to max seen value (get_next_short_code_number adds 1)
728            config_repo.set_counter_if_lower(&doc_type, max_counter)?;
729        }
730
731        Ok(())
732    }
733
734    /// Recover short code counters from filesystem by scanning all documents
735    ///
736    /// This should only be called when:
737    /// - Database is missing or corrupt
738    /// - Explicit recovery is requested by user
739    ///
740    /// Returns a map of document type to the highest counter found
741    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        // Guard: Ensure directory exists
755        if !dir_path.exists() {
756            tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
757            return Ok(counters);
758        }
759
760        // Find all markdown files
761        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            // Guard: Read file with error handling
766            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            // Parse frontmatter
776            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                // Extract short_code
786                if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
787                    // Guard: Validate format
788                    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                    // Parse: PREFIX-TYPE-NNNN
799                    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                            // Guard: Parse and validate number
811                            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    /// Validate short code format: PREFIX-TYPE-NNNN
858    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        // Prefix: 2-8 uppercase letters
869        if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
870            return false;
871        }
872
873        // Type: single letter from allowed set
874        if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
875            return false;
876        }
877
878        // Number: exactly 4 digits
879        number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
880    }
881}
882
883/// Result of synchronizing a single document
884#[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    /// Get the filepath for this result
902    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    /// Check if this result represents a change
916    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    /// Check if this result represents an error
928    pub fn is_error(&self) -> bool {
929        matches!(self, SyncResult::Error { .. })
930    }
931}
932
933/// Issues found during sync verification
934#[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        // Use metis.db to match what sync_service expects in with_workspace_dir
950        let db_path = temp_dir.path().join("metis.db");
951        let db = Database::new(db_path.to_str().unwrap()).expect("Failed to create test database");
952        // Initialize configuration with test prefix
953        let mut config_repo = db.configuration_repository().expect("Failed to create config repo");
954        config_repo.set_project_prefix("TEST").expect("Failed to set prefix");
955        let db_service = DatabaseService::new(db.into_repository());
956        (temp_dir, db_service)
957    }
958
959    fn create_test_document_content() -> String {
960        "---\n".to_string()
961            + "id: test-document\n"
962            + "title: Test Document\n"
963            + "level: vision\n"
964            + "created_at: \"2021-01-01T00:00:00Z\"\n"
965            + "updated_at: \"2021-01-01T00:00:00Z\"\n"
966            + "archived: false\n"
967            + "short_code: TEST-V-9003\n"
968            + "exit_criteria_met: false\n"
969            + "tags:\n"
970            + "  - \"#phase/draft\"\n"
971            + "---\n\n"
972            + "# Test Document\n\n"
973            + "Test content.\n"
974    }
975
976    #[tokio::test]
977    async fn test_import_from_file() {
978        let (temp_dir, mut db_service) = setup_services();
979        let mut sync_service = SyncService::new(&mut db_service);
980
981        let file_path = temp_dir.path().join("test.md");
982        FilesystemService::write_file(&file_path, &create_test_document_content())
983            .expect("Failed to write file");
984
985        let doc = sync_service
986            .import_from_file(&file_path)
987            .await
988            .expect("Failed to import");
989        assert_eq!(doc.title, "Test Document");
990        assert_eq!(doc.document_type, "vision");
991
992        // Verify it's in the database
993        assert!(db_service
994            .document_exists(&file_path.to_string_lossy())
995            .expect("Failed to check"));
996    }
997
998    #[tokio::test]
999    async fn test_sync_file_operations() {
1000        let (temp_dir, mut db_service) = setup_services();
1001        let mut sync_service = SyncService::new(&mut db_service);
1002
1003        let file_path = temp_dir.path().join("test.md");
1004        let path_str = file_path.to_string_lossy().to_string();
1005
1006        // Test sync when file doesn't exist
1007        let result = sync_service
1008            .sync_file(&file_path)
1009            .await
1010            .expect("Failed to sync");
1011        assert_eq!(
1012            result,
1013            SyncResult::NotFound {
1014                filepath: path_str.clone()
1015            }
1016        );
1017
1018        // Create file and sync
1019        FilesystemService::write_file(&file_path, &create_test_document_content())
1020            .expect("Failed to write file");
1021
1022        let result = sync_service
1023            .sync_file(&file_path)
1024            .await
1025            .expect("Failed to sync");
1026        assert_eq!(
1027            result,
1028            SyncResult::Imported {
1029                filepath: path_str.clone()
1030            }
1031        );
1032
1033        // Sync again - should be up to date
1034        let result = sync_service
1035            .sync_file(&file_path)
1036            .await
1037            .expect("Failed to sync");
1038        assert_eq!(
1039            result,
1040            SyncResult::UpToDate {
1041                filepath: path_str.clone()
1042            }
1043        );
1044
1045        // Modify file
1046        let modified_content =
1047            &create_test_document_content().replace("Test content.", "Modified content.");
1048        FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
1049
1050        let result = sync_service
1051            .sync_file(&file_path)
1052            .await
1053            .expect("Failed to sync");
1054        assert_eq!(
1055            result,
1056            SyncResult::Updated {
1057                filepath: path_str.clone()
1058            }
1059        );
1060
1061        // Delete file
1062        FilesystemService::delete_file(&file_path).expect("Failed to delete");
1063
1064        let result = sync_service
1065            .sync_file(&file_path)
1066            .await
1067            .expect("Failed to sync");
1068        assert_eq!(
1069            result,
1070            SyncResult::Deleted {
1071                filepath: path_str.clone()
1072            }
1073        );
1074
1075        // Verify it's gone from database
1076        assert!(!db_service
1077            .document_exists(&path_str)
1078            .expect("Failed to check"));
1079    }
1080
1081    #[tokio::test]
1082    async fn test_sync_directory() {
1083        let (temp_dir, mut db_service) = setup_services();
1084        let mut sync_service = SyncService::new(&mut db_service).with_workspace_dir(temp_dir.path());
1085
1086        // Create multiple files
1087        let files = vec![
1088            ("doc1.md", "test-1"),
1089            ("subdir/doc2.md", "test-2"),
1090            ("subdir/nested/doc3.md", "test-3"),
1091        ];
1092
1093        for (i, (file_path, id)) in files.iter().enumerate() {
1094            let full_path = temp_dir.path().join(file_path);
1095            let content = &create_test_document_content()
1096                .replace("Test Document", &format!("Test Document {}", id))
1097                .replace("test-document", id)
1098                .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
1099            FilesystemService::write_file(&full_path, content).expect("Failed to write");
1100        }
1101
1102        // Sync directory
1103        let results = sync_service
1104            .sync_directory(temp_dir.path())
1105            .await
1106            .expect("Failed to sync directory");
1107
1108        // Should have 3 imports
1109        let imports = results
1110            .iter()
1111            .filter(|r| matches!(r, SyncResult::Imported { .. }))
1112            .count();
1113        assert_eq!(imports, 3);
1114
1115        // Sync again - all should be up to date
1116        let results = sync_service
1117            .sync_directory(temp_dir.path())
1118            .await
1119            .expect("Failed to sync directory");
1120        let up_to_date = results
1121            .iter()
1122            .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
1123            .count();
1124        assert_eq!(up_to_date, 3);
1125
1126        // Check that we have results for all files
1127        // Note: with workspace_dir set, sync returns relative paths
1128        for (file_path, _) in &files {
1129            assert!(
1130                results.iter().any(|r| r.filepath() == *file_path),
1131                "Expected to find result for {}, but results were: {:?}",
1132                file_path,
1133                results.iter().map(|r| r.filepath()).collect::<Vec<_>>()
1134            );
1135        }
1136    }
1137}