use std::path::{Path, PathBuf};
use antigen_macros::{antigen_tolerance, dread, presents};
use syn::visit::Visit;
use walkdir::WalkDir;
use super::{
MAX_LINEAGE_DEPTH, ParseFailure, ScanReport, ScanVisitor, dedupe_lineage_edges,
detect_lineage_failures, finalize_report_with_catalog,
};
#[presents(ScannerBoundaryFalseNegative)]
#[antigen_tolerance(
ScannerBoundaryFalseNegative,
rationale = "Accepted v0.2 limitation: the scan is a static-heuristic walk that surfaces only \
explicitly-declared #[mucosal]/#[presents] sites — it cannot infer implicit trust \
boundaries from parameter types or call sites, by design (ADR-006 recognition-not-design: \
the scan recognizes declared structure, it does not guess). Adopters mark boundaries \
explicitly; the false-negative on unmarked sites is the honest cost of not guessing.",
until = "v0.3"
)]
pub fn scan_workspace(root: &Path, excluded_dirs: Option<&[&str]>) -> std::io::Result<ScanReport> {
scan_workspace_inner(root, excluded_dirs, BundledCatalog::None)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BundledCatalog {
None,
AutoDetect,
Always,
}
pub fn scan_workspace_bundled_catalog(
root: &Path,
excluded_dirs: Option<&[&str]>,
auto_detect: bool,
) -> std::io::Result<ScanReport> {
let mode = if auto_detect {
BundledCatalog::AutoDetect
} else {
BundledCatalog::Always
};
scan_workspace_inner(root, excluded_dirs, mode)
}
#[dread(
trigger = "scan_workspace_inner silently `continue`s past a file it cannot read \
(read_to_string Err -> skip), so an unreadable file lowers coverage \
without lowering the all-clear verdict: 'reported clean' conflates \
'found nothing' with 'could not look'. No counter, no surfaced skip."
)]
#[allow(clippy::unnecessary_wraps)]
fn scan_workspace_inner(
root: &Path,
excluded_dirs: Option<&[&str]>,
bundled: BundledCatalog,
) -> std::io::Result<ScanReport> {
let default_exclusions = ["target", ".git", "node_modules"];
let exclusions = excluded_dirs.unwrap_or(&default_exclusions);
let mut report = ScanReport::default();
let mut parsed_files: Vec<(PathBuf, syn::File)> = Vec::new();
for entry in WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_dir() {
let name = e.file_name().to_string_lossy();
!exclusions.iter().any(|x| *x == name)
} else {
true
}
})
{
let Ok(entry) = entry else { continue };
if !entry.file_type().is_file() {
continue;
}
if entry.path().extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let Ok(content) = std::fs::read_to_string(entry.path()) else {
continue;
};
match syn::parse_file(&content) {
Ok(file) => {
let file_path = entry.path().to_path_buf();
let mut visitor = ScanVisitor::new(file_path.clone(), &mut report);
visitor.visit_file(&file);
report.files_scanned += 1;
parsed_files.push((file_path, file));
},
Err(e) => {
report.parse_failures.push(ParseFailure {
file: entry.path().to_path_buf(),
error: e.to_string(),
});
},
}
}
let (deduped_edges, dedup_failures) = dedupe_lineage_edges(&report.lineage_edges);
report.lineage_edges = deduped_edges;
report.parse_failures.extend(dedup_failures);
let lineage_failures = detect_lineage_failures(&report.lineage_edges, MAX_LINEAGE_DEPTH);
report.parse_failures.extend(lineage_failures);
let inject_catalog = match bundled {
BundledCatalog::None => false,
BundledCatalog::AutoDetect => report.antigens.is_empty(),
BundledCatalog::Always => true,
};
let catalog_fingerprints: Vec<(String, antigen_fingerprint::Fingerprint)> = if inject_catalog {
crate::stdlib::catalog::stdlib_catalog()
} else {
Vec::new()
};
finalize_report_with_catalog(&mut report, &parsed_files, &catalog_fingerprints);
Ok(report)
}