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 | release-wasm | audit]";
11const 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}