cargo-matrix 0.4.0

Run feature matrices against cargo commands that support feature lists
// Copyright (c) 2024 cargo-matrix developers
//
// Licensed under the Apache License, Version 2.0
// <LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0> or the MIT
// license <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your
// option. All files in the project carrying such notice may not be copied,
// modified, or distributed except according to those terms.

use super::{Feature, FeatureSet};
use crate::config::Config;
use anyhow::Result;
use cargo_metadata::Package;
use derive_more::{AsMut, AsRef, Deref, DerefMut};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::{
    collections::{BTreeSet, HashSet},
    ops::Deref as OpsDeref,
};

#[derive(AsMut, AsRef, Clone, Debug, Default, Deref, DerefMut, Deserialize, Serialize)]
#[serde(transparent)]
pub(crate) struct Matrix(BTreeSet<FeatureSet>);

impl Matrix {
    pub(crate) fn new(package: &Package, config: &Config, channel: &str) -> Result<Self> {
        let deny = config.always_deny(channel)?;
        let skip = config.skip(channel)?;
        let include = config.always_include(channel)?;
        let mutually_exclusive = config.mutually_exclusive(channel)?;

        Ok(Self::extract_seed(package, config, channel)?
            .into_iter()
            .powerset()
            .map(FeatureSet::from_iter)
            // Add back the always included features
            .map(|mut set| {
                set.extend(include.clone());
                set
            })
            // Re-check deny in case a custom seed was used
            .filter(|set| set.is_disjoint(&deny))
            // Skip any configured matricies
            .filter(|set| !skip.iter().any(|skip| skip == set))
            // Remove any sets that contain two or more features from the same exclusive group
            .filter(|set| {
                mutually_exclusive
                    .iter()
                    .all(|group| set.intersection(group).count() < 2)
            })
            .collect())
    }

    /// Reads the package + config and outputs the set of features that should be used to seed the matrix.
    fn extract_seed(package: &Package, config: &Config, channel: &str) -> Result<FeatureSet> {
        Ok(if let Some(seed) = config.seed(channel)? {
            seed.clone()
        } else {
            let implicit_features = Self::find_implicits(package);
            let deny = config.always_deny(channel)?;
            let include = config.always_include(channel)?;

            let mut set: FeatureSet = package
                .features
                .keys()
                .map(Into::into)
                // exclude default feature
                .filter(|feature: &Feature| **feature != "default")
                // exclude implicit features
                .filter(|feature| !implicit_features.contains(feature))
                // exclude deny list because they will all end up denied anyways
                .filter(|package| !deny.iter().contains(package))
                // exclude the include list because it'll be easier to just add them all at once
                .filter(|package| !include.iter().contains(package))
                // exclude hidden features unless explicitly included
                .filter(|feature| {
                    config.include_hidden(channel).unwrap_or_default() || !feature.starts_with("__")
                })
                .collect();

            if config.include_all_optional(channel).unwrap_or_default() {
                set.extend(
                    package
                        .dependencies
                        .iter()
                        .filter(|dependency| dependency.optional)
                        .map(|dependency| {
                            dependency
                                .rename
                                .as_deref()
                                .unwrap_or(&dependency.name)
                                .to_string()
                        })
                        .map(Feature),
                );
            }

            // Add in the specific optional dependencies requested
            set.extend(config.include_optional(channel)?);

            set
        })
    }

    fn find_implicits(package: &Package) -> HashSet<Feature> {
        let mut implicit_features = HashSet::<Feature>::new();
        let mut optional_dep: HashSet<Feature> = HashSet::new();

        for (feature, implied_features) in &package.features {
            for implied_dep in implied_features
                .iter()
                .filter_map(|v| v.strip_prefix("dep:"))
            {
                if implied_features.len() == 1 && implied_dep == feature {
                    // Feature of the shape foo = ["dep:foo"]
                    let _ = implicit_features.insert(feature.clone().into());
                } else {
                    let _ = optional_dep.insert(implied_dep.into());
                }
            }
        }

        // If the dep is used with `dep:` syntax in another feature,
        // it's an explicit feature, because cargo wouldn't generate
        // the implicit feature.
        for x in &optional_dep {
            let _ = implicit_features.remove(x);
        }

        implicit_features
    }
}

impl FromIterator<FeatureSet> for Matrix {
    fn from_iter<T: IntoIterator<Item = FeatureSet>>(iter: T) -> Self {
        Matrix(iter.into_iter().collect())
    }
}

impl IntoIterator for Matrix {
    type Item = FeatureSet;
    type IntoIter = <<Self as OpsDeref>::Target as IntoIterator>::IntoIter;

    fn into_iter(self) -> Self::IntoIter {
        self.0.into_iter()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use figment::{
        Figment,
        providers::{Format, Json},
    };

    fn fixture_package(relative_path: &str) -> Package {
        let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
        let metadata = cargo_metadata::MetadataCommand::new()
            .manifest_path(&manifest)
            .exec()
            .unwrap();
        metadata.packages.into_iter().next().unwrap()
    }

    fn config_from_package(package: &Package) -> Config {
        let figment = if let Some(meta) = package.metadata.get("cargo-matrix") {
            Figment::from(Config::default()).merge(Figment::from(Json::string(&meta.to_string())))
        } else {
            Figment::from(Config::default())
        };
        Config::from(figment).unwrap()
    }

    fn feature_set(features: &[&str]) -> FeatureSet {
        features.iter().map(|&s| Feature::from(s)).collect()
    }

    #[test]
    fn mutually_exclusive_filters_pairs_from_same_group() {
        let package = fixture_package("tests/testdata/mutually_exclusive/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();

        assert!(!matrix.contains(&feature_set(&["feat-a", "feat-b"])));
        assert!(!matrix.contains(&feature_set(&["feat-a", "feat-c"])));
        assert!(!matrix.contains(&feature_set(&["feat-b", "feat-c"])));
        assert!(!matrix.contains(&feature_set(&["feat-a", "feat-b", "feat-c"])));
        assert!(!matrix.contains(&feature_set(&["feat-x", "feat-y"])));
        assert!(!matrix.contains(&feature_set(&["feat-a", "feat-x", "feat-y"])));
    }

    #[test]
    fn mutually_exclusive_allows_one_per_group() {
        let package = fixture_package("tests/testdata/mutually_exclusive/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();

        assert!(matrix.contains(&feature_set(&["feat-a", "feat-x"])));
        assert!(matrix.contains(&feature_set(&["feat-b", "feat-y"])));
        assert!(matrix.contains(&feature_set(&["feat-a"])));
        assert!(matrix.contains(&feature_set(&["feat-b"])));
        assert!(matrix.contains(&feature_set(&["feat-c"])));
        assert!(matrix.contains(&feature_set(&["feat-x"])));
        assert!(matrix.contains(&feature_set(&["feat-y"])));
    }

    #[test]
    fn mutually_exclusive_allows_features_outside_groups() {
        let package = fixture_package("tests/testdata/mutually_exclusive/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();

        assert!(matrix.contains(&feature_set(&["feat-z"])));
        assert!(matrix.contains(&feature_set(&["feat-a", "feat-z"])));
        assert!(matrix.contains(&feature_set(&["feat-x", "feat-z"])));
        assert!(matrix.contains(&feature_set(&["feat-a", "feat-x", "feat-z"])));
    }

    #[test]
    fn empty_mutually_exclusive_filters_nothing() {
        let package = fixture_package("tests/testdata/mutually_exclusive/Cargo.toml");
        let config = Config::from(Figment::from(Config::default())).unwrap();
        let matrix = Matrix::new(&package, &config, "default").unwrap();

        assert!(matrix.contains(&feature_set(&["feat-a", "feat-b"])));
        assert!(matrix.contains(&feature_set(&["feat-x", "feat-y"])));
    }

    // --- extract_seed ---

    #[test]
    fn extract_seed_uses_config_seed_when_set() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = Config::from(Figment::from(Config::default()).merge(Figment::from(
            Json::string(r#"{"channel": [{"name": "default", "seed": ["feat-a", "feat-b"]}]}"#),
        )))
        .unwrap();
        let seed = Matrix::extract_seed(&package, &config, "default").unwrap();
        assert_eq!(seed, feature_set(&["feat-a", "feat-b"]));
    }

    #[test]
    fn extract_seed_excludes_hidden_features_by_default() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = config_from_package(&package);
        let seed = Matrix::extract_seed(&package, &config, "default").unwrap();
        assert!(!seed.contains(&Feature::from("__vergen_test")));
    }

    #[test]
    fn extract_seed_includes_hidden_features_when_enabled() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = Config::from(Figment::from(Config::default()).merge(Figment::from(
            Json::string(r#"{"channel": [{"name": "default", "include_hidden": true}]}"#),
        )))
        .unwrap();
        let seed = Matrix::extract_seed(&package, &config, "default").unwrap();
        assert!(seed.contains(&Feature::from("__vergen_test")));
    }

    #[test]
    fn extract_seed_excludes_implicit_dep_features() {
        // rand and temp-env are optional deps; Cargo generates implicit "rand" and "temp-env"
        // features of the form foo = ["dep:foo"], which find_implicits detects and removes.
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = Config::from(Figment::from(Config::default())).unwrap();
        let seed = Matrix::extract_seed(&package, &config, "default").unwrap();
        assert!(!seed.contains(&Feature::from("rand")));
        assert!(!seed.contains(&Feature::from("temp-env")));
    }

    #[test]
    fn extract_seed_include_all_optional_adds_optional_deps() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = Config::from(Figment::from(Config::default()).merge(Figment::from(
            Json::string(r#"{"channel": [{"name": "default", "include_all_optional": true}]}"#),
        )))
        .unwrap();
        let seed = Matrix::extract_seed(&package, &config, "default").unwrap();
        assert!(seed.contains(&Feature::from("rand")));
        assert!(seed.contains(&Feature::from("temp-env")));
    }

    // --- find_implicits ---

    #[test]
    fn find_implicits_identifies_implicit_dep_features() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let implicits = Matrix::find_implicits(&package);
        // rand and temp-env are optional deps; Cargo creates implicit "dep:" features for them
        assert!(implicits.contains(&Feature::from("rand")));
        assert!(implicits.contains(&Feature::from("temp-env")));
    }

    #[test]
    fn find_implicits_empty_when_no_optional_deps() {
        let package = fixture_package("tests/testdata/include/Cargo.toml");
        let implicits = Matrix::find_implicits(&package);
        assert!(implicits.is_empty());
    }

    // --- Matrix::new filters ---

    #[test]
    fn new_applies_always_deny_filter() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();
        // Default channel: always_deny = ["feat-d", "unstable"]
        for set in matrix.iter() {
            assert!(!set.contains(&Feature::from("feat-d")));
            assert!(!set.contains(&Feature::from("unstable")));
        }
    }

    #[test]
    fn new_applies_skip_filter() {
        let package = fixture_package("tests/testdata/deny/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();
        // Default channel: skip = [["feat-c", "temp-env"]]
        assert!(!matrix.contains(&feature_set(&["feat-c", "temp-env"])));
    }

    #[test]
    fn new_produces_single_empty_set_for_no_features() {
        let package = fixture_package("tests/testdata/include/Cargo.toml");
        let config = config_from_package(&package);
        let matrix = Matrix::new(&package, &config, "default").unwrap();
        // No features → powerset of {} = {{}}, one empty set
        assert_eq!(matrix.len(), 1);
        assert!(matrix.contains(&FeatureSet::default()));
    }
}