Skip to main content

mollendorff_forge/parser/
model.rs

1//! Core model parsing for Forge YAML files
2//!
3//! Handles parsing of the main model structure including tables, scalars, and scenarios.
4
5use crate::error::{ForgeError, ForgeResult};
6use crate::types::{ParsedModel, Scenario};
7use serde_yaml_ng::Value;
8
9use super::includes::parse_includes;
10use super::schema::validate_against_schema;
11use super::variables::{is_nested_scalar_section, parse_scalar_variable, parse_table};
12
13/// Parse v1.0.0 array model
14///
15/// # Errors
16///
17/// Returns an error if the YAML fails schema validation or contains invalid table/scalar definitions.
18pub fn parse_v1_model(yaml: &Value) -> ForgeResult<ParsedModel> {
19    // Validate against JSON Schema - this is mandatory
20    validate_against_schema(yaml)?;
21
22    let mut model = ParsedModel::new();
23
24    // Parse each top-level key as either a table or scalar
25    if let Value::Mapping(map) = yaml {
26        for (key, value) in map {
27            let key_str = key
28                .as_str()
29                .ok_or_else(|| ForgeError::Parse("Table name must be a string".to_string()))?;
30
31            // Skip special keys (handled by specific commands)
32            // Note: scenarios is NOT skipped here - it has special handling below
33            // to distinguish scenario overrides from tables named "scenarios"
34            if key_str == "_forge_version"
35                || key_str == "_name"
36                || key_str == "monte_carlo"
37                || key_str == "tornado"
38                || key_str == "decision_tree"
39            {
40                continue;
41            }
42
43            // Parse _includes section (v4.0 cross-file references)
44            if key_str == "_includes" {
45                if let Value::Sequence(includes_seq) = value {
46                    parse_includes(includes_seq, &mut model)?;
47                }
48                continue;
49            }
50
51            // Parse scenarios section - but only if it looks like scenario overrides
52            // (mapping of scenario_name -> {variable: value}), not a table (mapping of column_name -> array)
53            if key_str == "scenarios" {
54                if let Value::Mapping(scenarios_map) = value {
55                    // Check if this is actually a scenarios section or a table named "scenarios"
56                    // Scenarios section has nested mappings with numeric values
57                    // Tables have arrays (sequences) as column values
58                    let is_scenarios_section = scenarios_map
59                        .iter()
60                        .all(|(_, v)| matches!(v, Value::Mapping(_)))
61                        && scenarios_map.iter().any(|(_, v)| {
62                            if let Value::Mapping(m) = v {
63                                m.iter().any(|(_, vv)| matches!(vv, Value::Number(_)))
64                            } else {
65                                false
66                            }
67                        });
68
69                    if is_scenarios_section {
70                        parse_scenarios(scenarios_map, &mut model)?;
71                        continue;
72                    }
73                    // Otherwise fall through to parse as table
74                }
75            }
76
77            // Check if this is a table (mapping with arrays) or scalar (mapping with value/formula)
78            if let Value::Mapping(inner_map) = value {
79                // Check if it has {value, formula} pattern (scalar)
80                if inner_map.contains_key("value") || inner_map.contains_key("formula") {
81                    // This is a scalar variable
82                    let variable = parse_scalar_variable(value, key_str)?;
83                    model.add_scalar(key_str.to_string(), variable);
84                } else if is_nested_scalar_section(inner_map) {
85                    // This is a section containing nested scalars (e.g., summary.total)
86                    parse_nested_scalars(key_str, inner_map, &mut model)?;
87                } else {
88                    // This is a table - parse it
89                    let table = parse_table(key_str, inner_map)?;
90                    model.add_table(table);
91                }
92            }
93        }
94    }
95
96    // Note: Table column length validation is deferred to calculation time
97    // This allows test files to have columns of different lengths when used independently
98    // Row-wise operations will still validate at runtime in array_calculator
99
100    Ok(model)
101}
102
103/// Parse nested scalar variables (e.g., summary.total, summary.average)
104///
105/// # Errors
106///
107/// Returns an error if a nested scalar name is not a string or has an invalid format.
108pub fn parse_nested_scalars(
109    parent_key: &str,
110    map: &serde_yaml_ng::Mapping,
111    model: &mut ParsedModel,
112) -> ForgeResult<()> {
113    for (key, value) in map {
114        let key_str = key
115            .as_str()
116            .ok_or_else(|| ForgeError::Parse("Scalar name must be a string".to_string()))?;
117
118        if let Value::Mapping(child_map) = value {
119            if child_map.contains_key("value") || child_map.contains_key("formula") {
120                // This is a scalar variable
121                let full_path = format!("{parent_key}.{key_str}");
122                let variable = parse_scalar_variable(value, &full_path)?;
123                model.add_scalar(full_path.clone(), variable);
124            }
125        }
126    }
127    Ok(())
128}
129
130/// Parse scenarios section from YAML
131///
132/// Supports two formats:
133///
134/// **Flat format** (variable overrides only):
135/// ```yaml
136/// scenarios:
137///   base:
138///     growth_rate: 0.05
139///     churn_rate: 0.02
140/// ```
141///
142/// **Structured format** (`ScenarioDefinition` with probability, description, scalars):
143/// ```yaml
144/// scenarios:
145///   base:
146///     probability: 0.50
147///     description: "Base case"
148///     scalars:
149///       growth_rate: 0.05
150///       churn_rate: 0.02
151/// ```
152///
153/// # Errors
154///
155/// Returns an error if a scenario name or variable is not valid, if a variable
156/// value is not a number, or if the `scalars` field is not a mapping.
157pub fn parse_scenarios(
158    scenarios_map: &serde_yaml_ng::Mapping,
159    model: &mut ParsedModel,
160) -> ForgeResult<()> {
161    for (scenario_name, scenario_value) in scenarios_map {
162        let name = scenario_name
163            .as_str()
164            .ok_or_else(|| ForgeError::Parse("Scenario name must be a string".to_string()))?;
165
166        if let Value::Mapping(overrides_map) = scenario_value {
167            let mut scenario = Scenario::new();
168
169            // Detect structured format: has reserved keys "probability" or "scalars"
170            let is_structured =
171                overrides_map.contains_key("probability") || overrides_map.contains_key("scalars");
172
173            if is_structured {
174                // Structured ScenarioDefinition format: extract overrides from "scalars" only.
175                // "probability" and "description" are intentionally skipped here;
176                // they are consumed by ScenarioEngine via serde deserialization.
177                if let Some(scalars_value) = overrides_map.get("scalars") {
178                    if let Value::Mapping(scalars_map) = scalars_value {
179                        for (var_name, var_value) in scalars_map {
180                            let var_name_str = var_name.as_str().ok_or_else(|| {
181                                ForgeError::Parse("Variable name must be a string".to_string())
182                            })?;
183
184                            let value = match var_value {
185                                Value::Number(n) => n.as_f64().ok_or_else(|| {
186                                    ForgeError::Parse(format!(
187                                        "Scenario '{name}': Variable '{var_name_str}' must be a number"
188                                    ))
189                                })?,
190                                _ => {
191                                    return Err(ForgeError::Parse(format!(
192                                        "Scenario '{name}': Variable '{var_name_str}' must be a number"
193                                    )));
194                                },
195                            };
196
197                            scenario.add_override(var_name_str.to_string(), value);
198                        }
199                    } else {
200                        return Err(ForgeError::Parse(format!(
201                            "Scenario '{name}': 'scalars' must be a mapping of variable overrides"
202                        )));
203                    }
204                }
205            } else {
206                // Flat format: each key is a variable name with a numeric override value
207                for (var_name, var_value) in overrides_map {
208                    let var_name_str = var_name.as_str().ok_or_else(|| {
209                        ForgeError::Parse("Variable name must be a string".to_string())
210                    })?;
211
212                    let value = match var_value {
213                        Value::Number(n) => n.as_f64().ok_or_else(|| {
214                            ForgeError::Parse(format!(
215                                "Scenario '{name}': Variable '{var_name_str}' must be a number"
216                            ))
217                        })?,
218                        _ => {
219                            return Err(ForgeError::Parse(format!(
220                                "Scenario '{name}': Variable '{var_name_str}' must be a number"
221                            )));
222                        },
223                    };
224
225                    scenario.add_override(var_name_str.to_string(), value);
226                }
227            }
228
229            model.add_scenario(name.to_string(), scenario);
230        } else {
231            return Err(ForgeError::Parse(format!(
232                "Scenario '{name}' must be a mapping of variable overrides"
233            )));
234        }
235    }
236
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::parser::parse_model;
244    use std::io::Write;
245    use tempfile::NamedTempFile;
246
247    #[test]
248    fn test_parse_v1_model_simple() {
249        let yaml_content = r#"
250_forge_version: "5.0.0"
251
252sales:
253  month: ["Jan", "Feb", "Mar"]
254  revenue: [100, 200, 300]
255  profit: "=revenue * 0.2"
256"#;
257
258        let mut temp_file = NamedTempFile::new().unwrap();
259        temp_file.write_all(yaml_content.as_bytes()).unwrap();
260
261        let result = parse_model(temp_file.path()).unwrap();
262
263        assert_eq!(result.tables.len(), 1);
264        assert!(result.tables.contains_key("sales"));
265
266        let sales_table = result.tables.get("sales").unwrap();
267        assert_eq!(sales_table.columns.len(), 2);
268        assert_eq!(sales_table.row_formulas.len(), 1);
269    }
270
271    #[test]
272    fn test_parse_v1_model_with_scalars() {
273        let yaml_content = r#"
274_forge_version: "5.0.0"
275
276data:
277  values: [1, 2, 3]
278
279summary:
280  total:
281    value: null
282    formula: "=SUM(data.values)"
283"#;
284
285        let mut temp_file = NamedTempFile::new().unwrap();
286        temp_file.write_all(yaml_content.as_bytes()).unwrap();
287
288        let result = parse_model(temp_file.path()).unwrap();
289
290        assert_eq!(result.tables.len(), 1);
291        assert_eq!(result.scalars.len(), 1);
292        assert!(result.scalars.contains_key("summary.total"));
293    }
294
295    #[test]
296    fn test_parse_scenarios() {
297        let yaml_content = r#"
298_forge_version: "1.0.0"
299
300growth_rate:
301  value: 0.05
302  formula: null
303
304scenarios:
305  base:
306    growth_rate: 0.05
307  optimistic:
308    growth_rate: 0.12
309  pessimistic:
310    growth_rate: 0.02
311"#;
312
313        let mut temp_file = NamedTempFile::new().unwrap();
314        temp_file.write_all(yaml_content.as_bytes()).unwrap();
315
316        let result = parse_model(temp_file.path()).unwrap();
317
318        assert_eq!(result.scenarios.len(), 3);
319        assert!(result.scenarios.contains_key("base"));
320        assert!(result.scenarios.contains_key("optimistic"));
321        assert!(result.scenarios.contains_key("pessimistic"));
322
323        let base = result.scenarios.get("base").unwrap();
324        assert_eq!(base.overrides.get("growth_rate"), Some(&0.05));
325
326        let optimistic = result.scenarios.get("optimistic").unwrap();
327        assert_eq!(optimistic.overrides.get("growth_rate"), Some(&0.12));
328
329        let pessimistic = result.scenarios.get("pessimistic").unwrap();
330        assert_eq!(pessimistic.overrides.get("growth_rate"), Some(&0.02));
331    }
332
333    #[test]
334    fn test_table_named_scenarios() {
335        let yaml_content = r#"
336_forge_version: "5.0.0"
337
338scenarios:
339  name: ["Base", "Optimistic", "Pessimistic"]
340  probability: [0.3, 0.5, 0.2]
341  revenue: [100000, 150000, 80000]
342"#;
343
344        let mut temp_file = NamedTempFile::new().unwrap();
345        temp_file.write_all(yaml_content.as_bytes()).unwrap();
346
347        let result = parse_model(temp_file.path()).unwrap();
348
349        assert_eq!(result.scenarios.len(), 0);
350        assert_eq!(result.tables.len(), 1);
351
352        let scenarios_table = result.tables.get("scenarios").unwrap();
353        assert_eq!(scenarios_table.columns.len(), 3);
354        assert!(scenarios_table.columns.contains_key("name"));
355        assert!(scenarios_table.columns.contains_key("probability"));
356        assert!(scenarios_table.columns.contains_key("revenue"));
357        assert_eq!(scenarios_table.row_count(), 3);
358    }
359
360    #[test]
361    fn test_parse_table_named_scenarios_as_table() {
362        let yaml_str = r#"
363_forge_version: "5.0.0"
364scenarios:
365  year: [2023, 2024, 2025]
366  revenue: [1000, 2000, 3000]
367"#;
368        let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
369        let result = parse_v1_model(&yaml).unwrap();
370        assert!(result.tables.contains_key("scenarios"));
371        assert!(result.scenarios.is_empty());
372    }
373
374    #[test]
375    fn test_parse_scenario_invalid_value_type() {
376        let yaml_content = r#"
377_forge_version: "1.0.0"
378rate:
379  value: 0.05
380  formula: null
381scenarios:
382  base:
383    rate: "not a number"
384"#;
385
386        let mut temp_file = NamedTempFile::new().unwrap();
387        temp_file.write_all(yaml_content.as_bytes()).unwrap();
388
389        let result = parse_model(temp_file.path());
390        assert!(result.is_err());
391    }
392
393    #[test]
394    fn test_parse_scenario_not_mapping() {
395        let yaml_content = r#"
396_forge_version: "1.0.0"
397rate:
398  value: 0.05
399  formula: null
400scenarios:
401  base: "not a mapping"
402"#;
403
404        let mut temp_file = NamedTempFile::new().unwrap();
405        temp_file.write_all(yaml_content.as_bytes()).unwrap();
406
407        let result = parse_model(temp_file.path());
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn test_parse_scenarios_structured_format() {
413        let yaml_content = r#"
414_forge_version: "5.0.0"
415
416price:
417  value: 100
418  formula: null
419units:
420  value: 50
421  formula: null
422
423scenarios:
424  high:
425    probability: 0.5
426    description: "High price"
427    scalars:
428      price: 200
429      units: 60
430  low:
431    probability: 0.5
432    description: "Low price"
433    scalars:
434      price: 50
435"#;
436
437        let mut temp_file = NamedTempFile::new().unwrap();
438        temp_file.write_all(yaml_content.as_bytes()).unwrap();
439
440        let result = parse_model(temp_file.path()).unwrap();
441
442        assert_eq!(result.scenarios.len(), 2);
443
444        let high = result.scenarios.get("high").unwrap();
445        assert_eq!(high.overrides.get("price"), Some(&200.0));
446        assert_eq!(high.overrides.get("units"), Some(&60.0));
447        // probability/description should NOT appear as overrides
448        assert!(!high.overrides.contains_key("probability"));
449        assert!(!high.overrides.contains_key("description"));
450
451        let low = result.scenarios.get("low").unwrap();
452        assert_eq!(low.overrides.get("price"), Some(&50.0));
453    }
454
455    #[test]
456    fn test_parse_scenarios_structured_no_scalars() {
457        // Structured format with empty/missing scalars section is valid (no overrides)
458        let yaml_content = r#"
459_forge_version: "5.0.0"
460
461rate:
462  value: 0.05
463  formula: null
464
465scenarios:
466  base:
467    probability: 1.0
468    description: "Base only"
469"#;
470
471        let mut temp_file = NamedTempFile::new().unwrap();
472        temp_file.write_all(yaml_content.as_bytes()).unwrap();
473
474        let result = parse_model(temp_file.path()).unwrap();
475
476        assert_eq!(result.scenarios.len(), 1);
477        let base = result.scenarios.get("base").unwrap();
478        assert!(base.overrides.is_empty());
479    }
480
481    #[test]
482    fn test_parse_scenarios_structured_invalid_scalars_type() {
483        let yaml_content = r#"
484_forge_version: "5.0.0"
485
486scenarios:
487  bad:
488    probability: 0.5
489    scalars: "not a mapping"
490"#;
491
492        let mut temp_file = NamedTempFile::new().unwrap();
493        temp_file.write_all(yaml_content.as_bytes()).unwrap();
494
495        let result = parse_model(temp_file.path());
496        assert!(result.is_err());
497        let err = result.unwrap_err().to_string();
498        assert!(err.contains("scalars"));
499    }
500
501    #[test]
502    fn test_parser_skips_tornado_section() {
503        let yaml_str = r#"
504_forge_version: "5.0.0"
505
506price:
507  value: 100
508
509quantity:
510  value: 50
511
512profit:
513  formula: "=price * quantity"
514
515tornado:
516  output: profit
517  inputs:
518    - name: price
519      low: 80
520      high: 120
521    - name: quantity
522      low: 40
523      high: 60
524"#;
525        let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
526        let result = parse_v1_model(&yaml).unwrap();
527
528        // Tornado should NOT be parsed as a table
529        assert!(!result.tables.contains_key("tornado"));
530        // Scalars should be parsed correctly
531        assert!(result.scalars.contains_key("price"));
532        assert!(result.scalars.contains_key("quantity"));
533        assert!(result.scalars.contains_key("profit"));
534    }
535
536    #[test]
537    fn test_parser_skips_decision_tree_section() {
538        let yaml_str = r#"
539_forge_version: "5.0.0"
540
541investment:
542  value: 50000
543
544decision_tree:
545  name: "Investment Decision"
546  root:
547    type: decision
548    name: "Invest?"
549    branches:
550      invest:
551        cost: 50000
552        next: market_outcome
553      dont_invest:
554        value: 0
555  nodes:
556    market_outcome:
557      type: chance
558      name: "Market Outcome"
559      branches:
560        success:
561          probability: 0.6
562          value: 150000
563        failure:
564          probability: 0.4
565          value: 20000
566"#;
567        let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
568        let result = parse_v1_model(&yaml).unwrap();
569
570        // Decision tree should NOT be parsed as a table
571        assert!(!result.tables.contains_key("decision_tree"));
572        // Scalars should be parsed correctly
573        assert!(result.scalars.contains_key("investment"));
574    }
575
576    #[test]
577    fn test_parser_skips_monte_carlo_section() {
578        let yaml_str = r#"
579_forge_version: "5.0.0"
580
581revenue:
582  value: 100000
583
584monte_carlo:
585  iterations: 10000
586  variables:
587    revenue:
588      distribution: normal
589      mean: 100000
590      std: 10000
591"#;
592        let yaml: Value = serde_yaml_ng::from_str(yaml_str).unwrap();
593        let result = parse_v1_model(&yaml).unwrap();
594
595        // Monte Carlo should NOT be parsed as a table
596        assert!(!result.tables.contains_key("monte_carlo"));
597        // Scalars should be parsed correctly
598        assert!(result.scalars.contains_key("revenue"));
599    }
600
601    #[test]
602    fn test_parse_v4_backward_compatible_with_v1() {
603        let yaml_content = r#"
604_forge_version: "5.0.0"
605
606sales:
607  month: ["Jan", "Feb", "Mar"]
608  revenue: [100, 200, 300]
609  profit: "=revenue * 0.3"
610
611summary:
612  total:
613    value: null
614    formula: "=SUM(sales.revenue)"
615"#;
616
617        let mut temp_file = NamedTempFile::new().unwrap();
618        temp_file.write_all(yaml_content.as_bytes()).unwrap();
619
620        let result = parse_model(temp_file.path()).unwrap();
621
622        assert_eq!(result.tables.len(), 1);
623        let sales = result.tables.get("sales").unwrap();
624        assert_eq!(sales.columns.len(), 2);
625        assert_eq!(sales.row_formulas.len(), 1);
626
627        assert_eq!(result.scalars.len(), 1);
628        let total = result.scalars.get("summary.total").unwrap();
629        assert_eq!(total.formula, Some("=SUM(sales.revenue)".to_string()));
630
631        assert!(sales.columns.get("revenue").unwrap().metadata.is_empty());
632        assert!(total.metadata.is_empty());
633    }
634
635    #[test]
636    fn test_parse_v4_mixed_formats() {
637        let yaml_content = r#"
638_forge_version: "5.0.0"
639sales:
640  month: ["Jan", "Feb", "Mar"]
641  revenue:
642    value: [100, 200, 300]
643    unit: "CAD"
644    notes: "Rich format column"
645  expenses: [50, 100, 150]
646  profit: "=revenue - expenses"
647"#;
648
649        let mut temp_file = NamedTempFile::new().unwrap();
650        temp_file.write_all(yaml_content.as_bytes()).unwrap();
651
652        let result = parse_model(temp_file.path()).unwrap();
653
654        let sales = result.tables.get("sales").unwrap();
655
656        assert!(sales.columns.get("month").unwrap().metadata.is_empty());
657        assert!(sales.columns.get("expenses").unwrap().metadata.is_empty());
658
659        let revenue = sales.columns.get("revenue").unwrap();
660        assert_eq!(revenue.metadata.unit, Some("CAD".to_string()));
661        assert_eq!(
662            revenue.metadata.notes,
663            Some("Rich format column".to_string())
664        );
665    }
666
667    #[test]
668    fn test_parse_v5_inputs_outputs_sections() {
669        let yaml_content = r#"
670_forge_version: "5.0.0"
671
672inputs:
673  tax_rate:
674    value: 0.25
675    formula: null
676    unit: "%"
677    notes: "Corporate tax rate"
678  discount_rate:
679    value: 0.10
680    formula: null
681    unit: "%"
682
683outputs:
684  net_profit:
685    value: 75000
686    formula: "=revenue * (1 - tax_rate)"
687    unit: "CAD"
688  npv:
689    value: null
690    formula: "=NPV(discount_rate, cashflows)"
691
692data:
693  quarter: ["Q1", "Q2", "Q3", "Q4"]
694  revenue: [100000, 120000, 150000, 180000]
695"#;
696
697        let mut temp_file = NamedTempFile::new().unwrap();
698        temp_file.write_all(yaml_content.as_bytes()).unwrap();
699
700        let result = parse_model(temp_file.path()).unwrap();
701
702        assert_eq!(result.tables.len(), 1);
703        assert!(result.tables.contains_key("data"));
704
705        assert_eq!(result.scalars.len(), 4);
706
707        let tax_rate = result.scalars.get("inputs.tax_rate").unwrap();
708        assert_eq!(tax_rate.value, Some(0.25));
709        assert!(tax_rate.formula.is_none());
710        assert_eq!(tax_rate.metadata.unit, Some("%".to_string()));
711        assert_eq!(
712            tax_rate.metadata.notes,
713            Some("Corporate tax rate".to_string())
714        );
715
716        let discount_rate = result.scalars.get("inputs.discount_rate").unwrap();
717        assert_eq!(discount_rate.value, Some(0.10));
718
719        let net_profit = result.scalars.get("outputs.net_profit").unwrap();
720        assert_eq!(net_profit.value, Some(75000.0));
721        assert_eq!(
722            net_profit.formula,
723            Some("=revenue * (1 - tax_rate)".to_string())
724        );
725        assert_eq!(net_profit.metadata.unit, Some("CAD".to_string()));
726
727        let npv = result.scalars.get("outputs.npv").unwrap();
728        assert!(npv.value.is_none());
729        assert_eq!(
730            npv.formula,
731            Some("=NPV(discount_rate, cashflows)".to_string())
732        );
733    }
734
735    #[test]
736    fn test_null_in_numeric_array_error() {
737        let yaml_content = r#"
738_forge_version: "5.0.0"
739data:
740  values: [1000, null, 2000]
741"#;
742
743        let mut temp_file = NamedTempFile::new().unwrap();
744        temp_file.write_all(yaml_content.as_bytes()).unwrap();
745
746        let result = parse_model(temp_file.path());
747        assert!(result.is_err());
748
749        let err = result.unwrap_err().to_string();
750        assert!(err.contains("null values not allowed"));
751        assert!(err.contains("Use 0 or remove the row"));
752    }
753
754    #[test]
755    fn test_parse_v4_table_column_with_metadata() {
756        let yaml_content = r#"
757_forge_version: "5.0.0"
758
759sales:
760  month:
761    value: ["Jan", "Feb", "Mar"]
762    unit: "month"
763  revenue:
764    value: [100, 200, 300]
765    unit: "CAD"
766    notes: "Monthly revenue projection"
767    validation_status: "PROJECTED"
768  profit:
769    formula: "=revenue * 0.3"
770    unit: "CAD"
771"#;
772
773        let mut temp_file = NamedTempFile::new().unwrap();
774        temp_file.write_all(yaml_content.as_bytes()).unwrap();
775
776        let result = parse_model(temp_file.path()).unwrap();
777
778        assert_eq!(result.tables.len(), 1);
779        let sales = result.tables.get("sales").unwrap();
780
781        let month = sales.columns.get("month").unwrap();
782        assert_eq!(month.metadata.unit, Some("month".to_string()));
783
784        let revenue = sales.columns.get("revenue").unwrap();
785        assert_eq!(revenue.metadata.unit, Some("CAD".to_string()));
786        assert_eq!(
787            revenue.metadata.notes,
788            Some("Monthly revenue projection".to_string())
789        );
790        assert_eq!(
791            revenue.metadata.validation_status,
792            Some("PROJECTED".to_string())
793        );
794
795        assert!(sales.row_formulas.contains_key("profit"));
796        assert_eq!(sales.row_formulas.get("profit").unwrap(), "=revenue * 0.3");
797    }
798}