1use crate::error::{ForgeError, ForgeResult};
4use crate::types::{ColumnValue, Metadata, ParsedModel, Table};
5use rust_xlsxwriter::{Formula, Note, Workbook, Worksheet};
6use std::collections::HashMap;
7use std::path::Path;
8
9#[derive(Clone, Debug)]
11pub struct ScalarLocation {
12 pub worksheet: String,
14 pub row: u32,
16}
17
18pub struct ExcelExporter {
20 model: ParsedModel,
21 table_column_maps: HashMap<String, HashMap<String, String>>,
23 table_row_counts: HashMap<String, usize>,
25 scalar_locations: HashMap<String, ScalarLocation>,
27}
28
29impl ExcelExporter {
30 pub fn new(model: ParsedModel) -> Self {
32 let mut table_column_maps = HashMap::new();
34 let mut table_row_counts = HashMap::new();
35
36 for (table_name, table) in &model.tables {
37 let mut column_names: Vec<String> = Vec::new();
38
39 for name in table.columns.keys() {
41 column_names.push(name.clone());
42 }
43
44 for name in table.row_formulas.keys() {
46 if !column_names.contains(name) {
47 column_names.push(name.clone());
48 }
49 }
50
51 column_names.sort(); let column_map: HashMap<String, String> = column_names
55 .iter()
56 .enumerate()
57 .map(|(idx, name)| {
58 let col_letter = super::FormulaTranslator::column_index_to_letter(idx);
59 (name.clone(), col_letter)
60 })
61 .collect();
62
63 let row_count = table
65 .columns
66 .values()
67 .next()
68 .map_or(0, super::super::types::Column::len);
69
70 table_column_maps.insert(table_name.clone(), column_map);
71 table_row_counts.insert(table_name.clone(), row_count);
72 }
73
74 let scalar_locations = Self::build_scalar_locations(&model);
76
77 Self {
78 model,
79 table_column_maps,
80 table_row_counts,
81 scalar_locations,
82 }
83 }
84
85 fn build_scalar_locations(model: &ParsedModel) -> HashMap<String, ScalarLocation> {
88 let mut locations = HashMap::new();
89
90 let mut groups: HashMap<String, Vec<String>> = HashMap::new();
92 for path in model.scalars.keys() {
93 let (group, _name) = Self::split_scalar_path(path);
94 groups.entry(group).or_default().push(path.clone());
95 }
96
97 for (group_name, mut scalar_paths) in groups {
99 scalar_paths.sort();
100 for (idx, path) in scalar_paths.iter().enumerate() {
101 #[allow(clippy::cast_possible_truncation)] let row = (idx + 2) as u32; locations.insert(
104 path.clone(),
105 ScalarLocation {
106 worksheet: group_name.clone(),
107 row,
108 },
109 );
110 }
111 }
112
113 locations
114 }
115
116 fn split_scalar_path(path: &str) -> (String, String) {
120 path.find('.').map_or_else(
121 || ("Scalars".to_string(), path.to_string()),
122 |dot_pos| {
123 let group = path[..dot_pos].to_string();
124 let name = path[dot_pos + 1..].to_string();
125 (group, name)
126 },
127 )
128 }
129
130 pub fn export(&self, output_path: &Path) -> ForgeResult<()> {
136 let mut workbook = Workbook::new();
137
138 for (table_name, table) in &self.model.tables {
140 self.export_table(&mut workbook, table_name, table)?;
141 }
142
143 if !self.model.scalars.is_empty() {
145 self.export_scalars(&mut workbook)?;
146 }
147
148 for (namespace, resolved) in &self.model.resolved_includes {
150 for (table_name, table) in &resolved.model.tables {
152 let prefixed_name = format!("{namespace}.{table_name}");
153 self.export_table(&mut workbook, &prefixed_name, table)?;
154 }
155
156 if !resolved.model.scalars.is_empty() {
158 Self::export_namespaced_scalars(&mut workbook, namespace, &resolved.model)?;
159 }
160 }
161
162 workbook
164 .save(output_path)
165 .map_err(|e| ForgeError::IO(format!("Failed to save Excel file: {e}")))?;
166
167 Ok(())
168 }
169
170 pub fn export_to_buffer(&self) -> ForgeResult<Vec<u8>> {
176 let mut workbook = Workbook::new();
177
178 for (table_name, table) in &self.model.tables {
179 self.export_table(&mut workbook, table_name, table)?;
180 }
181
182 if !self.model.scalars.is_empty() {
183 self.export_scalars(&mut workbook)?;
184 }
185
186 for (namespace, resolved) in &self.model.resolved_includes {
187 for (table_name, table) in &resolved.model.tables {
188 let prefixed_name = format!("{namespace}.{table_name}");
189 self.export_table(&mut workbook, &prefixed_name, table)?;
190 }
191 if !resolved.model.scalars.is_empty() {
192 Self::export_namespaced_scalars(&mut workbook, namespace, &resolved.model)?;
193 }
194 }
195
196 workbook
197 .save_to_buffer()
198 .map_err(|e| ForgeError::IO(format!("Failed to create Excel buffer: {e}")))
199 }
200
201 #[allow(clippy::cast_possible_truncation)] fn export_table(
204 &self,
205 workbook: &mut Workbook,
206 table_name: &str,
207 table: &Table,
208 ) -> ForgeResult<()> {
209 let worksheet = workbook.add_worksheet();
210 worksheet
211 .set_name(table_name)
212 .map_err(|e| ForgeError::Export(format!("Failed to set worksheet name: {e}")))?;
213
214 let mut column_names: Vec<String> = Vec::new();
216
217 for name in table.columns.keys() {
219 column_names.push(name.clone());
220 }
221
222 for name in table.row_formulas.keys() {
224 if !column_names.contains(name) {
225 column_names.push(name.clone());
226 }
227 }
228
229 column_names.sort(); let column_map = self
233 .table_column_maps
234 .get(table_name)
235 .cloned()
236 .unwrap_or_default();
237
238 let translator = super::FormulaTranslator::new_with_tables(
240 column_map,
241 self.table_column_maps.clone(),
242 self.table_row_counts.clone(),
243 );
244
245 for (col_idx, col_name) in column_names.iter().enumerate() {
247 worksheet
248 .write_string(0, col_idx as u16, col_name)
249 .map_err(|e| ForgeError::Export(format!("Failed to write header: {e}")))?;
250
251 if let Some(column) = table.columns.get(col_name) {
253 if let Some(note_text) = Self::format_metadata_note(&column.metadata) {
254 let note = Note::new(note_text).set_author("Forge");
255 worksheet
256 .insert_note(0, col_idx as u16, ¬e)
257 .map_err(|e| ForgeError::Export(format!("Failed to add note: {e}")))?;
258 }
259 }
260 }
261
262 let row_count = table
264 .columns
265 .values()
266 .next()
267 .map_or(0, super::super::types::Column::len);
268
269 for row_idx in 0..row_count {
271 let excel_row = (row_idx + 1) as u32 + 1; for (col_idx, col_name) in column_names.iter().enumerate() {
274 if let Some(formula) = table.row_formulas.get(col_name) {
276 let excel_formula = translator.translate_row_formula(formula, excel_row)?;
278 worksheet
279 .write_formula(excel_row - 1, col_idx as u16, Formula::new(&excel_formula))
280 .map_err(|e| ForgeError::Export(format!("Failed to write formula: {e}")))?;
281 } else if let Some(column) = table.columns.get(col_name) {
282 Self::write_cell_value(
284 worksheet,
285 excel_row - 1, col_idx as u16,
287 &column.values,
288 row_idx,
289 )?;
290 }
291 }
292 }
293
294 Ok(())
295 }
296
297 fn write_cell_value(
299 worksheet: &mut Worksheet,
300 row: u32,
301 col: u16,
302 values: &ColumnValue,
303 index: usize,
304 ) -> ForgeResult<()> {
305 match values {
306 ColumnValue::Number(nums) => {
307 if let Some(&value) = nums.get(index) {
308 worksheet
309 .write_number(row, col, value)
310 .map_err(|e| ForgeError::Export(format!("Failed to write number: {e}")))?;
311 }
312 },
313 ColumnValue::Text(texts) => {
314 if let Some(value) = texts.get(index) {
315 worksheet
316 .write_string(row, col, value)
317 .map_err(|e| ForgeError::Export(format!("Failed to write text: {e}")))?;
318 }
319 },
320 ColumnValue::Date(dates) => {
321 if let Some(value) = dates.get(index) {
322 worksheet
323 .write_string(row, col, value)
324 .map_err(|e| ForgeError::Export(format!("Failed to write date: {e}")))?;
325 }
326 },
327 ColumnValue::Boolean(bools) => {
328 if let Some(&value) = bools.get(index) {
329 worksheet
330 .write_boolean(row, col, value)
331 .map_err(|e| ForgeError::Export(format!("Failed to write boolean: {e}")))?;
332 }
333 },
334 }
335 Ok(())
336 }
337
338 fn export_scalars(&self, workbook: &mut Workbook) -> ForgeResult<()> {
342 let mut groups: HashMap<String, Vec<(&String, &crate::types::Variable)>> = HashMap::new();
344 for (path, var) in &self.model.scalars {
345 let (group, _name) = Self::split_scalar_path(path);
346 groups.entry(group).or_default().push((path, var));
347 }
348
349 let mut group_names: Vec<&String> = groups.keys().collect();
351 group_names.sort();
352
353 for group_name in group_names {
355 if let Some(scalars) = groups.get(group_name) {
356 self.export_scalar_group(workbook, group_name, scalars)?;
357 }
358 }
359
360 Ok(())
361 }
362
363 fn export_scalar_group(
365 &self,
366 workbook: &mut Workbook,
367 group_name: &str,
368 scalars: &[(&String, &crate::types::Variable)],
369 ) -> ForgeResult<()> {
370 let worksheet = workbook.add_worksheet();
371 worksheet.set_name(group_name).map_err(|e| {
372 ForgeError::Export(format!("Failed to set worksheet name '{group_name}': {e}"))
373 })?;
374
375 worksheet
377 .write_string(0, 0, "Name")
378 .map_err(|e| ForgeError::Export(format!("Failed to write header: {e}")))?;
379 worksheet
380 .write_string(0, 1, "Value")
381 .map_err(|e| ForgeError::Export(format!("Failed to write header: {e}")))?;
382
383 let mut sorted_scalars: Vec<_> = scalars.to_vec();
385 sorted_scalars.sort_by(|a, b| a.0.cmp(b.0));
386
387 for (idx, (full_path, var)) in sorted_scalars.iter().enumerate() {
388 #[allow(clippy::cast_possible_truncation)] let row = (idx + 1) as u32; let short_name = Self::split_scalar_path(full_path).1;
393
394 worksheet
396 .write_string(row, 0, &short_name)
397 .map_err(|e| ForgeError::Export(format!("Failed to write scalar name: {e}")))?;
398
399 if let Some(formula) = &var.formula {
401 if formula.contains("MC.") {
403 if let Some(value) = var.value {
405 worksheet.write_number(row, 1, value).map_err(|e| {
406 ForgeError::Export(format!("Failed to write scalar value: {e}"))
407 })?;
408 }
409 let note = Note::new(format!("Forge formula: {formula}")).set_author("Forge");
411 worksheet.insert_note(row, 1, ¬e).map_err(|e| {
412 ForgeError::Export(format!("Failed to add formula note: {e}"))
413 })?;
414 } else {
415 match self.translate_grouped_scalar_formula(formula) {
417 Ok(excel_formula) => {
418 worksheet
419 .write_formula(row, 1, Formula::new(&excel_formula))
420 .map_err(|e| {
421 ForgeError::Export(format!(
422 "Failed to write scalar formula: {e}"
423 ))
424 })?;
425 },
426 Err(_) => {
427 if let Some(value) = var.value {
429 worksheet.write_number(row, 1, value).map_err(|e| {
430 ForgeError::Export(format!("Failed to write scalar value: {e}"))
431 })?;
432 }
433 },
434 }
435 }
436 } else if let Some(value) = var.value {
437 worksheet.write_number(row, 1, value).map_err(|e| {
439 ForgeError::Export(format!("Failed to write scalar value: {e}"))
440 })?;
441 }
442
443 if let Some(note_text) = Self::format_metadata_note(&var.metadata) {
445 if var.formula.as_ref().is_none_or(|f| !f.contains("MC.")) {
447 let note = Note::new(note_text).set_author("Forge");
448 worksheet.insert_note(row, 1, ¬e).map_err(|e| {
449 ForgeError::Export(format!("Failed to add scalar note: {e}"))
450 })?;
451 }
452 }
453 }
454
455 Ok(())
456 }
457
458 fn translate_grouped_scalar_formula(&self, formula: &str) -> ForgeResult<String> {
462 use regex::Regex;
463
464 let formula_body = formula.strip_prefix('=').unwrap_or(formula);
466 let mut result = formula_body.to_string();
467
468 let scalar_pattern = Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b")
470 .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
471
472 let replacements: Vec<(std::ops::Range<usize>, String)> = scalar_pattern
474 .captures_iter(&result.clone())
475 .filter_map(|cap| {
476 let full_match = cap.get(0).unwrap();
477 let scalar_path = full_match.as_str();
478
479 if result[..full_match.start()].ends_with('\'')
481 || result[full_match.end()..].starts_with('!')
482 {
483 return None;
484 }
485
486 self.scalar_locations.get(scalar_path).map(|loc| {
488 let replacement = format!("'{}'!B{}", loc.worksheet, loc.row);
489 (full_match.range(), replacement)
490 })
491 })
492 .collect();
493
494 for (range, replacement) in replacements.into_iter().rev() {
496 result.replace_range(range, &replacement);
497 }
498
499 Ok(format!("={result}"))
500 }
501
502 fn export_namespaced_scalars(
504 workbook: &mut Workbook,
505 namespace: &str,
506 included_model: &ParsedModel,
507 ) -> ForgeResult<()> {
508 let sheet_name = format!("{namespace}.Scalars");
509 let worksheet = workbook.add_worksheet();
510 worksheet.set_name(&sheet_name).map_err(|e| {
511 ForgeError::Export(format!("Failed to set {sheet_name} worksheet name: {e}"))
512 })?;
513
514 worksheet
516 .write_string(0, 0, "Name")
517 .map_err(|e| ForgeError::Export(format!("Failed to write header: {e}")))?;
518 worksheet
519 .write_string(0, 1, "Value")
520 .map_err(|e| ForgeError::Export(format!("Failed to write header: {e}")))?;
521
522 let mut scalar_names: Vec<&String> = included_model.scalars.keys().collect();
524 scalar_names.sort();
525
526 for (idx, name) in scalar_names.iter().enumerate() {
527 #[allow(clippy::cast_possible_truncation)] let row = (idx + 1) as u32;
529
530 if let Some(var) = included_model.scalars.get(*name) {
531 let prefixed_name = format!("{namespace}.{name}");
533 worksheet
534 .write_string(row, 0, &prefixed_name)
535 .map_err(|e| ForgeError::Export(format!("Failed to write scalar name: {e}")))?;
536
537 if let Some(value) = var.value {
539 worksheet.write_number(row, 1, value).map_err(|e| {
540 ForgeError::Export(format!("Failed to write scalar value: {e}"))
541 })?;
542 }
543
544 if let Some(note_text) = Self::format_metadata_note(&var.metadata) {
546 let note = Note::new(note_text).set_author("Forge");
547 worksheet.insert_note(row, 1, ¬e).map_err(|e| {
548 ForgeError::Export(format!("Failed to add scalar note: {e}"))
549 })?;
550 }
551 }
552 }
553
554 Ok(())
555 }
556
557 fn format_metadata_note(metadata: &Metadata) -> Option<String> {
560 if metadata.is_empty() {
561 return None;
562 }
563
564 let mut parts = Vec::new();
565
566 if let Some(unit) = &metadata.unit {
567 parts.push(format!("Unit: {unit}"));
568 }
569 if let Some(notes) = &metadata.notes {
570 parts.push(format!("Notes: {notes}"));
571 }
572 if let Some(source) = &metadata.source {
573 parts.push(format!("Source: {source}"));
574 }
575 if let Some(status) = &metadata.validation_status {
576 parts.push(format!("Status: {status}"));
577 }
578 if let Some(updated) = &metadata.last_updated {
579 parts.push(format!("Updated: {updated}"));
580 }
581
582 if parts.is_empty() {
583 None
584 } else {
585 Some(parts.join("\n"))
586 }
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use crate::types::{Column, Variable};
594
595 #[test]
600 fn test_format_metadata_note_empty() {
601 let metadata = Metadata::default();
602 assert!(ExcelExporter::format_metadata_note(&metadata).is_none());
603 }
604
605 #[test]
606 fn test_format_metadata_note_with_unit() {
607 let metadata = Metadata {
608 unit: Some("CAD".to_string()),
609 ..Default::default()
610 };
611 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
612 assert!(note.contains("Unit: CAD"));
613 }
614
615 #[test]
616 fn test_format_metadata_note_with_notes() {
617 let metadata = Metadata {
618 notes: Some("Revenue projection".to_string()),
619 ..Default::default()
620 };
621 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
622 assert!(note.contains("Notes: Revenue projection"));
623 }
624
625 #[test]
626 fn test_format_metadata_note_with_source() {
627 let metadata = Metadata {
628 source: Some("data.yaml".to_string()),
629 ..Default::default()
630 };
631 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
632 assert!(note.contains("Source: data.yaml"));
633 }
634
635 #[test]
636 fn test_format_metadata_note_with_validation_status() {
637 let metadata = Metadata {
638 validation_status: Some("VALIDATED".to_string()),
639 ..Default::default()
640 };
641 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
642 assert!(note.contains("Status: VALIDATED"));
643 }
644
645 #[test]
646 fn test_format_metadata_note_with_last_updated() {
647 let metadata = Metadata {
648 last_updated: Some("2025-01-01".to_string()),
649 ..Default::default()
650 };
651 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
652 assert!(note.contains("Updated: 2025-01-01"));
653 }
654
655 #[test]
656 fn test_format_metadata_note_multiple_fields() {
657 let metadata = Metadata {
658 unit: Some("CAD".to_string()),
659 notes: Some("Important".to_string()),
660 source: Some("finance.yaml".to_string()),
661 validation_status: Some("PROJECTED".to_string()),
662 last_updated: Some("2025-11-26".to_string()),
663 };
664 let note = ExcelExporter::format_metadata_note(&metadata).unwrap();
665 assert!(note.contains("Unit: CAD"));
666 assert!(note.contains("Notes: Important"));
667 assert!(note.contains("Source: finance.yaml"));
668 assert!(note.contains("Status: PROJECTED"));
669 assert!(note.contains("Updated: 2025-11-26"));
670 assert!(note.contains('\n'));
672 }
673
674 #[test]
679 fn test_exporter_new_empty_model() {
680 let model = ParsedModel::new();
681 let exporter = ExcelExporter::new(model);
682 assert!(exporter.table_column_maps.is_empty());
683 assert!(exporter.table_row_counts.is_empty());
684 }
685
686 #[test]
687 fn test_exporter_new_with_table() {
688 let mut model = ParsedModel::new();
689 let mut table = Table::new("sales".to_string());
690 table.add_column(Column::new(
691 "revenue".to_string(),
692 ColumnValue::Number(vec![100.0, 200.0, 300.0]),
693 ));
694 model.add_table(table);
695
696 let exporter = ExcelExporter::new(model);
697
698 assert!(exporter.table_column_maps.contains_key("sales"));
699 assert_eq!(exporter.table_row_counts.get("sales"), Some(&3));
700 }
701
702 #[test]
703 fn test_exporter_new_with_row_formula() {
704 let mut model = ParsedModel::new();
705 let mut table = Table::new("calc".to_string());
706 table.add_column(Column::new(
707 "amount".to_string(),
708 ColumnValue::Number(vec![100.0]),
709 ));
710 table.add_row_formula("total".to_string(), "=SUM(amount)".to_string());
711 model.add_table(table);
712
713 let exporter = ExcelExporter::new(model);
714
715 let col_map = exporter.table_column_maps.get("calc").unwrap();
717 assert!(col_map.contains_key("amount"));
718 assert!(col_map.contains_key("total"));
719 }
720
721 #[test]
722 fn test_exporter_new_multiple_tables() {
723 let mut model = ParsedModel::new();
724
725 let mut table1 = Table::new("sales".to_string());
726 table1.add_column(Column::new(
727 "revenue".to_string(),
728 ColumnValue::Number(vec![100.0, 200.0]),
729 ));
730 model.add_table(table1);
731
732 let mut table2 = Table::new("costs".to_string());
733 table2.add_column(Column::new(
734 "expense".to_string(),
735 ColumnValue::Number(vec![50.0, 75.0, 100.0]),
736 ));
737 model.add_table(table2);
738
739 let exporter = ExcelExporter::new(model);
740
741 assert!(exporter.table_column_maps.contains_key("sales"));
742 assert!(exporter.table_column_maps.contains_key("costs"));
743 assert_eq!(exporter.table_row_counts.get("sales"), Some(&2));
744 assert_eq!(exporter.table_row_counts.get("costs"), Some(&3));
745 }
746
747 #[test]
748 fn test_exporter_column_maps_sorted_alphabetically() {
749 let mut model = ParsedModel::new();
750 let mut table = Table::new("data".to_string());
751 table.add_column(Column::new(
752 "zebra".to_string(),
753 ColumnValue::Number(vec![1.0]),
754 ));
755 table.add_column(Column::new(
756 "alpha".to_string(),
757 ColumnValue::Number(vec![2.0]),
758 ));
759 table.add_column(Column::new(
760 "beta".to_string(),
761 ColumnValue::Number(vec![3.0]),
762 ));
763 model.add_table(table);
764
765 let exporter = ExcelExporter::new(model);
766 let col_map = exporter.table_column_maps.get("data").unwrap();
767
768 assert_eq!(col_map.get("alpha"), Some(&"A".to_string()));
770 assert_eq!(col_map.get("beta"), Some(&"B".to_string()));
771 assert_eq!(col_map.get("zebra"), Some(&"C".to_string()));
772 }
773
774 #[test]
775 fn test_exporter_empty_table() {
776 let mut model = ParsedModel::new();
777 let table = Table::new("empty".to_string());
778 model.add_table(table);
779
780 let exporter = ExcelExporter::new(model);
781 assert_eq!(exporter.table_row_counts.get("empty"), Some(&0));
782 }
783
784 #[test]
789 fn test_export_empty_model() {
790 use tempfile::TempDir;
791
792 let model = ParsedModel::new();
793 let exporter = ExcelExporter::new(model);
794
795 let dir = TempDir::new().unwrap();
796 let output_path = dir.path().join("empty.xlsx");
797
798 let result = exporter.export(&output_path);
799 assert!(result.is_ok());
800 assert!(output_path.exists());
801 }
802
803 #[test]
804 fn test_export_single_table() {
805 use tempfile::TempDir;
806
807 let mut model = ParsedModel::new();
808 let mut table = Table::new("sales".to_string());
809 table.add_column(Column::new(
810 "revenue".to_string(),
811 ColumnValue::Number(vec![100.0, 200.0, 300.0]),
812 ));
813 model.add_table(table);
814
815 let exporter = ExcelExporter::new(model);
816
817 let dir = TempDir::new().unwrap();
818 let output_path = dir.path().join("sales.xlsx");
819
820 let result = exporter.export(&output_path);
821 assert!(result.is_ok());
822 assert!(output_path.exists());
823
824 let metadata = std::fs::metadata(&output_path).unwrap();
826 assert!(metadata.len() > 0);
827 }
828
829 #[test]
830 fn test_export_with_text_column() {
831 use tempfile::TempDir;
832
833 let mut model = ParsedModel::new();
834 let mut table = Table::new("data".to_string());
835 table.add_column(Column::new(
836 "names".to_string(),
837 ColumnValue::Text(vec![
838 "Alice".to_string(),
839 "Bob".to_string(),
840 "Charlie".to_string(),
841 ]),
842 ));
843 model.add_table(table);
844
845 let exporter = ExcelExporter::new(model);
846
847 let dir = TempDir::new().unwrap();
848 let output_path = dir.path().join("names.xlsx");
849
850 let result = exporter.export(&output_path);
851 assert!(result.is_ok());
852 assert!(output_path.exists());
853 }
854
855 #[test]
856 fn test_export_with_date_column() {
857 use tempfile::TempDir;
858
859 let mut model = ParsedModel::new();
860 let mut table = Table::new("timeline".to_string());
861 table.add_column(Column::new(
862 "date".to_string(),
863 ColumnValue::Date(vec![
864 "2024-01-01".to_string(),
865 "2024-02-01".to_string(),
866 "2024-03-01".to_string(),
867 ]),
868 ));
869 model.add_table(table);
870
871 let exporter = ExcelExporter::new(model);
872
873 let dir = TempDir::new().unwrap();
874 let output_path = dir.path().join("dates.xlsx");
875
876 let result = exporter.export(&output_path);
877 assert!(result.is_ok());
878 assert!(output_path.exists());
879 }
880
881 #[test]
882 fn test_export_with_boolean_column() {
883 use tempfile::TempDir;
884
885 let mut model = ParsedModel::new();
886 let mut table = Table::new("flags".to_string());
887 table.add_column(Column::new(
888 "active".to_string(),
889 ColumnValue::Boolean(vec![true, false, true]),
890 ));
891 model.add_table(table);
892
893 let exporter = ExcelExporter::new(model);
894
895 let dir = TempDir::new().unwrap();
896 let output_path = dir.path().join("flags.xlsx");
897
898 let result = exporter.export(&output_path);
899 assert!(result.is_ok());
900 assert!(output_path.exists());
901 }
902
903 #[test]
904 fn test_export_with_row_formula() {
905 use tempfile::TempDir;
906
907 let mut model = ParsedModel::new();
908 let mut table = Table::new("calc".to_string());
909 table.add_column(Column::new(
910 "price".to_string(),
911 ColumnValue::Number(vec![100.0, 200.0, 300.0]),
912 ));
913 table.add_column(Column::new(
914 "quantity".to_string(),
915 ColumnValue::Number(vec![2.0, 3.0, 4.0]),
916 ));
917 table.add_row_formula("total".to_string(), "=price * quantity".to_string());
918 model.add_table(table);
919
920 let exporter = ExcelExporter::new(model);
921
922 let dir = TempDir::new().unwrap();
923 let output_path = dir.path().join("calculated.xlsx");
924
925 let result = exporter.export(&output_path);
926 assert!(result.is_ok());
927 assert!(output_path.exists());
928 }
929
930 #[test]
931 fn test_export_with_scalars() {
932 use tempfile::TempDir;
933
934 let mut model = ParsedModel::new();
935 model.add_scalar(
936 "tax_rate".to_string(),
937 Variable::new("tax_rate".to_string(), Some(0.15), None),
938 );
939 model.add_scalar(
940 "profit".to_string(),
941 Variable::new(
942 "profit".to_string(),
943 Some(50000.0),
944 Some("=revenue - costs".to_string()),
945 ),
946 );
947
948 let exporter = ExcelExporter::new(model);
949
950 let dir = TempDir::new().unwrap();
951 let output_path = dir.path().join("scalars.xlsx");
952
953 let result = exporter.export(&output_path);
954 assert!(result.is_ok());
955 assert!(output_path.exists());
956 }
957
958 #[test]
959 fn test_export_multiple_tables() {
960 use tempfile::TempDir;
961
962 let mut model = ParsedModel::new();
963
964 let mut revenue_table = Table::new("revenue".to_string());
965 revenue_table.add_column(Column::new(
966 "amount".to_string(),
967 ColumnValue::Number(vec![1000.0, 2000.0, 3000.0]),
968 ));
969 model.add_table(revenue_table);
970
971 let mut costs_table = Table::new("costs".to_string());
972 costs_table.add_column(Column::new(
973 "amount".to_string(),
974 ColumnValue::Number(vec![500.0, 750.0, 1000.0]),
975 ));
976 model.add_table(costs_table);
977
978 let exporter = ExcelExporter::new(model);
979
980 let dir = TempDir::new().unwrap();
981 let output_path = dir.path().join("multi.xlsx");
982
983 let result = exporter.export(&output_path);
984 assert!(result.is_ok());
985 assert!(output_path.exists());
986 }
987
988 #[test]
989 fn test_export_with_metadata() {
990 use tempfile::TempDir;
991
992 let mut model = ParsedModel::new();
993 let mut table = Table::new("data".to_string());
994
995 let metadata = Metadata {
996 unit: Some("CAD".to_string()),
997 notes: Some("Revenue data".to_string()),
998 source: Some("finance.yaml".to_string()),
999 validation_status: Some("VALIDATED".to_string()),
1000 last_updated: Some("2024-01-01".to_string()),
1001 };
1002
1003 table.add_column(Column::with_metadata(
1004 "revenue".to_string(),
1005 ColumnValue::Number(vec![100.0, 200.0]),
1006 metadata,
1007 ));
1008 model.add_table(table);
1009
1010 let exporter = ExcelExporter::new(model);
1011
1012 let dir = TempDir::new().unwrap();
1013 let output_path = dir.path().join("metadata.xlsx");
1014
1015 let result = exporter.export(&output_path);
1016 assert!(result.is_ok());
1017 assert!(output_path.exists());
1018 }
1019
1020 #[test]
1021 fn test_export_mixed_column_types() {
1022 use tempfile::TempDir;
1023
1024 let mut model = ParsedModel::new();
1025 let mut table = Table::new("mixed".to_string());
1026
1027 table.add_column(Column::new(
1028 "amount".to_string(),
1029 ColumnValue::Number(vec![100.0, 200.0]),
1030 ));
1031 table.add_column(Column::new(
1032 "category".to_string(),
1033 ColumnValue::Text(vec!["A".to_string(), "B".to_string()]),
1034 ));
1035 table.add_column(Column::new(
1036 "date".to_string(),
1037 ColumnValue::Date(vec!["2024-01-01".to_string(), "2024-02-01".to_string()]),
1038 ));
1039 table.add_column(Column::new(
1040 "active".to_string(),
1041 ColumnValue::Boolean(vec![true, false]),
1042 ));
1043
1044 model.add_table(table);
1045
1046 let exporter = ExcelExporter::new(model);
1047
1048 let dir = TempDir::new().unwrap();
1049 let output_path = dir.path().join("mixed.xlsx");
1050
1051 let result = exporter.export(&output_path);
1052 assert!(result.is_ok());
1053 assert!(output_path.exists());
1054 }
1055
1056 #[test]
1057 fn test_export_to_nonexistent_directory_fails() {
1058 let model = ParsedModel::new();
1059 let exporter = ExcelExporter::new(model);
1060
1061 let output_path = std::path::Path::new("/nonexistent/dir/output.xlsx");
1062
1063 let result = exporter.export(output_path);
1064 assert!(result.is_err());
1065 }
1066
1067 #[test]
1072 fn test_export_with_row_formulas() {
1073 use tempfile::TempDir;
1074
1075 let mut model = ParsedModel::new();
1076 let mut table = Table::new("data".to_string());
1077
1078 table.add_column(Column::new(
1079 "price".to_string(),
1080 ColumnValue::Number(vec![100.0, 200.0]),
1081 ));
1082 table.add_column(Column::new(
1083 "quantity".to_string(),
1084 ColumnValue::Number(vec![10.0, 20.0]),
1085 ));
1086
1087 table
1089 .row_formulas
1090 .insert("total".to_string(), "=price * quantity".to_string());
1091
1092 model.add_table(table);
1093
1094 let exporter = ExcelExporter::new(model);
1095
1096 let dir = TempDir::new().unwrap();
1097 let output_path = dir.path().join("formulas.xlsx");
1098
1099 let result = exporter.export(&output_path);
1100 assert!(result.is_ok());
1101 assert!(output_path.exists());
1102 }
1103
1104 #[test]
1105 fn test_export_scalars_with_formulas() {
1106 use tempfile::TempDir;
1107
1108 let mut model = ParsedModel::new();
1109
1110 model.scalars.insert(
1112 "inputs.rate".to_string(),
1113 crate::types::Variable::new("inputs.rate".to_string(), Some(0.05), None),
1114 );
1115
1116 model.scalars.insert(
1118 "outputs.result".to_string(),
1119 crate::types::Variable::new(
1120 "outputs.result".to_string(),
1121 Some(500.0),
1122 Some("=inputs.rate * 10000".to_string()),
1123 ),
1124 );
1125
1126 let exporter = ExcelExporter::new(model);
1127
1128 let dir = TempDir::new().unwrap();
1129 let output_path = dir.path().join("scalars.xlsx");
1130
1131 let result = exporter.export(&output_path);
1132 assert!(result.is_ok());
1133 assert!(output_path.exists());
1134 }
1135
1136 #[test]
1137 fn test_export_aggregations() {
1138 use tempfile::TempDir;
1139
1140 let mut model = ParsedModel::new();
1141
1142 let mut table = Table::new("sales".to_string());
1144 table.add_column(Column::new(
1145 "amount".to_string(),
1146 ColumnValue::Number(vec![100.0, 200.0, 300.0]),
1147 ));
1148 model.add_table(table);
1149
1150 model
1152 .aggregations
1153 .insert("total_sales".to_string(), "=SUM(sales.amount)".to_string());
1154
1155 let exporter = ExcelExporter::new(model);
1156
1157 let dir = TempDir::new().unwrap();
1158 let output_path = dir.path().join("aggregations.xlsx");
1159
1160 let result = exporter.export(&output_path);
1161 assert!(result.is_ok());
1162 }
1163
1164 #[test]
1165 fn test_export_empty_table() {
1166 use tempfile::TempDir;
1167
1168 let mut model = ParsedModel::new();
1169 let table = Table::new("empty".to_string());
1170 model.add_table(table);
1172
1173 let exporter = ExcelExporter::new(model);
1174
1175 let dir = TempDir::new().unwrap();
1176 let output_path = dir.path().join("empty.xlsx");
1177
1178 let result = exporter.export(&output_path);
1179 assert!(result.is_ok());
1180 }
1181
1182 #[test]
1183 fn test_export_large_table() {
1184 use tempfile::TempDir;
1185
1186 let mut model = ParsedModel::new();
1187 let mut table = Table::new("large".to_string());
1188
1189 let values: Vec<f64> = (0..1000).map(f64::from).collect();
1191 table.add_column(Column::new("id".to_string(), ColumnValue::Number(values)));
1192
1193 model.add_table(table);
1194
1195 let exporter = ExcelExporter::new(model);
1196
1197 let dir = TempDir::new().unwrap();
1198 let output_path = dir.path().join("large.xlsx");
1199
1200 let result = exporter.export(&output_path);
1201 assert!(result.is_ok());
1202 }
1203
1204 #[test]
1205 fn test_export_cross_table_formula() {
1206 use tempfile::TempDir;
1207
1208 let mut model = ParsedModel::new();
1209
1210 let mut revenue = Table::new("revenue".to_string());
1212 revenue.add_column(Column::new(
1213 "amount".to_string(),
1214 ColumnValue::Number(vec![1000.0, 2000.0]),
1215 ));
1216 model.add_table(revenue);
1217
1218 let mut profit = Table::new("profit".to_string());
1220 profit.add_column(Column::new(
1221 "margin".to_string(),
1222 ColumnValue::Number(vec![0.2, 0.3]),
1223 ));
1224 profit
1225 .row_formulas
1226 .insert("amount".to_string(), "=revenue.amount * margin".to_string());
1227 model.add_table(profit);
1228
1229 let exporter = ExcelExporter::new(model);
1230
1231 let dir = TempDir::new().unwrap();
1232 let output_path = dir.path().join("cross_table.xlsx");
1233
1234 let result = exporter.export(&output_path);
1235 assert!(result.is_ok());
1236 }
1237
1238 #[test]
1239 fn test_export_all_metadata_fields() {
1240 use tempfile::TempDir;
1241
1242 let mut model = ParsedModel::new();
1243 let mut table = Table::new("complete".to_string());
1244
1245 let metadata = Metadata {
1247 unit: Some("USD".to_string()),
1248 notes: Some("Complete metadata test".to_string()),
1249 source: Some("test.yaml".to_string()),
1250 validation_status: Some("PENDING".to_string()),
1251 last_updated: Some("2025-12-04".to_string()),
1252 };
1253
1254 table.add_column(Column::with_metadata(
1255 "value".to_string(),
1256 ColumnValue::Number(vec![42.0]),
1257 metadata,
1258 ));
1259 model.add_table(table);
1260
1261 let exporter = ExcelExporter::new(model);
1262
1263 let dir = TempDir::new().unwrap();
1264 let output_path = dir.path().join("full_metadata.xlsx");
1265
1266 let result = exporter.export(&output_path);
1267 assert!(result.is_ok());
1268 }
1269
1270 #[test]
1275 fn test_export_grouped_scalars() {
1276 use tempfile::TempDir;
1277
1278 let mut model = ParsedModel::new();
1279
1280 model.scalars.insert(
1283 "utilities.extinction".to_string(),
1284 Variable::new("utilities.extinction".to_string(), Some(0.0), None),
1285 );
1286 model.scalars.insert(
1287 "utilities.flourishing".to_string(),
1288 Variable::new("utilities.flourishing".to_string(), Some(100.0), None),
1289 );
1290
1291 model.scalars.insert(
1293 "scenario_probs.p_unaligned".to_string(),
1294 Variable::new("scenario_probs.p_unaligned".to_string(), Some(0.35), None),
1295 );
1296 model.scalars.insert(
1297 "scenario_probs.p_aligned".to_string(),
1298 Variable::new("scenario_probs.p_aligned".to_string(), Some(0.25), None),
1299 );
1300
1301 model.scalars.insert(
1303 "analysis.expected_value".to_string(),
1304 Variable::new(
1305 "analysis.expected_value".to_string(),
1306 Some(25.0),
1307 Some("=scenario_probs.p_aligned * utilities.flourishing".to_string()),
1308 ),
1309 );
1310
1311 let exporter = ExcelExporter::new(model);
1312
1313 assert!(exporter
1315 .scalar_locations
1316 .contains_key("utilities.extinction"));
1317 assert!(exporter
1318 .scalar_locations
1319 .contains_key("scenario_probs.p_unaligned"));
1320 assert!(exporter
1321 .scalar_locations
1322 .contains_key("analysis.expected_value"));
1323
1324 let util_loc = exporter
1326 .scalar_locations
1327 .get("utilities.extinction")
1328 .unwrap();
1329 assert_eq!(util_loc.worksheet, "utilities");
1330
1331 let prob_loc = exporter
1332 .scalar_locations
1333 .get("scenario_probs.p_unaligned")
1334 .unwrap();
1335 assert_eq!(prob_loc.worksheet, "scenario_probs");
1336
1337 let analysis_loc = exporter
1338 .scalar_locations
1339 .get("analysis.expected_value")
1340 .unwrap();
1341 assert_eq!(analysis_loc.worksheet, "analysis");
1342
1343 let dir = TempDir::new().unwrap();
1345 let output_path = dir.path().join("grouped_scalars.xlsx");
1346
1347 let result = exporter.export(&output_path);
1348 assert!(result.is_ok());
1349 assert!(output_path.exists());
1350 }
1351
1352 #[test]
1353 fn test_export_scalars_without_dots() {
1354 use tempfile::TempDir;
1355
1356 let mut model = ParsedModel::new();
1357
1358 model.scalars.insert(
1360 "tax_rate".to_string(),
1361 Variable::new("tax_rate".to_string(), Some(0.15), None),
1362 );
1363 model.scalars.insert(
1364 "discount_rate".to_string(),
1365 Variable::new("discount_rate".to_string(), Some(0.10), None),
1366 );
1367
1368 let exporter = ExcelExporter::new(model);
1369
1370 let tax_loc = exporter.scalar_locations.get("tax_rate").unwrap();
1372 assert_eq!(tax_loc.worksheet, "Scalars");
1373
1374 let dir = TempDir::new().unwrap();
1375 let output_path = dir.path().join("ungrouped_scalars.xlsx");
1376
1377 let result = exporter.export(&output_path);
1378 assert!(result.is_ok());
1379 }
1380
1381 #[test]
1382 fn test_translate_grouped_scalar_formula() {
1383 let mut model = ParsedModel::new();
1384
1385 model.scalars.insert(
1387 "utilities.flourishing".to_string(),
1388 Variable::new("utilities.flourishing".to_string(), Some(100.0), None),
1389 );
1390 model.scalars.insert(
1391 "scenario_probs.p_aligned".to_string(),
1392 Variable::new("scenario_probs.p_aligned".to_string(), Some(0.25), None),
1393 );
1394
1395 let exporter = ExcelExporter::new(model);
1396
1397 let formula = "=scenario_probs.p_aligned * utilities.flourishing";
1399 let result = exporter.translate_grouped_scalar_formula(formula).unwrap();
1400
1401 assert!(result.contains("'scenario_probs'!B"));
1403 assert!(result.contains("'utilities'!B"));
1404 assert!(result.starts_with('='));
1405 }
1406
1407 #[test]
1408 fn test_export_scalar_with_mc_formula() {
1409 use tempfile::TempDir;
1410
1411 let mut model = ParsedModel::new();
1412
1413 model.scalars.insert(
1415 "inputs.probability".to_string(),
1416 Variable::new(
1417 "inputs.probability".to_string(),
1418 Some(0.35), Some("=MC.Triangular(0.25, 0.35, 0.45)".to_string()),
1420 ),
1421 );
1422
1423 let exporter = ExcelExporter::new(model);
1424
1425 let dir = TempDir::new().unwrap();
1426 let output_path = dir.path().join("mc_scalar.xlsx");
1427
1428 let result = exporter.export(&output_path);
1430 assert!(result.is_ok());
1431 }
1432}