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: Add any combinations from the include section
78    for include_item in &matrix.include {
79        combinations.push(MatrixCombination::from_include(include_item.clone()));
80    }
81
82    if combinations.is_empty() {
83        return Err(MatrixError::ExpansionError(
84            "No valid combinations found after applying filters".to_string(),
85        ));
86    }
87
88    Ok(combinations)
89}
90
91/// Generates all possible combinations of the base matrix parameters
92fn generate_base_combinations(
93    matrix: &MatrixConfig,
94) -> Result<Vec<MatrixCombination>, MatrixError> {
95    // Extract parameter arrays and prepare for combination generation
96    let mut param_arrays: IndexMap<String, Vec<Value>> = IndexMap::new();
97
98    for (param_name, param_value) in &matrix.parameters {
99        match param_value {
100            Value::Sequence(array) => {
101                param_arrays.insert(param_name.clone(), array.clone());
102            }
103            _ => {
104                // Handle non-array parameters
105                let single_value = vec![param_value.clone()];
106                param_arrays.insert(param_name.clone(), single_value);
107            }
108        }
109    }
110
111    if param_arrays.is_empty() {
112        return Err(MatrixError::InvalidParameterFormat(
113            "Matrix has no valid parameters".to_string(),
114        ));
115    }
116
117    // Generate the Cartesian product of all parameter arrays
118    let param_names: Vec<String> = param_arrays.keys().cloned().collect();
119    let param_values: Vec<Vec<Value>> = param_arrays.values().cloned().collect();
120
121    // Generate all combinations using itertools
122    let combinations = if !param_values.is_empty() {
123        generate_combinations(&param_names, &param_values, 0, &mut HashMap::new())?
124    } else {
125        vec![]
126    };
127
128    Ok(combinations)
129}
130
131/// Recursive function to generate combinations using depth-first approach
132fn generate_combinations(
133    param_names: &[String],
134    param_values: &[Vec<Value>],
135    current_depth: usize,
136    current_combination: &mut HashMap<String, Value>,
137) -> Result<Vec<MatrixCombination>, MatrixError> {
138    if current_depth == param_names.len() {
139        // We've reached a complete combination
140        return Ok(vec![MatrixCombination::new(current_combination.clone())]);
141    }
142
143    let mut result = Vec::new();
144    let param_name = &param_names[current_depth];
145    let values = &param_values[current_depth];
146
147    for value in values {
148        current_combination.insert(param_name.clone(), value.clone());
149
150        let mut new_combinations = generate_combinations(
151            param_names,
152            param_values,
153            current_depth + 1,
154            current_combination,
155        )?;
156
157        result.append(&mut new_combinations);
158    }
159
160    // Remove this level's parameter to backtrack
161    current_combination.remove(param_name);
162
163    Ok(result)
164}
165
166/// Filters out combinations that match any of the exclude patterns
167fn apply_exclude_filters(
168    combinations: Vec<MatrixCombination>,
169    exclude_patterns: &[HashMap<String, Value>],
170) -> Vec<MatrixCombination> {
171    if exclude_patterns.is_empty() {
172        return combinations;
173    }
174
175    combinations
176        .into_iter()
177        .filter(|combination| !is_excluded(combination, exclude_patterns))
178        .collect()
179}
180
181/// Checks if a combination matches any exclude pattern
182fn is_excluded(
183    combination: &MatrixCombination,
184    exclude_patterns: &[HashMap<String, Value>],
185) -> bool {
186    for exclude in exclude_patterns {
187        let mut excluded = true;
188
189        for (key, value) in exclude {
190            match combination.values.get(key) {
191                Some(combo_value) if combo_value == value => {
192                    // This exclude condition matches
193                    continue;
194                }
195                _ => {
196                    // This exclude condition doesn't match
197                    excluded = false;
198                    break;
199                }
200            }
201        }
202
203        if excluded {
204            return true;
205        }
206    }
207
208    false
209}
210
211/// Formats a combination name for display, e.g. "test (ubuntu, node 14)"
212pub fn format_combination_name(job_name: &str, combination: &MatrixCombination) -> String {
213    let params = combination
214        .values
215        .iter()
216        .map(|(k, v)| format!("{}: {}", k, value_to_string(v)))
217        .collect::<Vec<_>>()
218        .join(", ");
219
220    format!("{} ({})", job_name, params)
221}
222
223/// Converts a serde_yaml::Value to a string for display
224fn value_to_string(value: &Value) -> String {
225    match value {
226        Value::String(s) => s.clone(),
227        Value::Number(n) => n.to_string(),
228        Value::Bool(b) => b.to_string(),
229        Value::Sequence(seq) => {
230            let items = seq
231                .iter()
232                .map(value_to_string)
233                .collect::<Vec<_>>()
234                .join(", ");
235            format!("[{}]", items)
236        }
237        Value::Mapping(map) => {
238            let items = map
239                .iter()
240                .map(|(k, v)| format!("{}: {}", value_to_string(k), value_to_string(v)))
241                .collect::<Vec<_>>()
242                .join(", ");
243            format!("{{{}}}", items)
244        }
245        Value::Null => "null".to_string(),
246        _ => "unknown".to_string(),
247    }
248}