Skip to main content

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-excluded feature names (see [`is_excluded_feature`]), and produces every
5//! power-set combination crossed with a list of target operating systems. Each
6//! combination becomes 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"];
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 excluded from the matrix (see [`is_excluded_feature`]) are
49/// skipped — for example `default` (enabled by Cargo automatically), `bench`
50/// (only affects module visibility for benchmarks), and `telemetry` (an alias
51/// for `tracing + profiling` with no direct `cfg(feature = "telemetry")`
52/// branches to validate separately). Runtime features such as `tracing` and
53/// `profiling` stay in the matrix so CI checks them both standalone and in
54/// combinations.
55/// The matrix always starts with the default (no explicit features) entry,
56/// followed by every non-empty power-set combination in a stable order.
57/// Each feature combination is repeated once per OS in `os_list`.
58///
59/// # Errors
60///
61/// Returns an error if the workspace root cannot be located or any
62/// `Cargo.toml` cannot be read or parsed.
63pub fn scan(root: &Path, os_list: &[&str]) -> Result<Vec<MatrixEntry>> {
64    let features = collect_workspace_features(root)?;
65    Ok(build_matrix(features, os_list))
66}
67
68/// Serialises the matrix to a JSON shape for the `test` job.
69///
70/// The output includes `os` so the workflow can use only `include` entries
71/// without relying on matrix cross-product behavior:
72///
73/// ```json
74/// {"include": [{"label": "default", "features": "", "os": "ubuntu-latest"}, ...]}
75/// ```
76///
77/// # Errors
78///
79/// Returns an error if JSON serialisation fails.
80pub fn to_github_test_matrix_json(entries: &[MatrixEntry]) -> Result<String> {
81    #[derive(Serialize)]
82    struct TestEntry<'a> {
83        label: &'a str,
84        features: &'a str,
85        os: &'a str,
86    }
87
88    #[derive(Serialize)]
89    struct GithubMatrix<'a> {
90        include: Vec<TestEntry<'a>>,
91    }
92
93    let include: Vec<TestEntry<'_>> = entries
94        .iter()
95        .filter(|e| e.os == "ubuntu-latest")
96        .map(|e| TestEntry {
97            label: &e.label,
98            features: &e.features,
99            os: &e.os,
100        })
101        .collect();
102
103    serde_json::to_string(&GithubMatrix { include })
104        .context("failed to serialise test matrix to JSON")
105}
106
107/// Serialises the matrix to the JSON shape GitHub Actions expects for a
108/// dynamic matrix:
109///
110/// ```json
111/// {"include": [{"label": "default", "features": ""}, ...]}
112/// ```
113///
114/// Write this to `$GITHUB_OUTPUT` as `matrix=<json>` to consume it with
115/// `fromJson(needs.<job>.outputs.matrix)`.
116///
117/// # Errors
118///
119/// Returns an error if JSON serialisation fails.
120pub fn to_github_matrix_json(entries: &[MatrixEntry]) -> Result<String> {
121    #[derive(Serialize)]
122    struct GithubMatrix<'a> {
123        include: &'a [MatrixEntry],
124    }
125
126    serde_json::to_string(&GithubMatrix { include: entries })
127        .context("failed to serialise matrix to JSON")
128}
129
130/// Normalises a `--features` argument to the label format used by the matrix.
131///
132/// Accepts both the comma-separated cargo format (`"tracing,test"`) and the
133/// human-readable label format (`"test + tracing"`), sorts the parts
134/// alphabetically, and joins them with `" + "`.  An empty input returns
135/// `"default"`.
136///
137/// # Examples
138///
139/// ```
140/// use xtask_lib::tasks::util::matrix::normalize_features_arg;
141///
142/// assert_eq!(normalize_features_arg("tracing,test"), "test + tracing");
143/// assert_eq!(normalize_features_arg("test + tracing"), "test + tracing");
144/// assert_eq!(normalize_features_arg("test,tracing"), "test + tracing");
145/// assert_eq!(normalize_features_arg(""), "default");
146/// assert_eq!(normalize_features_arg("  "), "default");
147/// assert_eq!(normalize_features_arg(",,"), "default");
148/// assert_eq!(normalize_features_arg("+"), "default");
149/// assert_eq!(normalize_features_arg("tracing"), "tracing");
150/// ```
151pub fn normalize_features_arg(input: &str) -> String {
152    let mut parts: Vec<&str> = input
153        .split([',', '+'])
154        .map(str::trim)
155        .filter(|s| !s.is_empty())
156        .collect();
157
158    if parts.is_empty() {
159        return "default".to_owned();
160    }
161
162    parts.sort_unstable();
163    parts.join(" + ")
164}
165
166fn collect_workspace_features(root: &Path) -> Result<BTreeSet<String>> {
167    let mut features = BTreeSet::new();
168
169    for entry in walkdir::WalkDir::new(root)
170        .into_iter()
171        .filter_entry(|e| !is_ignored(e))
172    {
173        let entry = entry.context("failed to walk workspace")?;
174        if entry.file_name() != "Cargo.toml" {
175            continue;
176        }
177
178        let content = std::fs::read_to_string(entry.path())
179            .with_context(|| format!("failed to read {}", entry.path().display()))?;
180
181        let doc: toml::Table = toml::from_str(&content)
182            .with_context(|| format!("failed to parse {}", entry.path().display()))?;
183
184        if let Some(toml::Value::Table(feat_table)) = doc.get("features") {
185            for key in feat_table.keys() {
186                if !is_excluded_feature(key) {
187                    features.insert(key.clone());
188                }
189            }
190        }
191    }
192
193    Ok(features)
194}
195
196fn is_ignored(entry: &walkdir::DirEntry) -> bool {
197    let name = entry.file_name().to_string_lossy();
198    matches!(name.as_ref(), "target" | ".git" | "thirdparty" | "xtask")
199}
200
201/// Returns `true` for feature names that must not appear in the CI matrix.
202///
203/// - `default` is always enabled by Cargo automatically.
204/// - `bench` only changes module visibility for micro-benchmarks and does not
205///   need its own power-set of CI jobs.
206/// - `telemetry` only aliases `tracing + profiling`, so adding it to the
207///   powerset would duplicate combinations that already compile the same code.
208fn is_excluded_feature(name: &str) -> bool {
209    matches!(name, "default" | "bench" | "telemetry")
210}
211
212fn build_matrix(features: BTreeSet<String>, os_list: &[&str]) -> Vec<MatrixEntry> {
213    let features: Vec<String> = features.into_iter().collect();
214    let n = features.len();
215    let mut entries = Vec::with_capacity((1 << n) * os_list.len());
216
217    for mask in 0u32..(1 << n) {
218        let combo: Vec<&str> = (0..n)
219            .filter(|&i| mask & (1 << i) != 0)
220            .map(|i| features[i].as_str())
221            .collect();
222
223        let label = if combo.is_empty() {
224            "default".to_owned()
225        } else {
226            combo.join(" + ")
227        };
228
229        for os in os_list {
230            entries.push(MatrixEntry {
231                label: label.clone(),
232                features: combo.join(","),
233                os: os.to_string(),
234            });
235        }
236    }
237
238    entries
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::tasks::util::workspace;
245
246    const ONE_OS: &[&str] = &["ubuntu-latest"];
247
248    #[test]
249    fn build_matrix_empty_features_yields_one_entry_per_os() {
250        let entries = build_matrix(BTreeSet::new(), ONE_OS);
251        assert_eq!(entries.len(), 1);
252        assert!(entries.iter().all(|e| e.label == "default"));
253        assert!(entries.iter().all(|e| e.features.is_empty()));
254        let oses: Vec<&str> = entries.iter().map(|e| e.os.as_str()).collect();
255        assert!(oses.contains(&"ubuntu-latest"));
256    }
257
258    #[test]
259    fn build_matrix_single_feature_yields_two_combos_per_os() {
260        let features = BTreeSet::from(["tracing".to_owned()]);
261        let entries = build_matrix(features, ONE_OS);
262        assert_eq!(entries.len(), 2);
263        assert_eq!(entries[0].label, "default");
264        assert_eq!(entries[1].label, "tracing");
265    }
266
267    #[test]
268    fn build_matrix_two_features_yields_four_combos_per_os() {
269        let features = BTreeSet::from(["test".to_owned(), "tracing".to_owned()]);
270        let entries = build_matrix(features, ONE_OS);
271        assert_eq!(entries.len(), 4);
272        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
273        assert!(labels.contains(&"default"));
274        assert!(labels.contains(&"tracing"));
275        assert!(labels.contains(&"test"));
276        assert!(labels.contains(&"test + tracing"));
277    }
278
279    #[test]
280    fn build_matrix_three_features_one_os_yields_eight_entries() {
281        let features = BTreeSet::from([
282            "emulator".to_owned(),
283            "tracing".to_owned(),
284            "test".to_owned(),
285        ]);
286        let entries = build_matrix(features, ONE_OS);
287        assert_eq!(entries.len(), 8, "2³ combos × 1 OS = 8 entries");
288    }
289
290    #[test]
291    fn cargo_args_default_entry_has_no_features_flag() {
292        let entry = MatrixEntry {
293            label: "default".to_owned(),
294            features: String::new(),
295            os: "ubuntu-latest".to_owned(),
296        };
297        assert_eq!(entry.cargo_args(), vec!["--workspace"]);
298    }
299
300    #[test]
301    fn cargo_args_combo_entry_includes_features_flag() {
302        let entry = MatrixEntry {
303            label: "test + tracing".to_owned(),
304            features: "test,tracing".to_owned(),
305            os: "ubuntu-latest".to_owned(),
306        };
307        assert_eq!(
308            entry.cargo_args(),
309            vec!["--workspace", "--features", "test,tracing"]
310        );
311    }
312
313    #[test]
314    fn excludes_telemetry_alias_from_matrix() {
315        assert!(is_excluded_feature("telemetry"));
316    }
317
318    #[test]
319    fn to_github_matrix_json_produces_include_key_with_os() {
320        let entries = vec![MatrixEntry {
321            label: "default".to_owned(),
322            features: String::new(),
323            os: "ubuntu-latest".to_owned(),
324        }];
325        let json = to_github_matrix_json(&entries).unwrap();
326        assert!(json.contains("\"include\""));
327        assert!(json.contains("\"default\""));
328        assert!(json.contains("\"ubuntu-latest\""));
329    }
330
331    #[test]
332    fn normalize_single_feature() {
333        assert_eq!(normalize_features_arg("tracing"), "tracing");
334    }
335
336    #[test]
337    fn normalize_comma_separated_two_features() {
338        assert_eq!(normalize_features_arg("tracing,test"), "test + tracing");
339    }
340
341    #[test]
342    fn normalize_label_format_round_trips() {
343        assert_eq!(normalize_features_arg("test + tracing"), "test + tracing");
344    }
345
346    #[test]
347    fn normalize_out_of_order_comma_separated_sorts() {
348        assert_eq!(normalize_features_arg("tracing,test"), "test + tracing");
349    }
350
351    #[test]
352    fn normalize_empty_string_returns_default() {
353        assert_eq!(normalize_features_arg(""), "default");
354    }
355
356    #[test]
357    fn scan_workspace_includes_runtime_features_on_ubuntu() {
358        let root = workspace::root().expect("workspace root must be resolvable in tests");
359        let entries = scan(&root, ONE_OS).expect("scan must succeed");
360
361        let labels: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
362        assert!(labels.contains(&"default"));
363        assert!(labels.contains(&"emulator"));
364        assert!(labels.contains(&"kobo"));
365        assert!(labels.contains(&"profiling"));
366        assert!(labels.contains(&"tracing"));
367        assert!(labels.contains(&"test"));
368        assert!(!labels.contains(&"telemetry"));
369        assert_eq!(
370            entries.len(),
371            32,
372            "5 CI features after excluding telemetry alias and bench → 2⁵ = 32 combos × 1 OS = 32 entries"
373        );
374    }
375}