1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use clap::Args;
6
7#[derive(Args, Debug, Clone)]
8pub struct MatrixArgs {
9 #[arg(long)]
11 pub config: Option<PathBuf>,
12
13 #[arg(long)]
19 pub command: Option<String>,
20
21 #[arg(short = 'p', long = "package")]
25 pub packages: Vec<String>,
26
27 #[arg(long)]
29 pub verbose: bool,
30}
31
32#[derive(serde::Deserialize)]
33struct MatrixConfig {
34 #[serde(default)]
35 pre: Vec<String>,
36 #[serde(default)]
37 commands: BTreeMap<String, String>,
38 entries: Vec<MatrixEntry>,
39}
40
41#[derive(serde::Deserialize)]
42#[serde(untagged)]
43enum Targets {
44 One(String),
45 Many(Vec<TargetElem>),
46}
47
48#[derive(serde::Deserialize)]
49#[serde(untagged)]
50enum TargetElem {
51 One(String),
52 Many(Vec<TargetElem>),
53}
54
55#[derive(serde::Deserialize)]
56#[serde(untagged)]
57enum FeatureSpec {
58 One(String),
59 OneOf(Vec<String>),
60}
61
62#[derive(serde::Deserialize)]
63#[serde(untagged)]
64pub enum Packages {
65 One(String),
66 Many(Vec<String>),
67}
68
69#[derive(serde::Deserialize)]
70struct MatrixEntry {
71 #[serde(default)]
72 commands: BTreeMap<String, String>,
73 command: Option<String>,
74 package: Packages,
75 target: Targets,
76 #[serde(default)]
77 features: Vec<FeatureSpec>,
78}
79
80fn load_config(path: &Path) -> Result<MatrixConfig, String> {
81 let bytes =
82 std::fs::read(path).map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
83 serde_yaml::from_slice(&bytes).map_err(|e| format!("Invalid YAML {}: {}", path.display(), e))
84}
85
86fn find_upwards(start: &Path, filename: &str) -> Option<PathBuf> {
87 let mut dir = if start.is_dir() {
88 start.to_path_buf()
89 } else {
90 start.parent().unwrap_or(start).to_path_buf()
91 };
92
93 loop {
94 let candidate = dir.join(filename);
95 if candidate.exists() {
96 return Some(candidate);
97 }
98
99 if !dir.pop() {
100 break;
101 }
102 }
103
104 None
105}
106
107fn workspace_root() -> Result<PathBuf, String> {
108 let start = std::env::current_dir().map_err(|e| format!("Failed to get cwd: {}", e))?;
109
110 if let Some(m) = find_upwards(&start, "matrix.yaml") {
112 return Ok(m.parent().unwrap_or(m.as_path()).to_path_buf());
113 }
114
115 let lock = find_upwards(&start, "Cargo.lock").ok_or_else(|| {
117 "matrix.yaml/Cargo.lock not found (run from within a repo or pass --config)".to_string()
118 })?;
119 Ok(lock.parent().unwrap_or(lock.as_path()).to_path_buf())
120}
121
122fn host_target(workspace: &Path) -> Result<String, String> {
123 let out = Command::new("rustc")
124 .arg("-vV")
125 .current_dir(workspace)
126 .stdout(Stdio::piped())
127 .stderr(Stdio::piped())
128 .output()
129 .map_err(|e| format!("Failed to run rustc -vV: {}", e))?;
130
131 if !out.status.success() {
132 return Err(format!(
133 "rustc -vV failed (exit={:?}): {}",
134 out.status.code(),
135 String::from_utf8_lossy(&out.stderr)
136 ));
137 }
138
139 let s = String::from_utf8_lossy(&out.stdout);
140 for line in s.lines() {
141 if let Some(rest) = line.strip_prefix("host:") {
142 return Ok(rest.trim().to_string());
143 }
144 }
145 Err("rustc -vV output missing host line".to_string())
146}
147
148fn render_template(
149 template: &str,
150 workspace: &Path,
151 package: &str,
152 target: &str,
153 features: &str,
154 features_flag: &str,
155) -> String {
156 template
157 .replace("{workspace}", &workspace.to_string_lossy())
158 .replace("{package}", package)
159 .replace("{target}", target)
160 .replace("{features}", features)
161 .replace("{features_flag}", features_flag)
162}
163
164fn run_shell(cmd: &str, cwd: &Path, verbose: bool) -> Result<(), String> {
165 if verbose {
166 println!("$ {}", cmd);
167 }
168
169 let status = if cfg!(windows) {
170 if Command::new("sh")
172 .arg("-c")
173 .arg("true")
174 .stdout(Stdio::null())
175 .stderr(Stdio::null())
176 .status()
177 .is_ok()
178 {
179 Command::new("sh")
180 .arg("-c")
181 .arg(cmd)
182 .current_dir(cwd)
183 .status()
184 } else {
185 Command::new("cmd")
186 .args(["/C", cmd])
187 .current_dir(cwd)
188 .status()
189 }
190 } else {
191 Command::new("sh")
192 .arg("-c")
193 .arg(cmd)
194 .current_dir(cwd)
195 .status()
196 }
197 .map_err(|e| format!("Failed to execute shell: {}", e))?;
198
199 if !status.success() {
200 return Err(format!(
201 "Command failed (exit={:?}): {}",
202 status.code(),
203 cmd
204 ));
205 }
206 Ok(())
207}
208
209struct Step {
210 name: String,
211 cmd: String,
212}
213
214pub fn run(args: MatrixArgs) -> Result<(), String> {
215 let command = args.command.as_ref();
216
217 let workspace = workspace_root()?;
218 let config_path = args
219 .config
220 .clone()
221 .unwrap_or_else(|| workspace.join("matrix.yaml"));
222 let cfg = load_config(&config_path)?;
223
224 let host = host_target(&workspace)?;
225
226 let mut steps: Vec<Step> = Vec::new();
227 for (i, cmd) in cfg.pre.iter().enumerate() {
228 steps.push(Step {
229 name: format!("pre:{}", i + 1),
230 cmd: cmd.clone(),
231 });
232 }
233
234 for entry in &cfg.entries {
235 let entry_packages = match &entry.package {
236 Packages::One(s) => vec![s.as_str()],
237 Packages::Many(v) => v.iter().map(|s| s.as_str()).collect(),
238 };
239
240 for package in entry_packages {
241 if !args.packages.is_empty() && !args.packages.iter().any(|p| p == package) {
242 continue;
243 }
244
245 let cmd_name = entry
246 .command
247 .as_ref()
248 .or(command)
249 .ok_or_else(|| "no command selected (pass --command <name>)".to_string())?;
250
251 let template = entry
252 .commands
253 .get(cmd_name)
254 .or_else(|| cfg.commands.get(cmd_name));
255
256 let template: &str = if let Some(t) = template {
257 t.as_str()
258 } else {
259 continue;
263 };
264
265 let mut combos: Vec<Vec<String>> = vec![Vec::new()];
266 for spec in &entry.features {
267 match spec {
268 FeatureSpec::One(f) => {
269 for c in &mut combos {
270 c.push(f.clone());
271 }
272 }
273 FeatureSpec::OneOf(group) => {
274 let mut next: Vec<Vec<String>> = Vec::new();
275 for opt in group {
276 for c in &combos {
277 let mut nc = c.clone();
278 nc.push(opt.clone());
279 next.push(nc);
280 }
281 }
282 combos = next;
283 }
284 }
285 }
286
287 fn flatten_targets<'a>(t: &'a TargetElem, out: &mut Vec<&'a str>) {
288 match t {
289 TargetElem::One(s) => out.push(s.as_str()),
290 TargetElem::Many(v) => {
291 for inner in v {
292 flatten_targets(inner, out);
293 }
294 }
295 }
296 }
297
298 let targets: Vec<&str> = match &entry.target {
299 Targets::One(t) => vec![t.as_str()],
300 Targets::Many(ts) => {
301 let mut out: Vec<&str> = Vec::new();
302 for t in ts {
303 flatten_targets(t, &mut out);
304 }
305 out
306 }
307 };
308
309 for target in targets {
310 let target = if target == "host" {
311 host.as_str()
312 } else {
313 target
314 };
315 let total = combos.len();
316 for (idx, mut feats) in combos.iter().cloned().enumerate() {
317 feats.sort();
318 feats.dedup();
319 let feat_str = feats.join(",");
320 let features_flag = if feat_str.is_empty() {
321 String::new()
322 } else {
323 format!(r##"--features "{feat_str}""##)
324 };
325
326 let cmd = render_template(
327 template,
328 &workspace,
329 package,
330 target,
331 &feat_str,
332 &features_flag,
333 );
334
335 let suffix = if total > 1 {
336 format!(" #{}/{}", idx + 1, total)
337 } else {
338 String::new()
339 };
340
341 steps.push(Step {
342 name: format!("{package} [{target}] ({cmd_name}){suffix}"),
343 cmd,
344 });
345 }
346 }
347 }
348 }
349
350 for (i, step) in steps.iter().enumerate() {
351 println!("[{}/{}] {}", i + 1, steps.len(), step.name);
352 run_shell(&step.cmd, &workspace, args.verbose)?;
353 }
354
355 Ok(())
356}