Skip to main content

wrkflw_matrix/
lib.rs

1// matrix crate
2
3use 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, // Whether this was added via the include section
39}
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
66/// Expands a matrix configuration into a list of all valid combinations
67pub fn expand_matrix(matrix: &MatrixConfig) -> Result<Vec<MatrixCombination>, MatrixError> {
68    let mut combinations = Vec::new();
69
70    // Step 1: Generate base combinations from parameter arrays
71    let param_combinations = generate_base_combinations(matrix)?;
72
73    // Step 2: Filter out any combinations that match the exclude patterns
74    let filtered_combinations = apply_exclude_filters(param_combinations, &matrix.exclude);
75    combinations.extend(filtered_combinations);
76
77    // Step 3: Process include entries per GitHub Actions semantics:
78    // If an include entry matches all shared keys of an existing combination,
79    // merge extra keys into it. Otherwise, add as a new standalone combination.
80    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, // Key not in base combo = no conflict, it's a new key to add
87                }
88            });
89            // Only merge if there's at least one matching key (not purely new keys)
90            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                // Merge extra keys into the existing combination.
95                // or_insert_with is intentional: per GitHub Actions semantics, include
96                // entries add new keys but do NOT override existing matrix values.
97                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                // Don't break — merge into ALL matching combinations per GitHub Actions semantics
105            }
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
121/// Generates all possible combinations of the base matrix parameters
122fn generate_base_combinations(
123    matrix: &MatrixConfig,
124) -> Result<Vec<MatrixCombination>, MatrixError> {
125    // Extract parameter arrays and prepare for combination generation
126    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                // Handle non-array parameters
135                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    // Generate the Cartesian product of all parameter arrays
148    let param_names: Vec<String> = param_arrays.keys().cloned().collect();
149    let param_values: Vec<Vec<Value>> = param_arrays.values().cloned().collect();
150
151    // Generate all combinations using itertools
152    let combinations = if !param_values.is_empty() {
153        generate_combinations(&param_names, &param_values, 0, &mut HashMap::new())?
154    } else {
155        vec![]
156    };
157
158    Ok(combinations)
159}
160
161/// Recursive function to generate combinations using depth-first approach
162fn 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        // We've reached a complete combination
170        return Ok(vec![MatrixCombination::new(current_combination.clone())]);
171    }
172
173    let mut result = Vec::new();
174    let param_name = &param_names[current_depth];
175    let values = &param_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    // Remove this level's parameter to backtrack
191    current_combination.remove(param_name);
192
193    Ok(result)
194}
195
196/// Filters out combinations that match any of the exclude patterns
197fn 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
211/// Checks if a combination matches any exclude pattern
212fn 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                    // This exclude condition matches
223                    continue;
224                }
225                _ => {
226                    // This exclude condition doesn't match
227                    excluded = false;
228                    break;
229                }
230            }
231        }
232
233        if excluded {
234            return true;
235        }
236    }
237
238    false
239}
240
241/// Formats a combination name for display, e.g. "test (ubuntu, node 14)"
242pub 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
253/// Converts a serde_yaml::Value to a string for display
254fn 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        // Include entry that matches os=ubuntu and adds a new key
309        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        // Should have 2 combos: ubuntu (with compiler=gcc merged) and windows
317        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        // Include entry with a value that doesn't match any existing combo
336        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        // Should have 3 combos: ubuntu, windows, macos (standalone)
344        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        // Include entry matching os=ubuntu — should merge into both (ubuntu, 16) and (ubuntu, 18)
361        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        // 4 base combos, no new standalone (merged into 2 existing)
369        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        // Include entry with only keys that don't exist in base combos
393        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        // Should have 2: the base ubuntu + standalone arm64
400        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        // 4 - 1 excluded = 3
417        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}