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::path::Path;
9
10/// Synchronization service - bridges filesystem and database
11pub struct SyncService<'a> {
12    db_service: &'a mut DatabaseService,
13    workspace_dir: Option<&'a Path>,
14}
15
16impl<'a> SyncService<'a> {
17    pub fn new(db_service: &'a mut DatabaseService) -> Self {
18        Self {
19            db_service,
20            workspace_dir: None,
21        }
22    }
23
24    /// Set the workspace directory for lineage extraction
25    pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
26        self.workspace_dir = Some(workspace_dir);
27        self
28    }
29
30    /// Direction 1: File → DocumentObject → Database
31    /// Load a document from filesystem and store in database
32    pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
33        let path_str = file_path.as_ref().to_string_lossy().to_string();
34
35        // Use DocumentFactory to parse file into domain object
36        let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
37            MetisError::ValidationFailed {
38                message: format!("Failed to parse document: {}", e),
39            }
40        })?;
41
42        // Get file metadata
43        let file_hash = FilesystemService::compute_file_hash(&file_path)?;
44        let updated_at = FilesystemService::get_file_mtime(&file_path)?;
45        let content = FilesystemService::read_file(&file_path)?;
46
47        // Convert domain object to database model
48        let new_doc = self.domain_to_database_model(
49            document_obj.as_ref(),
50            &path_str,
51            file_hash,
52            updated_at,
53            content,
54        )?;
55
56        // Store in database
57        self.db_service.create_document(new_doc)
58    }
59
60    /// Direction 2: Database → DocumentObject → File  
61    /// Export a document from database to filesystem
62    pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
63        // Get document from database
64        let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
65            MetisError::DocumentNotFound {
66                id: filepath.to_string(),
67            }
68        })?;
69
70        // Get content from database
71        let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
72            message: "Document has no content".to_string(),
73        })?;
74
75        // Write to filesystem
76        FilesystemService::write_file(filepath, &content)?;
77
78        Ok(())
79    }
80
81    /// Convert domain object to database model
82    fn domain_to_database_model(
83        &self,
84        document_obj: &dyn DocumentTrait,
85        filepath: &str,
86        file_hash: String,
87        updated_at: f64,
88        content: String,
89    ) -> Result<NewDocument> {
90        let core = document_obj.core();
91        let phase = document_obj
92            .phase()
93            .map_err(|e| MetisError::ValidationFailed {
94                message: format!("Failed to get document phase: {}", e),
95            })?
96            .to_string();
97
98        // Extract lineage from filesystem path if workspace directory is available
99        let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
100            Self::extract_lineage_from_path(filepath, workspace_dir)
101        } else {
102            (None, None)
103        };
104
105        // Use filesystem lineage if available, otherwise use document lineage
106        let final_strategy_id = fs_strategy_id
107            .or_else(|| core.strategy_id.clone())
108            .map(|id| id.to_string());
109        let final_initiative_id = fs_initiative_id
110            .or_else(|| core.initiative_id.clone())
111            .map(|id| id.to_string());
112
113        Ok(NewDocument {
114            filepath: filepath.to_string(),
115            id: document_obj.id().to_string(),
116            title: core.title.clone(),
117            document_type: document_obj.document_type().to_string(),
118            created_at: core.metadata.created_at.timestamp() as f64,
119            updated_at,
120            archived: core.archived,
121            exit_criteria_met: document_obj.exit_criteria_met(),
122            file_hash,
123            frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
124            content: Some(content),
125            phase,
126            strategy_id: final_strategy_id,
127            initiative_id: final_initiative_id,
128            short_code: core.metadata.short_code.clone(),
129        })
130    }
131
132    /// Extract lineage information from file path
133    /// Returns (strategy_id, initiative_id) based on filesystem structure
134    fn extract_lineage_from_path<P: AsRef<Path>>(
135        file_path: P,
136        workspace_dir: &Path,
137    ) -> (Option<DocumentId>, Option<DocumentId>) {
138        let path = file_path.as_ref();
139
140        // Get relative path from workspace
141        let relative_path = match path.strip_prefix(workspace_dir) {
142            Ok(rel) => rel,
143            Err(_) => return (None, None),
144        };
145
146        let path_parts: Vec<&str> = relative_path
147            .components()
148            .filter_map(|c| c.as_os_str().to_str())
149            .collect();
150
151        // Match the path structure
152        match path_parts.as_slice() {
153            // strategies/{strategy-id}/strategy.md
154            ["strategies", strategy_id, "strategy.md"] => {
155                if strategy_id == &"NULL" {
156                    (None, None)
157                } else {
158                    (Some(DocumentId::from(*strategy_id)), None)
159                }
160            }
161            // strategies/{strategy-id}/initiatives/{initiative-id}/initiative.md
162            ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
163                let strat_id = if strategy_id == &"NULL" {
164                    None
165                } else {
166                    Some(DocumentId::from(*strategy_id))
167                };
168                let init_id = if initiative_id == &"NULL" {
169                    None
170                } else {
171                    Some(DocumentId::from(*initiative_id))
172                };
173                (strat_id, init_id)
174            }
175            // strategies/{strategy-id}/initiatives/{initiative-id}/tasks/{task-id}.md
176            ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
177                let strat_id = if strategy_id == &"NULL" {
178                    None
179                } else {
180                    Some(DocumentId::from(*strategy_id))
181                };
182                let init_id = if initiative_id == &"NULL" {
183                    None
184                } else {
185                    Some(DocumentId::from(*initiative_id))
186                };
187                (strat_id, init_id)
188            }
189            // backlog/{task-id}.md (no lineage)
190            ["backlog", _] => (None, None),
191            // adrs/{adr-id}.md (no lineage)
192            ["adrs", _] => (None, None),
193            // vision.md (no lineage)
194            ["vision.md"] => (None, None),
195            // Default: no lineage
196            _ => (None, None),
197        }
198    }
199
200    /// Extract document short code from file without keeping the document object around
201    fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
202        // Read file content to extract frontmatter and get document short code
203        let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
204            MetisError::ValidationFailed {
205                message: format!("Failed to read file: {}", e),
206            }
207        })?;
208
209        // Parse frontmatter to get document short code
210        use gray_matter::{engine::YAML, Matter};
211        let matter = Matter::<YAML>::new();
212        let result = matter.parse(&raw_content);
213
214        // Extract short_code from frontmatter
215        if let Some(frontmatter) = result.data {
216            let fm_map = match frontmatter {
217                gray_matter::Pod::Hash(map) => map,
218                _ => {
219                    return Err(MetisError::ValidationFailed {
220                        message: "Frontmatter must be a hash/map".to_string(),
221                    });
222                }
223            };
224
225            if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
226                return Ok(short_code_str.clone());
227            }
228        }
229
230        Err(MetisError::ValidationFailed {
231            message: "Document missing short_code in frontmatter".to_string(),
232        })
233    }
234
235    /// Update a document that has been moved to a new path
236    async fn update_moved_document<P: AsRef<Path>>(
237        &mut self,
238        existing_doc: &Document,
239        new_file_path: P,
240    ) -> Result<()> {
241        // Delete the old database entry first (to handle foreign key constraints)
242        self.db_service.delete_document(&existing_doc.filepath)?;
243
244        // Import the document at the new path
245        self.import_from_file(&new_file_path).await?;
246
247        Ok(())
248    }
249
250    /// Synchronize a single file between filesystem and database using directional methods
251    pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
252        let path_str = file_path.as_ref().to_string_lossy().to_string();
253
254        // Check if file exists on filesystem
255        let file_exists = FilesystemService::file_exists(&file_path);
256
257        // Check if document exists in database at this filepath
258        let db_doc_by_path = self.db_service.find_by_filepath(&path_str)?;
259
260        match (file_exists, db_doc_by_path) {
261            // File exists, not in database at this path - need to check if it's a moved document
262            (true, None) => {
263                // Extract the document short code without creating full document object
264                let short_code = Self::extract_document_short_code(&file_path)?;
265
266                // Check if a document with this short code exists at a different path
267                if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
268                    // Document moved - update the existing record
269                    let old_path = existing_doc.filepath.clone();
270                    self.update_moved_document(&existing_doc, &file_path)
271                        .await?;
272                    Ok(SyncResult::Moved {
273                        from: old_path,
274                        to: path_str,
275                    })
276                } else {
277                    // Truly new document - import it
278                    self.import_from_file(&file_path).await?;
279                    Ok(SyncResult::Imported { filepath: path_str })
280                }
281            }
282
283            // File doesn't exist, but in database - remove from database
284            (false, Some(_)) => {
285                self.db_service.delete_document(&path_str)?;
286                Ok(SyncResult::Deleted { filepath: path_str })
287            }
288
289            // Both exist - check if file changed
290            (true, Some(db_doc)) => {
291                let current_hash = FilesystemService::compute_file_hash(&file_path)?;
292
293                if db_doc.file_hash != current_hash {
294                    // File changed, reimport (file is source of truth)
295                    self.db_service.delete_document(&path_str)?;
296                    self.import_from_file(&file_path).await?;
297                    Ok(SyncResult::Updated { filepath: path_str })
298                } else {
299                    Ok(SyncResult::UpToDate { filepath: path_str })
300                }
301            }
302
303            // Neither exists
304            (false, None) => Ok(SyncResult::NotFound { filepath: path_str }),
305        }
306    }
307
308    /// Sync all markdown files in a directory
309    pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
310        let mut results = Vec::new();
311
312        // Find all markdown files
313        let files = FilesystemService::find_markdown_files(&dir_path)?;
314
315        // Sync each file
316        for file_path in files {
317            match self.sync_file(&file_path).await {
318                Ok(result) => results.push(result),
319                Err(e) => results.push(SyncResult::Error {
320                    filepath: file_path,
321                    error: e.to_string(),
322                }),
323            }
324        }
325
326        // Check for orphaned database entries (files that were deleted)
327        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
328        for (_, filepath) in db_pairs {
329            if !FilesystemService::file_exists(&filepath) {
330                // File no longer exists, delete from database
331                match self.db_service.delete_document(&filepath) {
332                    Ok(_) => results.push(SyncResult::Deleted { filepath }),
333                    Err(e) => results.push(SyncResult::Error {
334                        filepath,
335                        error: e.to_string(),
336                    }),
337                }
338            }
339        }
340
341        Ok(results)
342    }
343
344    /// Verify database and filesystem are in sync
345    pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
346        let mut issues = Vec::new();
347
348        // Find all markdown files
349        let files = FilesystemService::find_markdown_files(&dir_path)?;
350
351        // Check each file
352        for file_path in &files {
353            let path_str = file_path.to_string();
354
355            if let Some(db_doc) = self.db_service.find_by_filepath(&path_str)? {
356                let current_hash = FilesystemService::compute_file_hash(file_path)?;
357                if db_doc.file_hash != current_hash {
358                    issues.push(SyncIssue::OutOfSync {
359                        filepath: path_str,
360                        reason: "File hash mismatch".to_string(),
361                    });
362                }
363            } else {
364                issues.push(SyncIssue::MissingFromDatabase { filepath: path_str });
365            }
366        }
367
368        // Check for orphaned database entries
369        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
370        for (_, filepath) in db_pairs {
371            if !files.contains(&filepath) && !FilesystemService::file_exists(&filepath) {
372                issues.push(SyncIssue::MissingFromFilesystem {
373                    filepath: filepath.clone(),
374                });
375            }
376        }
377
378        Ok(issues)
379    }
380}
381
382/// Result of synchronizing a single document
383#[derive(Debug, Clone, PartialEq)]
384pub enum SyncResult {
385    Imported { filepath: String },
386    Updated { filepath: String },
387    Deleted { filepath: String },
388    UpToDate { filepath: String },
389    NotFound { filepath: String },
390    Error { filepath: String, error: String },
391    Moved { from: String, to: String },
392}
393
394impl SyncResult {
395    /// Get the filepath for this result
396    pub fn filepath(&self) -> &str {
397        match self {
398            SyncResult::Imported { filepath }
399            | SyncResult::Updated { filepath }
400            | SyncResult::Deleted { filepath }
401            | SyncResult::UpToDate { filepath }
402            | SyncResult::NotFound { filepath }
403            | SyncResult::Error { filepath, .. } => filepath,
404            SyncResult::Moved { to, .. } => to,
405        }
406    }
407
408    /// Check if this result represents a change
409    pub fn is_change(&self) -> bool {
410        matches!(
411            self,
412            SyncResult::Imported { .. }
413                | SyncResult::Updated { .. }
414                | SyncResult::Deleted { .. }
415                | SyncResult::Moved { .. }
416        )
417    }
418
419    /// Check if this result represents an error
420    pub fn is_error(&self) -> bool {
421        matches!(self, SyncResult::Error { .. })
422    }
423}
424
425/// Issues found during sync verification
426#[derive(Debug, Clone)]
427pub enum SyncIssue {
428    MissingFromDatabase { filepath: String },
429    MissingFromFilesystem { filepath: String },
430    OutOfSync { filepath: String, reason: String },
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::dal::Database;
437    use tempfile::tempdir;
438
439    fn setup_services() -> (tempfile::TempDir, DatabaseService) {
440        let temp_dir = tempdir().expect("Failed to create temp dir");
441        let db = Database::new(":memory:").expect("Failed to create test database");
442        let db_service = DatabaseService::new(db.into_repository());
443        (temp_dir, db_service)
444    }
445
446    fn create_test_document_content() -> String {
447        "---\n".to_string()
448            + "id: test-document\n"
449            + "title: Test Document\n"
450            + "level: vision\n"
451            + "created_at: \"2021-01-01T00:00:00Z\"\n"
452            + "updated_at: \"2021-01-01T00:00:00Z\"\n"
453            + "archived: false\n"
454            + "short_code: TEST-V-9003\n"
455            + "exit_criteria_met: false\n"
456            + "tags:\n"
457            + "  - \"#phase/draft\"\n"
458            + "---\n\n"
459            + "# Test Document\n\n"
460            + "Test content.\n"
461    }
462
463    #[tokio::test]
464    async fn test_import_from_file() {
465        let (temp_dir, mut db_service) = setup_services();
466        let mut sync_service = SyncService::new(&mut db_service);
467
468        let file_path = temp_dir.path().join("test.md");
469        FilesystemService::write_file(&file_path, &create_test_document_content())
470            .expect("Failed to write file");
471
472        let doc = sync_service
473            .import_from_file(&file_path)
474            .await
475            .expect("Failed to import");
476        assert_eq!(doc.title, "Test Document");
477        assert_eq!(doc.document_type, "vision");
478
479        // Verify it's in the database
480        assert!(db_service
481            .document_exists(&file_path.to_string_lossy())
482            .expect("Failed to check"));
483    }
484
485    #[tokio::test]
486    async fn test_sync_file_operations() {
487        let (temp_dir, mut db_service) = setup_services();
488        let mut sync_service = SyncService::new(&mut db_service);
489
490        let file_path = temp_dir.path().join("test.md");
491        let path_str = file_path.to_string_lossy().to_string();
492
493        // Test sync when file doesn't exist
494        let result = sync_service
495            .sync_file(&file_path)
496            .await
497            .expect("Failed to sync");
498        assert_eq!(
499            result,
500            SyncResult::NotFound {
501                filepath: path_str.clone()
502            }
503        );
504
505        // Create file and sync
506        FilesystemService::write_file(&file_path, &create_test_document_content())
507            .expect("Failed to write file");
508
509        let result = sync_service
510            .sync_file(&file_path)
511            .await
512            .expect("Failed to sync");
513        assert_eq!(
514            result,
515            SyncResult::Imported {
516                filepath: path_str.clone()
517            }
518        );
519
520        // Sync again - should be up to date
521        let result = sync_service
522            .sync_file(&file_path)
523            .await
524            .expect("Failed to sync");
525        assert_eq!(
526            result,
527            SyncResult::UpToDate {
528                filepath: path_str.clone()
529            }
530        );
531
532        // Modify file
533        let modified_content =
534            &create_test_document_content().replace("Test content.", "Modified content.");
535        FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
536
537        let result = sync_service
538            .sync_file(&file_path)
539            .await
540            .expect("Failed to sync");
541        assert_eq!(
542            result,
543            SyncResult::Updated {
544                filepath: path_str.clone()
545            }
546        );
547
548        // Delete file
549        FilesystemService::delete_file(&file_path).expect("Failed to delete");
550
551        let result = sync_service
552            .sync_file(&file_path)
553            .await
554            .expect("Failed to sync");
555        assert_eq!(
556            result,
557            SyncResult::Deleted {
558                filepath: path_str.clone()
559            }
560        );
561
562        // Verify it's gone from database
563        assert!(!db_service
564            .document_exists(&path_str)
565            .expect("Failed to check"));
566    }
567
568    #[tokio::test]
569    async fn test_sync_directory() {
570        let (temp_dir, mut db_service) = setup_services();
571        let mut sync_service = SyncService::new(&mut db_service);
572
573        // Create multiple files
574        let files = vec![
575            ("doc1.md", "test-1"),
576            ("subdir/doc2.md", "test-2"),
577            ("subdir/nested/doc3.md", "test-3"),
578        ];
579
580        for (i, (file_path, id)) in files.iter().enumerate() {
581            let full_path = temp_dir.path().join(file_path);
582            let content = &create_test_document_content()
583                .replace("Test Document", &format!("Test Document {}", id))
584                .replace("test-document", id)
585                .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
586            FilesystemService::write_file(&full_path, content).expect("Failed to write");
587        }
588
589        // Sync directory
590        let results = sync_service
591            .sync_directory(temp_dir.path())
592            .await
593            .expect("Failed to sync directory");
594
595        // Should have 3 imports
596        let imports = results
597            .iter()
598            .filter(|r| matches!(r, SyncResult::Imported { .. }))
599            .count();
600        assert_eq!(imports, 3);
601
602        // Sync again - all should be up to date
603        let results = sync_service
604            .sync_directory(temp_dir.path())
605            .await
606            .expect("Failed to sync directory");
607        let up_to_date = results
608            .iter()
609            .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
610            .count();
611        assert_eq!(up_to_date, 3);
612
613        // Check that we have results for all files
614        for (file_path, _) in &files {
615            let full_path = temp_dir
616                .path()
617                .join(file_path)
618                .to_string_lossy()
619                .to_string();
620            assert!(results.iter().any(|r| r.filepath() == full_path));
621        }
622    }
623}