Skip to main content

candle_graph/
cargo_context.rs

1//! Bounded Cargo/config discovery for analyzing a crate in its real feature/cfg context.
2//!
3//! Discovers the nearest `Cargo.toml`, runs `cargo metadata` and `rustc --print cfg` via
4//! [`std::process::Command`] (never a shell), and returns a deterministic, serializable snapshot.
5
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10use anyhow::{anyhow, bail, Context, Result};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Feature / target selection passed through to `cargo metadata` and `rustc --print cfg`.
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
16pub struct CargoOptions {
17    /// Explicit features forwarded as `--features`.
18    pub features: Vec<String>,
19    /// Forwarded as `--all-features`.
20    pub all_features: bool,
21    /// Forwarded as `--no-default-features`.
22    pub no_default_features: bool,
23    /// Optional `--filter-platform` / `rustc --target` triple.
24    pub target: Option<String>,
25    /// Optional Cargo target name (`lib`/binary target), independent of the target triple.
26    pub package_target: Option<String>,
27}
28
29/// Names in candle-graph's own `[features]` table — not valid on arbitrary model crates.
30const CANDLE_GRAPH_FEATURES: &[&str] = &["static", "visualizer", "runtime", "all"];
31
32impl CargoOptions {
33    /// Strip feature flags that refer to candle-graph itself, not the crate under analysis.
34    ///
35    /// Users often run `cargo candle-graph view --features visualizer` (or `--features all`)
36    /// intending to enable the HTML visualizer on candle-graph. Those flags must not be
37    /// forwarded to `cargo metadata` for the analyzed model crate.
38    pub fn strip_candle_graph_features(&mut self) -> Vec<String> {
39        let mut stripped = Vec::new();
40        self.features.retain(|feature| {
41            if CANDLE_GRAPH_FEATURES.contains(&feature.as_str()) {
42                stripped.push(feature.clone());
43                false
44            } else {
45                true
46            }
47        });
48        stripped
49    }
50}
51
52/// One compile target of the selected package (lib, bin, …).
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct CargoTarget {
55    pub name: String,
56    pub kind: Vec<String>,
57    pub src_path: PathBuf,
58}
59
60/// Deterministic snapshot of the Cargo package / workspace / feature / cfg context.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct CargoContext {
63    pub package_name: String,
64    pub package_version: String,
65    pub package_id: String,
66    pub manifest_path: PathBuf,
67    pub workspace_root: PathBuf,
68    pub target_directory: PathBuf,
69    /// Targets of the selected package, sorted by `(name, kind, src_path)`.
70    pub targets: Vec<CargoTarget>,
71    /// Resolved active features for the selected package, sorted.
72    pub active_features: Vec<String>,
73    /// Versions of every package whose name starts with `candle-`, keyed by name.
74    pub candle_versions: BTreeMap<String, String>,
75    /// Rust crate identifier (including dependency renames) to Cargo package name.
76    pub dependency_aliases: BTreeMap<String, String>,
77    /// Active `rustc --print cfg` lines plus `feature="…"` for each active feature, sorted.
78    pub cfgs: Vec<String>,
79}
80
81/// Discover Cargo context for `path`, which may be a crate root or a nested source directory.
82pub fn discover(path: impl AsRef<Path>, options: &CargoOptions) -> Result<CargoContext> {
83    CargoContext::discover(path, options)
84}
85
86impl CargoContext {
87    /// Discover Cargo context for `path`, which may be a crate root or a nested source directory.
88    pub fn discover(path: impl AsRef<Path>, options: &CargoOptions) -> Result<Self> {
89        let path = path.as_ref();
90        let manifest_path = find_manifest(path)
91            .with_context(|| format!("failed to locate Cargo.toml from {}", path.display()))?;
92
93        let metadata = run_cargo_metadata(&manifest_path, options)?;
94        let package = select_package(&metadata, &manifest_path)?;
95
96        let package_name = package
97            .get("name")
98            .and_then(|v| v.as_str())
99            .ok_or_else(|| anyhow!("package missing name"))?
100            .to_string();
101        let package_version = package
102            .get("version")
103            .and_then(|v| v.as_str())
104            .ok_or_else(|| anyhow!("package missing version"))?
105            .to_string();
106        let package_id = package
107            .get("id")
108            .and_then(|v| v.as_str())
109            .ok_or_else(|| anyhow!("package missing id"))?
110            .to_string();
111
112        let manifest_path = path_from_json(package, "manifest_path")?;
113        let workspace_root = path_from_root(&metadata, "workspace_root")?;
114        let target_directory = path_from_root(&metadata, "target_directory")?;
115
116        let mut targets = parse_targets(package)?;
117        targets
118            .sort_by(|a, b| (&a.name, &a.kind, &a.src_path).cmp(&(&b.name, &b.kind, &b.src_path)));
119
120        let mut active_features = resolve_active_features(&metadata, &package_id)?;
121        active_features.sort();
122        active_features.dedup();
123
124        let candle_versions = collect_candle_versions(&metadata, &package_id)?;
125        let dependency_aliases = collect_dependency_aliases(package)?;
126
127        let mut cfgs = collect_rustc_cfgs(options.target.as_deref())?;
128        for feature in &active_features {
129            cfgs.push(format!("feature=\"{feature}\""));
130        }
131        cfgs.sort();
132        cfgs.dedup();
133
134        Ok(Self {
135            package_name,
136            package_version,
137            package_id,
138            manifest_path,
139            workspace_root,
140            target_directory,
141            targets,
142            active_features,
143            candle_versions,
144            dependency_aliases,
145            cfgs,
146        })
147    }
148
149    /// Crate roots selected for source analysis.
150    ///
151    /// By default a library target is preferred because binaries normally consume it. Packages
152    /// without a library select their first ordinary binary. Tests/examples/benches are included
153    /// only when explicitly selected by target name.
154    pub fn selected_source_roots(&self, requested: Option<&str>) -> Result<Vec<PathBuf>> {
155        if let Some(name) = requested {
156            let selected = self
157                .targets
158                .iter()
159                .filter(|target| target.name == name)
160                .map(|target| target.src_path.clone())
161                .collect::<Vec<_>>();
162            if selected.is_empty() {
163                bail!(
164                    "Cargo target `{name}` not found; available targets: {}",
165                    self.targets
166                        .iter()
167                        .map(|target| target.name.as_str())
168                        .collect::<Vec<_>>()
169                        .join(", ")
170                );
171            }
172            return Ok(selected);
173        }
174
175        if let Some(library) = self
176            .targets
177            .iter()
178            .find(|target| target.kind.iter().any(|kind| kind == "lib"))
179        {
180            return Ok(vec![library.src_path.clone()]);
181        }
182        if let Some(binary) = self
183            .targets
184            .iter()
185            .find(|target| target.kind.iter().any(|kind| kind == "bin"))
186        {
187            return Ok(vec![binary.src_path.clone()]);
188        }
189        bail!(
190            "package `{}` has no library or binary target; select a target explicitly",
191            self.package_name
192        )
193    }
194}
195
196fn collect_dependency_aliases(package: &Value) -> Result<BTreeMap<String, String>> {
197    let dependencies = package
198        .get("dependencies")
199        .and_then(Value::as_array)
200        .ok_or_else(|| anyhow!("package missing dependencies array"))?;
201    let mut aliases = BTreeMap::new();
202    for dependency in dependencies {
203        let Some(name) = dependency.get("name").and_then(Value::as_str) else {
204            continue;
205        };
206        let alias = dependency
207            .get("rename")
208            .and_then(Value::as_str)
209            .unwrap_or(name)
210            .replace('-', "_");
211        aliases.insert(alias, name.to_string());
212    }
213    Ok(aliases)
214}
215
216/// Evaluate item-level `cfg` predicates against an active Cargo/rustc cfg snapshot.
217///
218/// `Some(true)` and `Some(false)` are exact for the standard `all`, `any`, `not`, key/value,
219/// and bare-name forms. `None` means the source used a predicate form this bounded evaluator
220/// does not understand; callers must preserve that branch rather than guessing.
221pub fn cfg_predicates_active(predicates: &[String], active_cfg: &[String]) -> Option<bool> {
222    let active = active_cfg
223        .iter()
224        .map(|item| normalize_cfg(item))
225        .collect::<std::collections::HashSet<_>>();
226    let mut unknown = false;
227    for predicate in predicates {
228        match eval_cfg(&normalize_cfg(predicate), &active) {
229            Some(false) => return Some(false),
230            Some(true) => {}
231            None => unknown = true,
232        }
233    }
234    (!unknown).then_some(true)
235}
236
237fn eval_cfg(predicate: &str, active: &std::collections::HashSet<String>) -> Option<bool> {
238    if let Some(arguments) = outer_arguments(predicate, "all") {
239        let parts = split_cfg_arguments(arguments)?;
240        let mut unknown = false;
241        for part in parts {
242            match eval_cfg(part, active) {
243                Some(false) => return Some(false),
244                Some(true) => {}
245                None => unknown = true,
246            }
247        }
248        return (!unknown).then_some(true);
249    }
250    if let Some(arguments) = outer_arguments(predicate, "any") {
251        let parts = split_cfg_arguments(arguments)?;
252        let mut unknown = false;
253        for part in parts {
254            match eval_cfg(part, active) {
255                Some(true) => return Some(true),
256                Some(false) => {}
257                None => unknown = true,
258            }
259        }
260        return (!unknown).then_some(false);
261    }
262    if let Some(arguments) = outer_arguments(predicate, "not") {
263        let parts = split_cfg_arguments(arguments)?;
264        let [inner] = parts.as_slice() else {
265            return None;
266        };
267        return eval_cfg(inner, active).map(|value| !value);
268    }
269    if predicate.is_empty()
270        || predicate.contains('(')
271        || predicate.contains(')')
272        || predicate.contains(',')
273    {
274        None
275    } else {
276        Some(active.contains(predicate))
277    }
278}
279
280fn normalize_cfg(value: &str) -> String {
281    let mut normalized = String::with_capacity(value.len());
282    let mut quoted = false;
283    for character in value.chars() {
284        if character == '"' {
285            quoted = !quoted;
286            normalized.push(character);
287        } else if quoted || !character.is_whitespace() {
288            normalized.push(character);
289        }
290    }
291    normalized
292}
293
294fn outer_arguments<'a>(value: &'a str, name: &str) -> Option<&'a str> {
295    value
296        .strip_prefix(name)?
297        .strip_prefix('(')?
298        .strip_suffix(')')
299}
300
301fn split_cfg_arguments(value: &str) -> Option<Vec<&str>> {
302    if value.is_empty() {
303        return Some(Vec::new());
304    }
305    let mut parts = Vec::new();
306    let mut depth = 0usize;
307    let mut quoted = false;
308    let mut start = 0usize;
309    for (index, character) in value.char_indices() {
310        match character {
311            '"' => quoted = !quoted,
312            '(' if !quoted => depth = depth.checked_add(1)?,
313            ')' if !quoted => depth = depth.checked_sub(1)?,
314            ',' if !quoted && depth == 0 => {
315                parts.push(&value[start..index]);
316                start = index + character.len_utf8();
317            }
318            _ => {}
319        }
320    }
321    if quoted || depth != 0 {
322        return None;
323    }
324    parts.push(&value[start..]);
325    Some(parts)
326}
327
328/// Walk upward from `start` until a `Cargo.toml` is found.
329fn find_manifest(start: &Path) -> Result<PathBuf> {
330    if !start.exists() {
331        bail!("path does not exist: {}", start.display());
332    }
333
334    let mut dir = if start.is_file() {
335        start
336            .parent()
337            .ok_or_else(|| anyhow!("path has no parent: {}", start.display()))?
338            .to_path_buf()
339    } else {
340        start.to_path_buf()
341    };
342
343    // Prefer a stable absolute base when possible.
344    if let Ok(canon) = dir.canonicalize() {
345        dir = canon;
346    }
347
348    loop {
349        let candidate = dir.join("Cargo.toml");
350        if candidate.is_file() {
351            return Ok(candidate);
352        }
353        if !dir.pop() {
354            bail!("Cargo.toml not found starting from {}", start.display());
355        }
356    }
357}
358
359fn run_cargo_metadata(manifest_path: &Path, options: &CargoOptions) -> Result<Value> {
360    let mut cmd = Command::new("cargo");
361    cmd.arg("metadata")
362        .arg("--format-version")
363        .arg("1")
364        .arg("--manifest-path")
365        .arg(manifest_path);
366
367    if options.all_features {
368        cmd.arg("--all-features");
369    }
370    if options.no_default_features {
371        cmd.arg("--no-default-features");
372    }
373    if !options.features.is_empty() {
374        cmd.arg("--features").arg(options.features.join(","));
375    }
376    if let Some(target) = options.target.as_deref() {
377        cmd.arg("--filter-platform").arg(target);
378    }
379
380    let output = cmd.output().with_context(|| {
381        format!(
382            "failed to spawn cargo metadata for {}",
383            manifest_path.display()
384        )
385    })?;
386
387    if !output.status.success() {
388        let stderr = String::from_utf8_lossy(&output.stderr);
389        bail!(
390            "cargo metadata failed for {} (status {}): {}",
391            manifest_path.display(),
392            output.status,
393            stderr.trim()
394        );
395    }
396
397    let stdout = String::from_utf8(output.stdout).context("cargo metadata stdout was not UTF-8")?;
398    serde_json::from_str(&stdout).context("failed to parse cargo metadata JSON")
399}
400
401fn select_package<'a>(metadata: &'a Value, manifest_path: &Path) -> Result<&'a Value> {
402    let packages = metadata
403        .get("packages")
404        .and_then(|v| v.as_array())
405        .ok_or_else(|| anyhow!("cargo metadata missing packages array"))?;
406
407    let want = normalize_path(manifest_path);
408
409    for package in packages {
410        let Some(mp) = package.get("manifest_path").and_then(|v| v.as_str()) else {
411            continue;
412        };
413        if normalize_path(Path::new(mp)) == want {
414            return Ok(package);
415        }
416    }
417
418    bail!(
419        "no package in cargo metadata matched manifest {}",
420        manifest_path.display()
421    )
422}
423
424fn normalize_path(path: &Path) -> PathBuf {
425    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
426}
427
428fn path_from_json(obj: &Value, key: &str) -> Result<PathBuf> {
429    let s = obj
430        .get(key)
431        .and_then(|v| v.as_str())
432        .ok_or_else(|| anyhow!("missing string field `{key}`"))?;
433    Ok(PathBuf::from(s))
434}
435
436fn path_from_root(metadata: &Value, key: &str) -> Result<PathBuf> {
437    path_from_json(metadata, key).with_context(|| format!("cargo metadata missing `{key}`"))
438}
439
440fn parse_targets(package: &Value) -> Result<Vec<CargoTarget>> {
441    let targets = package
442        .get("targets")
443        .and_then(|v| v.as_array())
444        .ok_or_else(|| anyhow!("package missing targets array"))?;
445
446    let mut out = Vec::with_capacity(targets.len());
447    for target in targets {
448        let name = target
449            .get("name")
450            .and_then(|v| v.as_str())
451            .ok_or_else(|| anyhow!("target missing name"))?
452            .to_string();
453        let kind = target
454            .get("kind")
455            .and_then(|v| v.as_array())
456            .ok_or_else(|| anyhow!("target missing kind"))?
457            .iter()
458            .filter_map(|v| v.as_str().map(str::to_string))
459            .collect::<Vec<_>>();
460        let src_path = path_from_json(target, "src_path")?;
461        out.push(CargoTarget {
462            name,
463            kind,
464            src_path,
465        });
466    }
467    Ok(out)
468}
469
470fn resolve_active_features(metadata: &Value, package_id: &str) -> Result<Vec<String>> {
471    let resolve = metadata
472        .get("resolve")
473        .ok_or_else(|| anyhow!("cargo metadata missing resolve"))?;
474    let nodes = resolve
475        .get("nodes")
476        .and_then(|v| v.as_array())
477        .ok_or_else(|| anyhow!("cargo metadata resolve missing nodes"))?;
478
479    for node in nodes {
480        let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("");
481        if id == package_id {
482            let features = node
483                .get("features")
484                .and_then(|v| v.as_array())
485                .ok_or_else(|| anyhow!("resolve node missing features for {package_id}"))?;
486            return Ok(features
487                .iter()
488                .filter_map(|v| v.as_str().map(str::to_string))
489                .collect());
490        }
491    }
492
493    bail!("resolve node not found for package id {package_id}")
494}
495
496fn collect_candle_versions(
497    metadata: &Value,
498    selected_package_id: &str,
499) -> Result<BTreeMap<String, String>> {
500    let packages = metadata
501        .get("packages")
502        .and_then(|v| v.as_array())
503        .ok_or_else(|| anyhow!("cargo metadata missing packages array"))?;
504
505    let mut versions: BTreeMap<String, std::collections::BTreeSet<String>> = BTreeMap::new();
506    for package in packages {
507        if package.get("id").and_then(Value::as_str) == Some(selected_package_id) {
508            continue;
509        }
510        let name = match package.get("name").and_then(|v| v.as_str()) {
511            Some(n) if n.starts_with("candle-") => n,
512            _ => continue,
513        };
514        let version = package
515            .get("version")
516            .and_then(|v| v.as_str())
517            .ok_or_else(|| anyhow!("package `{name}` missing version"))?;
518        versions
519            .entry(name.to_string())
520            .or_default()
521            .insert(version.to_string());
522    }
523    Ok(versions
524        .into_iter()
525        .map(|(name, versions)| (name, versions.into_iter().collect::<Vec<_>>().join(",")))
526        .collect())
527}
528
529fn collect_rustc_cfgs(target: Option<&str>) -> Result<Vec<String>> {
530    let mut cmd = Command::new("rustc");
531    cmd.arg("--print").arg("cfg");
532    if let Some(triple) = target {
533        cmd.arg("--target").arg(triple);
534    }
535
536    let output = cmd.output().context("failed to spawn rustc --print cfg")?;
537
538    if !output.status.success() {
539        let stderr = String::from_utf8_lossy(&output.stderr);
540        bail!(
541            "rustc --print cfg failed (status {}): {}",
542            output.status,
543            stderr.trim()
544        );
545    }
546
547    let stdout =
548        String::from_utf8(output.stdout).context("rustc --print cfg stdout was not UTF-8")?;
549    let mut cfgs = stdout
550        .lines()
551        .map(str::trim)
552        .filter(|l| !l.is_empty())
553        .map(str::to_string)
554        .collect::<Vec<_>>();
555    cfgs.sort();
556    cfgs.dedup();
557    Ok(cfgs)
558}