Skip to main content

cargo_bless/
parser.rs

1//! Parser layer — extracts the full dependency tree from Cargo.toml / Cargo.lock
2//! using `cargo_metadata` for feature-aware resolution.
3
4use anyhow::{bail, Result};
5use cargo_metadata::{CargoOpt, DependencyKind, MetadataCommand, Node, Package, PackageId, Resolve};
6use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// A resolved dependency with its name, version, and enabled features.
11#[derive(Debug, Clone)]
12pub struct ResolvedDep {
13    pub name: String,
14    pub version: String,
15    /// Features that are **actually enabled** in the resolved build plan.
16    pub enabled_features: Vec<String>,
17    /// All features **declared** by the crate (available but not necessarily enabled).
18    pub available_features: Vec<String>,
19    pub source: Option<String>,
20    pub repository: Option<String>,
21    pub is_direct: bool,
22}
23
24/// One Cargo package in the workspace (or the resolved root crate) plus its dependency tree.
25///
26/// Produced before suggestion analysis runs; see Phase 3 workspace design (`docs/`).
27#[derive(Debug, Clone)]
28pub struct PackageResult {
29    pub name: String,
30    pub version: String,
31    pub manifest_path: PathBuf,
32    pub deps: Vec<ResolvedDep>,
33}
34
35impl fmt::Display for ResolvedDep {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        let tag = if self.is_direct {
38            "direct"
39        } else {
40            "transitive"
41        };
42        write!(f, "{} v{} ({})", self.name, self.version, tag)?;
43        if !self.enabled_features.is_empty() {
44            write!(f, " [enabled: {}]", self.enabled_features.join(", "))?;
45        }
46        if !self.available_features.is_empty() && self.available_features != self.enabled_features {
47            write!(f, " (available: {})", self.available_features.join(", "))?;
48        }
49        Ok(())
50    }
51}
52
53/// Get the root project's name and version from Cargo metadata.
54pub fn get_project_info(manifest_path: Option<&Path>) -> Result<(String, String)> {
55    let cmd = metadata_command(manifest_path);
56    let metadata = cmd.exec()?;
57    let root_id = metadata
58        .resolve
59        .as_ref()
60        .and_then(|r| r.root.as_ref())
61        .ok_or_else(|| anyhow::anyhow!("No root package found"))?;
62    let root_pkg = metadata
63        .packages
64        .iter()
65        .find(|p| &p.id == root_id)
66        .ok_or_else(|| anyhow::anyhow!("Root package not in packages list"))?;
67    Ok((root_pkg.name.to_string(), root_pkg.version.to_string()))
68}
69
70/// Build a lookup from package ID to the resolved Node (which carries enabled features).
71fn build_node_lookup(resolve: &Resolve) -> HashMap<String, Node> {
72    resolve
73        .nodes
74        .iter()
75        .map(|n| (n.id.to_string(), n.clone()))
76        .collect()
77}
78
79/// Build a map from package ID to package reference.
80fn build_pkg_lookup(metadata: &cargo_metadata::Metadata) -> HashMap<String, Package> {
81    metadata
82        .packages
83        .iter()
84        .map(|p| (p.id.to_string(), p.clone()))
85        .collect()
86}
87
88/// Parse the dependency tree for the project at `manifest_path` (resolved root crate only).
89///
90/// **Key change**: uses `resolve.nodes[].features` for the **actual enabled features**
91/// rather than `pkg.features.keys()` which only lists declared/available features.
92pub fn get_deps(manifest_path: Option<&Path>) -> Result<Vec<ResolvedDep>> {
93    let snapshots =
94        fetch_metadata_and_snapshots(manifest_path, SnapshotMode::RootOnly, false)?;
95    snapshots
96        .into_iter()
97        .next()
98        .map(|p| p.deps)
99        .ok_or_else(|| anyhow::anyhow!("No dependency snapshot produced"))
100}
101
102/// Load dependency snapshots for workspace members according to Cargo metadata.
103///
104/// - **Root-only**: `workspace_all_members` false and `package_filters` empty — only `[package]` at the resolved root (`resolve.root`).
105/// - **All members**: `workspace_all_members` true — every entry in `metadata.workspace_members`.
106/// - **Filtered**: non-empty `package_filters` — members whose names match case-insensitively (comma-separated CLI values become one slice).
107///
108/// `all_targets` widens what counts as a "direct" dep to include `[dev-dependencies]` and
109/// `[build-dependencies]` in addition to normal `[dependencies]`.
110pub fn get_package_snapshots(
111    manifest_path: Option<&Path>,
112    workspace_all_members: bool,
113    package_filters: &[String],
114    all_targets: bool,
115) -> Result<Vec<PackageResult>> {
116    fetch_metadata_and_snapshots(
117        manifest_path,
118        SnapshotMode::Custom {
119            workspace_all_members,
120            filters: package_filters,
121        },
122        all_targets,
123    )
124}
125
126#[derive(Clone, Copy, Debug)]
127enum SnapshotMode<'a> {
128    RootOnly,
129    Custom {
130        workspace_all_members: bool,
131        filters: &'a [String],
132    },
133}
134
135fn fetch_metadata_and_snapshots(
136    manifest_path: Option<&Path>,
137    mode: SnapshotMode<'_>,
138    all_targets: bool,
139) -> Result<Vec<PackageResult>> {
140    let mut cmd = metadata_command(manifest_path);
141    cmd.features(CargoOpt::AllFeatures);
142
143    let metadata = cmd.exec()?;
144    let resolve = metadata
145        .resolve
146        .as_ref()
147        .ok_or_else(|| anyhow::anyhow!("No dependency resolution found"))?;
148
149    let node_map = build_node_lookup(resolve);
150    let pkg_map = build_pkg_lookup(&metadata);
151
152    let workspace_packages_ordered: Vec<&Package> = metadata
153        .workspace_members
154        .iter()
155        .filter_map(|id| metadata.packages.iter().find(|p| &p.id == id))
156        .collect();
157
158    let resolve_workspace_root_pkg = || -> Result<&Package> {
159        let root_id = resolve
160            .root
161            .as_ref()
162            .ok_or_else(|| anyhow::anyhow!("No root package in resolve"))?;
163        pkg_map
164            .get(&root_id.to_string())
165            .ok_or_else(|| anyhow::anyhow!("Root package not in cargo metadata packages list"))
166    };
167
168    let targets = match mode {
169        SnapshotMode::RootOnly => vec![resolve_workspace_root_pkg()?],
170        SnapshotMode::Custom {
171            workspace_all_members,
172            filters,
173        } => {
174            if workspace_all_members && filters.is_empty() {
175                workspace_packages_ordered
176            } else if !filters.is_empty() {
177                let wanted_set: HashSet<String> = filters
178                    .iter()
179                    .map(|s| s.trim().to_ascii_lowercase())
180                    .filter(|s| !s.is_empty())
181                    .collect();
182
183                let mut matched: Vec<&Package> = workspace_packages_ordered
184                    .iter()
185                    .copied()
186                    .filter(|p| wanted_set.contains(&p.name.to_ascii_lowercase()))
187                    .collect();
188
189                matched.sort_by(|a, b| a.name.cmp(&b.name));
190
191                let mut missing = Vec::new();
192                for w in &wanted_set {
193                    if !matched
194                        .iter()
195                        .any(|p| p.name.eq_ignore_ascii_case(w.as_str()))
196                    {
197                        missing.push(w.clone());
198                    }
199                }
200
201                if !missing.is_empty() {
202                    bail!(
203                        "no workspace package(s) matching {:?} — available: {}",
204                        missing,
205                        workspace_packages_ordered
206                            .iter()
207                            .map(|p| p.name.as_str())
208                            .collect::<Vec<_>>()
209                            .join(", ")
210                    );
211                }
212
213                matched
214            } else {
215                vec![resolve_workspace_root_pkg()?]
216            }
217        }
218    };
219
220    let mut out = Vec::with_capacity(targets.len());
221    for pkg in targets {
222        let deps = resolve_deps_for_root(&pkg.id, resolve, &pkg_map, &node_map, all_targets)?;
223        out.push(PackageResult {
224            name: pkg.name.to_string(),
225            version: pkg.version.to_string(),
226            manifest_path: PathBuf::from(pkg.manifest_path.as_str()),
227            deps,
228        });
229    }
230
231    Ok(out)
232}
233
234/// Resolve flattened dependency list relative to `root_pkg_id`'s subtree.
235///
236/// When `all_targets` is false (the default), only `[dependencies]` entries are treated as
237/// direct. When true, `[dev-dependencies]` and `[build-dependencies]` are also considered direct.
238fn resolve_deps_for_root(
239    root_pkg_id: &PackageId,
240    resolve: &Resolve,
241    pkg_map: &HashMap<String, Package>,
242    node_map: &HashMap<String, Node>,
243    all_targets: bool,
244) -> Result<Vec<ResolvedDep>> {
245    let root_node = node_map
246        .get(&root_pkg_id.to_string())
247        .ok_or_else(|| anyhow::anyhow!("Root node missing in resolve.nodes"))?;
248
249    let direct_dep_ids: HashSet<String> = root_node
250        .deps
251        .iter()
252        .filter(|d| {
253            all_targets
254                || d.dep_kinds
255                    .iter()
256                    .any(|k| k.kind == DependencyKind::Normal)
257        })
258        .map(|d| d.pkg.to_string())
259        .collect();
260
261    let mut deps = Vec::new();
262
263    for node in &resolve.nodes {
264        if node.id == *root_pkg_id {
265            continue;
266        }
267
268        let pkg = match pkg_map.get(&node.id.to_string()) {
269            Some(p) => p,
270            None => continue,
271        };
272
273        let is_direct = direct_dep_ids.contains(&node.id.to_string());
274
275        let enabled_features: Vec<String> = node
276            .features
277            .iter()
278            .map(|s| s.as_str().to_string())
279            .collect();
280
281        let available_features: Vec<String> = pkg.features.keys().map(|s| s.to_string()).collect();
282
283        deps.push(ResolvedDep {
284            name: pkg.name.to_string(),
285            version: pkg.version.to_string(),
286            enabled_features,
287            available_features,
288            source: pkg.source.as_ref().map(|s| s.to_string()),
289            repository: pkg.repository.clone(),
290            is_direct,
291        });
292    }
293
294    Ok(deps)
295}
296
297fn metadata_command(manifest_path: Option<&Path>) -> MetadataCommand {
298    let mut cmd = MetadataCommand::new();
299
300    if let Some(path) = manifest_path {
301        cmd.manifest_path(path);
302    }
303
304    if lockfile_path(manifest_path).is_file() {
305        cmd.other_options(vec!["--locked".to_string()]);
306    }
307
308    cmd
309}
310
311fn lockfile_path(manifest_path: Option<&Path>) -> PathBuf {
312    manifest_path
313        .and_then(Path::parent)
314        .map(|path| path.join("Cargo.lock"))
315        .unwrap_or_else(|| PathBuf::from("Cargo.lock"))
316}