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", "macos-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>> {
58 let features = collect_workspace_features(root)?;
59 Ok(build_matrix(features, os_list))
60}
61
62pub 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
101pub 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
124pub 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}