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::Result;
5use cargo_metadata::{CargoOpt, MetadataCommand, Node, Package, 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
24impl fmt::Display for ResolvedDep {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        let tag = if self.is_direct {
27            "direct"
28        } else {
29            "transitive"
30        };
31        write!(f, "{} v{} ({})", self.name, self.version, tag)?;
32        if !self.enabled_features.is_empty() {
33            write!(f, " [enabled: {}]", self.enabled_features.join(", "))?;
34        }
35        if !self.available_features.is_empty() && self.available_features != self.enabled_features {
36            write!(f, " (available: {})", self.available_features.join(", "))?;
37        }
38        Ok(())
39    }
40}
41
42/// Get the root project's name and version from Cargo metadata.
43pub fn get_project_info(manifest_path: Option<&Path>) -> Result<(String, String)> {
44    let cmd = metadata_command(manifest_path);
45    let metadata = cmd.exec()?;
46    let root_id = metadata
47        .resolve
48        .as_ref()
49        .and_then(|r| r.root.as_ref())
50        .ok_or_else(|| anyhow::anyhow!("No root package found"))?;
51    let root_pkg = metadata
52        .packages
53        .iter()
54        .find(|p| &p.id == root_id)
55        .ok_or_else(|| anyhow::anyhow!("Root package not in packages list"))?;
56    Ok((root_pkg.name.to_string(), root_pkg.version.to_string()))
57}
58
59/// Build a lookup from package ID to the resolved Node (which carries enabled features).
60fn build_node_lookup(resolve: &Resolve) -> HashMap<String, Node> {
61    resolve
62        .nodes
63        .iter()
64        .map(|n| (n.id.to_string(), n.clone()))
65        .collect()
66}
67
68/// Build a map from package ID to package reference.
69fn build_pkg_lookup(metadata: &cargo_metadata::Metadata) -> HashMap<String, Package> {
70    metadata
71        .packages
72        .iter()
73        .map(|p| (p.id.to_string(), p.clone()))
74        .collect()
75}
76
77/// Parse the dependency tree for the project at `manifest_path`.
78///
79/// **Key change**: uses `resolve.nodes[].features` for the **actual enabled features**
80/// rather than `pkg.features.keys()` which only lists declared/available features.
81pub fn get_deps(manifest_path: Option<&Path>) -> Result<Vec<ResolvedDep>> {
82    let mut cmd = metadata_command(manifest_path);
83    cmd.features(CargoOpt::AllFeatures);
84
85    let metadata = cmd.exec()?;
86    let resolve = metadata
87        .resolve
88        .as_ref()
89        .ok_or_else(|| anyhow::anyhow!("No dependency resolution found"))?;
90
91    // Build node lookup for resolved (enabled) features
92    let node_map = build_node_lookup(resolve);
93    let pkg_map = build_pkg_lookup(&metadata);
94
95    // Collect root/direct dependency names for tagging
96    let root_id = resolve
97        .root
98        .as_ref()
99        .ok_or_else(|| anyhow::anyhow!("No root package in resolve"))?;
100
101    // Find the root node to get its direct dependencies
102    let root_node = node_map
103        .get(&root_id.to_string())
104        .ok_or_else(|| anyhow::anyhow!("Root node not found in resolve nodes"))?;
105
106    // Direct dependency IDs are those listed as deps of the root node
107    let direct_dep_ids: HashSet<String> =
108        root_node.deps.iter().map(|d| d.pkg.to_string()).collect();
109
110    let mut deps = Vec::new();
111
112    for node in &resolve.nodes {
113        // Skip the root package itself
114        if node.id == *root_id {
115            continue;
116        }
117
118        let pkg = match pkg_map.get(&node.id.to_string()) {
119            Some(p) => p,
120            None => continue, // not a real crate (e.g. virtual manifest)
121        };
122
123        let is_direct = direct_dep_ids.contains(&node.id.to_string());
124
125        // Enabled features come from the resolved node (what's actually turned on)
126        let enabled_features: Vec<String> = node
127            .features
128            .iter()
129            .map(|s| s.as_str().to_string())
130            .collect();
131
132        // Available features come from the package manifest (what's declared)
133        let available_features: Vec<String> = pkg.features.keys().map(|s| s.to_string()).collect();
134
135        deps.push(ResolvedDep {
136            name: pkg.name.to_string(),
137            version: pkg.version.to_string(),
138            enabled_features,
139            available_features,
140            source: pkg.source.as_ref().map(|s| s.to_string()),
141            repository: pkg.repository.clone(),
142            is_direct,
143        });
144    }
145
146    Ok(deps)
147}
148
149fn metadata_command(manifest_path: Option<&Path>) -> MetadataCommand {
150    let mut cmd = MetadataCommand::new();
151
152    if let Some(path) = manifest_path {
153        cmd.manifest_path(path);
154    }
155
156    if lockfile_path(manifest_path).is_file() {
157        cmd.other_options(vec!["--locked".to_string()]);
158    }
159
160    cmd
161}
162
163fn lockfile_path(manifest_path: Option<&Path>) -> PathBuf {
164    manifest_path
165        .and_then(Path::parent)
166        .map(|path| path.join("Cargo.lock"))
167        .unwrap_or_else(|| PathBuf::from("Cargo.lock"))
168}