Skip to main content

archidoc_engine/
validate.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use archidoc_types::{GhostEntry, ModuleDoc, OrphanEntry, ValidationReport};
5
6/// Validate file tables against the actual filesystem.
7///
8/// For each module with a file catalog:
9/// - **Ghost detection** (B4): catalog entries pointing to files that don't exist on disk
10/// - **Orphan detection** (B3): `.rs` files on disk not listed in any catalog
11///
12/// Modules without file catalogs are silently skipped.
13pub fn validate_file_tables(docs: &[ModuleDoc]) -> ValidationReport {
14    let mut report = ValidationReport::default();
15
16    for doc in docs {
17        if doc.files.is_empty() {
18            continue;
19        }
20
21        let source_dir = match Path::new(&doc.source_file).parent() {
22            Some(dir) => dir,
23            None => continue,
24        };
25
26        let source_dir_str = source_dir.to_string_lossy().to_string();
27
28        // Ghost detection: catalog entries pointing to non-existent files
29        let cataloged_names: HashSet<&str> = doc.files.iter().map(|f| f.name.as_str()).collect();
30
31        for file in &doc.files {
32            let file_path = source_dir.join(&file.name);
33            if !file_path.exists() {
34                report.ghosts.push(GhostEntry {
35                    element: doc.module_path.clone(),
36                    filename: file.name.clone(),
37                    source_dir: source_dir_str.clone(),
38                });
39            }
40        }
41
42        // Orphan detection: .rs files on disk not in the catalog
43        // Skip structural files (mod.rs, lib.rs)
44        let structural_files: HashSet<&str> =
45            ["mod.rs", "lib.rs", "main.rs"].iter().copied().collect();
46
47        if let Ok(entries) = std::fs::read_dir(source_dir) {
48            for entry in entries.filter_map(|e| e.ok()) {
49                let filename = entry.file_name();
50                let name = filename.to_string_lossy();
51
52                if name.ends_with(".rs")
53                    && !structural_files.contains(name.as_ref())
54                    && !cataloged_names.contains(name.as_ref())
55                {
56                    report.orphans.push(OrphanEntry {
57                        element: doc.module_path.clone(),
58                        filename: name.to_string(),
59                        source_dir: source_dir_str.clone(),
60                    });
61                }
62            }
63        }
64    }
65
66    report
67}
68
69/// Format a validation report as human-readable text.
70pub fn format_validation_report(report: &ValidationReport) -> String {
71    let mut out = String::new();
72
73    if report.is_clean() {
74        out.push_str("File validation: all clear\n");
75        return out;
76    }
77
78    if !report.ghosts.is_empty() {
79        out.push_str(&format!("Ghost entries ({} found):\n", report.ghosts.len()));
80        for ghost in &report.ghosts {
81            out.push_str(&format!(
82                "  {} — '{}' listed in catalog but not found on disk\n",
83                ghost.element, ghost.filename
84            ));
85        }
86    }
87
88    if !report.orphans.is_empty() {
89        out.push_str(&format!(
90            "Orphan files ({} found):\n",
91            report.orphans.len()
92        ));
93        for orphan in &report.orphans {
94            out.push_str(&format!(
95                "  {} — '{}' exists on disk but not in catalog\n",
96                orphan.element, orphan.filename
97            ));
98        }
99    }
100
101    out
102}