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