Skip to main content

cargo_feature_combinations/
implication.rs

1//! Feature implication graph and redundant-combination pruning.
2//!
3//! Cargo features can imply other features (e.g. `B = ["A"]`). When this
4//! happens, the combination `[A, B]` resolves to the same effective feature
5//! set as `[B]` alone. This module detects and prunes such redundant
6//! combinations.
7
8use petgraph::graphmap::DiGraphMap;
9use petgraph::visit::Dfs;
10use std::collections::{BTreeMap, BTreeSet};
11
12/// A feature combination that was pruned because another (smaller)
13/// combination resolves to the same effective feature set.
14#[derive(Debug, Clone)]
15pub struct PrunedCombination {
16    /// The feature names in this pruned combination.
17    pub features: Vec<String>,
18    /// The feature names in the representative (kept) combination that has
19    /// the same resolved set.
20    pub equivalent_to: Vec<String>,
21}
22
23/// Result of partitioning feature combinations via implied-feature pruning.
24#[derive(Debug)]
25pub struct PruneResult<'a> {
26    /// Combinations to actually execute.
27    pub keep: Vec<Vec<&'a String>>,
28    /// Combinations that were pruned, with their equivalence info.
29    pub pruned: Vec<PrunedCombination>,
30}
31
32/// Build a directed implication graph from a package's feature definitions.
33///
34/// An edge `A → B` means enabling feature A also enables feature B. Only
35/// plain feature names are considered — dependency activations (`dep:X`,
36/// `crate/feature`, `?dep:X/feature`) are filtered out.
37fn build_implication_graph(features: &BTreeMap<String, Vec<String>>) -> DiGraphMap<&str, ()> {
38    let mut graph = DiGraphMap::new();
39
40    // Every feature is a node, even if it implies nothing — resolved_set
41    // relies on DFS starting from nodes that exist in the graph.
42    for key in features.keys() {
43        graph.add_node(key.as_str());
44    }
45
46    for (feature_name, implied_values) in features {
47        for implied in implied_values {
48            // Feature values can be:
49            //   "other_feature"          → intra-crate implication (what we want)
50            //   "dep:name"               → optional dep activation
51            //   "crate/feature"          → dep feature activation
52            //   "?dep:name/feature"      → weak dep feature
53            // Only plain names that match a key in the feature map form edges.
54            if implied.contains('/') || implied.contains(':') {
55                continue;
56            }
57            if features.contains_key(implied) && implied != feature_name {
58                graph.add_edge(feature_name.as_str(), implied.as_str(), ());
59            }
60        }
61    }
62
63    graph
64}
65
66/// Compute the resolved feature set for a single combination.
67///
68/// The resolved set is the combination itself plus all features transitively
69/// reachable via the implication graph. Uses `&str` references into the
70/// features map to avoid allocations.
71fn resolved_set<'a>(combo: &[&'a String], graph: &DiGraphMap<&'a str, ()>) -> BTreeSet<&'a str> {
72    let mut resolved = BTreeSet::new();
73    for feature in combo {
74        if resolved.insert(feature.as_str()) {
75            let mut dfs = Dfs::new(graph, feature.as_str());
76            while let Some(reachable) = dfs.next(graph) {
77                resolved.insert(reachable);
78            }
79        }
80    }
81    resolved
82}
83
84/// Apply implied-feature pruning if the config enables it, otherwise return
85/// combos unchanged.
86///
87/// Callers that only need the kept combinations can use `.keep` on the result
88/// and ignore `.pruned`.
89#[must_use]
90pub fn maybe_prune<'a>(
91    combos: Vec<Vec<&'a String>>,
92    features: &'a BTreeMap<String, Vec<String>>,
93    config: &crate::config::Config,
94    no_prune: bool,
95) -> PruneResult<'a> {
96    // allow_feature_sets is an explicit allowlist where the user declared the
97    // exact sets they care about — pruning would silently drop entries they
98    // asked for.
99    let active = config.prune_implied && !no_prune && config.allow_feature_sets.is_empty();
100    if active {
101        prune_implied_combinations(combos, features)
102    } else {
103        PruneResult {
104            keep: combos,
105            pruned: Vec::new(),
106        }
107    }
108}
109
110/// Partition feature combinations into kept and pruned groups.
111///
112/// Combinations with identical resolved feature sets are grouped together.
113/// From each group the smallest combination (fewest features, then
114/// lexicographic) is kept as the representative; the rest are pruned.
115fn prune_implied_combinations<'a>(
116    combos: Vec<Vec<&'a String>>,
117    features: &'a BTreeMap<String, Vec<String>>,
118) -> PruneResult<'a> {
119    let graph = build_implication_graph(features);
120
121    // Group combos by their resolved feature set — the full set of features
122    // that Cargo would enable after applying all transitive implications.
123    // Combos in the same group produce identical compiled output.
124    let mut groups: BTreeMap<BTreeSet<&'a str>, Vec<Vec<&'a String>>> = BTreeMap::new();
125    for combo in combos {
126        let resolved = resolved_set(&combo, &graph);
127        groups.entry(resolved).or_default().push(combo);
128    }
129
130    let mut keep = Vec::new();
131    let mut pruned = Vec::new();
132
133    for (_resolved, mut group) in groups {
134        // Within each equivalence group, the smallest combo (fewest explicit
135        // features) is the canonical representative — it's the one that
136        // actually needs to be tested. Ties are broken lexicographically for
137        // deterministic output.
138        group.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
139
140        let representative = group.remove(0);
141        let representative_features: Vec<String> =
142            representative.iter().copied().cloned().collect();
143
144        for redundant in group {
145            pruned.push(PrunedCombination {
146                features: redundant.iter().copied().cloned().collect(),
147                equivalent_to: representative_features.clone(),
148            });
149        }
150
151        keep.push(representative);
152    }
153
154    keep.sort();
155    pruned.sort_by(|a, b| a.features.cmp(&b.features));
156
157    PruneResult { keep, pruned }
158}
159
160#[cfg(test)]
161mod test {
162    use super::*;
163    use color_eyre::eyre;
164    use similar_asserts::assert_eq as sim_assert_eq;
165
166    fn features(pairs: &[(&str, &[&str])]) -> BTreeMap<String, Vec<String>> {
167        pairs
168            .iter()
169            .map(|(k, v)| {
170                (
171                    (*k).to_string(),
172                    v.iter().copied().map(String::from).collect(),
173                )
174            })
175            .collect()
176    }
177
178    fn vs(strs: &[&str]) -> Vec<String> {
179        strs.iter().copied().map(String::from).collect()
180    }
181
182    type Kept = Vec<Vec<String>>;
183    type Pruned = Vec<(Vec<String>, Vec<String>)>;
184
185    fn run_prune(feat: &BTreeMap<String, Vec<String>>) -> eyre::Result<(Kept, Pruned)> {
186        use crate::config::Config;
187        use crate::package::Package;
188
189        let mut pkg = crate::package::test::package_with_features(&[])?;
190        pkg.features = feat.clone();
191
192        let config = Config {
193            prune_implied: true,
194            ..Config::default()
195        };
196        let combos = pkg.feature_combinations(&config)?;
197        let result = prune_implied_combinations(combos, &pkg.features);
198
199        let kept: Vec<Vec<String>> = result
200            .keep
201            .iter()
202            .map(|c| c.iter().map(|s| (*s).clone()).collect())
203            .collect();
204        let pruned: Vec<(Vec<String>, Vec<String>)> = result
205            .pruned
206            .iter()
207            .map(|p| (p.features.clone(), p.equivalent_to.clone()))
208            .collect();
209        Ok((kept, pruned))
210    }
211
212    #[test]
213    fn no_implications_no_pruning() -> eyre::Result<()> {
214        let feat = features(&[("A", &[]), ("B", &[]), ("C", &[])]);
215        let (kept, pruned) = run_prune(&feat)?;
216        sim_assert_eq!(pruned.len(), 0);
217        sim_assert_eq!(kept.len(), 8); // 2^3 = 8
218        Ok(())
219    }
220
221    #[test]
222    fn simple_implication_b_implies_a() -> eyre::Result<()> {
223        let feat = features(&[("A", &[]), ("B", &["A"])]);
224        let (kept, pruned) = run_prune(&feat)?;
225
226        // [A, B] has the same resolved set as [B]: {A, B}
227        sim_assert_eq!(kept, vec![vs(&[]), vs(&["A"]), vs(&["B"])]);
228        sim_assert_eq!(pruned, vec![(vs(&["A", "B"]), vs(&["B"]))]);
229        Ok(())
230    }
231
232    #[test]
233    fn transitive_chain_c_b_a() -> eyre::Result<()> {
234        let feat = features(&[("A", &[]), ("B", &["A"]), ("C", &["B"])]);
235        let (kept, pruned) = run_prune(&feat)?;
236
237        sim_assert_eq!(kept, vec![vs(&[]), vs(&["A"]), vs(&["B"]), vs(&["C"])]);
238        sim_assert_eq!(pruned.len(), 4);
239        Ok(())
240    }
241
242    #[test]
243    fn dep_syntax_ignored() -> eyre::Result<()> {
244        let feat = features(&[("A", &[]), ("B", &["dep:some_dep", "A"])]);
245        let (kept, pruned) = run_prune(&feat)?;
246
247        sim_assert_eq!(kept, vec![vs(&[]), vs(&["A"]), vs(&["B"])]);
248        sim_assert_eq!(pruned, vec![(vs(&["A", "B"]), vs(&["B"]))]);
249        Ok(())
250    }
251
252    #[test]
253    fn slash_syntax_ignored() -> eyre::Result<()> {
254        let feat = features(&[("A", &[]), ("B", &["some-crate/feature", "A"])]);
255        let (_kept, pruned) = run_prune(&feat)?;
256
257        sim_assert_eq!(pruned, vec![(vs(&["A", "B"]), vs(&["B"]))]);
258        Ok(())
259    }
260
261    #[test]
262    fn weak_dep_syntax_ignored() -> eyre::Result<()> {
263        let feat = features(&[("A", &[]), ("B", &["?dep:x/y", "A"])]);
264        let (_kept, pruned) = run_prune(&feat)?;
265
266        sim_assert_eq!(pruned, vec![(vs(&["A", "B"]), vs(&["B"]))]);
267        Ok(())
268    }
269
270    #[test]
271    fn nonexistent_implied_feature_ignored() -> eyre::Result<()> {
272        let feat = features(&[("A", &[]), ("B", &["NonExistent"])]);
273        let (kept, pruned) = run_prune(&feat)?;
274
275        sim_assert_eq!(pruned.len(), 0);
276        sim_assert_eq!(kept.len(), 4); // 2^2 = 4
277        Ok(())
278    }
279
280    #[test]
281    fn diamond_graph() -> eyre::Result<()> {
282        let feat = features(&[("A", &[]), ("B", &["A"]), ("C", &["A"]), ("D", &["B", "C"])]);
283        let (kept, pruned) = run_prune(&feat)?;
284
285        sim_assert_eq!(
286            kept,
287            vec![
288                vs(&[]),
289                vs(&["A"]),
290                vs(&["B"]),
291                vs(&["B", "C"]),
292                vs(&["C"]),
293                vs(&["D"]),
294            ]
295        );
296        sim_assert_eq!(pruned.len(), 10);
297        Ok(())
298    }
299
300    #[test]
301    fn mutual_implication_lexicographic_tiebreak() -> eyre::Result<()> {
302        let feat = features(&[("A", &["B"]), ("B", &["A"])]);
303        let (kept, pruned) = run_prune(&feat)?;
304
305        sim_assert_eq!(kept, vec![vs(&[]), vs(&["A"])]);
306        sim_assert_eq!(pruned.len(), 2);
307        Ok(())
308    }
309
310    #[test]
311    fn resolved_set_correctness() {
312        let feat = features(&[("A", &[]), ("B", &["A"]), ("C", &["B"])]);
313        let graph = build_implication_graph(&feat);
314
315        let a = "A".to_string();
316        let b = "B".to_string();
317        let c = "C".to_string();
318
319        sim_assert_eq!(resolved_set(&[], &graph), BTreeSet::<&str>::new());
320        sim_assert_eq!(resolved_set(&[&a], &graph), BTreeSet::from(["A"]));
321        sim_assert_eq!(resolved_set(&[&b], &graph), BTreeSet::from(["A", "B"]));
322        sim_assert_eq!(resolved_set(&[&c], &graph), BTreeSet::from(["A", "B", "C"]));
323        sim_assert_eq!(resolved_set(&[&a, &b], &graph), BTreeSet::from(["A", "B"]));
324    }
325
326    #[test]
327    fn self_referencing_feature_no_crash() -> eyre::Result<()> {
328        let feat = features(&[("A", &["A"]), ("B", &[])]);
329        let (kept, pruned) = run_prune(&feat)?;
330
331        sim_assert_eq!(pruned.len(), 0);
332        sim_assert_eq!(kept.len(), 4);
333        Ok(())
334    }
335
336    #[test]
337    fn empty_features_no_pruning() {
338        let feat: BTreeMap<String, Vec<String>> = BTreeMap::new();
339        let result = prune_implied_combinations(Vec::new(), &feat);
340        sim_assert_eq!(result.keep.len(), 0);
341        sim_assert_eq!(result.pruned.len(), 0);
342    }
343}