metis_core/application/services/
synchronization.rs

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