Skip to main content

mollendorff_forge/parser/
variables.rs

1//! Table and scalar variable parsing for Forge models
2//!
3//! Handles parsing of tables (with columns and row formulas) and scalar variables.
4
5use crate::error::{ForgeError, ForgeResult};
6use crate::types::{Column, Metadata, Table, Variable};
7use serde_yaml_ng::Value;
8
9use super::arrays::parse_array_value;
10
11/// Parse a table from a YAML mapping (v4.0 enhanced with metadata)
12///
13/// # Errors
14///
15/// Returns an error if a column name is not a string, or column data has an invalid type or format.
16pub fn parse_table(name: &str, map: &serde_yaml_ng::Mapping) -> ForgeResult<Table> {
17    let mut table = Table::new(name.to_string());
18
19    for (key, value) in map {
20        let col_name = key
21            .as_str()
22            .ok_or_else(|| ForgeError::Parse("Column name must be a string".to_string()))?;
23
24        // Skip _metadata table-level metadata (v4.0)
25        if col_name == "_metadata" {
26            continue;
27        }
28
29        // Check if this is a formula (string starting with =)
30        if let Value::String(s) = value {
31            if s.starts_with('=') {
32                // This is a row-wise formula
33                table.add_row_formula(col_name.to_string(), s.clone());
34                continue;
35            }
36        }
37
38        // Check for v4.0 rich column format: { value: [...], unit: "...", notes: "..." }
39        if let Value::Mapping(col_map) = value {
40            // Check if it has a 'value' key with an array (v4.0 rich format)
41            if let Some(Value::Sequence(seq)) = col_map.get("value") {
42                let column_value = parse_array_value(col_name, seq)?;
43                let metadata = parse_metadata(col_map);
44                let column = Column::with_metadata(col_name.to_string(), column_value, metadata);
45                table.add_column(column);
46                continue;
47            }
48            // Check if it has a 'formula' key (v4.0 rich formula format)
49            if let Some(formula_val) = col_map.get("formula") {
50                if let Some(formula_str) = formula_val.as_str() {
51                    if formula_str.starts_with('=') {
52                        // This is a row-wise formula with metadata
53                        // TODO: Store formula metadata when we add formula metadata support
54                        table.add_row_formula(col_name.to_string(), formula_str.to_string());
55                        continue;
56                    }
57                }
58            }
59        }
60
61        // Otherwise, it's a simple data column (array) - v1.0 format
62        if let Value::Sequence(seq) = value {
63            let column_value = parse_array_value(col_name, seq)?;
64            let column = Column::new(col_name.to_string(), column_value);
65            table.add_column(column);
66        } else {
67            return Err(ForgeError::Parse(format!(
68                "Column '{col_name}' in table '{name}' must be an array or formula"
69            )));
70        }
71    }
72
73    Ok(table)
74}
75
76/// Parse a scalar variable (v4.0 enhanced with metadata)
77///
78/// # Errors
79///
80/// Returns an error if the value is not a mapping.
81pub fn parse_scalar_variable(value: &Value, path: &str) -> ForgeResult<Variable> {
82    if let Value::Mapping(map) = value {
83        let val = map.get("value").and_then(serde_yaml_ng::Value::as_f64);
84        let formula = map
85            .get("formula")
86            .and_then(|f| f.as_str().map(std::string::ToString::to_string));
87
88        // Extract v4.0 metadata fields
89        let metadata = parse_metadata(map);
90
91        Ok(Variable {
92            path: path.to_string(),
93            value: val,
94            formula,
95            metadata,
96        })
97    } else {
98        Err(ForgeError::Parse(format!(
99            "Expected mapping for scalar variable '{path}'"
100        )))
101    }
102}
103
104/// Extract metadata fields from a YAML mapping (v4.0)
105#[must_use]
106pub fn parse_metadata(map: &serde_yaml_ng::Mapping) -> Metadata {
107    Metadata {
108        unit: map
109            .get("unit")
110            .and_then(|v| v.as_str().map(std::string::ToString::to_string)),
111        notes: map
112            .get("notes")
113            .and_then(|v| v.as_str().map(std::string::ToString::to_string)),
114        source: map
115            .get("source")
116            .and_then(|v| v.as_str().map(std::string::ToString::to_string)),
117        validation_status: map
118            .get("validation_status")
119            .and_then(|v| v.as_str().map(std::string::ToString::to_string)),
120        last_updated: map
121            .get("last_updated")
122            .and_then(|v| v.as_str().map(std::string::ToString::to_string)),
123    }
124}
125
126/// Check if a mapping contains nested scalar sections (e.g., summary.total)
127/// Returns false for v4.0 rich table columns (where value is an array)
128#[must_use]
129pub fn is_nested_scalar_section(map: &serde_yaml_ng::Mapping) -> bool {
130    // Check if children are mappings with {value, formula} pattern where value is a scalar (not array)
131    for (_key, value) in map {
132        if let Value::Mapping(child_map) = value {
133            // Check if this child has value or formula keys
134            if child_map.contains_key("value") || child_map.contains_key("formula") {
135                // If value is an array, this is a v4.0 rich table column, not a scalar
136                if let Some(val) = child_map.get("value") {
137                    if matches!(val, Value::Sequence(_)) {
138                        return false; // v4.0 rich table column
139                    }
140                }
141                return true; // Nested scalar section
142            }
143        }
144    }
145    false
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::types::ColumnValue;
152    use std::io::Write;
153    use tempfile::NamedTempFile;
154
155    #[test]
156    fn test_parse_table_with_arrays() {
157        let yaml = r"
158    month: ['Jan', 'Feb', 'Mar']
159    revenue: [100, 200, 300]
160    ";
161        let parsed: Value = serde_yaml_ng::from_str(yaml).unwrap();
162
163        if let Value::Mapping(map) = parsed {
164            let table = parse_table("test_table", &map).unwrap();
165
166            assert_eq!(table.name, "test_table");
167            assert_eq!(table.columns.len(), 2);
168            assert!(table.columns.contains_key("month"));
169            assert!(table.columns.contains_key("revenue"));
170            assert_eq!(table.row_count(), 3);
171        } else {
172            panic!("Expected mapping");
173        }
174    }
175
176    #[test]
177    fn test_parse_table_with_formula() {
178        let yaml = r"
179    revenue: [100, 200, 300]
180    expenses: [50, 100, 150]
181    profit: '=revenue - expenses'
182    ";
183        let parsed: Value = serde_yaml_ng::from_str(yaml).unwrap();
184
185        if let Value::Mapping(map) = parsed {
186            let table = parse_table("test_table", &map).unwrap();
187
188            assert_eq!(table.columns.len(), 2);
189            assert_eq!(table.row_formulas.len(), 1);
190            assert!(table.row_formulas.contains_key("profit"));
191            assert_eq!(
192                table.row_formulas.get("profit").unwrap(),
193                "=revenue - expenses"
194            );
195        } else {
196            panic!("Expected mapping");
197        }
198    }
199
200    #[test]
201    fn test_table_validate_lengths_ok() {
202        let mut table = Table::new("test".to_string());
203        table.add_column(Column::new(
204            "col1".to_string(),
205            ColumnValue::Number(vec![1.0, 2.0, 3.0]),
206        ));
207        table.add_column(Column::new(
208            "col2".to_string(),
209            ColumnValue::Number(vec![4.0, 5.0, 6.0]),
210        ));
211
212        assert!(table.validate_lengths().is_ok());
213    }
214
215    #[test]
216    fn test_table_validate_lengths_error() {
217        let mut table = Table::new("test".to_string());
218        table.add_column(Column::new(
219            "col1".to_string(),
220            ColumnValue::Number(vec![1.0, 2.0, 3.0]),
221        ));
222        table.add_column(Column::new(
223            "col2".to_string(),
224            ColumnValue::Number(vec![4.0, 5.0]),
225        ));
226
227        let result = table.validate_lengths();
228        assert!(result.is_err());
229        let err_msg = result.unwrap_err();
230        assert!(err_msg.contains("col1") || err_msg.contains("col2"));
231        assert!(err_msg.contains("2 rows"));
232        assert!(err_msg.contains("3 rows"));
233    }
234
235    #[test]
236    fn test_column_value_type_name() {
237        let num_col = ColumnValue::Number(vec![1.0]);
238        let text_col = ColumnValue::Text(vec!["A".to_string()]);
239        let date_col = ColumnValue::Date(vec!["2025-01".to_string()]);
240        let bool_col = ColumnValue::Boolean(vec![true]);
241
242        assert_eq!(num_col.type_name(), "Number");
243        assert_eq!(text_col.type_name(), "Text");
244        assert_eq!(date_col.type_name(), "Date");
245        assert_eq!(bool_col.type_name(), "Boolean");
246    }
247
248    #[test]
249    fn test_column_value_len() {
250        let col = ColumnValue::Number(vec![1.0, 2.0, 3.0]);
251        assert_eq!(col.len(), 3);
252        assert!(!col.is_empty());
253
254        let empty_col = ColumnValue::Number(vec![]);
255        assert_eq!(empty_col.len(), 0);
256        assert!(empty_col.is_empty());
257    }
258
259    #[test]
260    fn test_parse_v4_scalar_with_metadata() {
261        let yaml_content = r#"
262_forge_version: "5.0.0"
263
264price:
265  value: 100
266  formula: null
267  unit: "CAD"
268  notes: "Base price per unit"
269  source: "market_research.yaml"
270  validation_status: "VALIDATED"
271"#;
272
273        let mut temp_file = NamedTempFile::new().unwrap();
274        temp_file.write_all(yaml_content.as_bytes()).unwrap();
275
276        let content = std::fs::read_to_string(temp_file.path()).unwrap();
277        let yaml: Value = serde_yaml_ng::from_str(&content).unwrap();
278
279        if let Some(price_val) = yaml.get("price") {
280            let price = parse_scalar_variable(price_val, "price").unwrap();
281            assert_eq!(price.value, Some(100.0));
282            assert!(price.formula.is_none());
283            assert_eq!(price.metadata.unit, Some("CAD".to_string()));
284            assert_eq!(
285                price.metadata.notes,
286                Some("Base price per unit".to_string())
287            );
288            assert_eq!(
289                price.metadata.source,
290                Some("market_research.yaml".to_string())
291            );
292            assert_eq!(
293                price.metadata.validation_status,
294                Some("VALIDATED".to_string())
295            );
296        }
297    }
298
299    #[test]
300    fn test_parse_scalar_variable_not_mapping() {
301        let val = Value::String("not a mapping".to_string());
302        let result = parse_scalar_variable(&val, "test");
303        assert!(result.is_err());
304        assert!(result.unwrap_err().to_string().contains("Expected mapping"));
305    }
306
307    #[test]
308    fn test_metadata_last_updated() {
309        let mut map = serde_yaml_ng::Mapping::new();
310        map.insert(
311            Value::String("last_updated".to_string()),
312            Value::String("2025-01-01".to_string()),
313        );
314        let metadata = parse_metadata(&map);
315        assert_eq!(metadata.last_updated, Some("2025-01-01".to_string()));
316    }
317
318    #[test]
319    fn test_is_nested_scalar_section_false_for_v4_rich_column() {
320        let mut map = serde_yaml_ng::Mapping::new();
321        let mut child = serde_yaml_ng::Mapping::new();
322        child.insert(
323            Value::String("value".to_string()),
324            Value::Sequence(vec![Value::Number(1.into())]),
325        );
326        map.insert(Value::String("col".to_string()), Value::Mapping(child));
327        assert!(!is_nested_scalar_section(&map));
328    }
329
330    #[test]
331    fn test_is_nested_scalar_section_true_for_scalar() {
332        let mut map = serde_yaml_ng::Mapping::new();
333        let mut child = serde_yaml_ng::Mapping::new();
334        child.insert(Value::String("value".to_string()), Value::Number(42.into()));
335        map.insert(Value::String("total".to_string()), Value::Mapping(child));
336        assert!(is_nested_scalar_section(&map));
337    }
338
339    #[test]
340    fn test_is_nested_scalar_section_empty_child() {
341        let mut map = serde_yaml_ng::Mapping::new();
342        map.insert(
343            Value::String("empty".to_string()),
344            Value::Mapping(serde_yaml_ng::Mapping::new()),
345        );
346        assert!(!is_nested_scalar_section(&map));
347    }
348
349    #[test]
350    fn test_parse_table_column_scalar_not_array() {
351        let mut map = serde_yaml_ng::Mapping::new();
352        map.insert(Value::String("col".to_string()), Value::Number(42.into()));
353        let result = parse_table("test", &map);
354        assert!(result.is_err());
355    }
356}