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    /// Note: We store the original path reference without canonicalizing here
26    /// because canonicalization requires owned PathBuf. The caller should ensure
27    /// paths are properly resolved when needed.
28    pub fn with_workspace_dir(mut self, workspace_dir: &'a Path) -> Self {
29        self.workspace_dir = Some(workspace_dir);
30        self
31    }
32
33    /// Convert absolute path to relative path (relative to workspace directory)
34    /// Returns the path as-is if workspace_dir is not set or if stripping fails
35    fn to_relative_path<P: AsRef<Path>>(&self, absolute_path: P) -> String {
36        if let Some(workspace_dir) = self.workspace_dir {
37            if let Ok(relative) = absolute_path.as_ref().strip_prefix(workspace_dir) {
38                return relative.to_string_lossy().to_string();
39            }
40        }
41        // Fallback to absolute path if no workspace or stripping fails
42        absolute_path.as_ref().to_string_lossy().to_string()
43    }
44
45    /// Convert relative path to absolute path (prepends workspace directory)
46    /// Returns the path as-is if workspace_dir is not set
47    fn to_absolute_path(&self, relative_path: &str) -> std::path::PathBuf {
48        if let Some(workspace_dir) = self.workspace_dir {
49            workspace_dir.join(relative_path)
50        } else {
51            // Fallback: assume it's already absolute
52            std::path::PathBuf::from(relative_path)
53        }
54    }
55
56    /// Direction 1: File → DocumentObject → Database
57    /// Load a document from filesystem and store in database
58    pub async fn import_from_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<Document> {
59        // Convert absolute path to relative path for database storage
60        let path_str = self.to_relative_path(&file_path);
61
62        // Use DocumentFactory to parse file into domain object
63        let document_obj = DocumentFactory::from_file(&file_path).await.map_err(|e| {
64            MetisError::ValidationFailed {
65                message: format!("Failed to parse document: {}", e),
66            }
67        })?;
68
69        // Get file metadata
70        let file_hash = FilesystemService::compute_file_hash(&file_path)?;
71        let updated_at = FilesystemService::get_file_mtime(&file_path)?;
72        let content = FilesystemService::read_file(&file_path)?;
73
74        // Convert domain object to database model
75        let new_doc = self.domain_to_database_model(
76            document_obj.as_ref(),
77            &path_str,
78            file_hash,
79            updated_at,
80            content,
81        )?;
82
83        // Store in database
84        self.db_service.create_document(new_doc)
85    }
86
87    /// Direction 2: Database → DocumentObject → File
88    /// Export a document from database to filesystem
89    pub async fn export_to_file(&mut self, filepath: &str) -> Result<()> {
90        // Get document from database (filepath in DB is relative)
91        let db_doc = self.db_service.find_by_filepath(filepath)?.ok_or_else(|| {
92            MetisError::DocumentNotFound {
93                id: filepath.to_string(),
94            }
95        })?;
96
97        // Get content from database
98        let content = db_doc.content.ok_or_else(|| MetisError::ValidationFailed {
99            message: "Document has no content".to_string(),
100        })?;
101
102        // Convert relative path to absolute for filesystem access
103        let absolute_path = self.to_absolute_path(filepath);
104
105        // Write to filesystem
106        FilesystemService::write_file(absolute_path, &content)?;
107
108        Ok(())
109    }
110
111    /// Convert domain object to database model
112    fn domain_to_database_model(
113        &self,
114        document_obj: &dyn DocumentTrait,
115        filepath: &str,
116        file_hash: String,
117        updated_at: f64,
118        content: String,
119    ) -> Result<NewDocument> {
120        let core = document_obj.core();
121        let phase = document_obj
122            .phase()
123            .map_err(|e| MetisError::ValidationFailed {
124                message: format!("Failed to get document phase: {}", e),
125            })?
126            .to_string();
127
128        // Extract lineage from filesystem path if workspace directory is available
129        let (fs_strategy_id, fs_initiative_id) = if let Some(workspace_dir) = self.workspace_dir {
130            Self::extract_lineage_from_path(filepath, workspace_dir)
131        } else {
132            (None, None)
133        };
134
135        // Use filesystem lineage if available, otherwise use document lineage
136        let final_strategy_id = fs_strategy_id
137            .or_else(|| core.strategy_id.clone())
138            .map(|id| id.to_string());
139        let final_initiative_id = fs_initiative_id
140            .or_else(|| core.initiative_id.clone())
141            .map(|id| id.to_string());
142
143        Ok(NewDocument {
144            filepath: filepath.to_string(),
145            id: document_obj.id().to_string(),
146            title: core.title.clone(),
147            document_type: document_obj.document_type().to_string(),
148            created_at: core.metadata.created_at.timestamp() as f64,
149            updated_at,
150            archived: core.archived,
151            exit_criteria_met: document_obj.exit_criteria_met(),
152            file_hash,
153            frontmatter_json: serde_json::to_string(&core.metadata).map_err(MetisError::Json)?,
154            content: Some(content),
155            phase,
156            strategy_id: final_strategy_id,
157            initiative_id: final_initiative_id,
158            short_code: core.metadata.short_code.clone(),
159        })
160    }
161
162    /// Extract lineage information from file path
163    /// Returns (strategy_id, initiative_id) based on filesystem structure
164    fn extract_lineage_from_path<P: AsRef<Path>>(
165        file_path: P,
166        workspace_dir: &Path,
167    ) -> (Option<DocumentId>, Option<DocumentId>) {
168        let path = file_path.as_ref();
169
170        // Get relative path from workspace
171        let relative_path = match path.strip_prefix(workspace_dir) {
172            Ok(rel) => rel,
173            Err(_) => return (None, None),
174        };
175
176        let path_parts: Vec<&str> = relative_path
177            .components()
178            .filter_map(|c| c.as_os_str().to_str())
179            .collect();
180
181        // Match the path structure
182        match path_parts.as_slice() {
183            // strategies/{strategy-id}/strategy.md
184            ["strategies", strategy_id, "strategy.md"] => {
185                if strategy_id == &"NULL" {
186                    (None, None)
187                } else {
188                    (Some(DocumentId::from(*strategy_id)), None)
189                }
190            }
191            // strategies/{strategy-id}/initiatives/{initiative-id}/initiative.md
192            ["strategies", strategy_id, "initiatives", initiative_id, "initiative.md"] => {
193                let strat_id = if strategy_id == &"NULL" {
194                    None
195                } else {
196                    Some(DocumentId::from(*strategy_id))
197                };
198                let init_id = if initiative_id == &"NULL" {
199                    None
200                } else {
201                    Some(DocumentId::from(*initiative_id))
202                };
203                (strat_id, init_id)
204            }
205            // strategies/{strategy-id}/initiatives/{initiative-id}/tasks/{task-id}.md
206            ["strategies", strategy_id, "initiatives", initiative_id, "tasks", _] => {
207                let strat_id = if strategy_id == &"NULL" {
208                    None
209                } else {
210                    Some(DocumentId::from(*strategy_id))
211                };
212                let init_id = if initiative_id == &"NULL" {
213                    None
214                } else {
215                    Some(DocumentId::from(*initiative_id))
216                };
217                (strat_id, init_id)
218            }
219            // backlog/{task-id}.md (no lineage)
220            ["backlog", _] => (None, None),
221            // adrs/{adr-id}.md (no lineage)
222            ["adrs", _] => (None, None),
223            // vision.md (no lineage)
224            ["vision.md"] => (None, None),
225            // Default: no lineage
226            _ => (None, None),
227        }
228    }
229
230    /// Extract document short code from file without keeping the document object around
231    fn extract_document_short_code<P: AsRef<Path>>(file_path: P) -> Result<String> {
232        // Read file content to extract frontmatter and get document short code
233        let raw_content = std::fs::read_to_string(file_path.as_ref()).map_err(|e| {
234            MetisError::ValidationFailed {
235                message: format!("Failed to read file: {}", e),
236            }
237        })?;
238
239        // Parse frontmatter to get document short code
240        use gray_matter::{engine::YAML, Matter};
241        let matter = Matter::<YAML>::new();
242        let result = matter.parse(&raw_content);
243
244        // Extract short_code from frontmatter
245        if let Some(frontmatter) = result.data {
246            let fm_map = match frontmatter {
247                gray_matter::Pod::Hash(map) => map,
248                _ => {
249                    return Err(MetisError::ValidationFailed {
250                        message: "Frontmatter must be a hash/map".to_string(),
251                    });
252                }
253            };
254
255            if let Some(gray_matter::Pod::String(short_code_str)) = fm_map.get("short_code") {
256                return Ok(short_code_str.clone());
257            }
258        }
259
260        Err(MetisError::ValidationFailed {
261            message: "Document missing short_code in frontmatter".to_string(),
262        })
263    }
264
265    /// Update a document that has been moved to a new path
266    async fn update_moved_document<P: AsRef<Path>>(
267        &mut self,
268        existing_doc: &Document,
269        new_file_path: P,
270    ) -> Result<()> {
271        // Delete the old database entry first (to handle foreign key constraints)
272        self.db_service.delete_document(&existing_doc.filepath)?;
273
274        // Import the document at the new path
275        self.import_from_file(&new_file_path).await?;
276
277        Ok(())
278    }
279
280    /// Synchronize a single file between filesystem and database using directional methods
281    pub async fn sync_file<P: AsRef<Path>>(&mut self, file_path: P) -> Result<SyncResult> {
282        // Convert absolute path to relative for database queries
283        let relative_path_str = self.to_relative_path(&file_path);
284
285        // Check if file exists on filesystem
286        let file_exists = FilesystemService::file_exists(&file_path);
287
288        // Check if document exists in database at this filepath (DB stores relative paths)
289        let db_doc_by_path = self.db_service.find_by_filepath(&relative_path_str)?;
290
291        match (file_exists, db_doc_by_path) {
292            // File exists, not in database at this path - need to check if it's a moved document
293            (true, None) => {
294                // Extract the document short code without creating full document object
295                let short_code = Self::extract_document_short_code(&file_path)?;
296
297                // Check if a document with this short code exists at a different path
298                if let Some(existing_doc) = self.db_service.find_by_short_code(&short_code)? {
299                    // Document moved - update the existing record
300                    let old_path = existing_doc.filepath.clone();
301                    self.update_moved_document(&existing_doc, &file_path)
302                        .await?;
303                    Ok(SyncResult::Moved {
304                        from: old_path,
305                        to: relative_path_str,
306                    })
307                } else {
308                    // Truly new document - import it
309                    self.import_from_file(&file_path).await?;
310                    Ok(SyncResult::Imported {
311                        filepath: relative_path_str,
312                    })
313                }
314            }
315
316            // File doesn't exist, but in database - remove from database
317            (false, Some(_)) => {
318                self.db_service.delete_document(&relative_path_str)?;
319                Ok(SyncResult::Deleted {
320                    filepath: relative_path_str,
321                })
322            }
323
324            // Both exist - check if file changed
325            (true, Some(db_doc)) => {
326                let current_hash = FilesystemService::compute_file_hash(&file_path)?;
327
328                if db_doc.file_hash != current_hash {
329                    // File changed, reimport (file is source of truth)
330                    self.db_service.delete_document(&relative_path_str)?;
331                    self.import_from_file(&file_path).await?;
332                    Ok(SyncResult::Updated {
333                        filepath: relative_path_str,
334                    })
335                } else {
336                    Ok(SyncResult::UpToDate {
337                        filepath: relative_path_str,
338                    })
339                }
340            }
341
342            // Neither exists
343            (false, None) => Ok(SyncResult::NotFound {
344                filepath: relative_path_str,
345            }),
346        }
347    }
348
349    /// Sync all markdown files in a directory
350    pub async fn sync_directory<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncResult>> {
351        let mut results = Vec::new();
352
353        // Find all markdown files
354        let files = FilesystemService::find_markdown_files(&dir_path)?;
355
356        // Sync each file
357        for file_path in files {
358            match self.sync_file(&file_path).await {
359                Ok(result) => results.push(result),
360                Err(e) => results.push(SyncResult::Error {
361                    filepath: file_path,
362                    error: e.to_string(),
363                }),
364            }
365        }
366
367        // Check for orphaned database entries (files that were deleted)
368        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
369        for (_, relative_filepath) in db_pairs {
370            // Convert relative path from DB to absolute for filesystem check
371            let absolute_path = self.to_absolute_path(&relative_filepath);
372            if !FilesystemService::file_exists(&absolute_path) {
373                // File no longer exists, delete from database
374                match self.db_service.delete_document(&relative_filepath) {
375                    Ok(_) => results.push(SyncResult::Deleted {
376                        filepath: relative_filepath,
377                    }),
378                    Err(e) => results.push(SyncResult::Error {
379                        filepath: relative_filepath,
380                        error: e.to_string(),
381                    }),
382                }
383            }
384        }
385
386        Ok(results)
387    }
388
389    /// Verify database and filesystem are in sync
390    pub fn verify_sync<P: AsRef<Path>>(&mut self, dir_path: P) -> Result<Vec<SyncIssue>> {
391        let mut issues = Vec::new();
392
393        // Find all markdown files (returns absolute paths)
394        let files = FilesystemService::find_markdown_files(&dir_path)?;
395
396        // Check each file
397        for file_path in &files {
398            // Convert absolute path to relative for DB query
399            let relative_path = self.to_relative_path(file_path);
400
401            if let Some(db_doc) = self.db_service.find_by_filepath(&relative_path)? {
402                let current_hash = FilesystemService::compute_file_hash(file_path)?;
403                if db_doc.file_hash != current_hash {
404                    issues.push(SyncIssue::OutOfSync {
405                        filepath: relative_path,
406                        reason: "File hash mismatch".to_string(),
407                    });
408                }
409            } else {
410                issues.push(SyncIssue::MissingFromDatabase {
411                    filepath: relative_path,
412                });
413            }
414        }
415
416        // Check for orphaned database entries
417        let db_pairs = self.db_service.get_all_id_filepath_pairs()?;
418        for (_, relative_filepath) in db_pairs {
419            // Convert relative path from DB to absolute for filesystem check
420            let absolute_path = self.to_absolute_path(&relative_filepath);
421            let absolute_str = absolute_path.to_string_lossy().to_string();
422            if !files.contains(&absolute_str) && !FilesystemService::file_exists(&absolute_path) {
423                issues.push(SyncIssue::MissingFromFilesystem {
424                    filepath: relative_filepath,
425                });
426            }
427        }
428
429        Ok(issues)
430    }
431
432    /// Recover short code counters from filesystem by scanning all documents
433    ///
434    /// This should only be called when:
435    /// - Database is missing or corrupt
436    /// - Explicit recovery is requested by user
437    ///
438    /// Returns a map of document type to the highest counter found
439    pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
440        &self,
441        dir_path: P,
442    ) -> Result<std::collections::HashMap<String, u32>> {
443        use gray_matter::{engine::YAML, Matter};
444        use std::collections::HashMap;
445
446        let mut counters: HashMap<String, u32> = HashMap::new();
447        let mut skipped_files = 0;
448        let mut invalid_short_codes = 0;
449
450        let dir_path = dir_path.as_ref();
451
452        // Guard: Ensure directory exists
453        if !dir_path.exists() {
454            tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
455            return Ok(counters);
456        }
457
458        // Find all markdown files
459        let files = FilesystemService::find_markdown_files(&dir_path)?;
460        tracing::info!("Counter recovery: scanning {} markdown files", files.len());
461
462        for file_path in files {
463            // Guard: Read file with error handling
464            let content = match std::fs::read_to_string(&file_path) {
465                Ok(c) => c,
466                Err(e) => {
467                    tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
468                    skipped_files += 1;
469                    continue;
470                }
471            };
472
473            // Parse frontmatter
474            let matter = Matter::<YAML>::new();
475            let result = matter.parse(&content);
476
477            if let Some(frontmatter) = result.data {
478                let fm_map = match frontmatter {
479                    gray_matter::Pod::Hash(map) => map,
480                    _ => continue,
481                };
482
483                // Extract short_code
484                if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
485                    // Guard: Validate format
486                    if !Self::is_valid_short_code_format(short_code) {
487                        tracing::warn!(
488                            "Counter recovery: invalid short code '{}' in {}",
489                            short_code,
490                            file_path
491                        );
492                        invalid_short_codes += 1;
493                        continue;
494                    }
495
496                    // Parse: PREFIX-TYPE-NNNN
497                    if let Some((_, type_and_num)) = short_code.split_once('-') {
498                        if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
499                            let doc_type = match type_letter {
500                                "V" => "vision",
501                                "S" => "strategy",
502                                "I" => "initiative",
503                                "T" => "task",
504                                "A" => "adr",
505                                _ => continue,
506                            };
507
508                            // Guard: Parse and validate number
509                            match num_str.parse::<u32>() {
510                                Ok(num) if num <= 1_000_000 => {
511                                    counters
512                                        .entry(doc_type.to_string())
513                                        .and_modify(|max| {
514                                            if num > *max {
515                                                *max = num;
516                                            }
517                                        })
518                                        .or_insert(num);
519                                }
520                                Ok(num) => {
521                                    tracing::warn!(
522                                        "Counter recovery: suspiciously large counter {} in {}, skipping",
523                                        num,
524                                        file_path
525                                    );
526                                }
527                                Err(e) => {
528                                    tracing::warn!(
529                                        "Counter recovery: invalid number '{}' in {}: {}",
530                                        num_str,
531                                        file_path,
532                                        e
533                                    );
534                                    invalid_short_codes += 1;
535                                }
536                            }
537                        }
538                    }
539                }
540            }
541        }
542
543        if skipped_files > 0 || invalid_short_codes > 0 {
544            tracing::warn!(
545                "Counter recovery: {} files skipped, {} invalid short codes",
546                skipped_files,
547                invalid_short_codes
548            );
549        }
550
551        tracing::info!("Recovered counters: {:?}", counters);
552        Ok(counters)
553    }
554
555    /// Validate short code format: PREFIX-TYPE-NNNN
556    fn is_valid_short_code_format(short_code: &str) -> bool {
557        let parts: Vec<&str> = short_code.split('-').collect();
558        if parts.len() != 3 {
559            return false;
560        }
561
562        let prefix = parts[0];
563        let type_letter = parts[1];
564        let number = parts[2];
565
566        // Prefix: 2-8 uppercase letters
567        if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
568            return false;
569        }
570
571        // Type: single letter from allowed set
572        if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
573            return false;
574        }
575
576        // Number: exactly 4 digits
577        number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
578    }
579}
580
581/// Result of synchronizing a single document
582#[derive(Debug, Clone, PartialEq)]
583pub enum SyncResult {
584    Imported { filepath: String },
585    Updated { filepath: String },
586    Deleted { filepath: String },
587    UpToDate { filepath: String },
588    NotFound { filepath: String },
589    Error { filepath: String, error: String },
590    Moved { from: String, to: String },
591}
592
593impl SyncResult {
594    /// Get the filepath for this result
595    pub fn filepath(&self) -> &str {
596        match self {
597            SyncResult::Imported { filepath }
598            | SyncResult::Updated { filepath }
599            | SyncResult::Deleted { filepath }
600            | SyncResult::UpToDate { filepath }
601            | SyncResult::NotFound { filepath }
602            | SyncResult::Error { filepath, .. } => filepath,
603            SyncResult::Moved { to, .. } => to,
604        }
605    }
606
607    /// Check if this result represents a change
608    pub fn is_change(&self) -> bool {
609        matches!(
610            self,
611            SyncResult::Imported { .. }
612                | SyncResult::Updated { .. }
613                | SyncResult::Deleted { .. }
614                | SyncResult::Moved { .. }
615        )
616    }
617
618    /// Check if this result represents an error
619    pub fn is_error(&self) -> bool {
620        matches!(self, SyncResult::Error { .. })
621    }
622}
623
624/// Issues found during sync verification
625#[derive(Debug, Clone)]
626pub enum SyncIssue {
627    MissingFromDatabase { filepath: String },
628    MissingFromFilesystem { filepath: String },
629    OutOfSync { filepath: String, reason: String },
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::dal::Database;
636    use tempfile::tempdir;
637
638    fn setup_services() -> (tempfile::TempDir, DatabaseService) {
639        let temp_dir = tempdir().expect("Failed to create temp dir");
640        let db = Database::new(":memory:").expect("Failed to create test database");
641        let db_service = DatabaseService::new(db.into_repository());
642        (temp_dir, db_service)
643    }
644
645    fn create_test_document_content() -> String {
646        "---\n".to_string()
647            + "id: test-document\n"
648            + "title: Test Document\n"
649            + "level: vision\n"
650            + "created_at: \"2021-01-01T00:00:00Z\"\n"
651            + "updated_at: \"2021-01-01T00:00:00Z\"\n"
652            + "archived: false\n"
653            + "short_code: TEST-V-9003\n"
654            + "exit_criteria_met: false\n"
655            + "tags:\n"
656            + "  - \"#phase/draft\"\n"
657            + "---\n\n"
658            + "# Test Document\n\n"
659            + "Test content.\n"
660    }
661
662    #[tokio::test]
663    async fn test_import_from_file() {
664        let (temp_dir, mut db_service) = setup_services();
665        let mut sync_service = SyncService::new(&mut db_service);
666
667        let file_path = temp_dir.path().join("test.md");
668        FilesystemService::write_file(&file_path, &create_test_document_content())
669            .expect("Failed to write file");
670
671        let doc = sync_service
672            .import_from_file(&file_path)
673            .await
674            .expect("Failed to import");
675        assert_eq!(doc.title, "Test Document");
676        assert_eq!(doc.document_type, "vision");
677
678        // Verify it's in the database
679        assert!(db_service
680            .document_exists(&file_path.to_string_lossy())
681            .expect("Failed to check"));
682    }
683
684    #[tokio::test]
685    async fn test_sync_file_operations() {
686        let (temp_dir, mut db_service) = setup_services();
687        let mut sync_service = SyncService::new(&mut db_service);
688
689        let file_path = temp_dir.path().join("test.md");
690        let path_str = file_path.to_string_lossy().to_string();
691
692        // Test sync when file doesn't exist
693        let result = sync_service
694            .sync_file(&file_path)
695            .await
696            .expect("Failed to sync");
697        assert_eq!(
698            result,
699            SyncResult::NotFound {
700                filepath: path_str.clone()
701            }
702        );
703
704        // Create file and sync
705        FilesystemService::write_file(&file_path, &create_test_document_content())
706            .expect("Failed to write file");
707
708        let result = sync_service
709            .sync_file(&file_path)
710            .await
711            .expect("Failed to sync");
712        assert_eq!(
713            result,
714            SyncResult::Imported {
715                filepath: path_str.clone()
716            }
717        );
718
719        // Sync again - should be up to date
720        let result = sync_service
721            .sync_file(&file_path)
722            .await
723            .expect("Failed to sync");
724        assert_eq!(
725            result,
726            SyncResult::UpToDate {
727                filepath: path_str.clone()
728            }
729        );
730
731        // Modify file
732        let modified_content =
733            &create_test_document_content().replace("Test content.", "Modified content.");
734        FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
735
736        let result = sync_service
737            .sync_file(&file_path)
738            .await
739            .expect("Failed to sync");
740        assert_eq!(
741            result,
742            SyncResult::Updated {
743                filepath: path_str.clone()
744            }
745        );
746
747        // Delete file
748        FilesystemService::delete_file(&file_path).expect("Failed to delete");
749
750        let result = sync_service
751            .sync_file(&file_path)
752            .await
753            .expect("Failed to sync");
754        assert_eq!(
755            result,
756            SyncResult::Deleted {
757                filepath: path_str.clone()
758            }
759        );
760
761        // Verify it's gone from database
762        assert!(!db_service
763            .document_exists(&path_str)
764            .expect("Failed to check"));
765    }
766
767    #[tokio::test]
768    async fn test_sync_directory() {
769        let (temp_dir, mut db_service) = setup_services();
770        let mut sync_service = SyncService::new(&mut db_service);
771
772        // Create multiple files
773        let files = vec![
774            ("doc1.md", "test-1"),
775            ("subdir/doc2.md", "test-2"),
776            ("subdir/nested/doc3.md", "test-3"),
777        ];
778
779        for (i, (file_path, id)) in files.iter().enumerate() {
780            let full_path = temp_dir.path().join(file_path);
781            let content = &create_test_document_content()
782                .replace("Test Document", &format!("Test Document {}", id))
783                .replace("test-document", id)
784                .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
785            FilesystemService::write_file(&full_path, content).expect("Failed to write");
786        }
787
788        // Sync directory
789        let results = sync_service
790            .sync_directory(temp_dir.path())
791            .await
792            .expect("Failed to sync directory");
793
794        // Should have 3 imports
795        let imports = results
796            .iter()
797            .filter(|r| matches!(r, SyncResult::Imported { .. }))
798            .count();
799        assert_eq!(imports, 3);
800
801        // Sync again - all should be up to date
802        let results = sync_service
803            .sync_directory(temp_dir.path())
804            .await
805            .expect("Failed to sync directory");
806        let up_to_date = results
807            .iter()
808            .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
809            .count();
810        assert_eq!(up_to_date, 3);
811
812        // Check that we have results for all files
813        for (file_path, _) in &files {
814            let full_path = temp_dir
815                .path()
816                .join(file_path)
817                .to_string_lossy()
818                .to_string();
819            assert!(results.iter().any(|r| r.filepath() == full_path));
820        }
821    }
822}