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