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 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 TaskKind {
    Build,
    Check,
    Clippy,
    LlvmCov,
    Test,
}

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

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

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

    pub(crate) fn execute(self) -> Result<TaskResult> {
        for feature_set in self.matrix {
            match self.kind {
                TaskKind::Build => print!("{}", Paint::cyan("    Building ").bold()),
                TaskKind::Check => print!("{}", Paint::cyan("    Checking ").bold()),
                TaskKind::Clippy => print!("{}", Paint::cyan("      Clippy ").bold()),
                TaskKind::LlvmCov => print!("{}", Paint::cyan("    Coverage ").bold()),
                TaskKind::Test => print!("{}", Paint::cyan("     Testing ").bold()),
            }

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

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

            let cmd = match self.kind {
                TaskKind::Build => cmd.arg("build"),
                TaskKind::Check => cmd.arg("check"),
                TaskKind::Clippy => cmd.arg("clippy"),
                TaskKind::LlvmCov => cmd.arg("llvm-cov"),
                TaskKind::Test => cmd.arg("test"),
            };

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

            let cmd = cmd
                .stderr(Stdio::inherit())
                .stdout(Stdio::inherit())
                .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(self.args.clone());

            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()
    }

    #[test]
    fn dry_run_empty_matrix_returns_success() {
        let task = Task::new(
            TaskKind::Build,
            "pkg".to_string(),
            FeatureMatrix::default(),
            None,
            vec![],
            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(
            TaskKind::Check,
            "pkg".to_string(),
            matrix,
            None,
            vec![],
            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(
            TaskKind::Clippy,
            "pkg".to_string(),
            matrix,
            None,
            vec![],
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_all_task_kinds() {
        let matrix = make_matrix(&[&["feat-a"]]);
        for kind in [
            TaskKind::Build,
            TaskKind::Check,
            TaskKind::Clippy,
            TaskKind::Test,
            TaskKind::LlvmCov,
        ] {
            let task = Task::new(kind, "pkg".to_string(), matrix.clone(), None, vec![], 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(
            TaskKind::Build,
            "pkg".to_string(),
            matrix,
            Some(PathBuf::from("Cargo.toml")),
            vec![],
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }

    #[test]
    fn dry_run_with_extra_args_returns_success() {
        let matrix = make_matrix(&[&[]]);
        let task = Task::new(
            TaskKind::Clippy,
            "pkg".to_string(),
            matrix,
            None,
            vec!["--".to_string(), "-D".to_string(), "warnings".to_string()],
            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(
            TaskKind::Build,
            "pkg".to_string(),
            matrix,
            None,
            vec![],
            true,
        );
        assert!(matches!(task.execute().unwrap(), TaskResult::Success));
    }
}