Skip to main content

cargo_feature_lens/
resolver.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::manifest::ManifestCache;
4use crate::metadata::{Metadata, ResolveNode};
5
6/// A best-effort, enriched dependency graph keyed by Cargo package ID.
7#[derive(Debug, Clone, Default)]
8pub struct FeatureGraph {
9    pub workspace_members: BTreeSet<String>,
10    pub nodes: BTreeMap<String, FeatureNode>,
11}
12
13impl FeatureGraph {
14    pub fn sorted_nodes(&self) -> impl Iterator<Item = &FeatureNode> {
15        self.nodes.values()
16    }
17
18    pub fn get(&self, package_id: &str) -> Option<&FeatureNode> {
19        self.nodes.get(package_id)
20    }
21}
22
23/// Feature information for a single resolved package.
24#[derive(Debug, Clone, Default)]
25pub struct FeatureNode {
26    pub package_id: String,
27    pub name: String,
28    pub version: String,
29    pub manifest_path: String,
30    pub active_features: BTreeSet<String>,
31    pub available_features: BTreeMap<String, Vec<String>>,
32    pub optional_dependencies: BTreeSet<String>,
33    pub dependency_features: BTreeMap<String, Vec<String>>,
34    pub feature_sources: BTreeMap<String, Vec<FeatureSource>>,
35    pub dependencies: Vec<String>,
36}
37
38/// A recorded reason that a feature is active.
39#[derive(Debug, Clone, Eq, PartialEq)]
40pub struct FeatureSource {
41    pub requested_by: String,
42    pub path: Vec<String>,
43}
44
45/// Build an initial feature graph from `cargo metadata` and package manifests.
46pub fn resolve(
47    metadata: &Metadata,
48    manifests: &mut ManifestCache,
49) -> Result<FeatureGraph, Box<dyn std::error::Error>> {
50    let mut graph = FeatureGraph {
51        workspace_members: metadata.workspace_members.iter().cloned().collect(),
52        ..FeatureGraph::default()
53    };
54
55    for package in &metadata.packages {
56        let manifest = manifests.get_or_parse(&package.manifest_path)?;
57        let package_id = package.id.clone();
58        let mut available_features = manifest.features;
59        merge_feature_map(&mut available_features, package.features.clone());
60        sort_feature_map(&mut available_features);
61        let mut optional_dependencies = manifest.optional_dependencies;
62        optional_dependencies.extend(package.optional_dependencies.clone());
63        let mut dependency_features = manifest.dependency_features;
64        merge_feature_map(
65            &mut dependency_features,
66            package.dependency_features.clone(),
67        );
68        sort_feature_map(&mut dependency_features);
69
70        graph.nodes.insert(
71            package_id.clone(),
72            FeatureNode {
73                package_id,
74                name: package.name.clone(),
75                version: package.version.clone(),
76                manifest_path: package.manifest_path.display().to_string(),
77                available_features,
78                optional_dependencies,
79                dependency_features,
80                ..FeatureNode::default()
81            },
82        );
83    }
84
85    for node in &metadata.resolve_nodes {
86        let package_id = node.id.clone();
87        if let Some(feature_node) = graph.nodes.get_mut(&package_id) {
88            feature_node
89                .active_features
90                .extend(node.features.iter().cloned());
91            feature_node.dependencies = sorted_unique(node.dependencies.clone());
92            let self_name = feature_node.name.clone();
93            let active_features = feature_node.active_features.clone();
94            for feature in active_features {
95                add_feature_source(
96                    feature_node,
97                    &feature,
98                    FeatureSource {
99                        requested_by: self_name.clone(),
100                        path: vec![self_name.clone()],
101                    },
102                );
103            }
104        }
105    }
106
107    record_dependency_sources(&mut graph, &metadata.resolve_nodes);
108    record_manifest_feature_sources(&mut graph, &metadata.resolve_nodes);
109    Ok(graph)
110}
111
112fn record_dependency_sources(graph: &mut FeatureGraph, nodes: &[ResolveNode]) {
113    let resolve_nodes = nodes
114        .iter()
115        .map(|node| (node.id.as_str(), node))
116        .collect::<BTreeMap<_, _>>();
117
118    for parent_id in graph.nodes.keys().cloned().collect::<Vec<_>>() {
119        let Some(metadata_node) = resolve_nodes.get(parent_id.as_str()).copied() else {
120            continue;
121        };
122        let Some(parent) = graph.get(&parent_id) else {
123            continue;
124        };
125        let parent_name = parent.name.clone();
126        let dependency_features = parent.dependency_features.clone();
127
128        for (dependency_name, child_id) in &metadata_node.dependency_names {
129            let child_name = graph
130                .nodes
131                .get(child_id)
132                .map(|child| child.name.clone())
133                .unwrap_or_else(|| dependency_name.clone());
134            let requested_features = dependency_features
135                .get(dependency_name)
136                .or_else(|| dependency_features.get(&child_name))
137                .cloned()
138                .unwrap_or_default();
139            if requested_features.is_empty() {
140                continue;
141            }
142
143            for feature in requested_features {
144                let Some(child) = graph.nodes.get_mut(child_id) else {
145                    continue;
146                };
147                if child.active_features.contains(&feature) {
148                    add_feature_source(
149                        child,
150                        &feature,
151                        FeatureSource {
152                            requested_by: format!("{parent_name}/{child_name}"),
153                            path: vec![parent_name.clone(), child_name.clone(), feature.clone()],
154                        },
155                    );
156                }
157            }
158        }
159    }
160}
161
162fn record_manifest_feature_sources(graph: &mut FeatureGraph, nodes: &[ResolveNode]) {
163    let resolve_nodes = nodes
164        .iter()
165        .map(|node| (node.id.as_str(), node))
166        .collect::<BTreeMap<_, _>>();
167    let package_names = graph
168        .nodes
169        .iter()
170        .map(|(id, node)| (node.name.clone(), id.clone()))
171        .collect::<BTreeMap<_, _>>();
172
173    for package_id in graph.nodes.keys().cloned().collect::<Vec<_>>() {
174        let Some(node_snapshot) = graph.nodes.get(&package_id).cloned() else {
175            continue;
176        };
177        let metadata_node = resolve_nodes.get(package_id.as_str()).copied();
178
179        for feature in &node_snapshot.active_features {
180            let Some(expanded) = node_snapshot.available_features.get(feature) else {
181                continue;
182            };
183            for item in expanded {
184                if let Some((dependency_name, dependency_feature)) = item.split_once('/') {
185                    let child_id = metadata_node
186                        .and_then(|node| node.dependency_names.get(dependency_name))
187                        .cloned()
188                        .or_else(|| package_names.get(dependency_name).cloned());
189                    let Some(child_id) = child_id else { continue };
190                    let Some(child) = graph.nodes.get_mut(&child_id) else {
191                        continue;
192                    };
193                    if child.active_features.contains(dependency_feature) {
194                        add_feature_source(
195                            child,
196                            dependency_feature,
197                            FeatureSource {
198                                requested_by: format!("{}/{}", node_snapshot.name, feature),
199                                path: vec![
200                                    node_snapshot.name.clone(),
201                                    feature.clone(),
202                                    dependency_name.to_string(),
203                                    dependency_feature.to_string(),
204                                ],
205                            },
206                        );
207                    }
208                    continue;
209                }
210
211                let normalized = normalize_feature_reference(item);
212                if let Some(node) = graph.nodes.get_mut(&package_id) {
213                    if node.active_features.contains(&normalized) {
214                        add_feature_source(
215                            node,
216                            &normalized,
217                            FeatureSource {
218                                requested_by: format!("{}/{}", node_snapshot.name, feature),
219                                path: vec![
220                                    node_snapshot.name.clone(),
221                                    feature.clone(),
222                                    normalized.clone(),
223                                ],
224                            },
225                        );
226                    }
227                }
228            }
229        }
230    }
231}
232
233fn add_feature_source(node: &mut FeatureNode, feature: &str, source: FeatureSource) {
234    let sources = node.feature_sources.entry(feature.to_string()).or_default();
235    if !sources.contains(&source) {
236        sources.push(source);
237        sources.sort_by(|left, right| {
238            left.requested_by
239                .cmp(&right.requested_by)
240                .then_with(|| left.path.cmp(&right.path))
241        });
242    }
243}
244
245fn merge_feature_map(
246    target: &mut BTreeMap<String, Vec<String>>,
247    source: BTreeMap<String, Vec<String>>,
248) {
249    for (feature, values) in source {
250        target.entry(feature).or_default().extend(values);
251    }
252}
253
254fn sort_feature_map(map: &mut BTreeMap<String, Vec<String>>) {
255    for values in map.values_mut() {
256        *values = sorted_unique(std::mem::take(values));
257    }
258}
259
260fn sorted_unique(mut values: Vec<String>) -> Vec<String> {
261    values.sort();
262    values.dedup();
263    values
264}
265
266fn normalize_feature_reference(feature: &str) -> String {
267    feature
268        .strip_prefix("dep:")
269        .unwrap_or(feature)
270        .split('/')
271        .next_back()
272        .unwrap_or(feature)
273        .to_string()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::normalize_feature_reference;
279    use crate::manifest::ManifestCache;
280    use crate::{metadata, resolver};
281    use std::path::Path;
282
283    fn resolver_v2_graph() -> resolver::FeatureGraph {
284        let metadata = metadata::load_metadata(Path::new("tests/fixtures/resolver-v2"))
285            .expect("fixture metadata should load");
286        resolver::resolve(&metadata, &mut ManifestCache::default())
287            .expect("fixture graph should resolve")
288    }
289
290    fn node<'a>(graph: &'a resolver::FeatureGraph, name: &str) -> &'a resolver::FeatureNode {
291        graph
292            .sorted_nodes()
293            .find(|node| node.name == name)
294            .unwrap_or_else(|| panic!("missing node {name}"))
295    }
296
297    #[test]
298    fn normalizes_dep_and_namespaced_features() {
299        assert_eq!(normalize_feature_reference("dep:serde_json"), "serde_json");
300        assert_eq!(normalize_feature_reference("serde/derive"), "derive");
301    }
302
303    #[test]
304    fn uses_cargo_metadata_resolved_features_for_direct_dependency_features() {
305        let graph = resolver_v2_graph();
306        let leaf = node(&graph, "leaf");
307
308        assert!(leaf.active_features.contains("direct-leaf"));
309        assert!(leaf.feature_sources["direct-leaf"].iter().any(|source| {
310            source.requested_by == "resolver-app/leaf"
311                && source.path == ["resolver-app", "leaf", "direct-leaf"]
312        }));
313    }
314
315    #[test]
316    fn records_transitive_feature_propagation_from_manifest_expansion() {
317        let graph = resolver_v2_graph();
318        let leaf = node(&graph, "leaf");
319
320        assert!(leaf.active_features.contains("propagated"));
321        assert!(leaf.feature_sources["propagated"].iter().any(|source| {
322            source.requested_by == "mid/propagated"
323                && source.path == ["mid", "propagated", "leaf", "propagated"]
324        }));
325        assert!(leaf.feature_sources["mid-forward"].iter().any(|source| {
326            source.requested_by == "mid/direct-mid"
327                && source.path == ["mid", "direct-mid", "leaf", "mid-forward"]
328        }));
329    }
330
331    #[test]
332    fn includes_cargo_metadata_dev_and_build_dependency_resolution() {
333        let graph = resolver_v2_graph();
334        let helper = node(&graph, "helper");
335        let leaf = node(&graph, "leaf");
336
337        assert!(helper.active_features.contains("build-only"));
338        assert!(leaf.active_features.contains("dev-only"));
339        assert!(leaf.active_features.contains("build-transitive"));
340        assert!(leaf.feature_sources["dev-only"].iter().any(|source| {
341            source.requested_by == "resolver-app/leaf"
342                && source.path == ["resolver-app", "leaf", "dev-only"]
343        }));
344        assert!(leaf.feature_sources["build-transitive"]
345            .iter()
346            .any(|source| {
347                source.requested_by == "helper/leaf"
348                    && source.path == ["helper", "leaf", "build-transitive"]
349            }));
350    }
351
352    #[test]
353    fn preserves_workspace_member_feature_unification_and_deterministic_edges() {
354        let graph = resolver_v2_graph();
355        let tool = node(&graph, "resolver-tool");
356        let leaf = node(&graph, "leaf");
357
358        assert_eq!(
359            tool.dependency_features["leaf"],
360            vec!["tool-only", "workspace-base"]
361        );
362        assert!(leaf.active_features.contains("workspace-base"));
363        assert!(leaf.active_features.contains("tool-only"));
364        assert_eq!(
365            node(&graph, "resolver-app").dependencies,
366            vec![
367                node(&graph, "helper").package_id.clone(),
368                leaf.package_id.clone(),
369                node(&graph, "mid").package_id.clone(),
370            ]
371        );
372    }
373}