1use std::path::Path;
2use std::process::Command;
3use std::str::FromStr;
4
5use rootcause::prelude::ResultExt;
6
7use crate::cargo_metadata::Metadata;
8
9const 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}