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    /// Recover short code counters from filesystem by scanning all documents
382    ///
383    /// This should only be called when:
384    /// - Database is missing or corrupt
385    /// - Explicit recovery is requested by user
386    ///
387    /// Returns a map of document type to the highest counter found
388    pub fn recover_counters_from_filesystem<P: AsRef<Path>>(
389        &self,
390        dir_path: P,
391    ) -> Result<std::collections::HashMap<String, u32>> {
392        use gray_matter::{engine::YAML, Matter};
393        use std::collections::HashMap;
394
395        let mut counters: HashMap<String, u32> = HashMap::new();
396        let mut skipped_files = 0;
397        let mut invalid_short_codes = 0;
398
399        let dir_path = dir_path.as_ref();
400
401        // Guard: Ensure directory exists
402        if !dir_path.exists() {
403            tracing::warn!("Counter recovery: directory does not exist: {}", dir_path.display());
404            return Ok(counters);
405        }
406
407        // Find all markdown files
408        let files = FilesystemService::find_markdown_files(&dir_path)?;
409        tracing::info!("Counter recovery: scanning {} markdown files", files.len());
410
411        for file_path in files {
412            // Guard: Read file with error handling
413            let content = match std::fs::read_to_string(&file_path) {
414                Ok(c) => c,
415                Err(e) => {
416                    tracing::warn!("Counter recovery: skipping unreadable file {}: {}", file_path, e);
417                    skipped_files += 1;
418                    continue;
419                }
420            };
421
422            // Parse frontmatter
423            let matter = Matter::<YAML>::new();
424            let result = matter.parse(&content);
425
426            if let Some(frontmatter) = result.data {
427                let fm_map = match frontmatter {
428                    gray_matter::Pod::Hash(map) => map,
429                    _ => continue,
430                };
431
432                // Extract short_code
433                if let Some(gray_matter::Pod::String(short_code)) = fm_map.get("short_code") {
434                    // Guard: Validate format
435                    if !Self::is_valid_short_code_format(short_code) {
436                        tracing::warn!(
437                            "Counter recovery: invalid short code '{}' in {}",
438                            short_code,
439                            file_path
440                        );
441                        invalid_short_codes += 1;
442                        continue;
443                    }
444
445                    // Parse: PREFIX-TYPE-NNNN
446                    if let Some((_, type_and_num)) = short_code.split_once('-') {
447                        if let Some((type_letter, num_str)) = type_and_num.split_once('-') {
448                            let doc_type = match type_letter {
449                                "V" => "vision",
450                                "S" => "strategy",
451                                "I" => "initiative",
452                                "T" => "task",
453                                "A" => "adr",
454                                _ => continue,
455                            };
456
457                            // Guard: Parse and validate number
458                            match num_str.parse::<u32>() {
459                                Ok(num) if num <= 1_000_000 => {
460                                    counters
461                                        .entry(doc_type.to_string())
462                                        .and_modify(|max| {
463                                            if num > *max {
464                                                *max = num;
465                                            }
466                                        })
467                                        .or_insert(num);
468                                }
469                                Ok(num) => {
470                                    tracing::warn!(
471                                        "Counter recovery: suspiciously large counter {} in {}, skipping",
472                                        num,
473                                        file_path
474                                    );
475                                }
476                                Err(e) => {
477                                    tracing::warn!(
478                                        "Counter recovery: invalid number '{}' in {}: {}",
479                                        num_str,
480                                        file_path,
481                                        e
482                                    );
483                                    invalid_short_codes += 1;
484                                }
485                            }
486                        }
487                    }
488                }
489            }
490        }
491
492        if skipped_files > 0 || invalid_short_codes > 0 {
493            tracing::warn!(
494                "Counter recovery: {} files skipped, {} invalid short codes",
495                skipped_files,
496                invalid_short_codes
497            );
498        }
499
500        tracing::info!("Recovered counters: {:?}", counters);
501        Ok(counters)
502    }
503
504    /// Validate short code format: PREFIX-TYPE-NNNN
505    fn is_valid_short_code_format(short_code: &str) -> bool {
506        let parts: Vec<&str> = short_code.split('-').collect();
507        if parts.len() != 3 {
508            return false;
509        }
510
511        let prefix = parts[0];
512        let type_letter = parts[1];
513        let number = parts[2];
514
515        // Prefix: 2-8 uppercase letters
516        if prefix.len() < 2 || prefix.len() > 8 || !prefix.chars().all(|c| c.is_ascii_uppercase()) {
517            return false;
518        }
519
520        // Type: single letter from allowed set
521        if !matches!(type_letter, "V" | "S" | "I" | "T" | "A") {
522            return false;
523        }
524
525        // Number: exactly 4 digits
526        number.len() == 4 && number.chars().all(|c| c.is_ascii_digit())
527    }
528}
529
530/// Result of synchronizing a single document
531#[derive(Debug, Clone, PartialEq)]
532pub enum SyncResult {
533    Imported { filepath: String },
534    Updated { filepath: String },
535    Deleted { filepath: String },
536    UpToDate { filepath: String },
537    NotFound { filepath: String },
538    Error { filepath: String, error: String },
539    Moved { from: String, to: String },
540}
541
542impl SyncResult {
543    /// Get the filepath for this result
544    pub fn filepath(&self) -> &str {
545        match self {
546            SyncResult::Imported { filepath }
547            | SyncResult::Updated { filepath }
548            | SyncResult::Deleted { filepath }
549            | SyncResult::UpToDate { filepath }
550            | SyncResult::NotFound { filepath }
551            | SyncResult::Error { filepath, .. } => filepath,
552            SyncResult::Moved { to, .. } => to,
553        }
554    }
555
556    /// Check if this result represents a change
557    pub fn is_change(&self) -> bool {
558        matches!(
559            self,
560            SyncResult::Imported { .. }
561                | SyncResult::Updated { .. }
562                | SyncResult::Deleted { .. }
563                | SyncResult::Moved { .. }
564        )
565    }
566
567    /// Check if this result represents an error
568    pub fn is_error(&self) -> bool {
569        matches!(self, SyncResult::Error { .. })
570    }
571}
572
573/// Issues found during sync verification
574#[derive(Debug, Clone)]
575pub enum SyncIssue {
576    MissingFromDatabase { filepath: String },
577    MissingFromFilesystem { filepath: String },
578    OutOfSync { filepath: String, reason: String },
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::dal::Database;
585    use tempfile::tempdir;
586
587    fn setup_services() -> (tempfile::TempDir, DatabaseService) {
588        let temp_dir = tempdir().expect("Failed to create temp dir");
589        let db = Database::new(":memory:").expect("Failed to create test database");
590        let db_service = DatabaseService::new(db.into_repository());
591        (temp_dir, db_service)
592    }
593
594    fn create_test_document_content() -> String {
595        "---\n".to_string()
596            + "id: test-document\n"
597            + "title: Test Document\n"
598            + "level: vision\n"
599            + "created_at: \"2021-01-01T00:00:00Z\"\n"
600            + "updated_at: \"2021-01-01T00:00:00Z\"\n"
601            + "archived: false\n"
602            + "short_code: TEST-V-9003\n"
603            + "exit_criteria_met: false\n"
604            + "tags:\n"
605            + "  - \"#phase/draft\"\n"
606            + "---\n\n"
607            + "# Test Document\n\n"
608            + "Test content.\n"
609    }
610
611    #[tokio::test]
612    async fn test_import_from_file() {
613        let (temp_dir, mut db_service) = setup_services();
614        let mut sync_service = SyncService::new(&mut db_service);
615
616        let file_path = temp_dir.path().join("test.md");
617        FilesystemService::write_file(&file_path, &create_test_document_content())
618            .expect("Failed to write file");
619
620        let doc = sync_service
621            .import_from_file(&file_path)
622            .await
623            .expect("Failed to import");
624        assert_eq!(doc.title, "Test Document");
625        assert_eq!(doc.document_type, "vision");
626
627        // Verify it's in the database
628        assert!(db_service
629            .document_exists(&file_path.to_string_lossy())
630            .expect("Failed to check"));
631    }
632
633    #[tokio::test]
634    async fn test_sync_file_operations() {
635        let (temp_dir, mut db_service) = setup_services();
636        let mut sync_service = SyncService::new(&mut db_service);
637
638        let file_path = temp_dir.path().join("test.md");
639        let path_str = file_path.to_string_lossy().to_string();
640
641        // Test sync when file doesn't exist
642        let result = sync_service
643            .sync_file(&file_path)
644            .await
645            .expect("Failed to sync");
646        assert_eq!(
647            result,
648            SyncResult::NotFound {
649                filepath: path_str.clone()
650            }
651        );
652
653        // Create file and sync
654        FilesystemService::write_file(&file_path, &create_test_document_content())
655            .expect("Failed to write file");
656
657        let result = sync_service
658            .sync_file(&file_path)
659            .await
660            .expect("Failed to sync");
661        assert_eq!(
662            result,
663            SyncResult::Imported {
664                filepath: path_str.clone()
665            }
666        );
667
668        // Sync again - should be up to date
669        let result = sync_service
670            .sync_file(&file_path)
671            .await
672            .expect("Failed to sync");
673        assert_eq!(
674            result,
675            SyncResult::UpToDate {
676                filepath: path_str.clone()
677            }
678        );
679
680        // Modify file
681        let modified_content =
682            &create_test_document_content().replace("Test content.", "Modified content.");
683        FilesystemService::write_file(&file_path, modified_content).expect("Failed to write");
684
685        let result = sync_service
686            .sync_file(&file_path)
687            .await
688            .expect("Failed to sync");
689        assert_eq!(
690            result,
691            SyncResult::Updated {
692                filepath: path_str.clone()
693            }
694        );
695
696        // Delete file
697        FilesystemService::delete_file(&file_path).expect("Failed to delete");
698
699        let result = sync_service
700            .sync_file(&file_path)
701            .await
702            .expect("Failed to sync");
703        assert_eq!(
704            result,
705            SyncResult::Deleted {
706                filepath: path_str.clone()
707            }
708        );
709
710        // Verify it's gone from database
711        assert!(!db_service
712            .document_exists(&path_str)
713            .expect("Failed to check"));
714    }
715
716    #[tokio::test]
717    async fn test_sync_directory() {
718        let (temp_dir, mut db_service) = setup_services();
719        let mut sync_service = SyncService::new(&mut db_service);
720
721        // Create multiple files
722        let files = vec![
723            ("doc1.md", "test-1"),
724            ("subdir/doc2.md", "test-2"),
725            ("subdir/nested/doc3.md", "test-3"),
726        ];
727
728        for (i, (file_path, id)) in files.iter().enumerate() {
729            let full_path = temp_dir.path().join(file_path);
730            let content = &create_test_document_content()
731                .replace("Test Document", &format!("Test Document {}", id))
732                .replace("test-document", id)
733                .replace("TEST-V-9003", &format!("TEST-V-900{}", i + 3));
734            FilesystemService::write_file(&full_path, content).expect("Failed to write");
735        }
736
737        // Sync directory
738        let results = sync_service
739            .sync_directory(temp_dir.path())
740            .await
741            .expect("Failed to sync directory");
742
743        // Should have 3 imports
744        let imports = results
745            .iter()
746            .filter(|r| matches!(r, SyncResult::Imported { .. }))
747            .count();
748        assert_eq!(imports, 3);
749
750        // Sync again - all should be up to date
751        let results = sync_service
752            .sync_directory(temp_dir.path())
753            .await
754            .expect("Failed to sync directory");
755        let up_to_date = results
756            .iter()
757            .filter(|r| matches!(r, SyncResult::UpToDate { .. }))
758            .count();
759        assert_eq!(up_to_date, 3);
760
761        // Check that we have results for all files
762        for (file_path, _) in &files {
763            let full_path = temp_dir
764                .path()
765                .join(file_path)
766                .to_string_lossy()
767                .to_string();
768            assert!(results.iter().any(|r| r.filepath() == full_path));
769        }
770    }
771}