Skip to main content

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, 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        // Use filesystem lineage if available, otherwise use document lineage
144        // Exception: backlog items should NEVER have initiative_id (filesystem overrides frontmatter)
145        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 // Backlog items must not have initiative_id, regardless of frontmatter
150        } 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    /// Extract lineage information from file path
176    /// Returns (strategy_id, initiative_id) based on filesystem structure
177    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        // Get relative path from workspace
184        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 the path structure
195        match path_parts.as_slice() {
196            // strategies/{strategy-id}/strategy.md
197            ["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
205            ["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/{task-id}.md
219            ["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/{task-id}.md (no lineage)
233            ["backlog", _] => (None, None),
234            // backlog/{category}/{task-id}.md (no lineage) - handles bugs, features, tech-debt subdirs
235            ["backlog", _, _] => (None, None),
236            // adrs/{adr-id}.md (no lineage)
237            ["adrs", _] => (None, None),
238            // vision.md (no lineage)
239            ["vision.md"] => (None, None),
240            // Default: no lineage
241            _ => (None, None),
242        }
243    }
244
245    /// Check if a file path is within the backlog directory
246    /// Backlog items should never have initiative_id, regardless of frontmatter content
247    fn is_backlog_path<P: AsRef<Path>>(file_path: P, workspace_dir: &Path) -> bool {
248        let path = file_path.as_ref();
249
250        // Get relative path from workspace
251        let relative_path = match path.strip_prefix(workspace_dir) {
252            Ok(rel) => rel,
253            Err(_) => return false,
254        };
255
256        // Get path components
257        let components: Vec<&str> = relative_path
258            .components()
259            .filter_map(|c| c.as_os_str().to_str())
260            .collect();
261
262        // Check if first component is "backlog"
263        matches!(components.first(), Some(&"backlog"))
264    }
265
266    /// Extract document short code from file without keeping the document object around
267    fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
268        // Read file content to extract frontmatter and get document short code
269        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        // Parse frontmatter to get document short code
276        use gray_matter::{engine::YAML, Matter};
277        let matter = Matter::<YAML>::new();
278        let result = matter.parse(&raw_content);
279
280        // Extract short_code from frontmatter
281        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    /// Update a document that has been moved to a new path
302    async fn update_moved_document<P: AsRef<Path>>(
303        &mut self,
304        existing_doc: &Document,
305        new_file_path: P,
306    ) -> Result<()> {
307        // Delete the old database entry first (to handle foreign key constraints)
308        self.db_service.delete_document(&existing_doc.filepath)?;
309
310        // Import the document at the new path
311        self.import_from_file(&new_file_path).await?;
312
313        Ok(())
314    }
315
316    /// Detect and resolve short code collisions across all markdown files
317    /// Returns list of renumbering results
318    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        // Step 0: Update counters from filesystem FIRST
325        // This ensures the counter knows about all existing short codes before we generate new ones
326        self.update_counters_from_filesystem(&dir_path)?;
327
328        // Step 1: Scan all markdown files and group by short code
329        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        // Step 2: Find collisions (short codes with multiple files)
347        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        // Step 3: Sort collision groups by path depth (resolve parents first)
357        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        // Step 4: Resolve each collision group
366        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            // First path keeps original short code, rest get renumbered
374            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    /// Renumber a single document to resolve short code collision
401    /// Returns the new short code
402    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        // Step 1: Read current document content
410        let content = FilesystemService::read_file(file_path)?;
411
412        // Step 2: Parse frontmatter
413        use gray_matter::{engine::YAML, Matter};
414        let matter = Matter::<YAML>::new();
415        let parsed = matter.parse(&content);
416
417        // Step 3: Extract document type from frontmatter to generate new short code
418        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        // Step 4: Generate new short code
439        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        // Step 5: Update frontmatter with new short code using regex
463        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        // Step 6: Update cross-references in sibling documents
474        self.update_sibling_references(file_path, old_short_code, &new_short_code)
475            .await?;
476
477        // Step 7: Write updated content back to file
478        FilesystemService::write_file(file_path, &updated_content)?;
479
480        // Step 8: Rename file if filename contains the short code
481        // Extract just the suffix (e.g., "T-0001" from "TEST-T-0001")
482        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    /// Update cross-references in sibling documents (same directory)
511    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        // Get parent directory (sibling group)
520        let parent_dir = file_path.parent().ok_or_else(|| MetisError::ValidationFailed {
521            message: "File has no parent directory".to_string(),
522        })?;
523
524        // Find all markdown files in same directory
525        let siblings = FilesystemService::find_markdown_files(parent_dir)?;
526
527        // Create regex pattern to match short code as whole word
528        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        // Update each sibling file
535        for sibling_path in siblings {
536            let sibling_path_buf = PathBuf::from(&sibling_path);
537            if sibling_path_buf == file_path {
538                continue; // Skip the document we just renumbered
539            }
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    /// Synchronize a single file between filesystem and database using directional methods
571    pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
572        // Convert absolute path to relative for database queries
573        let relative_path_str = self.to_relative_path(&file_path);
574
575        // Check if file exists on filesystem
576        let file_exists = FilesystemService::file_exists(&file_path);
577
578        // Check if document exists in database at this filepath (DB stores relative paths)
579        let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
580
581        match (file_exists, db_doc_by_path) {
582            // File exists, not in database at this path - need to check if it's a moved document
583            (true, None) => {
584                // Extract the document short code without creating full document object
585                let short_code = Self::extract_document_short_code(&file_path)?;
586
587                // Check if a document with this short code exists at a different path
588                if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
589                    // Document moved - update the existing record
590                    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                    // Truly new document - import it
599                    self.import_from_file(&file_path).await?;
600                    Ok(SyncResult::Imported {
601                        filepath: relative_path_str,
602                    })
603                }
604            }
605
606            // File doesn't exist, but in database - remove from database
607            (false, Some(_)) => {
608                self.db_service.delete_document(&relative_path_str)?;
609                Ok(SyncResult::Deleted {
610                    filepath: relative_path_str,
611                })
612            }
613
614            // Both exist - check if file changed
615            (true, Some(db_doc)) => {
616                let current_hash = FilesystemService::compute_file_hash(&file_path)?;
617
618                if db_doc.file_hash != current_hash {
619                    // File changed, reimport (file is source of truth)
620                    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            // Neither exists
633            (false, None) => Ok(SyncResult::NotFound {
634                filepath: relative_path_str,
635            }),
636        }
637    }
638
639    /// Sync all markdown files in a directory
640    pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
641        let mut results = Vec::new();
642
643        // Step 1: Detect and resolve short code collisions BEFORE syncing to database
644        // This ensures we don't try to import duplicate short codes
645        let collision_results = self.resolve_short_code_collisions(&dir_path).await?;
646        results.extend(collision_results);
647
648        // Step 2: Re-scan all markdown files AFTER renumbering
649        // This picks up renamed files with new short codes
650        let files = FilesystemService::find_markdown_files(&dir_path)?;
651
652        // Step 3: Sync each file
653        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        // Step 4: Check for orphaned database entries (files that were deleted)
664        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
665        for (_, relative_filepath) in db_pairs {
666            // Convert relative path from DB to absolute for filesystem check
667            let absolute_path = self.to_absolute_path(&relative_filepath);
668            if !FilesystemService::file_exists(&absolute_path) {
669                // File no longer exists, delete from database
670                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        // Step 5: Update counters based on max seen values
683        self.update_counters_from_filesystem(&dir_path)?;
684
685        Ok(results)
686    }
687
688    /// Verify database and filesystem are in sync
689    pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
690        let mut issues = Vec::new();
691
692        // Find all markdown files (returns absolute paths)
693        let files = FilesystemService::find_markdown_files(&dir_path)?;
694
695        // Check each file
696        for file_path in &files {
697            // Convert absolute path to relative for DB query
698            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        // Check for orphaned database entries
716        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
717        for (_, relative_filepath) in db_pairs {
718            // Convert relative path from DB to absolute for filesystem check
719            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    /// Update counters in database based on max values seen in filesystem
732    /// Called after collision resolution to ensure counters are up to date
733    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            // Set counter to max seen value (get_next_short_code_number adds 1)
759            config_repo.set_counter_if_lower(&doc_type, max_counter)?;
760        }
761
762        Ok(())
763    }
764
765    /// Recover short code counters from filesystem by scanning all documents
766    ///
767    /// This should only be called when:
768    /// - Database is missing or corrupt
769    /// - Explicit recovery is requested by user
770    ///
771    /// Returns a map of document type to the highest counter found
772    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        // Guard: Ensure directory exists
786        if !dir_path.exists() {
787            tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
788            return Ok(counters);
789        }
790
791        // Find all markdown files
792        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            // Guard: Read file with error handling
797            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            // Parse frontmatter
807            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                // Extract short_code
817                if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
818                    // Guard: Validate format
819                    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                    // Parse: PREFIX-TYPE-NNNN
830                    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                            // Guard: Parse and validate number
842                            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    /// Validate short code format: PREFIX-TYPE-NNNN
889    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        // Prefix: 2-8 uppercase letters
900        if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
901            return false;
902        }
903
904        // Type: single letter from allowed set
905        if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
906            return false;
907        }
908
909        // Number: exactly 4 digits
910        number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
911    }
912}
913
914/// Result of synchronizing a single document
915#[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    /// Get the filepath for this result
933    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    /// Check if this result represents a change
947    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    /// Check if this result represents an error
959    pub fn is_error(&self) -> bool {
960        matches!(self, SyncResult::Error { .. })
961    }
962}
963
964/// Issues found during sync verification
965#[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        // Use metis.db to match what sync_service expects in with_workspace_dir
981        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        // Initialize configuration with test prefix
984        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        // Verify it's in the database
1024        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        // Test sync when file doesn't exist
1038        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        // Create file and sync
1050        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        // Sync again - should be up to date
1065        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        // Modify file
1077        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        // Delete file
1093        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        // Verify it's gone from database
1107        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        // Create multiple files
1118        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        // Sync directory
1134        let results = sync_service
1135            .sync_directory(temp_dir.path())
1136            .await
1137            .expect("Failed to sync directory");
1138
1139        // Should have 3 imports
1140        let imports = results
1141            .iter()
1142            .filter(|r| matches!(r, SyncResult::Imported { .. }))
1143            .count();
1144        assert_eq!(imports, 3);
1145
1146        // Sync again - all should be up to date
1147        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        // Check that we have results for all files
1158        // Note: with workspace_dir set, sync returns relative paths
1159        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        // Backlog paths should return true
1174        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        // Non-backlog paths should return false
1192        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        // Path outside workspace should return false
1210        assert!(!SyncService::is_backlog_path(
1211            "/other/backlog/task.md",
1212            workspace
1213        ));
1214    }
1215}