Skip to main content

mollendorff_forge/excel/
exporter.rs

1//! Excel exporter implementation
2
3use 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/// Location of a scalar in the exported Excel workbook
10#[derive(Clone, Debug)]
11pub struct ScalarLocation {
12    /// Worksheet name (e.g., "utilities" or "Scalars" for ungrouped)
13    pub worksheet: String,
14    /// Row number (1-indexed, after header)
15    pub row: u32,
16}
17
18/// Excel exporter for v1.0.0 array models and v5.0.0 scalar models
19pub struct ExcelExporter {
20    model: ParsedModel,
21    /// Global mapping: `table_name` -> (`column_name` -> `column_letter`)
22    table_column_maps: HashMap<String, HashMap<String, String>>,
23    /// Global mapping: `table_name` -> `row_count`
24    table_row_counts: HashMap<String, usize>,
25    /// Global mapping: `scalar_path` -> `ScalarLocation` (worksheet + row)
26    scalar_locations: HashMap<String, ScalarLocation>,
27}
28
29impl ExcelExporter {
30    /// Create a new Excel exporter
31    pub fn new(model: ParsedModel) -> Self {
32        // Build global column mappings for all tables
33        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            // Add data columns
40            for name in table.columns.keys() {
41                column_names.push(name.clone());
42            }
43
44            // Add formula columns
45            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(); // Alphabetical order
52
53            // Build column name → letter mapping
54            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            // Get row count
64            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        // Build scalar location map for grouped export
75        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    /// Build scalar location map - groups scalars by prefix for separate worksheets
86    /// e.g., "utilities.extinction" -> worksheet "utilities", row 2
87    fn build_scalar_locations(model: &ParsedModel) -> HashMap<String, ScalarLocation> {
88        let mut locations = HashMap::new();
89
90        // Group scalars by prefix (part before first dot)
91        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        // Sort scalars within each group and assign row numbers
98        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)] // scalar count is well within u32 range
102                let row = (idx + 2) as u32; // +1 for header, +1 for 1-indexing
103                locations.insert(
104                    path.clone(),
105                    ScalarLocation {
106                        worksheet: group_name.clone(),
107                        row,
108                    },
109                );
110            }
111        }
112
113        locations
114    }
115
116    /// Split a scalar path into group and name
117    /// "utilities.extinction" -> ("utilities", "extinction")
118    /// "`tax_rate`" -> ("Scalars", "`tax_rate`")
119    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    /// Export the model to an Excel .xlsx file
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the workbook cannot be saved to the specified path.
135    pub fn export(&self, output_path: &Path) -> ForgeResult<()> {
136        let mut workbook = Workbook::new();
137
138        // Export each table as a separate worksheet
139        for (table_name, table) in &self.model.tables {
140            self.export_table(&mut workbook, table_name, table)?;
141        }
142
143        // Export scalars to dedicated worksheet (if any)
144        if !self.model.scalars.is_empty() {
145            self.export_scalars(&mut workbook)?;
146        }
147
148        // Export included files' tables with namespace prefix (v4.4.2)
149        for (namespace, resolved) in &self.model.resolved_includes {
150            // Export tables from included file
151            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            // Export scalars from included file with namespace prefix
157            if !resolved.model.scalars.is_empty() {
158                Self::export_namespaced_scalars(&mut workbook, namespace, &resolved.model)?;
159            }
160        }
161
162        // Save workbook to file
163        workbook
164            .save(output_path)
165            .map_err(|e| ForgeError::IO(format!("Failed to save Excel file: {e}")))?;
166
167        Ok(())
168    }
169
170    /// Export the model to an in-memory Excel buffer.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if the workbook cannot be serialized.
175    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    /// Export a single table to a worksheet
202    #[allow(clippy::cast_possible_truncation)] // column count and row count are within u16/u32 Excel limits
203    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        // Get column names in a deterministic order (data + formula columns)
215        let mut column_names: Vec<String> = Vec::new();
216
217        // Add data columns
218        for name in table.columns.keys() {
219            column_names.push(name.clone());
220        }
221
222        // Add formula columns
223        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(); // Alphabetical order for now
230
231        // Get the column map for this table (already built in new())
232        let column_map = self
233            .table_column_maps
234            .get(table_name)
235            .cloned()
236            .unwrap_or_default();
237
238        // Create formula translator with global table knowledge
239        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        // Write header row (row 0) with metadata as notes (v4.0)
246        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            // Add metadata as cell note if column has metadata (v4.0)
252            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, &note)
257                        .map_err(|e| ForgeError::Export(format!("Failed to add note: {e}")))?;
258                }
259            }
260        }
261
262        // Get row count from first data column
263        let row_count = table
264            .columns
265            .values()
266            .next()
267            .map_or(0, super::super::types::Column::len);
268
269        // Write data rows (starting at row 1)
270        for row_idx in 0..row_count {
271            let excel_row = (row_idx + 1) as u32 + 1; // +1 for header row, +1 for Excel 1-indexing = row 2 for first data row
272
273            for (col_idx, col_name) in column_names.iter().enumerate() {
274                // Check if this is a calculated column (has formula)
275                if let Some(formula) = table.row_formulas.get(col_name) {
276                    // Translate and write formula
277                    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                    // Write data value
283                    Self::write_cell_value(
284                        worksheet,
285                        excel_row - 1, // Excel row is 1-indexed, worksheet API is 0-indexed
286                        col_idx as u16,
287                        &column.values,
288                        row_idx,
289                    )?;
290                }
291            }
292        }
293
294        Ok(())
295    }
296
297    /// Write a single cell value based on column type
298    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    /// Export scalars to grouped worksheets (one per prefix group)
339    /// e.g., "utilities.extinction", "utilities.flourishing" -> worksheet "utilities"
340    ///       "`scenario_probs.p_unaligned`" -> worksheet "`scenario_probs`"
341    fn export_scalars(&self, workbook: &mut Workbook) -> ForgeResult<()> {
342        // Group scalars by prefix
343        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        // Sort groups by name for deterministic output
350        let mut group_names: Vec<&String> = groups.keys().collect();
351        group_names.sort();
352
353        // Export each group as a separate worksheet
354        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    /// Export a group of scalars to a single worksheet
364    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        // Write header row
376        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        // Sort scalars within group by name
384        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)] // scalar count is well within u32 range
389            let row = (idx + 1) as u32; // +1 for header row
390
391            // Get short name (part after the dot, or full path if no dot)
392            let short_name = Self::split_scalar_path(full_path).1;
393
394            // Write name (short name without prefix)
395            worksheet
396                .write_string(row, 0, &short_name)
397                .map_err(|e| ForgeError::Export(format!("Failed to write scalar name: {e}")))?;
398
399            // Write formula or value
400            if let Some(formula) = &var.formula {
401                // Check if formula contains MC.* functions (Monte Carlo - not supported in Excel)
402                if formula.contains("MC.") {
403                    // Write calculated value and add formula as comment
404                    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                    // Add original formula as a note since Excel doesn't support MC functions
410                    let note = Note::new(format!("Forge formula: {formula}")).set_author("Forge");
411                    worksheet.insert_note(row, 1, &note).map_err(|e| {
412                        ForgeError::Export(format!("Failed to add formula note: {e}"))
413                    })?;
414                } else {
415                    // Translate and write as actual Excel formula
416                    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                            // Fallback: write calculated value if formula translation fails
428                            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                // No formula, just write the value
438                worksheet.write_number(row, 1, value).map_err(|e| {
439                    ForgeError::Export(format!("Failed to write scalar value: {e}"))
440                })?;
441            }
442
443            // Add metadata as cell note for scalars (v4.0)
444            if let Some(note_text) = Self::format_metadata_note(&var.metadata) {
445                // Check if we already added a note for MC formula
446                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, &note).map_err(|e| {
449                        ForgeError::Export(format!("Failed to add scalar note: {e}"))
450                    })?;
451                }
452            }
453        }
454
455        Ok(())
456    }
457
458    /// Translate a scalar formula using grouped worksheet references
459    /// e.g., "=utilities.extinction * `scenario_probs.p_unaligned`"
460    ///    -> "='utilities'!B2 * '`scenario_probs`'!B3"
461    fn translate_grouped_scalar_formula(&self, formula: &str) -> ForgeResult<String> {
462        use regex::Regex;
463
464        // Remove leading = if present
465        let formula_body = formula.strip_prefix('=').unwrap_or(formula);
466        let mut result = formula_body.to_string();
467
468        // Pattern for scalar references: group.name
469        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        // Collect replacements
473        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                // Skip if already processed (contains !)
480                if result[..full_match.start()].ends_with('\'')
481                    || result[full_match.end()..].starts_with('!')
482                {
483                    return None;
484                }
485
486                // Look up scalar location
487                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        // Apply replacements in reverse order
495        for (range, replacement) in replacements.into_iter().rev() {
496            result.replace_range(range, &replacement);
497        }
498
499        Ok(format!("={result}"))
500    }
501
502    /// Export scalars from an included file with namespace prefix (v4.4.2)
503    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        // Write header row
515        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        // Write scalars (sorted by name)
523        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)] // scalar count is well within u32 range
528            let row = (idx + 1) as u32;
529
530            if let Some(var) = included_model.scalars.get(*name) {
531                // Write name with namespace prefix
532                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                // Write value (formulas not translated for included scalars yet)
538                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                // Add metadata as cell note
545                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, &note).map_err(|e| {
548                        ForgeError::Export(format!("Failed to add scalar note: {e}"))
549                    })?;
550                }
551            }
552        }
553
554        Ok(())
555    }
556
557    /// Format metadata fields as a note text for Excel cell comments (v4.0)
558    /// Returns None if metadata is empty
559    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    // =========================================================================
596    // Metadata Note Formatting Tests
597    // =========================================================================
598
599    #[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        // Check newlines
671        assert!(note.contains('\n'));
672    }
673
674    // =========================================================================
675    // ExcelExporter Construction Tests
676    // =========================================================================
677
678    #[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        // Column map should include both data column and formula column
716        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        // alpha -> A, beta -> B, zebra -> C (alphabetical order)
769        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    // =========================================================================
785    // Excel Export Tests (File I/O)
786    // =========================================================================
787
788    #[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        // File should have non-zero size
825        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    // ═══════════════════════════════════════════════════════════════════════════
1068    // Additional coverage tests
1069    // ═══════════════════════════════════════════════════════════════════════════
1070
1071    #[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        // Add a formula column
1088        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        // Add input scalar (no formula)
1111        model.scalars.insert(
1112            "inputs.rate".to_string(),
1113            crate::types::Variable::new("inputs.rate".to_string(), Some(0.05), None),
1114        );
1115
1116        // Add output scalar (with formula)
1117        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        // Add a table
1143        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        // Add aggregation
1151        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        // Don't add any columns
1171        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        // Create 1000 row table
1190        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        // First table
1211        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        // Second table referencing first
1219        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        // Full metadata
1246        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    // =========================================================================
1271    // Grouped Scalar Export Tests (v5.0.0 scalar models)
1272    // =========================================================================
1273
1274    #[test]
1275    fn test_export_grouped_scalars() {
1276        use tempfile::TempDir;
1277
1278        let mut model = ParsedModel::new();
1279
1280        // Add scalars in different groups (simulating v5.0.0 scalar model)
1281        // Group: utilities
1282        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        // Group: scenario_probs
1292        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        // Group: analysis (with formula referencing other groups)
1302        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        // Verify scalar locations were built correctly
1314        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        // Verify worksheet assignments
1325        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        // Export and verify file creation
1344        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        // Scalars without dots should go to "Scalars" worksheet
1359        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        // Verify they go to "Scalars" worksheet
1371        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        // Set up scalars
1386        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        // Test formula translation
1398        let formula = "=scenario_probs.p_aligned * utilities.flourishing";
1399        let result = exporter.translate_grouped_scalar_formula(formula).unwrap();
1400
1401        // Should reference worksheets: ='scenario_probs'!B2 * 'utilities'!B2
1402        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        // Scalar with Monte Carlo formula (not supported in Excel)
1414        model.scalars.insert(
1415            "inputs.probability".to_string(),
1416            Variable::new(
1417                "inputs.probability".to_string(),
1418                Some(0.35), // Calculated value
1419                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        // Should succeed - MC formulas are written as values with formula in comment
1429        let result = exporter.export(&output_path);
1430        assert!(result.is_ok());
1431    }
1432}