Skip to main content

candle_graph/
cli.rs

1//! Shared CLI engine for `candle-graph` and `cargo-candle-graph`.
2
3use anyhow::{Context, Result};
4use std::{
5    io::{self, Write},
6    path::{Path, PathBuf},
7};
8
9use crate::{
10    analysis_cache,
11    cargo_context::CargoOptions,
12    diagnostics::{self, MessageFormat},
13    discover::{self, ScanOptions},
14    extract::Extractor,
15    load,
16    model_baseline,
17    model_ir::ModelIr,
18    query::{self, QueryKind},
19    verify,
20};
21
22/// Output format for model IR / query responses.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum OutputFormat {
25    Json,
26    Tree,
27}
28
29/// How to render analyzed model facts.
30#[derive(Debug, Clone)]
31pub enum ReportMode {
32    FullIr {
33        format: OutputFormat,
34    },
35    Query {
36        kind: String,
37        selector: Option<String>,
38        to: Option<String>,
39        limit: usize,
40        offset: usize,
41        format: OutputFormat,
42    },
43}
44
45/// Inputs for a unified-model scan.
46#[derive(Debug, Clone)]
47pub struct AnalyzeRequest {
48    pub path: PathBuf,
49    pub cargo: CargoOptions,
50    pub runtime_trace: Option<PathBuf>,
51    pub component_root: Option<String>,
52    pub dataflow: bool,
53    pub heuristic_architecture: bool,
54    pub use_cache: bool,
55}
56
57/// Bundle written by `--audit-dir` / `cargo candle-graph audit`.
58#[derive(Debug, Clone)]
59pub struct AuditBundle {
60    pub output_dir: PathBuf,
61    pub checkpoint: Option<PathBuf>,
62    pub verify_root: Option<String>,
63    pub legacy_root: Option<String>,
64    pub legacy_entry: Option<String>,
65    pub deny_rules: Vec<String>,
66    pub strict: bool,
67}
68
69/// One invocation of the shared model-mode engine.
70#[derive(Debug, Clone)]
71pub struct ModelRun {
72    pub analyze: AnalyzeRequest,
73    pub report: ReportMode,
74    pub output: Option<PathBuf>,
75    /// When set, finding diagnostics are written to stderr in this format.
76    pub diagnostics: Option<MessageFormat>,
77    /// Exit non-zero when findings include proven Error defects.
78    pub fail_on_warning_error: bool,
79    /// Exit non-zero when proven findings match any of these rule names.
80    pub deny_rules: Vec<String>,
81    pub check_baseline: Option<PathBuf>,
82    pub update_baseline: Option<PathBuf>,
83    pub audit: Option<AuditBundle>,
84}
85
86/// Analyze a crate into the unified model IR, optionally reusing a disk cache.
87pub fn analyze_model(request: &AnalyzeRequest) -> Result<ModelIr> {
88    if analysis_cache::cache_enabled(request.use_cache) {
89        let canonical = request
90            .path
91            .canonicalize()
92            .unwrap_or_else(|_| request.path.clone());
93        let key = format!("{canonical:?}|{:?}", request.cargo);
94        let cache_path = analysis_cache::cache_path(&key);
95        if let Some(cached) = analysis_cache::load(&cache_path)? {
96            return Ok(cached);
97        }
98        let model = analyze_model_uncached(request)?;
99        analysis_cache::save(&cache_path, &model)?;
100        return Ok(model);
101    }
102    analyze_model_uncached(request)
103}
104
105fn analyze_model_uncached(request: &AnalyzeRequest) -> Result<ModelIr> {
106    let options = ScanOptions {
107        cargo: request.cargo.clone(),
108        runtime_trace: request.runtime_trace.clone(),
109        component_root: request.component_root.clone(),
110        dataflow: request.dataflow,
111        heuristic_architecture: request.heuristic_architecture,
112    };
113    discover::analyze(&request.path, &options)
114}
115
116/// Render a report from an already-analyzed model.
117pub fn render_report(model: &ModelIr, report: &ReportMode) -> Result<String> {
118    match report {
119        ReportMode::FullIr { format } => match format {
120            OutputFormat::Json => Ok(serde_json::to_string_pretty(model)? + "\n"),
121            OutputFormat::Tree => {
122                let request = query::QueryRequest::new(query::QueryKind::Summary);
123                Ok(query::render_text(&query::execute(model, &request)?))
124            }
125        },
126        ReportMode::Query {
127            kind,
128            selector,
129            to,
130            limit,
131            offset,
132            format,
133        } => {
134            let mut request = query::QueryRequest::new(kind.parse()?);
135            request.selector = selector.clone();
136            request.to = to.clone();
137            request.limit = *limit;
138            request.offset = *offset;
139            let response = query::execute(model, &request)?;
140            match format {
141                OutputFormat::Json => Ok(serde_json::to_string_pretty(&response)? + "\n"),
142                OutputFormat::Tree => Ok(query::render_text(&response)),
143            }
144        }
145    }
146}
147
148fn write_query(model: &ModelIr, kind: QueryKind, path: &Path) -> Result<()> {
149    let request = query::QueryRequest::new(kind);
150    let response = query::execute(model, &request)?;
151    let rendered = serde_json::to_string_pretty(&response)? + "\n";
152    write_output(Some(path), rendered.as_bytes())
153}
154
155fn write_legacy_world_model(
156    request: &AnalyzeRequest,
157    legacy_root: &str,
158    legacy_entry: Option<&str>,
159    path: &Path,
160) -> Result<()> {
161    let cargo_context =
162        crate::cargo_context::CargoContext::discover(&request.path, &request.cargo).ok();
163    let mut krate = match cargo_context.as_ref() {
164        Some(context) => {
165            let roots = context.selected_source_roots(request.cargo.package_target.as_deref())?;
166            load::load_from_roots(&request.path, &roots)?
167        }
168        None => load::load(&request.path)?,
169    };
170    if let Some(context) = cargo_context.as_ref() {
171        krate.set_dependency_aliases(context.dependency_aliases.clone());
172    }
173    let candle_nn_version = cargo_context.as_ref().and_then(|context| {
174        crate::op_semantics::matched_candle_version(
175            context
176                .candle_versions
177                .get("candle-core")
178                .map(String::as_str),
179            context.candle_versions.get("candle-nn").map(String::as_str),
180        )
181        .map(str::to_string)
182    });
183    let structure =
184        Extractor::for_candle_version(&krate, candle_nn_version.as_deref()).run(legacy_root, None)?;
185    let mut payload = serde_json::json!({
186        "root": legacy_root,
187        "parameters": structure.params.len(),
188        "parameter_keys": structure.params.iter().map(|p| p.key.to_string()).collect::<Vec<_>>(),
189    });
190    if let Some(entry) = legacy_entry {
191        if let Ok(graph) =
192            crate::dataflow::analyze_with_candle_version(&krate, entry, candle_nn_version.as_deref())
193        {
194            payload["entry"] = entry.into();
195            payload["dataflow_nodes"] = graph.nodes.len().into();
196        }
197    }
198    write_output(
199        Some(path),
200        (serde_json::to_string_pretty(&payload)? + "\n").as_bytes(),
201    )
202}
203
204fn write_checkpoint_audit(
205    request: &AnalyzeRequest,
206    model: &ModelIr,
207    checkpoint: &Path,
208    verify_root: Option<&str>,
209    path: &Path,
210) -> Result<()> {
211    let header = verify::read_header(checkpoint)?;
212    let root = verify_root
213        .or_else(|| model.components.first().map(|c| c.builders.first().map(|b| b.name.as_str())).flatten())
214        .unwrap_or("vb");
215    let mut structure = legacy_structure_for_verify(request, root)?;
216    let report = verify::verify(&mut structure, &header, root);
217    write_output(
218        Some(path),
219        (serde_json::to_string_pretty(&report)? + "\n").as_bytes(),
220    )
221}
222
223fn legacy_structure_for_verify(request: &AnalyzeRequest, root: &str) -> Result<crate::ir::Structure> {
224    let cargo_context =
225        crate::cargo_context::CargoContext::discover(&request.path, &request.cargo).ok();
226    let mut krate = match cargo_context.as_ref() {
227        Some(context) => {
228            let roots = context.selected_source_roots(request.cargo.package_target.as_deref())?;
229            load::load_from_roots(&request.path, &roots)?
230        }
231        None => load::load(&request.path)?,
232    };
233    if let Some(context) = cargo_context.as_ref() {
234        krate.set_dependency_aliases(context.dependency_aliases.clone());
235    }
236    let candle_nn_version = cargo_context.as_ref().and_then(|context| {
237        crate::op_semantics::matched_candle_version(
238            context
239                .candle_versions
240                .get("candle-core")
241                .map(String::as_str),
242            context.candle_versions.get("candle-nn").map(String::as_str),
243        )
244        .map(str::to_string)
245    });
246    Extractor::for_candle_version(&krate, candle_nn_version.as_deref()).run(root, None)
247}
248
249fn run_audit_bundle(model: &ModelIr, request: &AnalyzeRequest, audit: &AuditBundle) -> Result<()> {
250    std::fs::create_dir_all(&audit.output_dir)
251        .with_context(|| format!("creating {}", audit.output_dir.display()))?;
252    write_query(model, QueryKind::Summary, &audit.output_dir.join("summary.json"))?;
253    write_query(model, QueryKind::Doctor, &audit.output_dir.join("doctor.json"))?;
254    write_query(
255        model,
256        QueryKind::ModelImprovement,
257        &audit.output_dir.join("model-improvement.json"),
258    )?;
259    write_query(model, QueryKind::Findings, &audit.output_dir.join("findings.json"))?;
260    let rendered = render_report(model, &ReportMode::FullIr {
261        format: OutputFormat::Json,
262    })?;
263    write_output(
264        Some(&audit.output_dir.join("model-ir.json")),
265        rendered.as_bytes(),
266    )?;
267    if let Some(runtime) = &request.runtime_trace {
268        write_query(model, QueryKind::Runtime, &audit.output_dir.join("runtime.json"))?;
269        let _ = runtime;
270    }
271    if let Some(root) = audit.legacy_root.as_deref() {
272        write_legacy_world_model(
273            request,
274            root,
275            audit.legacy_entry.as_deref(),
276            &audit.output_dir.join("world-model.json"),
277        )?;
278    }
279    if let Some(checkpoint) = &audit.checkpoint {
280        write_checkpoint_audit(
281            request,
282            model,
283            checkpoint,
284            audit.verify_root.as_deref(),
285            &audit.output_dir.join("checkpoint.json"),
286        )?;
287    }
288    Ok(())
289}
290
291fn enforce_exit_policy(model: &ModelIr, run: &ModelRun) -> Result<()> {
292    if run.fail_on_warning_error && diagnostics::has_proven_defect_findings(model) {
293        anyhow::bail!(
294            "strict: unified model analysis contains proven error findings \
295             (coverage gaps and non-proven warnings do not fail)"
296        );
297    }
298    let denied = diagnostics::denied_findings(model, &run.deny_rules);
299    if !denied.is_empty() {
300        let rules: Vec<_> = denied.iter().map(|f| f.rule.as_str()).collect();
301        anyhow::bail!(
302            "deny: proven findings matched blocked rules: {}",
303            rules.join(", ")
304        );
305    }
306    if run.audit.as_ref().is_some_and(|a| a.strict)
307        && (diagnostics::has_proven_defect_findings(model)
308            || diagnostics::has_denied_findings(model, &run.deny_rules))
309    {
310        anyhow::bail!("audit strict gate failed");
311    }
312    Ok(())
313}
314
315/// Run a full model-mode invocation: analyze, optional baseline, report, diagnostics, exit policy.
316pub fn run_model(run: &ModelRun) -> Result<()> {
317    let model = analyze_model(&run.analyze)?;
318
319    if let Some(path) = &run.update_baseline {
320        model_baseline::update(&model, path)
321            .with_context(|| format!("updating model baseline {}", path.display()))?;
322        eprintln!("updated model baseline {}", path.display());
323    }
324    if let Some(path) = &run.check_baseline {
325        model_baseline::check(&model, path)
326            .with_context(|| format!("checking model baseline {}", path.display()))?;
327    }
328
329    if let Some(audit) = &run.audit {
330        run_audit_bundle(&model, &run.analyze, audit)?;
331    }
332
333    let rendered = render_report(&model, &run.report)?;
334    if run.audit.is_none() || run.output.is_some() {
335        write_output(run.output.as_deref(), rendered.as_bytes())?;
336    }
337
338    if let Some(format) = run.diagnostics {
339        let diagnostics = diagnostics::from_model(&model);
340        let text = diagnostics::render(&diagnostics, format);
341        if !text.is_empty() {
342            eprint!("{text}");
343        }
344    }
345
346    enforce_exit_policy(&model, run)?;
347    Ok(())
348}
349
350/// Resolve a package path from an optional directory / manifest path.
351pub fn resolve_package_path(path: Option<&Path>, manifest_path: Option<&Path>) -> Result<PathBuf> {
352    if let Some(manifest) = manifest_path {
353        if manifest
354            .file_name()
355            .is_some_and(|name| name == "Cargo.toml")
356        {
357            return Ok(manifest
358                .parent()
359                .map(Path::to_path_buf)
360                .unwrap_or_else(|| PathBuf::from(".")));
361        }
362        return Ok(manifest.to_path_buf());
363    }
364    Ok(path.map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")))
365}
366
367pub fn write_output(path: Option<&Path>, bytes: &[u8]) -> Result<()> {
368    match path {
369        Some(path) => {
370            if let Some(parent) = path
371                .parent()
372                .filter(|parent| !parent.as_os_str().is_empty())
373            {
374                std::fs::create_dir_all(parent)
375                    .with_context(|| format!("creating {}", parent.display()))?;
376            }
377            std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
378        }
379        None => io::stdout().lock().write_all(bytes)?,
380    }
381    Ok(())
382}