1use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use serde_yaml::Value;
6use std::collections::HashMap;
7use thiserror::Error;
8
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct MatrixConfig {
11 #[serde(flatten)]
12 pub parameters: IndexMap<String, Value>,
13 #[serde(default)]
14 pub include: Vec<HashMap<String, Value>>,
15 #[serde(default)]
16 pub exclude: Vec<HashMap<String, Value>>,
17 #[serde(default, rename = "max-parallel")]
18 pub max_parallel: Option<usize>,
19 #[serde(default, rename = "fail-fast")]
20 pub fail_fast: Option<bool>,
21}
22
23impl Default for MatrixConfig {
24 fn default() -> Self {
25 Self {
26 parameters: IndexMap::new(),
27 include: Vec::new(),
28 exclude: Vec::new(),
29 max_parallel: None,
30 fail_fast: Some(true),
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq)]
36pub struct MatrixCombination {
37 pub values: HashMap<String, Value>,
38 pub is_included: bool, }
40
41impl MatrixCombination {
42 pub fn new(values: HashMap<String, Value>) -> Self {
43 Self {
44 values,
45 is_included: false,
46 }
47 }
48
49 pub fn from_include(values: HashMap<String, Value>) -> Self {
50 Self {
51 values,
52 is_included: true,
53 }
54 }
55}
56
57#[derive(Error, Debug)]
58pub enum MatrixError {
59 #[error("Invalid matrix parameter format: {0}")]
60 InvalidParameterFormat(String),
61
62 #[error("Failed to expand matrix: {0}")]
63 ExpansionError(String),
64}
65
66pub fn expand_matrix(matrix: &MatrixConfig) -> Result<Vec<MatrixCombination>, MatrixError> {
68 let mut combinations = Vec::new();
69
70 let param_combinations = generate_base_combinations(matrix)?;
72
73 let filtered_combinations = apply_exclude_filters(param_combinations, &matrix.exclude);
75 combinations.extend(filtered_combinations);
76
77 for include_item in &matrix.include {
81 let mut merged = false;
82 for combo in &mut combinations {
83 let all_shared_keys_match = include_item.iter().all(|(key, value)| {
84 match combo.values.get(key) {
85 Some(existing_value) => existing_value == value,
86 None => true, }
88 });
89 let has_matching_key = include_item
91 .keys()
92 .any(|key| combo.values.contains_key(key));
93 if all_shared_keys_match && has_matching_key {
94 for (key, value) in include_item {
98 combo
99 .values
100 .entry(key.clone())
101 .or_insert_with(|| value.clone());
102 }
103 merged = true;
104 }
106 }
107 if !merged {
108 combinations.push(MatrixCombination::from_include(include_item.clone()));
109 }
110 }
111
112 if combinations.is_empty() {
113 return Err(MatrixError::ExpansionError(
114 "No valid combinations found after applying filters".to_string(),
115 ));
116 }
117
118 Ok(combinations)
119}
120
121fn generate_base_combinations(
123 matrix: &MatrixConfig,
124) -> Result<Vec<MatrixCombination>, MatrixError> {
125 let mut param_arrays: IndexMap<String, Vec<Value>> = IndexMap::new();
127
128 for (param_name, param_value) in &matrix.parameters {
129 match param_value {
130 Value::Sequence(array) => {
131 param_arrays.insert(param_name.clone(), array.clone());
132 }
133 _ => {
134 let single_value = vec![param_value.clone()];
136 param_arrays.insert(param_name.clone(), single_value);
137 }
138 }
139 }
140
141 if param_arrays.is_empty() {
142 return Err(MatrixError::InvalidParameterFormat(
143 "Matrix has no valid parameters".to_string(),
144 ));
145 }
146
147 let param_names: Vec<String> = param_arrays.keys().cloned().collect();
149 let param_values: Vec<Vec<Value>> = param_arrays.values().cloned().collect();
150
151 let combinations = if !param_values.is_empty() {
153 generate_combinations(¶m_names, ¶m_values, 0, &mut HashMap::new())?
154 } else {
155 vec![]
156 };
157
158 Ok(combinations)
159}
160
161fn generate_combinations(
163 param_names: &[String],
164 param_values: &[Vec<Value>],
165 current_depth: usize,
166 current_combination: &mut HashMap<String, Value>,
167) -> Result<Vec<MatrixCombination>, MatrixError> {
168 if current_depth == param_names.len() {
169 return Ok(vec![MatrixCombination::new(current_combination.clone())]);
171 }
172
173 let mut result = Vec::new();
174 let param_name = ¶m_names[current_depth];
175 let values = ¶m_values[current_depth];
176
177 for value in values {
178 current_combination.insert(param_name.clone(), value.clone());
179
180 let mut new_combinations = generate_combinations(
181 param_names,
182 param_values,
183 current_depth + 1,
184 current_combination,
185 )?;
186
187 result.append(&mut new_combinations);
188 }
189
190 current_combination.remove(param_name);
192
193 Ok(result)
194}
195
196fn apply_exclude_filters(
198 combinations: Vec<MatrixCombination>,
199 exclude_patterns: &[HashMap<String, Value>],
200) -> Vec<MatrixCombination> {
201 if exclude_patterns.is_empty() {
202 return combinations;
203 }
204
205 combinations
206 .into_iter()
207 .filter(|combination| !is_excluded(combination, exclude_patterns))
208 .collect()
209}
210
211fn is_excluded(
213 combination: &MatrixCombination,
214 exclude_patterns: &[HashMap<String, Value>],
215) -> bool {
216 for exclude in exclude_patterns {
217 let mut excluded = true;
218
219 for (key, value) in exclude {
220 match combination.values.get(key) {
221 Some(combo_value) if combo_value == value => {
222 continue;
224 }
225 _ => {
226 excluded = false;
228 break;
229 }
230 }
231 }
232
233 if excluded {
234 return true;
235 }
236 }
237
238 false
239}
240
241pub fn format_combination_name(job_name: &str, combination: &MatrixCombination) -> String {
243 let params = combination
244 .values
245 .iter()
246 .map(|(k, v)| format!("{}: {}", k, value_to_string(v)))
247 .collect::<Vec<_>>()
248 .join(", ");
249
250 format!("{} ({})", job_name, params)
251}
252
253fn value_to_string(value: &Value) -> String {
255 match value {
256 Value::String(s) => s.clone(),
257 Value::Number(n) => n.to_string(),
258 Value::Bool(b) => b.to_string(),
259 Value::Sequence(seq) => {
260 let items = seq
261 .iter()
262 .map(value_to_string)
263 .collect::<Vec<_>>()
264 .join(", ");
265 format!("[{}]", items)
266 }
267 Value::Mapping(map) => {
268 let items = map
269 .iter()
270 .map(|(k, v)| format!("{}: {}", value_to_string(k), value_to_string(v)))
271 .collect::<Vec<_>>()
272 .join(", ");
273 format!("{{{}}}", items)
274 }
275 Value::Null => "null".to_string(),
276 _ => "unknown".to_string(),
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn val(s: &str) -> Value {
285 Value::String(s.to_string())
286 }
287
288 fn make_matrix(params: Vec<(&str, Vec<&str>)>) -> MatrixConfig {
289 let mut parameters = IndexMap::new();
290 for (name, values) in params {
291 parameters.insert(
292 name.to_string(),
293 Value::Sequence(values.into_iter().map(val).collect()),
294 );
295 }
296 MatrixConfig {
297 parameters,
298 include: Vec::new(),
299 exclude: Vec::new(),
300 max_parallel: None,
301 fail_fast: Some(true),
302 }
303 }
304
305 #[test]
306 fn include_merges_into_matching_combination() {
307 let mut matrix = make_matrix(vec![("os", vec!["ubuntu", "windows"])]);
308 let mut include_entry = HashMap::new();
310 include_entry.insert("os".to_string(), val("ubuntu"));
311 include_entry.insert("compiler".to_string(), val("gcc"));
312 matrix.include.push(include_entry);
313
314 let combos = expand_matrix(&matrix).unwrap();
315
316 assert_eq!(combos.len(), 2);
318
319 let ubuntu = combos
320 .iter()
321 .find(|c| c.values.get("os") == Some(&val("ubuntu")))
322 .unwrap();
323 assert_eq!(ubuntu.values.get("compiler"), Some(&val("gcc")));
324
325 let windows = combos
326 .iter()
327 .find(|c| c.values.get("os") == Some(&val("windows")))
328 .unwrap();
329 assert_eq!(windows.values.get("compiler"), None);
330 }
331
332 #[test]
333 fn include_adds_standalone_when_no_match() {
334 let mut matrix = make_matrix(vec![("os", vec!["ubuntu", "windows"])]);
335 let mut include_entry = HashMap::new();
337 include_entry.insert("os".to_string(), val("macos"));
338 include_entry.insert("special".to_string(), val("true"));
339 matrix.include.push(include_entry);
340
341 let combos = expand_matrix(&matrix).unwrap();
342
343 assert_eq!(combos.len(), 3);
345
346 let macos = combos
347 .iter()
348 .find(|c| c.values.get("os") == Some(&val("macos")))
349 .unwrap();
350 assert_eq!(macos.values.get("special"), Some(&val("true")));
351 assert!(macos.is_included);
352 }
353
354 #[test]
355 fn include_merges_into_all_matching_combinations() {
356 let mut matrix = make_matrix(vec![
357 ("os", vec!["ubuntu", "windows"]),
358 ("node", vec!["16", "18"]),
359 ]);
360 let mut include_entry = HashMap::new();
362 include_entry.insert("os".to_string(), val("ubuntu"));
363 include_entry.insert("extra".to_string(), val("yes"));
364 matrix.include.push(include_entry);
365
366 let combos = expand_matrix(&matrix).unwrap();
367
368 assert_eq!(combos.len(), 4);
370
371 let ubuntu_combos: Vec<_> = combos
372 .iter()
373 .filter(|c| c.values.get("os") == Some(&val("ubuntu")))
374 .collect();
375 assert_eq!(ubuntu_combos.len(), 2);
376 for combo in &ubuntu_combos {
377 assert_eq!(combo.values.get("extra"), Some(&val("yes")));
378 }
379
380 let windows_combos: Vec<_> = combos
381 .iter()
382 .filter(|c| c.values.get("os") == Some(&val("windows")))
383 .collect();
384 for combo in &windows_combos {
385 assert_eq!(combo.values.get("extra"), None);
386 }
387 }
388
389 #[test]
390 fn include_with_only_new_keys_adds_standalone() {
391 let mut matrix = make_matrix(vec![("os", vec!["ubuntu"])]);
392 let mut include_entry = HashMap::new();
394 include_entry.insert("arch".to_string(), val("arm64"));
395 matrix.include.push(include_entry);
396
397 let combos = expand_matrix(&matrix).unwrap();
398
399 assert_eq!(combos.len(), 2);
401 }
402
403 #[test]
404 fn exclude_removes_matching_combinations() {
405 let mut matrix = make_matrix(vec![
406 ("os", vec!["ubuntu", "windows"]),
407 ("node", vec!["16", "18"]),
408 ]);
409 let mut exclude_entry = HashMap::new();
410 exclude_entry.insert("os".to_string(), val("windows"));
411 exclude_entry.insert("node".to_string(), val("16"));
412 matrix.exclude.push(exclude_entry);
413
414 let combos = expand_matrix(&matrix).unwrap();
415
416 assert_eq!(combos.len(), 3);
418 assert!(!combos.iter().any(|c| {
419 c.values.get("os") == Some(&val("windows")) && c.values.get("node") == Some(&val("16"))
420 }));
421 }
422}