Skip to main content

evoke/
ci.rs

1use std::path::Path;
2use std::process::Command;
3use std::str::FromStr;
4
5use rootcause::prelude::ResultExt;
6
7use crate::cargo_metadata::Metadata;
8
9/// Usage summary for CI subcommands.
10const CI_USAGE: &str = "Usage: evoke ci [all | lint | test | release-native | audit]";
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum CmdKind {
14    All,
15    Audit,
16    Lint,
17    ReleaseNative,
18    Test,
19}
20
21impl FromStr for CmdKind {
22    type Err = rootcause::Report;
23
24    fn from_str(value: &str) -> Result<Self, Self::Err> {
25        match value {
26            "all" => Ok(Self::All),
27            "audit" => Ok(Self::Audit),
28            "lint" => Ok(Self::Lint),
29            "release-native" => Ok(Self::ReleaseNative),
30            "test" => Ok(Self::Test),
31            unknown => rootcause::bail!("unknown evoke ci command: {unknown}\n{CI_USAGE}"),
32        }
33    }
34}
35
36impl CmdKind {
37    pub fn run(self, workspace_root: &Path) -> rootcause::Result<()> {
38        match self {
39            Self::All => {
40                Self::Lint.run(workspace_root)?;
41                Self::Test.run(workspace_root)?;
42                Self::ReleaseNative.run(workspace_root)?;
43                Self::Audit.run(workspace_root)
44            }
45            Self::Audit => run_audit(workspace_root),
46            Self::Lint => run_in_workspace(
47                workspace_root,
48                "cargo",
49                &["run", "--quiet", "--bin", "tec", "--", "--all"],
50            ),
51            Self::ReleaseNative => run_native_release_build(workspace_root, &["build"]),
52            Self::Test => run_test(workspace_root),
53        }
54    }
55}
56
57pub fn cmd_from_args(args: &[String]) -> rootcause::Result<Option<CmdKind>> {
58    let Some(first) = args.first() else {
59        return Ok(None);
60    };
61    if first != "ci" {
62        return Ok(None);
63    }
64
65    let mut rest = args.iter().skip(1);
66    let command = rest.next().map_or("all", String::as_str);
67    if let Some(extra) = rest.next() {
68        rootcause::bail!("unexpected extra evoke ci arg: {extra}\n{CI_USAGE}");
69    }
70
71    Ok(Some(command.parse()?))
72}
73
74fn run_audit(workspace_root: &Path) -> rootcause::Result<()> {
75    let metadata = Metadata::read(workspace_root)?;
76    run_native_auditable_build(workspace_root, &metadata)?;
77    audit_native_bins(workspace_root, &metadata)
78}
79
80fn audit_native_bins(workspace_root: &Path, metadata: &Metadata) -> rootcause::Result<()> {
81    let mut command = ytil_cmd::silent_cmd("cargo");
82    command.args(["audit", "bin"]).current_dir(workspace_root);
83    for bin_path in metadata.native_audit_bin_paths() {
84        command.arg(bin_path);
85    }
86    run_command(&mut command)
87}
88
89fn run_test(workspace_root: &Path) -> rootcause::Result<()> {
90    run_in_workspace(workspace_root, "rustup", &["component", "add", "llvm-tools-preview"])?;
91
92    let repo_root = git_root(workspace_root)?;
93    let rustflags = format!("--remap-path-prefix={repo_root}/=");
94    let mut command = ytil_cmd::silent_cmd("cargo");
95    command
96        .args([
97            "llvm-cov",
98            "nextest",
99            "--profile",
100            "ci",
101            "--workspace",
102            "--all-features",
103            "--lcov",
104            "--output-path",
105            "lcov.info",
106        ])
107        .current_dir(workspace_root)
108        .env("CARGO_TARGET_DIR", "target/coverage")
109        .env("RUSTFLAGS", rustflags);
110    run_command(&mut command)
111}
112
113fn run_native_release_build(workspace_root: &Path, cargo_args: &[&str]) -> rootcause::Result<()> {
114    let mut command = ytil_cmd::silent_cmd("cargo");
115    command
116        .args(cargo_args)
117        .args(["--release", "--workspace"])
118        .current_dir(workspace_root);
119    run_command(&mut command)
120}
121
122fn run_native_auditable_build(workspace_root: &Path, metadata: &Metadata) -> rootcause::Result<()> {
123    let package_names = metadata.native_bin_package_names();
124    if package_names.is_empty() {
125        rootcause::bail!("no native binary packages found for auditable build");
126    }
127
128    let mut command = ytil_cmd::silent_cmd("cargo");
129    command
130        .args(["auditable", "build", "--release"])
131        .current_dir(workspace_root);
132    for package_name in package_names {
133        command.args(["--package", package_name]);
134    }
135    run_command(&mut command)
136}
137
138fn git_root(workspace_root: &Path) -> rootcause::Result<String> {
139    let mut command = Command::new("git");
140    command
141        .args(["rev-parse", "--show-toplevel"])
142        .current_dir(workspace_root);
143    let output = command.output().context("failed to spawn git rev-parse")?;
144    output.status.exit_ok().context("git rev-parse failed")?;
145    Ok(std::str::from_utf8(&output.stdout)
146        .context("failed to decode git rev-parse stdout")?
147        .trim()
148        .into())
149}
150
151fn run_in_workspace(workspace_root: &Path, program: &str, args: &[&str]) -> rootcause::Result<()> {
152    let mut command = ytil_cmd::silent_cmd(program);
153    command.args(args).current_dir(workspace_root);
154    run_command(&mut command)
155}
156
157fn run_command(command: &mut Command) -> rootcause::Result<()> {
158    let command_debug = format!("{command:?}");
159    command
160        .status()
161        .context("failed to spawn command")
162        .attach_with(|| format!("command={command_debug}"))?
163        .exit_ok()
164        .context("command failed")
165        .attach_with(|| format!("command={command_debug}"))?;
166    Ok(())
167}
168
169#[cfg(test)]
170mod tests {
171    use rstest::rstest;
172    use test_that::prelude::*;
173
174    use super::*;
175
176    #[rstest]
177    #[case::without_ci_arg(&["--debug"], None)]
178    #[case::default_all(&["ci"], Some(CmdKind::All))]
179    fn test_cmd_from_args_returns_expected_command(#[case] input: &[&str], #[case] expected: Option<CmdKind>) {
180        assert_that!(cmd_from_args(&args(input)), ok(eq(expected)));
181    }
182
183    #[rstest]
184    #[case::audit("audit", CmdKind::Audit)]
185    #[case::lint("lint", CmdKind::Lint)]
186    #[case::release_native("release-native", CmdKind::ReleaseNative)]
187    #[case::test("test", CmdKind::Test)]
188    fn test_cmd_from_args_accepts_known_subcommands(#[case] subcommand: &str, #[case] expected: CmdKind) {
189        assert_that!(cmd_from_args(&args(&["ci", subcommand])), ok(eq(Some(expected))));
190    }
191
192    #[test]
193    fn test_cmd_from_args_rejects_unknown_subcommand() {
194        assert_that!(cmd_from_args(&args(&["ci", "wat"])), err(anything()));
195    }
196
197    #[test]
198    fn test_cmd_from_args_rejects_extra_arg() {
199        assert_that!(cmd_from_args(&args(&["ci", "lint", "extra"])), err(anything()));
200    }
201
202    fn args(values: &[&str]) -> Vec<String> {
203        values.iter().map(ToString::to_string).collect()
204    }
205}