Skip to main content

ic_build/cargo/observation/
mod.rs

1//! Bounded Cargo catalog, resolved-graph and package-tree observations.
2//!
3//! Callers own the selected command/environment, graph policy and output budgets.
4//! Cargo's canonical Rust schema owns metadata decoding; consumers interpret it.
5//! No observation creates a cache or grants build/release admission.
6
7#[cfg(test)]
8mod tests;
9
10mod workspace;
11pub use workspace::workspace_root_for;
12
13/// Canonical Cargo metadata and build-message types, without consumer mirrors.
14pub use cargo_metadata as schema;
15
16use ic_host_process::tool::{CompletedExecution, OutputLimits, capture_command};
17use serde::Deserialize;
18use serde_json::Value;
19use std::{collections::BTreeSet, path::Path, process::Command};
20
21// Borrow the captured document so cache owners can retain its exact projection
22// without cloning the JSON tree or rebuilding it from normalized schema fields.
23pub(crate) fn decode_metadata(value: &Value) -> Result<schema::Metadata, serde_json::Error> {
24    schema::Metadata::deserialize(value)
25}
26
27/// Caller-selected features for one Cargo graph.
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct CargoFeatureSelection {
30    /// Features to activate, ordered and deduplicated before passing to Cargo.
31    pub features: BTreeSet<String>,
32    /// Whether Cargo activates the selected packages' default features.
33    pub default_features: bool,
34}
35
36impl Default for CargoFeatureSelection {
37    fn default() -> Self {
38        Self {
39            features: BTreeSet::new(),
40            default_features: true,
41        }
42    }
43}
44
45impl CargoFeatureSelection {
46    fn configure(&self, command: &mut Command) {
47        if !self.default_features {
48            command.arg("--no-default-features");
49        }
50        if !self.features.is_empty() {
51            let mut features = String::new();
52            for (index, feature) in self.features.iter().enumerate() {
53                if index > 0 {
54                    features.push(',');
55                }
56                features.push_str(feature);
57            }
58            command.arg("--features").arg(features);
59        }
60    }
61}
62
63/// One Cargo metadata observation; package/role interpretation stays with callers.
64pub struct CargoMetadataSpec<'a> {
65    /// Exact manifest selecting the workspace/graph.
66    pub manifest_path: &'a Path,
67    /// Include dependency packages and Cargo's resolve graph; otherwise use `--no-deps`.
68    pub include_dependencies: bool,
69    /// Optional target filter for the resolved graph, independent of catalog policy.
70    pub filter_platform: Option<&'a str>,
71    /// Require an unchanged existing lockfile.
72    pub locked: bool,
73    /// Prohibit Cargo network access.
74    pub offline: bool,
75    /// Features selected by the consuming recipe.
76    pub features: &'a CargoFeatureSelection,
77}
78
79/// One package-selected Cargo tree, returned without imposing role graph policy.
80pub struct CargoTreeSpec<'a> {
81    /// Exact manifest selecting the workspace/graph.
82    pub manifest_path: &'a Path,
83    /// Cargo package ID specifications; Cargo diagnoses missing or ambiguous selectors.
84    pub packages: &'a [&'a str],
85    /// Target whose dependencies Cargo resolves.
86    pub target: &'a str,
87    /// Cargo edge selection, for example `normal` or `normal,build`.
88    pub edges: &'a str,
89    /// Caller-selected Cargo tree format, retaining aliases/features when requested.
90    pub format: &'a str,
91    /// Require an unchanged existing lockfile.
92    pub locked: bool,
93    /// Prohibit Cargo network access.
94    pub offline: bool,
95    /// Features selected by the consuming recipe.
96    pub features: &'a CargoFeatureSelection,
97}
98
99/// Observe Cargo metadata through the caller's selected command and capture limits.
100///
101/// The command carries the executable, complete environment and any caller-bound
102/// launcher arguments. This adds the metadata subcommand and recipe arguments,
103/// selects the manifest's parent as the working directory and sets no deadline
104/// beyond `limits.timeout`. Output must be complete before canonical decoding.
105///
106/// # Errors
107/// Returns the original typed Host spawn/capture/exit failure (including bounded
108/// diagnostics and truncation evidence), or the canonical schema's JSON error.
109pub fn observe_metadata(
110    spec: &CargoMetadataSpec<'_>,
111    limits: OutputLimits,
112    mut command: Command,
113) -> Result<schema::Metadata, Box<dyn std::error::Error>> {
114    command.args(["metadata", "--format-version=1"]);
115    configure(
116        &mut command,
117        spec.manifest_path,
118        spec.locked,
119        spec.offline,
120        spec.features,
121    )?;
122    if !spec.include_dependencies {
123        command.arg("--no-deps");
124    }
125    if let Some(target) = spec.filter_platform {
126        command.arg("--filter-platform").arg(target);
127    }
128    let output = capture(&mut command, limits)?;
129    Ok(serde_json::from_slice(&output.stdout)?)
130}
131
132/// Observe a depth-prefixed, non-deduplicated package-selected Cargo tree.
133///
134/// Capture, environment and deadline semantics match [`observe_metadata`]. The
135/// text is complete UTF-8; package/alias correlation remains caller-owned.
136///
137/// # Errors
138/// Returns typed Host spawn/capture/exit failures or invalid UTF-8. Cargo itself
139/// rejects invalid selectors, feature combinations and tree formatting options.
140pub fn observe_tree(
141    spec: &CargoTreeSpec<'_>,
142    limits: OutputLimits,
143    mut command: Command,
144) -> Result<String, Box<dyn std::error::Error>> {
145    if spec.packages.is_empty() || spec.packages.iter().any(|package| package.is_empty()) {
146        return Err("Cargo tree requires nonempty package selectors".into());
147    }
148    command.args([
149        "tree",
150        "--color",
151        "never",
152        "--target",
153        spec.target,
154        "--edges",
155        spec.edges,
156        "--prefix",
157        "depth",
158        "--no-dedupe",
159        "--charset",
160        "ascii",
161        "--format",
162        spec.format,
163    ]);
164    configure(
165        &mut command,
166        spec.manifest_path,
167        spec.locked,
168        spec.offline,
169        spec.features,
170    )?;
171    for package in spec.packages {
172        command.arg("--package").arg(package);
173    }
174    let output = capture(&mut command, limits)?;
175    Ok(String::from_utf8(output.stdout)?)
176}
177
178fn configure(
179    command: &mut Command,
180    manifest: &Path,
181    locked: bool,
182    offline: bool,
183    features: &CargoFeatureSelection,
184) -> std::io::Result<()> {
185    let manifest = if manifest.is_absolute() {
186        manifest.to_path_buf()
187    } else {
188        std::env::current_dir()?.join(manifest)
189    };
190    command.current_dir(manifest.parent().unwrap_or_else(|| Path::new(".")));
191    command.arg("--manifest-path").arg(manifest);
192    features.configure(command);
193    if locked {
194        command.arg("--locked");
195    }
196    if offline {
197        command.arg("--offline");
198    }
199    Ok(())
200}
201
202fn capture(
203    command: &mut Command,
204    limits: OutputLimits,
205) -> Result<CompletedExecution, ic_host_process::tool::ToolError> {
206    capture_command(command, limits)
207        .and_then(CompletedExecution::require_success)
208        .and_then(CompletedExecution::require_complete)
209}