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