cargo-matrix 0.4.4

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 crate::feature::FeatureMatrix;
use anyhow::Result;
use itertools::Itertools;
use std::{
    env::var_os,
    ffi::OsString,
    path::PathBuf,
    process::{Command, Stdio},
    sync::LazyLock,
};
use yansi::Paint;

static CARGO: LazyLock<OsString> =
    LazyLock::new(|| var_os("CARGO").unwrap_or_else(|| "cargo".into()));

#[derive(Clone, Copy, Debug)]
pub(crate) enum TaskResult {
    Success,
    Fail(i32),
}

pub(crate) struct Task {
    command: Vec<String>,
    package: String,
    matrix: FeatureMatrix,
    manifest_path: Option<PathBuf>,
    dry_run: bool,
}

impl Task {
    pub(crate) fn new(
        command: Vec<String>,
        package: String,
        matrix: FeatureMatrix,
        manifest_path: Option<PathBuf>,
        dry_run: bool,
    ) -> Self {
        Self {
            command,
            package,
            matrix,
            manifest_path,
            dry_run,
        }
    }

    pub(crate) fn execute(self) -> Result<TaskResult> {
        // Split the command at the first `--` so the matrix flags are injected
        // into the cargo subcommand rather than forwarded to the inner tool.
        let (pre, post): (&[String], &[String]) =
            match self.command.iter().position(|arg| arg == "--") {
                Some(idx) => (&self.command[..idx], &self.command[idx..]),
                None => (&self.command[..], &[]),
            };
        let label = pre.first().map_or("cargo", String::as_str);

        for feature_set in self.matrix {
            let padded = format!("{label:>11}");
            print!("{} ", Paint::cyan(&padded).bold());

            println!("package={} features=[{feature_set}]", self.package);

            let mut cmd = Command::new(CARGO.as_os_str());

            let on_success = || {
                println!(
                    "{} {}",
                    Paint::cyan("      Result").bold(),
                    Paint::bright_green("OK")
                );
                println!();
            };

            let cmd = cmd
                .stderr(Stdio::inherit())
                .stdout(Stdio::inherit())
                .args(pre)
                .arg("-p")
                .arg(self.package.clone())
                .arg("--no-default-features");

            let cmd = if feature_set.is_empty() {
                cmd
            } else {
                cmd.arg("-F").arg(feature_set.to_string())
            };

            let cmd = if let Some(manifest_path) = &self.manifest_path {
                cmd.arg("--manifest-path")
                    .arg(format!("{}", manifest_path.display()))
            } else {
                cmd
            };

            let cmd = cmd.args(post);

            display_command(cmd);

            if !self.dry_run {
                let output = cmd.output()?;
                if output.status.success() {
                    on_success();
                } else {
                    let code = output.status.code().unwrap_or(-1);
                    return Ok(TaskResult::Fail(code));
                }
            }
        }

        Ok(TaskResult::Success)
    }
}

fn display_command(cmd: &Command) {
    let args = cmd.get_args().map(|x| x.to_string_lossy()).join(" ");
    println!(
        "{} {} {}",
        Paint::cyan("     Running").bold(),
        cmd.get_program().to_string_lossy(),
        args
    );
    println!();
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::feature::{Feature, FeatureSet};

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

    fn cmd(tokens: &[&str]) -> Vec<String> {
        tokens.iter().map(|&s| s.to_string()).collect()
    }

    #[test]
    fn dry_run_empty_matrix_returns_success() {
        let task = Task::new(
            cmd(&["build"]),
            "pkg".to_string(),
            FeatureMatrix::default(),
            None,
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_single_empty_feature_set_returns_success() {
        let matrix = make_matrix(&[&[]]);
        let task = Task::new(cmd(&["check"]), "pkg".to_string(), matrix, None, true);
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_nonempty_feature_set_returns_success() {
        let matrix = make_matrix(&[&["feat-a", "feat-b"]]);
        let task = Task::new(cmd(&["clippy"]), "pkg".to_string(), matrix, None, true);
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_all_commands() {
        let matrix = make_matrix(&[&["feat-a"]]);
        for command in [
            cmd(&["build"]),
            cmd(&["check"]),
            cmd(&["clippy"]),
            cmd(&["test"]),
            cmd(&["doc"]),
            cmd(&["llvm-cov"]),
        ] {
            let task = Task::new(command, "pkg".to_string(), matrix.clone(), None, true);
            assert!(matches!(task.execute().unwrap(), TaskResult::Success));
        }
    }

    #[test]
    fn dry_run_multi_token_command_returns_success() {
        let matrix = make_matrix(&[&["feat-a"]]);
        let task = Task::new(
            cmd(&["nextest", "run"]),
            "pkg".to_string(),
            matrix,
            None,
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_with_manifest_path_returns_success() {
        let matrix = make_matrix(&[&["feat-a"]]);
        let task = Task::new(
            cmd(&["build"]),
            "pkg".to_string(),
            matrix,
            Some(PathBuf::from("Cargo.toml")),
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_with_passthrough_args_returns_success() {
        let matrix = make_matrix(&[&[]]);
        let task = Task::new(
            cmd(&["clippy", "--", "-D", "warnings"]),
            "pkg".to_string(),
            matrix,
            None,
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_multiple_feature_sets_all_succeed() {
        let matrix = make_matrix(&[&[], &["feat-a"], &["feat-b"], &["feat-a", "feat-b"]]);
        let task = Task::new(cmd(&["build"]), "pkg".to_string(), matrix, None, true);
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }
}