xtask_lib/tasks/util/
matrix.rs

1//! Feature matrix generation for `cargo xtask test` and `cargo xtask clippy`.
2//!
3//! Scans every `Cargo.toml` in the workspace, collects the union of all
4//! non-`default` feature names, and produces every power-set combination
5//! crossed with a list of target operating systems.  Each combination becomes
6//! one [`MatrixEntry`] that maps to a single CI job.
7//!
8//! The same entries are used locally (by `test` and `clippy` tasks) and in CI
9//! (by `cargo xtask ci matrix`, which serialises them to GitHub Actions JSON).
10
11use std::collections::BTreeSet;
12use std::path::Path;
13
14use anyhow::{Context, Result};
15use serde::Serialize;
16
17/// Operating systems included in the CI clippy matrix.
18pub const CI_CLIPPY_OS: &[&str] = &["ubuntu-latest", "macos-latest"];
19
20/// One entry in the feature × OS matrix.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct MatrixEntry {
23    /// Human-readable label used in log output and CI job names.
24    pub label: String,
25    /// Comma-separated feature list passed to `--features`, or empty for the
26    /// default build.
27    pub features: String,
28    /// GitHub Actions runner OS (e.g. `ubuntu-latest`, `macos-latest`).
29    pub os: String,
30}
31
32impl MatrixEntry {
33    /// Returns the `cargo` arguments for this entry (excluding the subcommand).
34    ///
35    /// For clippy, callers append `-- -D warnings`; for test, callers prepend
36    /// `nextest run` or `test --doc`.
37    pub fn cargo_args(&self) -> Vec<&str> {
38        let mut args = vec!["--workspace"];
39        if !self.features.is_empty() {
40            args.extend_from_slice(&["--features", &self.features]);
41        }
42        args
43    }
44}
45
46/// Scans the workspace and returns the full feature × OS matrix.
47///
48/// Features named `default` are excluded — Cargo enables them automatically.
49/// The matrix always starts with the default (no explicit features) entry,
50/// followed by every non-empty power-set combination in a stable order.
51/// Each feature combination is repeated once per OS in `os_list`.
52///
53/// # Errors
54///
55/// Returns an error if the workspace root cannot be located or any
56/// `Cargo.toml` cannot be read or parsed.
57pub fn scan(root: &Path, os_list: &[&str]) -> Result<Vec<MatrixEntry>> {
58    let features = collect_workspace_features(root)?;
59    Ok(build_matrix(features, os_list))
60}
61
62/// Serialises the matrix to a JSON shape for the `test` job.
63///
64/// The output includes `os` so the workflow can use only `include` entries
65/// without relying on matrix cross-product behavior:
66///
67/// ```json
68/// {"include": [{"label": "default", "features": "", "os": "ubuntu-latest"}, ...]}
69/// ```
70///
71/// # Errors
72///
73/// Returns an error if JSON serialisation fails.
74pub fn to_github_test_matrix_json(entries: &[MatrixEntry]) -> Result<String> {
75    #[derive(Serialize)]
76    struct TestEntry<'a> {
77        label: &'a str,
78        features: &'a str,
79        os: &'a str,
80    }
81
82    #[derive(Serialize)]
83    struct GithubMatrix<'a> {
84        include: Vec<TestEntry<'a>>,
85    }
86
87    let include: Vec<TestEntry<'_>> = entries
88        .iter()
89        .filter(|e| e.os == "ubuntu-latest")
90        .map(|e| TestEntry {
91            label: &e.label,
92            features: &e.features,
93            os: &e.os,
94        })
95        .collect();
96
97    serde_json::to_string(&GithubMatrix { include })
98        .context("failed to serialise test matrix to JSON")
99}
100
101/// Serialises the matrix to the JSON shape GitHub Actions expects for a
102/// dynamic matrix:
103///
104/// ```json
105/// {"include": [{"label": "default", "features": ""}, ...]}
106/// ```
107///
108/// Write this to `$GITHUB_OUTPUT` as `matrix=<json>` to consume it with
109/// `fromJson(needs.<job>.outputs.matrix)`.
110///
111/// # Errors
112///
113/// Returns an error if JSON serialisation fails.
114pub fn to_github_matrix_json(entries: &[MatrixEntry]) -> Result<String> {
115    #[derive(Serialize)]
116    struct GithubMatrix<'a> {
117        include: &'a [MatrixEntry],
118    }
119
120    serde_json::to_string(&GithubMatrix { include: entries })
121        .context("failed to serialise matrix to JSON")
122}
123
124/// Normalises a `--features` argument to the label format used by the matrix.
125///
126/// Accepts both the comma-separated cargo format (`"otel,test"`) and the
127/// human-readable label format (`"otel + test"`), sorts the parts
128/// alphabetically, and joins them with `" + "`.  An empty input returns
129/// `"default"`.
130///
131/// # Examples
132///
133/// ```
134/// use xtask_lib::tasks::util::matrix::normalize_features_arg;
135///
136/// assert_eq!(normalize_features_arg("otel,test"), "otel + test");
137/// assert_eq!(normalize_features_arg("otel + test"), "otel + test");
138/// assert_eq!(normalize_features_arg("test,otel"), "otel + test");
139/// assert_eq!(normalize_features_arg(""), "default");
140/// assert_eq!(normalize_features_arg("  "), "default");
141/// assert_eq!(normalize_features_arg(",,"), "default");
142/// assert_eq!(normalize_features_arg("+"), "default");
143/// assert_eq!(normalize_features_arg("otel"), "otel");
144/// ```
145pub fn normalize_features_arg(input: &str) -> String {
146    let mut parts: Vec<&str> = input
147        .split([',', '+'])
148        .map(str::trim)
149        .filter(|s| !s.is_empty())
150        .collect();
151
152    if parts.is_empty() {
153        return "default".to_owned();
154    }
155
156    parts.sort_unstable();
157    parts.join(" + ")
158}
159
160fn collect_workspace_features(root: &Path) -> Result<BTreeSet<String>> {
161    let mut features = BTreeSet::new();
162
163    for entry in walkdir::WalkDir::new(root)
164        .into_iter()
165        .filter_entry(|e| !is_ignored(e))
166    {
167        let entry = entry.context("failed to walk workspace")?;
168        if entry.file_name() != "Cargo.toml" {
169            continue;
170        }
171
172        let content = std::fs::read_to_string(entry.path())
173            .with_context(|| format!("failed to read {}", entry.path().display()))?;
174
175        let doc: toml::Table = toml::from_str(&content)
176            .with_context(|| format!("failed to parse {}", entry.path().display()))?;
177
178        if let Some(toml::Value::Table(feat_table)) = doc.get("features") {
179            for key in feat_table.keys() {
180                if key != "default" {
181                    features.insert(key.clone());
182                }
183            }
184        }
185    }
186
187    Ok(features)
188}
189
190fn is_ignored(entry: &walkdir::DirEntry) -> bool {
191    let name = entry.file_name().to_string_lossy();
192    matches!(name.as_ref(), "target" | ".git" | "thirdparty" | "xtask")
193}
194
195fn build_matrix(features: BTreeSet<String>, os_list: &[&str]) -> Vec<MatrixEntry> {
196    let features: Vec<String> = features.into_iter().collect();
197    let n = features.len();
198    let mut entries = Vec::with_capacity((1 << n) * os_list.len());
199
200    for mask in 0u32..(1 << n) {
201        let combo: Vec<&str> = (0..n)
202            .filter(|&i| mask & (1 << i) != 0)
203            .map(|i| features[i].as_str())
204            .collect();
205
206        let label = if combo.is_empty() {
207            "default".to_owned()
208        } else {
209            combo.join(" + ")
210        };
211
212        for os in os_list {
213            entries.push(MatrixEntry {
214                label: label.clone(),
215                features: combo.join(","),
216                os: os.to_string(),
217            });
218        }
219    }
220
221    entries
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::tasks::util::workspace;
228
229    const TWO_OS: &[&str] = &["ubuntu-latest", "macos-latest"];
230    const ONE_OS: &[&str] = &["ubuntu-latest"];
231
232    #[test]
233    fn build_matrix_empty_features_yields_one_entry_per_os() {
234        let entries = build_matrix(BTreeSet::new(), TWO_OS);
235        assert_eq!(entries.len(), 2);
236        assert!(entries.iter().all(|e| e.label == "default"));
237        assert!(entries.iter().all(|e| e.features.is_empty()));
238        let oses: Vec<&str> = entries.iter().map(|e| e.os.as_str()).collect();
239        assert!(oses.contains(&"ubuntu-latest"));
240        assert!(oses.contains(&"macos-latest"));
241    }
242
243    #[test]
244    fn build_matrix_single_feature_yields_two_combos_per_os() {
245        let features = BTreeSet::from(["otel".to_owned()]);
246        let entries = build_matrix(features, ONE_OS);
247        assert_eq!(entries.len(), 2);
248        assert_eq!(entries[0].label, "default");
249        assert_eq!(entries[1].label, "otel");
250    }
251
252    #[test]
253    fn build_matrix_two_features_yields_four_combos_per_os() {
254        let features = BTreeSet::from(["otel".to_owned(), "test".to_owned()]);
255        let entries = build_matrix(features, ONE_OS);
256        assert_eq!(entries.len(), 4);
257        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
258        assert!(labels.contains(&"default"));
259        assert!(labels.contains(&"otel"));
260        assert!(labels.contains(&"test"));
261        assert!(labels.contains(&"otel + test"));
262    }
263
264    #[test]
265    fn build_matrix_three_features_two_os_yields_sixteen_entries() {
266        let features =
267            BTreeSet::from(["emulator".to_owned(), "otel".to_owned(), "test".to_owned()]);
268        let entries = build_matrix(features, TWO_OS);
269        assert_eq!(entries.len(), 16, "2³ combos × 2 OSes = 16 entries");
270    }
271
272    #[test]
273    fn cargo_args_default_entry_has_no_features_flag() {
274        let entry = MatrixEntry {
275            label: "default".to_owned(),
276            features: String::new(),
277            os: "ubuntu-latest".to_owned(),
278        };
279        assert_eq!(entry.cargo_args(), vec!["--workspace"]);
280    }
281
282    #[test]
283    fn cargo_args_combo_entry_includes_features_flag() {
284        let entry = MatrixEntry {
285            label: "test + otel".to_owned(),
286            features: "test,otel".to_owned(),
287            os: "ubuntu-latest".to_owned(),
288        };
289        assert_eq!(
290            entry.cargo_args(),
291            vec!["--workspace", "--features", "test,otel"]
292        );
293    }
294
295    #[test]
296    fn to_github_matrix_json_produces_include_key_with_os() {
297        let entries = vec![MatrixEntry {
298            label: "default".to_owned(),
299            features: String::new(),
300            os: "ubuntu-latest".to_owned(),
301        }];
302        let json = to_github_matrix_json(&entries).unwrap();
303        assert!(json.contains("\"include\""));
304        assert!(json.contains("\"default\""));
305        assert!(json.contains("\"ubuntu-latest\""));
306    }
307
308    #[test]
309    fn normalize_single_feature() {
310        assert_eq!(normalize_features_arg("otel"), "otel");
311    }
312
313    #[test]
314    fn normalize_comma_separated_two_features() {
315        assert_eq!(normalize_features_arg("otel,test"), "otel + test");
316    }
317
318    #[test]
319    fn normalize_label_format_round_trips() {
320        assert_eq!(normalize_features_arg("otel + test"), "otel + test");
321    }
322
323    #[test]
324    fn normalize_out_of_order_comma_separated_sorts() {
325        assert_eq!(normalize_features_arg("test,otel"), "otel + test");
326    }
327
328    #[test]
329    fn normalize_empty_string_returns_default() {
330        assert_eq!(normalize_features_arg(""), "default");
331    }
332
333    #[test]
334    fn scan_workspace_finds_known_features_across_two_os() {
335        let root = workspace::root().expect("workspace root must be resolvable in tests");
336        let entries = scan(&root, TWO_OS).expect("scan must succeed");
337
338        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
339        assert!(labels.contains(&"default"));
340        assert!(labels.contains(&"emulator"));
341        assert!(labels.contains(&"otel"));
342        assert!(labels.contains(&"test"));
343        assert_eq!(
344            entries.len(),
345            32,
346            "4 features → 2⁴ = 16 combos × 2 OSes = 32 entries"
347        );
348    }
349}