Skip to main content

cargo_matrix/
matrix.rs

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    /// Path to YAML config (defaults to `<workspace>/matrix.yaml`)
10    #[arg(long)]
11    pub config: Option<PathBuf>,
12
13    /// Which command to run. This can be either:
14    /// - a name from `commands:` (recommended), or
15    /// - an inline command template string.
16    ///
17    /// Per-entry `command:` overrides this.
18    #[arg(long)]
19    pub command: Option<String>,
20
21    /// Only run matrix entries for these packages (repeatable).
22    ///
23    /// Example: `cargo matrix --command check -p zeroos -p spike-platform`
24    #[arg(short = 'p', long = "package")]
25    pub packages: Vec<String>,
26
27    /// Print commands as they run
28    #[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    // Prefer matrix.yaml as a marker (works for external repos without Cargo.lock).
111    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    // Fallback to Cargo.lock for compatibility with workspaces.
116    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        // Best-effort: allow running under Windows if a POSIX shell is available.
171        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                // Strict Structural Resolution.
260                // If a command is not defined in the package-local or global commands map,
261                // the package is considered to not support this command and is skipped.
262                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}