Skip to main content

dead_poets/
engine.rs

1//! Library entry point — config file in, classification out, behind one call.
2//!
3//! `run` loads the config, resolves the whitelist and source roots, then drives
4//! `po` (key universe) → `scan` (source usage) → `liveness` (classification),
5//! with an optional `audit` pass over the Dead bucket. It owns every path-
6//! resolution convention (so callers never replicate it) and returns data only:
7//! rendering, the dead-key budget, output format, and exit codes are CLI concerns
8//! and stay in the binary (`main.rs`), keeping this seam free of the `cli`
9//! feature's terminal dependencies.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::Result;
14
15use crate::audit::{self, AuditReport};
16use crate::config::{Config, resolve_whitelist};
17use crate::liveness::{self, LivenessReport};
18use crate::{po, scan};
19
20/// The outcome of a full run: the loaded config the run used, the per-key
21/// liveness verdicts, and — when requested — the advisory audit over the Dead
22/// bucket. The config is returned so the caller drives its own gate/exit policy
23/// (`output.fail_on`, the budget) without re-reading the file. The key universe
24/// size (for a ratio budget) is `report.verdicts.len()`.
25#[derive(Debug)]
26pub struct Outcome {
27    pub config: Config,
28    pub report: LivenessReport,
29    pub audit: Option<AuditReport>,
30}
31
32/// Run the full pipeline: load `config_path`, scan `project_root`, classify.
33///
34/// `scan.source_roots` are resolved relative to `project_root`; the whitelist
35/// file is resolved relative to the config file's own directory (so a config may
36/// live outside the scanned tree). With `run_audit`, the Dead bucket is greped
37/// against raw source for a trust score — advisory, never changing classification.
38pub fn run(config_path: &Path, project_root: &Path, run_audit: bool) -> Result<Outcome> {
39    let config = Config::load(config_path)?;
40
41    // The whitelist file is resolved relative to the config file's directory.
42    let config_dir = config_path
43        .parent()
44        .filter(|p| !p.as_os_str().is_empty())
45        .unwrap_or_else(|| Path::new("."));
46    let whitelist = resolve_whitelist(&config.whitelist, config_dir)?;
47
48    // Source roots are resolved relative to the scanned project path.
49    let roots: Vec<PathBuf> = config
50        .scan
51        .source_roots
52        .iter()
53        .map(|r| project_root.join(r))
54        .collect();
55
56    log::info!("scanning {} root(s) for PO catalogs", roots.len());
57    let index = po::load_index(&roots, &config.scan.po_patterns, &config.scan.ignore_dirs)?;
58    log::info!("PO universe: {} unique keys", index.len());
59
60    let usage = scan::scan_sources(
61        &roots,
62        &config.scan.source_extensions,
63        &config.scan.ignore_dirs,
64        &config.calls,
65        config.guard.min_len,
66    )?;
67    log::info!(
68        "usage: {} literals, {} guards, {} blind sites",
69        usage.literals.len(),
70        usage.guards.len(),
71        usage.blind.values().sum::<usize>(),
72    );
73
74    let report = liveness::classify(&index, &usage, &whitelist);
75
76    // Opt-in advisory pass: grep the Dead bucket against raw source for a trust
77    // score. Never touches classification or the exit code.
78    let audit = if run_audit {
79        let dead: Vec<&po::PoKey> = report.dead().map(|v| &v.key).collect();
80        log::info!("auditing {} dead keys against raw source", dead.len());
81        Some(audit::audit(
82            &dead,
83            &roots,
84            &config.scan.source_extensions,
85            &config.scan.ignore_dirs,
86            config.guard.min_len,
87        )?)
88    } else {
89        None
90    };
91
92    Ok(Outcome {
93        config,
94        report,
95        audit,
96    })
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
104        let path = dir.join(name);
105        if let Some(parent) = path.parent() {
106            std::fs::create_dir_all(parent).unwrap();
107        }
108        std::fs::write(&path, body).unwrap();
109        path
110    }
111
112    fn temp_dir(tag: &str) -> PathBuf {
113        let dir = std::env::temp_dir().join(format!("dead-poets-engine-{tag}"));
114        std::fs::remove_dir_all(&dir).ok();
115        std::fs::create_dir_all(&dir).unwrap();
116        dir
117    }
118
119    const HEADER: &str = "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\n";
120
121    /// A one-PO + one-PHP fixture: `used` is referenced, `gone` is not — `run`
122    /// loads the config, reports one alive and one dead; `run_audit` populates
123    /// the audit.
124    #[test]
125    fn run_classifies_used_and_dead() {
126        let dir = temp_dir("classify");
127        write(
128            &dir,
129            "messages.po",
130            &format!("{HEADER}msgid \"used\"\nmsgstr \"u\"\n\nmsgid \"gone\"\nmsgstr \"g\"\n"),
131        );
132        write(&dir, "app.php", "<?php i18n('used'); ?>");
133        let cfg_path = write(
134            &dir,
135            "dp.toml",
136            "[scan]\npo_patterns = [\"**/*.po\"]\nsource_extensions = [\"php\"]\n\n\
137             [[calls]]\nlang = \"php\"\nkind = \"function\"\nname = \"i18n\"\n",
138        );
139
140        let out = run(&cfg_path, &dir, false).unwrap();
141        assert_eq!(out.report.alive_count(), 1);
142        assert_eq!(out.report.dead_count(), 1);
143        assert!(out.report.dead().any(|v| v.key.msgid == "gone"));
144        assert!(out.audit.is_none(), "no audit without run_audit");
145
146        let out = run(&cfg_path, &dir, true).unwrap();
147        assert!(out.audit.is_some(), "run_audit populates the audit");
148
149        std::fs::remove_dir_all(&dir).ok();
150    }
151}