xtask_lib/tasks/util/
matrix.rs1use std::collections::BTreeSet;
12use std::path::Path;
13
14use anyhow::{Context, Result};
15use serde::Serialize;
16
17pub const CI_CLIPPY_OS: &[&str] = &["ubuntu-latest"];
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct MatrixEntry {
23 pub label: String,
25 pub features: String,
28 pub os: String,
30}
31
32impl MatrixEntry {
33 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
46pub 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
68pub 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
107pub 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
130pub 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
201fn 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}