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