mint_cli/variant/
mod.rs

1pub mod args;
2pub mod errors;
3mod helpers;
4
5use calamine::{open_workbook, Data, Range, Reader, Xlsx};
6use std::collections::{HashMap, HashSet};
7
8use crate::layout::value::{DataValue, ValueSource};
9use errors::VariantError;
10
11pub struct DataSheet {
12    names: Vec<String>,
13    default_values: Vec<Data>,
14    variant_columns: Vec<Vec<Data>>,
15    sheets: HashMap<String, Range<Data>>,
16}
17
18impl DataSheet {
19    pub fn new(args: &args::VariantArgs) -> Result<Option<Self>, VariantError> {
20        let Some(xlsx_path) = &args.xlsx else {
21            return Ok(None);
22        };
23
24        let mut workbook: Xlsx<_> = open_workbook(xlsx_path)
25            .map_err(|_| VariantError::FileError(format!("failed to open file: {}", xlsx_path)))?;
26
27        let main_sheet = workbook
28            .worksheet_range(&args.main_sheet)
29            .map_err(|_| VariantError::MiscError("Main sheet not found.".to_string()))?;
30
31        let rows: Vec<_> = main_sheet.rows().collect();
32        let (headers, data_rows) = match rows.split_first() {
33            Some((hdr, tail)) => (hdr, tail.len()),
34            None => {
35                return Err(VariantError::RetrievalError(
36                    "invalid main sheet format.".to_string(),
37                ));
38            }
39        };
40
41        let name_index = headers
42            .iter()
43            .position(|cell| Self::cell_eq_ascii(cell, "Name"))
44            .ok_or(VariantError::ColumnNotFound("Name".to_string()))?;
45
46        let default_index = headers
47            .iter()
48            .position(|cell| Self::cell_eq_ascii(cell, "Default"))
49            .ok_or(VariantError::ColumnNotFound("Default".to_string()))?;
50
51        let mut names: Vec<String> = Vec::with_capacity(data_rows);
52        names.extend(rows.iter().skip(1).map(|row| {
53            row.get(name_index)
54                .map(|c| c.to_string().trim().to_string())
55                .unwrap_or_default()
56        }));
57        helpers::warn_duplicate_names(&names);
58
59        let default_values = Self::collect_column(&rows, default_index, data_rows);
60
61        let variant_columns = Self::collect_variant_columns(headers, &rows, data_rows, args)?;
62
63        let mut sheets: HashMap<String, Range<Data>> =
64            HashMap::with_capacity(workbook.worksheets().len().saturating_sub(1));
65        for (name, sheet) in workbook.worksheets() {
66            if name != args.main_sheet {
67                sheets.insert(name.to_string(), sheet);
68            }
69        }
70
71        Ok(Some(Self {
72            names,
73            default_values,
74            variant_columns,
75            sheets,
76        }))
77    }
78
79    pub fn retrieve_single_value(&self, name: &str) -> Result<DataValue, VariantError> {
80        let result = (|| match self.retrieve_cell(name)? {
81            Data::Int(i) => Ok(DataValue::I64(*i)),
82            Data::Float(f) => Ok(DataValue::F64(*f)),
83            Data::Bool(b) => Ok(DataValue::Bool(*b)),
84            _ => Err(VariantError::RetrievalError(
85                "Found non-numeric single value".to_string(),
86            )),
87        })();
88
89        result.map_err(|e| VariantError::WhileRetrieving {
90            name: name.to_string(),
91            source: Box::new(e),
92        })
93    }
94
95    pub fn retrieve_1d_array_or_string(&self, name: &str) -> Result<ValueSource, VariantError> {
96        let result = (|| {
97            let Data::String(cell_string) = self.retrieve_cell(name)? else {
98                return Err(VariantError::RetrievalError(
99                    "Expected string value for 1D array or string".to_string(),
100                ));
101            };
102
103            // Check if the value starts with '#' to indicate a sheet reference
104            if let Some(sheet_name) = cell_string.strip_prefix('#') {
105                let sheet = self.sheets.get(sheet_name).ok_or_else(|| {
106                    let available: Vec<_> = self.sheets.keys().map(|s| s.as_str()).collect();
107                    VariantError::RetrievalError(format!(
108                        "Sheet not found: '{}'. Available sheets: {}",
109                        sheet_name,
110                        available.join(", ")
111                    ))
112                })?;
113
114                let mut out = Vec::new();
115
116                for row in sheet.rows().skip(1) {
117                    match row.first() {
118                        Some(cell) if !Self::cell_is_empty(cell) => {
119                            let v = match cell {
120                                Data::Int(i) => DataValue::I64(*i),
121                                Data::Float(f) => DataValue::F64(*f),
122                                Data::Bool(b) => DataValue::Bool(*b),
123                                Data::String(s) => DataValue::Str(s.to_owned()),
124                                _ => {
125                                    return Err(VariantError::RetrievalError(
126                                        "Unsupported data type in 1D array".to_string(),
127                                    ));
128                                }
129                            };
130                            out.push(v);
131                        }
132                        _ => break,
133                    }
134                }
135                return Ok(ValueSource::Array(out));
136            }
137
138            // No '#' prefix, treat as a literal string
139            Ok(ValueSource::Single(DataValue::Str(cell_string.to_owned())))
140        })();
141
142        result.map_err(|e| VariantError::WhileRetrieving {
143            name: name.to_string(),
144            source: Box::new(e),
145        })
146    }
147
148    pub fn retrieve_2d_array(&self, name: &str) -> Result<Vec<Vec<DataValue>>, VariantError> {
149        let result = (|| {
150            let Data::String(cell_string) = self.retrieve_cell(name)? else {
151                return Err(VariantError::RetrievalError(
152                    "Expected string value for 2D array".to_string(),
153                ));
154            };
155
156            let sheet_name = cell_string.strip_prefix('#').ok_or_else(|| {
157                VariantError::RetrievalError(format!(
158                    "2D array reference must start with '#' prefix, got: {}",
159                    cell_string
160                ))
161            })?;
162
163            let sheet = self.sheets.get(sheet_name).ok_or_else(|| {
164                let available: Vec<_> = self.sheets.keys().map(|s| s.as_str()).collect();
165                VariantError::RetrievalError(format!(
166                    "Sheet not found: '{}'. Available sheets: {}",
167                    sheet_name,
168                    available.join(", ")
169                ))
170            })?;
171
172            let convert = |cell: &Data| -> Result<DataValue, VariantError> {
173                match cell {
174                    Data::Int(i) => Ok(DataValue::I64(*i)),
175                    Data::Float(f) => Ok(DataValue::F64(*f)),
176                    Data::Bool(b) => Ok(DataValue::Bool(*b)),
177                    _ => Err(VariantError::RetrievalError(
178                        "Unsupported data type in 2D array".to_string(),
179                    )),
180                }
181            };
182
183            let mut rows = sheet.rows();
184            let hdrs = rows.next().ok_or_else(|| {
185                VariantError::RetrievalError("No headers found in 2D array".to_string())
186            })?;
187            let width = hdrs.iter().take_while(|c| !Self::cell_is_empty(c)).count();
188            if width == 0 {
189                return Err(VariantError::RetrievalError(
190                    "Detected zero width 2D array".to_string(),
191                ));
192            }
193
194            let mut out = Vec::new();
195
196            'outer: for row in rows {
197                if row.first().is_none_or(Self::cell_is_empty) {
198                    break;
199                }
200
201                let mut vals = Vec::with_capacity(width);
202                for col in 0..width {
203                    let Some(cell) = row.get(col) else {
204                        break 'outer;
205                    };
206                    if Self::cell_is_empty(cell) {
207                        break 'outer;
208                    };
209                    vals.push(convert(cell)?);
210                }
211                out.push(vals);
212            }
213
214            Ok(out)
215        })();
216
217        result.map_err(|e| VariantError::WhileRetrieving {
218            name: name.to_string(),
219            source: Box::new(e),
220        })
221    }
222
223    fn retrieve_cell(&self, name: &str) -> Result<&Data, VariantError> {
224        let index =
225            self.names
226                .iter()
227                .position(|n| n == name)
228                .ok_or(VariantError::RetrievalError(
229                    "index not found in data sheet".to_string(),
230                ))?;
231
232        for column in &self.variant_columns {
233            if let Some(value) = column.get(index) {
234                if !Self::cell_is_empty(value) {
235                    return Ok(value);
236                }
237            }
238        }
239
240        if let Some(default) = self.default_values.get(index) {
241            if !Self::cell_is_empty(default) {
242                return Ok(default);
243            }
244        }
245
246        Err(VariantError::RetrievalError(
247            "data not found in any variant column".to_string(),
248        ))
249    }
250
251    fn cell_eq_ascii(cell: &Data, target: &str) -> bool {
252        match cell {
253            Data::String(s) => s.trim().eq_ignore_ascii_case(target),
254            _ => false,
255        }
256    }
257
258    fn cell_is_empty(cell: &Data) -> bool {
259        match cell {
260            Data::Empty => true,
261            Data::String(s) => s.trim().is_empty(),
262            _ => false,
263        }
264    }
265
266    fn collect_column(rows: &[&[Data]], index: usize, data_rows: usize) -> Vec<Data> {
267        let mut column = Vec::with_capacity(data_rows);
268        column.extend(
269            rows.iter()
270                .skip(1)
271                .map(|row| row.get(index).cloned().unwrap_or(Data::Empty)),
272        );
273        column
274    }
275
276    fn parse_variant_stack(raw: &str) -> Vec<String> {
277        raw.split('/')
278            .map(|name| name.trim())
279            .filter(|name| !name.is_empty())
280            .map(|name| name.to_string())
281            .collect()
282    }
283
284    fn collect_variant_columns(
285        headers: &[Data],
286        rows: &[&[Data]],
287        data_rows: usize,
288        args: &args::VariantArgs,
289    ) -> Result<Vec<Vec<Data>>, VariantError> {
290        let mut names: Vec<String> = Vec::new();
291
292        if args.debug {
293            eprintln!("Warning: --debug flag is deprecated; include 'Debug' in --variant instead.");
294            names.push("Debug".to_string());
295        }
296
297        if let Some(raw) = &args.variant {
298            names.extend(Self::parse_variant_stack(raw));
299        }
300
301        let mut seen = HashSet::new();
302        let mut columns = Vec::new();
303
304        for name in names {
305            if !seen.insert(name.clone()) {
306                continue;
307            }
308
309            let index = headers
310                .iter()
311                .position(|cell| Self::cell_eq_ascii(cell, &name))
312                .ok_or_else(|| VariantError::ColumnNotFound(name.clone()))?;
313
314            columns.push(Self::collect_column(rows, index, data_rows));
315        }
316
317        Ok(columns)
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::collections::HashMap;
325
326    fn datasheet_with_default(value: Data) -> DataSheet {
327        DataSheet {
328            names: vec!["Flag".to_string()],
329            default_values: vec![value],
330            variant_columns: Vec::new(),
331            sheets: HashMap::new(),
332        }
333    }
334
335    #[test]
336    fn retrieve_single_value_accepts_bool_cell() {
337        let ds = datasheet_with_default(Data::Bool(true));
338        let value = ds.retrieve_single_value("Flag").expect("bool cell");
339        match value {
340            DataValue::Bool(v) => assert!(v),
341            _ => panic!("expected bool value"),
342        }
343    }
344}