mollendorff_forge/parser/
model.rs1use 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
13pub fn parse_v1_model(yaml: &Value) -> ForgeResult<ParsedModel> {
19 validate_against_schema(yaml)?;
21
22 let mut model = ParsedModel::new();
23
24 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 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 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 if key_str == "scenarios" {
54 if let Value::Mapping(scenarios_map) = value {
55 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 }
75 }
76
77 if let Value::Mapping(inner_map) = value {
79 if inner_map.contains_key("value") || inner_map.contains_key("formula") {
81 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 parse_nested_scalars(key_str, inner_map, &mut model)?;
87 } else {
88 let table = parse_table(key_str, inner_map)?;
90 model.add_table(table);
91 }
92 }
93 }
94 }
95
96 Ok(model)
101}
102
103pub 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 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
130pub 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 let is_structured =
171 overrides_map.contains_key("probability") || overrides_map.contains_key("scalars");
172
173 if is_structured {
174 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 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 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 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 assert!(!result.tables.contains_key("tornado"));
530 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 assert!(!result.tables.contains_key("decision_tree"));
572 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 assert!(!result.tables.contains_key("monte_carlo"));
597 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}