Skip to main content

mollendorff_forge/excel/
formula_translator.rs

1//! Formula translation from YAML to Excel syntax
2
3use crate::error::{ForgeError, ForgeResult};
4use regex::Regex;
5use std::collections::HashMap;
6
7/// Translates YAML row formulas to Excel cell formulas
8pub struct FormulaTranslator {
9    /// Maps column names to Excel column letters (revenue → A, cogs → B, etc.)
10    column_map: HashMap<String, String>,
11    /// Global mapping: `table_name` -> (`column_name` -> `column_letter`)
12    table_column_maps: HashMap<String, HashMap<String, String>>,
13    /// Global mapping: `table_name` -> `row_count`
14    table_row_counts: HashMap<String, usize>,
15}
16
17impl FormulaTranslator {
18    /// Create a new formula translator with column mappings (legacy, for backwards compat)
19    #[must_use]
20    pub fn new(column_map: HashMap<String, String>) -> Self {
21        Self {
22            column_map,
23            table_column_maps: HashMap::new(),
24            table_row_counts: HashMap::new(),
25        }
26    }
27
28    /// Create a new formula translator with full table knowledge
29    #[must_use]
30    pub const fn new_with_tables(
31        column_map: HashMap<String, String>,
32        table_column_maps: HashMap<String, HashMap<String, String>>,
33        table_row_counts: HashMap<String, usize>,
34    ) -> Self {
35        Self {
36            column_map,
37            table_column_maps,
38            table_row_counts,
39        }
40    }
41
42    /// Translate a row formula to an Excel cell formula for a specific row
43    ///
44    /// Example:
45    /// - Input: `=revenue - cogs`, `row_idx` 0, `excel_row` 2
46    /// - Output: `=A2-B2`
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if a column reference cannot be resolved.
51    ///
52    /// # Panics
53    ///
54    /// Panics if a regex capture group 0 is missing (should never occur).
55    #[allow(clippy::too_many_lines)] // splitting hurts readability: single coherent translation pipeline
56    pub fn translate_row_formula(&self, formula: &str, excel_row: u32) -> ForgeResult<String> {
57        // Remove leading = if present
58        let formula_body = formula.strip_prefix('=').unwrap_or(formula);
59
60        let mut result = formula_body.to_string();
61
62        // FIRST: Handle table.column references in aggregation functions that need ranges
63        // Pattern for simple aggregations: SUM(table.column) → SUM(table!A2:A4)
64        let agg_pattern = Regex::new(r"(SUM|AVERAGE|MAX|MIN|COUNT|COUNTA|PRODUCT)\(([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\)")
65            .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
66
67        let agg_replacements: Vec<(std::ops::Range<usize>, String)> = agg_pattern
68            .captures_iter(&result.clone())
69            .map(|cap| {
70                let full_match = cap.get(0).unwrap();
71                let func_name = &cap[1];
72                let table_name = &cap[2];
73                let col_name = &cap[3];
74
75                let col_letter = self
76                    .table_column_maps
77                    .get(table_name)
78                    .and_then(|cols| cols.get(col_name))
79                    .cloned()
80                    .unwrap_or_else(|| col_name.to_string());
81
82                let row_count = self.table_row_counts.get(table_name).copied().unwrap_or(1);
83                let end_row = row_count + 1;
84                let replacement =
85                    format!("{func_name}('{table_name}'!{col_letter}2:{col_letter}{end_row})");
86                (full_match.range(), replacement)
87            })
88            .collect();
89
90        for (range, replacement) in agg_replacements.into_iter().rev() {
91            result.replace_range(range, &replacement);
92        }
93
94        // SECOND: Handle general table.column references that need ranges (for functions like SUMIFS, PERCENTILE, etc.)
95        let general_table_pattern =
96            Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b")
97                .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
98
99        let general_replacements: Vec<(std::ops::Range<usize>, String)> = general_table_pattern
100            .captures_iter(&result.clone())
101            .filter_map(|cap| {
102                let full_match = cap.get(0).unwrap();
103                let table_name = &cap[1];
104                let col_name = &cap[2];
105
106                // Skip if already processed (would contain !)
107                if result[..full_match.start()].ends_with('\'')
108                    || result[full_match.end()..].starts_with('!')
109                {
110                    return None;
111                }
112
113                // Only convert if this is a known table
114                if !self.table_column_maps.contains_key(table_name) {
115                    return None;
116                }
117
118                let col_letter = self
119                    .table_column_maps
120                    .get(table_name)
121                    .and_then(|cols| cols.get(col_name))
122                    .cloned()
123                    .unwrap_or_else(|| col_name.to_string());
124
125                let row_count = self.table_row_counts.get(table_name).copied().unwrap_or(1);
126                let end_row = row_count + 1;
127                let replacement = format!("'{table_name}'!{col_letter}2:{col_letter}{end_row}");
128                Some((full_match.range(), replacement))
129            })
130            .collect();
131
132        for (range, replacement) in general_replacements.into_iter().rev() {
133            result.replace_range(range, &replacement);
134        }
135
136        // THIRD: Handle remaining table.column references (not in known tables, use fallback)
137        let remaining_table_pattern =
138            Regex::new(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)")
139                .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
140
141        let remaining_replacements: Vec<(std::ops::Range<usize>, String)> = remaining_table_pattern
142            .captures_iter(&result.clone())
143            .filter_map(|cap| {
144                let full_match = cap.get(0).unwrap();
145                let table_name = &cap[1];
146                let col_name = &cap[2];
147
148                // Skip if already processed
149                if result[..full_match.start()].ends_with('\'')
150                    || result[full_match.end()..].starts_with('!')
151                {
152                    return None;
153                }
154
155                // Fallback: convert to single cell reference with table name and column name
156                let replacement = format!("'{table_name}'!{col_name}{excel_row}");
157                Some((full_match.range(), replacement))
158            })
159            .collect();
160
161        for (range, replacement) in remaining_replacements.into_iter().rev() {
162            result.replace_range(range, &replacement);
163        }
164
165        // FOURTH: Handle simple column references in the current table
166        let var_pattern = Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\b")
167            .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
168
169        let result_clone = result.clone();
170        let matches: Vec<_> = var_pattern.find_iter(&result_clone).collect();
171
172        for match_obj in matches.iter().rev() {
173            let var_name = match_obj.as_str();
174
175            // Skip Excel functions
176            if Self::is_excel_function(var_name) {
177                continue;
178            }
179
180            // Skip if already contains ! (already processed as table reference)
181            let start = match_obj.start();
182            let end = match_obj.end();
183            if start > 0 && result[..start].ends_with('!') {
184                continue;
185            }
186
187            // Skip if inside single quotes (already processed as sheet name)
188            if start > 0 && result[..start].ends_with('\'') {
189                continue;
190            }
191            if end < result.len() && result[end..].starts_with('!') {
192                continue;
193            }
194
195            // Simple column reference in current table
196            if let Some(col_letter) = self.column_map.get(var_name) {
197                let excel_ref = format!("{col_letter}{excel_row}");
198                result.replace_range(match_obj.range(), &excel_ref);
199            } else {
200                // Check if this looks like an Excel cell reference (e.g., A6, B2)
201                let looks_like_cell_ref = var_name.len() >= 2
202                    && var_name.chars().all(char::is_alphanumeric)
203                    && var_name.chars().any(char::is_alphabetic)
204                    && var_name.chars().any(char::is_numeric);
205
206                // Check if it's a number
207                let is_number = var_name.parse::<f64>().is_ok();
208
209                if !looks_like_cell_ref && !is_number {
210                    // Column not found and it's not a cell reference or number - this is an error
211                    return Err(ForgeError::Export(format!(
212                        "Column '{var_name}' not found in table"
213                    )));
214                }
215                // If it's a cell reference or number, leave it as is
216            }
217        }
218
219        Ok(format!("={result}"))
220    }
221
222    /// Check if a word is an Excel function
223    #[allow(clippy::too_many_lines)] // comprehensive function list is intentionally exhaustive
224    fn is_excel_function(word: &str) -> bool {
225        let upper = word.to_uppercase();
226        matches!(
227            upper.as_str(),
228            // Aggregation functions
229            "SUM"
230                | "AVERAGE"
231                | "MAX"
232                | "MIN"
233                | "COUNT"
234                | "COUNTA"
235                | "PRODUCT"
236                | "MEDIAN"
237                // Conditional aggregations
238                | "SUMIF"
239                | "SUMIFS"
240                | "COUNTIF"
241                | "COUNTIFS"
242                | "AVERAGEIF"
243                | "AVERAGEIFS"
244                | "MAXIFS"
245                | "MINIFS"
246                // Logical functions
247                | "IF"
248                | "AND"
249                | "OR"
250                | "NOT"
251                | "XOR"
252                | "TRUE"
253                | "FALSE"
254                | "IFERROR"
255                | "IFNA"
256                | "CHOOSE"
257                // Math functions
258                | "ABS"
259                | "ROUND"
260                | "ROUNDUP"
261                | "ROUNDDOWN"
262                | "SQRT"
263                | "POW"
264                | "POWER"
265                | "EXP"
266                | "LN"
267                | "LOG"
268                | "LOG10"
269                | "PI"
270                | "E"
271                | "MOD"
272                | "CEILING"
273                | "FLOOR"
274                | "INT"
275                | "TRUNC"
276                | "SIGN"
277                // Trigonometric functions
278                | "SIN"
279                | "COS"
280                | "TAN"
281                | "ASIN"
282                | "ACOS"
283                | "ATAN"
284                | "SINH"
285                | "COSH"
286                | "TANH"
287                | "RADIANS"
288                | "DEGREES"
289                // Text functions
290                | "CONCATENATE"
291                | "CONCAT"
292                | "LEFT"
293                | "RIGHT"
294                | "MID"
295                | "LEN"
296                | "UPPER"
297                | "LOWER"
298                | "TRIM"
299                | "TEXT"
300                | "VALUE"
301                | "FIND"
302                | "SEARCH"
303                | "REPLACE"
304                | "SUBSTITUTE"
305                // Date functions
306                | "TODAY"
307                | "NOW"
308                | "DATE"
309                | "YEAR"
310                | "MONTH"
311                | "DAY"
312                | "WEEKDAY"
313                | "HOUR"
314                | "MINUTE"
315                | "SECOND"
316                | "DATEDIF"
317                | "EDATE"
318                | "EOMONTH"
319                | "NETWORKDAYS"
320                | "WORKDAY"
321                | "YEARFRAC"
322                | "DAYS"
323                | "TIME"
324                // Financial functions
325                | "NPV"
326                | "IRR"
327                | "MIRR"
328                | "XNPV"
329                | "XIRR"
330                | "PMT"
331                | "FV"
332                | "PV"
333                | "RATE"
334                | "NPER"
335                | "SLN"
336                | "DB"
337                | "DDB"
338                // Information functions
339                | "ISEVEN"
340                | "ISODD"
341                | "ISBLANK"
342                | "ISERROR"
343                | "ISNA"
344                | "ISNUMBER"
345                | "ISTEXT"
346                | "ISLOGICAL"
347                | "ISREF"
348                | "ISFORMULA"
349                | "NA"
350                | "TYPE"
351                | "N"
352                // Lookup functions
353                | "VLOOKUP"
354                | "HLOOKUP"
355                | "XLOOKUP"
356                | "INDEX"
357                | "MATCH"
358                | "OFFSET"
359                | "INDIRECT"
360                | "ADDRESS"
361                | "ROW"
362                | "COLUMN"
363                | "ROWS"
364                | "COLUMNS"
365                // Array functions (v4.1.0)
366                | "UNIQUE"
367                | "COUNTUNIQUE"
368                | "SORT"
369                | "FILTER"
370                | "SEQUENCE"
371                | "RANDARRAY"
372                // Statistical functions
373                | "VAR"
374                | "VARP"
375                | "STDEV"
376                | "STDEVP"
377                | "CORREL"
378                | "PERCENTILE"
379                | "QUARTILE"
380                | "LARGE"
381                | "SMALL"
382                | "RANK"
383                // Advanced functions
384                | "LET"
385                | "LAMBDA"
386                | "SWITCH"
387                | "IFS"
388        )
389    }
390
391    /// Translate a scalar formula to an Excel formula
392    ///
393    /// Examples:
394    /// - `=SUM(table.column)` → `=SUM(table!A2:A4)`
395    /// - `=table.column[0]` → `=table!A2`
396    /// - `=scalar_name / 100` → `=B3 / 100`
397    ///
398    /// # Errors
399    ///
400    /// Returns an error if a regex compilation fails.
401    ///
402    /// # Panics
403    ///
404    /// Panics if a regex capture group 0 is missing (should never occur).
405    #[allow(clippy::too_many_lines)] // splitting hurts readability: single coherent translation pipeline
406    pub fn translate_scalar_formula(
407        &self,
408        formula: &str,
409        scalar_row_map: &HashMap<String, u32>,
410    ) -> ForgeResult<String> {
411        // Remove leading = if present
412        let formula_body = formula.strip_prefix('=').unwrap_or(formula);
413
414        let mut result = formula_body.to_string();
415
416        // Pattern for table.column[index] references (must be processed first)
417        let indexed_pattern =
418            Regex::new(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]")
419                .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
420
421        // Replace indexed references: table.column[0] → table!A2
422        let indexed_replacements: Vec<(std::ops::Range<usize>, String)> = indexed_pattern
423            .captures_iter(&result.clone())
424            .map(|cap| {
425                let full_match = cap.get(0).unwrap();
426                let table_name = &cap[1];
427                let col_name = &cap[2];
428                let index: usize = cap[3].parse().unwrap_or(0);
429
430                let col_letter = self
431                    .table_column_maps
432                    .get(table_name)
433                    .and_then(|cols| cols.get(col_name))
434                    .cloned()
435                    .unwrap_or_else(|| col_name.to_string());
436
437                // Excel row = index + 2 (1 for header, 1 for 1-indexing)
438                let excel_row = index + 2;
439                let replacement = format!("'{table_name}'!{col_letter}{excel_row}");
440                (full_match.range(), replacement)
441            })
442            .collect();
443
444        // Apply indexed replacements in reverse order
445        for (range, replacement) in indexed_replacements.into_iter().rev() {
446            result.replace_range(range, &replacement);
447        }
448
449        // Pattern for table.column references inside aggregation functions
450        // SUM(table.column) → SUM(table!A2:A4)
451        let agg_pattern = Regex::new(r"(SUM|AVERAGE|MAX|MIN|COUNT|COUNTA|PRODUCT)\(([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\)")
452            .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
453
454        let agg_replacements: Vec<(std::ops::Range<usize>, String)> = agg_pattern
455            .captures_iter(&result.clone())
456            .map(|cap| {
457                let full_match = cap.get(0).unwrap();
458                let func_name = &cap[1];
459                let table_name = &cap[2];
460                let col_name = &cap[3];
461
462                let col_letter = self
463                    .table_column_maps
464                    .get(table_name)
465                    .and_then(|cols| cols.get(col_name))
466                    .cloned()
467                    .unwrap_or_else(|| col_name.to_string());
468
469                let row_count = self.table_row_counts.get(table_name).copied().unwrap_or(1);
470                // Range: row 2 to row (row_count + 1) - header is row 1
471                let end_row = row_count + 1;
472                let replacement =
473                    format!("{func_name}('{table_name}'!{col_letter}2:{col_letter}{end_row})");
474                (full_match.range(), replacement)
475            })
476            .collect();
477
478        // Apply aggregation replacements in reverse order
479        for (range, replacement) in agg_replacements.into_iter().rev() {
480            result.replace_range(range, &replacement);
481        }
482
483        // Pattern for general table.column references that need range conversion
484        // This handles functions like SUMIFS, AVERAGEIFS, PERCENTILE, QUARTILE, RANK, CORREL, etc.
485        // that weren't caught by the simple aggregation pattern above
486        let general_table_pattern =
487            Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\b")
488                .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
489
490        // Collect all table.column references that should be converted to ranges
491        let general_replacements: Vec<(std::ops::Range<usize>, String)> = general_table_pattern
492            .captures_iter(&result.clone())
493            .filter_map(|cap| {
494                let full_match = cap.get(0).unwrap();
495                let table_name = &cap[1];
496                let col_name = &cap[2];
497
498                // Skip if already processed (contains !)
499                if result[..full_match.start()].ends_with('\'')
500                    || result[full_match.end()..].starts_with('!')
501                {
502                    return None;
503                }
504
505                // Only convert if this is a known table (not a scalar reference)
506                if !self.table_column_maps.contains_key(table_name) {
507                    return None;
508                }
509
510                let col_letter = self
511                    .table_column_maps
512                    .get(table_name)
513                    .and_then(|cols| cols.get(col_name))
514                    .cloned()
515                    .unwrap_or_else(|| col_name.to_string());
516
517                let row_count = self.table_row_counts.get(table_name).copied().unwrap_or(1);
518                // Range: row 2 to row (row_count + 1) - header is row 1
519                let end_row = row_count + 1;
520                let replacement = format!("'{table_name}'!{col_letter}2:{col_letter}{end_row}");
521                Some((full_match.range(), replacement))
522            })
523            .collect();
524
525        // Apply general replacements in reverse order
526        for (range, replacement) in general_replacements.into_iter().rev() {
527            result.replace_range(range, &replacement);
528        }
529
530        // Pattern for simple table.column references (not in aggregations, not indexed)
531        // These become references to row 2 (first data row)
532        let simple_table_pattern =
533            Regex::new(r"([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)")
534                .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
535
536        let simple_replacements: Vec<(std::ops::Range<usize>, String)> = simple_table_pattern
537            .captures_iter(&result.clone())
538            .filter_map(|cap| {
539                let full_match = cap.get(0).unwrap();
540                let table_name = &cap[1];
541                let col_name = &cap[2];
542
543                // Skip if this looks like it's already been processed (contains !)
544                if result[full_match.range()].contains('!') {
545                    return None;
546                }
547
548                // Check if this is actually a table reference
549                if !self.table_column_maps.contains_key(table_name) {
550                    // Could be a scalar like metrics.total_savings
551                    // Check if it's a scalar reference
552                    let scalar_name = format!("{table_name}.{col_name}");
553                    if let Some(&row) = scalar_row_map.get(&scalar_name) {
554                        return Some((full_match.range(), format!("B{row}")));
555                    }
556                    return None;
557                }
558
559                let col_letter = self
560                    .table_column_maps
561                    .get(table_name)
562                    .and_then(|cols| cols.get(col_name))
563                    .cloned()
564                    .unwrap_or_else(|| col_name.to_string());
565
566                // Default to row 2 (first data row)
567                let replacement = format!("'{table_name}'!{col_letter}2");
568                Some((full_match.range(), replacement))
569            })
570            .collect();
571
572        // Apply simple replacements in reverse order
573        for (range, replacement) in simple_replacements.into_iter().rev() {
574            result.replace_range(range, &replacement);
575        }
576
577        // Handle scalar-to-scalar references (e.g., metrics.total_savings → B3)
578        // Pattern for standalone scalar names
579        let scalar_pattern = Regex::new(r"\b([a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*)\b")
580            .map_err(|e| ForgeError::Export(format!("Regex error: {e}")))?;
581
582        let scalar_replacements: Vec<(std::ops::Range<usize>, String)> = scalar_pattern
583            .captures_iter(&result.clone())
584            .filter_map(|cap| {
585                let full_match = cap.get(0).unwrap();
586                let scalar_name = &cap[1];
587
588                // Skip if already processed (contains ! or is a number)
589                if result[full_match.range()].contains('!') {
590                    return None;
591                }
592
593                if let Some(&row) = scalar_row_map.get(scalar_name) {
594                    return Some((full_match.range(), format!("B{row}")));
595                }
596                None
597            })
598            .collect();
599
600        // Apply scalar replacements in reverse order
601        for (range, replacement) in scalar_replacements.into_iter().rev() {
602            result.replace_range(range, &replacement);
603        }
604
605        Ok(format!("={result}"))
606    }
607
608    /// Convert a column name to an Excel column letter
609    ///
610    /// Examples:
611    /// - 0 → A
612    /// - 1 → B
613    /// - 25 → Z
614    /// - 26 → AA
615    #[must_use]
616    pub fn column_index_to_letter(index: usize) -> String {
617        let mut result = String::new();
618        let mut idx = index;
619
620        loop {
621            let remainder = idx % 26;
622            // Remainder is always 0..25, fits in u8
623            #[allow(clippy::cast_possible_truncation)] // remainder is 0..25, always fits in u8
624            let ch = (b'A' + remainder as u8) as char;
625            result.insert(0, ch);
626            if idx < 26 {
627                break;
628            }
629            idx = idx / 26 - 1;
630        }
631
632        result
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn test_column_index_to_letter() {
642        assert_eq!(FormulaTranslator::column_index_to_letter(0), "A");
643        assert_eq!(FormulaTranslator::column_index_to_letter(1), "B");
644        assert_eq!(FormulaTranslator::column_index_to_letter(25), "Z");
645        assert_eq!(FormulaTranslator::column_index_to_letter(26), "AA");
646        assert_eq!(FormulaTranslator::column_index_to_letter(27), "AB");
647        assert_eq!(FormulaTranslator::column_index_to_letter(701), "ZZ");
648    }
649
650    #[test]
651    fn test_simple_formula_translation() {
652        let mut column_map = HashMap::new();
653        column_map.insert("revenue".to_string(), "A".to_string());
654        column_map.insert("cogs".to_string(), "B".to_string());
655
656        let translator = FormulaTranslator::new(column_map);
657
658        // Test simple subtraction (row 2 in Excel)
659        let result = translator
660            .translate_row_formula("=revenue - cogs", 2)
661            .unwrap();
662        assert_eq!(result, "=A2 - B2");
663
664        // Test division (row 3 in Excel)
665        let result = translator
666            .translate_row_formula("=revenue / cogs", 3)
667            .unwrap();
668        assert_eq!(result, "=A3 / B3");
669    }
670
671    #[test]
672    fn test_formula_with_multiple_columns() {
673        let mut column_map = HashMap::new();
674        column_map.insert("sales_marketing".to_string(), "A".to_string());
675        column_map.insert("rd".to_string(), "B".to_string());
676        column_map.insert("ga".to_string(), "C".to_string());
677
678        let translator = FormulaTranslator::new(column_map);
679
680        let result = translator
681            .translate_row_formula("=sales_marketing + rd + ga", 2)
682            .unwrap();
683        assert_eq!(result, "=A2 + B2 + C2");
684    }
685
686    #[test]
687    fn test_formula_with_parentheses() {
688        let mut column_map = HashMap::new();
689        column_map.insert("gross_profit".to_string(), "A".to_string());
690        column_map.insert("revenue".to_string(), "B".to_string());
691
692        let translator = FormulaTranslator::new(column_map);
693
694        let result = translator
695            .translate_row_formula("=(gross_profit / revenue) * 100", 2)
696            .unwrap();
697        assert_eq!(result, "=(A2 / B2) * 100");
698    }
699
700    #[test]
701    fn test_cross_table_reference() {
702        let column_map = HashMap::new(); // Empty for this test
703
704        let translator = FormulaTranslator::new(column_map);
705
706        let result = translator
707            .translate_row_formula("=pl_2025.revenue", 2)
708            .unwrap();
709        assert_eq!(result, "='pl_2025'!revenue2");
710    }
711
712    #[test]
713    fn test_formula_without_leading_equals() {
714        let mut column_map = HashMap::new();
715        column_map.insert("revenue".to_string(), "A".to_string());
716        column_map.insert("cogs".to_string(), "B".to_string());
717
718        let translator = FormulaTranslator::new(column_map);
719
720        // Test formula without leading =
721        let result = translator
722            .translate_row_formula("revenue - cogs", 2)
723            .unwrap();
724        assert_eq!(result, "=A2 - B2");
725    }
726
727    #[test]
728    fn test_financial_functions_preserved() {
729        let mut column_map = HashMap::new();
730        column_map.insert("cashflow".to_string(), "A".to_string());
731
732        let translator = FormulaTranslator::new(column_map);
733
734        // Test that NPV, IRR, XNPV, XIRR are preserved as functions (use literals for other args)
735        let result = translator
736            .translate_row_formula("=NPV(0.1, cashflow)", 2)
737            .unwrap();
738        assert!(result.contains("NPV"));
739        assert!(result.contains("A2")); // cashflow translated
740
741        // Test XNPV with literals
742        let result = translator
743            .translate_row_formula("=XNPV(0.1, cashflow, 45000)", 2)
744            .unwrap();
745        assert!(result.contains("XNPV"));
746
747        // Test PMT with literals
748        let result = translator
749            .translate_row_formula("=PMT(0.05, 12, 1000)", 2)
750            .unwrap();
751        assert!(result.contains("PMT"));
752
753        // Test IRR, PV, FV, RATE, NPER
754        let result = translator
755            .translate_row_formula("=IRR(cashflow)", 2)
756            .unwrap();
757        assert!(result.contains("IRR"));
758
759        let result = translator
760            .translate_row_formula("=PV(0.1, 10, 100)", 2)
761            .unwrap();
762        assert!(result.contains("PV"));
763
764        let result = translator
765            .translate_row_formula("=FV(0.1, 10, 100)", 2)
766            .unwrap();
767        assert!(result.contains("FV"));
768    }
769
770    #[test]
771    fn test_date_functions_preserved() {
772        let column_map = HashMap::new();
773        let translator = FormulaTranslator::new(column_map);
774
775        // Test DATEDIF, EDATE, EOMONTH are preserved (use numeric literals only)
776        let result = translator
777            .translate_row_formula("=DATEDIF(45000, 45365, 1)", 2)
778            .unwrap();
779        assert!(result.contains("DATEDIF"));
780
781        let result = translator
782            .translate_row_formula("=EDATE(45000, 3)", 2)
783            .unwrap();
784        assert!(result.contains("EDATE"));
785
786        let result = translator
787            .translate_row_formula("=EOMONTH(45000, 1)", 2)
788            .unwrap();
789        assert!(result.contains("EOMONTH"));
790    }
791
792    #[test]
793    fn test_other_new_functions_preserved() {
794        let column_map = HashMap::new();
795        let translator = FormulaTranslator::new(column_map);
796
797        // Test CHOOSE, MAXIFS, MINIFS, POWER, CONCAT (use numeric literals only)
798        let result = translator
799            .translate_row_formula("=CHOOSE(1, 10, 20, 30)", 2)
800            .unwrap();
801        assert!(result.contains("CHOOSE"));
802
803        let result = translator.translate_row_formula("=POWER(2, 8)", 2).unwrap();
804        assert!(result.contains("POWER"));
805
806        // CONCAT with numbers to avoid string parsing issues
807        let result = translator
808            .translate_row_formula("=CONCAT(1, 2)", 2)
809            .unwrap();
810        assert!(result.contains("CONCAT"));
811
812        let result = translator
813            .translate_row_formula("=MAXIFS(1, 2, 3)", 2)
814            .unwrap();
815        assert!(result.contains("MAXIFS"));
816
817        let result = translator
818            .translate_row_formula("=MINIFS(1, 2, 3)", 2)
819            .unwrap();
820        assert!(result.contains("MINIFS"));
821    }
822
823    // ═══════════════════════════════════════════════════════════════════════════
824    // Additional coverage tests
825    // ═══════════════════════════════════════════════════════════════════════════
826
827    #[test]
828    fn test_new_with_tables() {
829        let column_map = HashMap::new();
830        let mut table_column_maps = HashMap::new();
831        let mut sales_cols = HashMap::new();
832        sales_cols.insert("revenue".to_string(), "A".to_string());
833        sales_cols.insert("cost".to_string(), "B".to_string());
834        table_column_maps.insert("sales".to_string(), sales_cols);
835
836        let mut table_row_counts = HashMap::new();
837        table_row_counts.insert("sales".to_string(), 5);
838
839        let translator =
840            FormulaTranslator::new_with_tables(column_map, table_column_maps, table_row_counts);
841
842        // Test cross-table reference with full knowledge
843        let result = translator
844            .translate_row_formula("=sales.revenue", 2)
845            .unwrap();
846        assert!(result.contains("sales"));
847    }
848
849    #[test]
850    fn test_translate_scalar_formula_basic() {
851        let mut table_column_maps = HashMap::new();
852        let mut sales_cols = HashMap::new();
853        sales_cols.insert("amount".to_string(), "A".to_string());
854        table_column_maps.insert("sales".to_string(), sales_cols);
855
856        let mut table_row_counts = HashMap::new();
857        table_row_counts.insert("sales".to_string(), 3);
858
859        let translator =
860            FormulaTranslator::new_with_tables(HashMap::new(), table_column_maps, table_row_counts);
861
862        let scalar_row_map = HashMap::new();
863
864        // Test SUM aggregation
865        let result = translator
866            .translate_scalar_formula("=SUM(sales.amount)", &scalar_row_map)
867            .unwrap();
868        assert!(result.contains("SUM"));
869        assert!(result.contains("sales"));
870    }
871
872    #[test]
873    fn test_translate_scalar_formula_with_index() {
874        let mut table_column_maps = HashMap::new();
875        let mut data_cols = HashMap::new();
876        data_cols.insert("value".to_string(), "C".to_string());
877        table_column_maps.insert("data".to_string(), data_cols);
878
879        let mut table_row_counts = HashMap::new();
880        table_row_counts.insert("data".to_string(), 10);
881
882        let translator =
883            FormulaTranslator::new_with_tables(HashMap::new(), table_column_maps, table_row_counts);
884
885        let scalar_row_map = HashMap::new();
886
887        // Test indexed reference: data.value[0]
888        let result = translator
889            .translate_scalar_formula("=data.value[0]", &scalar_row_map)
890            .unwrap();
891        assert!(result.contains("data"));
892        assert!(result.contains("C2")); // index 0 = row 2
893    }
894
895    #[test]
896    fn test_translate_scalar_formula_with_scalar_ref() {
897        let translator = FormulaTranslator::new(HashMap::new());
898
899        let mut scalar_row_map = HashMap::new();
900        scalar_row_map.insert("metrics.total".to_string(), 5);
901
902        // Test scalar-to-scalar reference
903        let result = translator
904            .translate_scalar_formula("=metrics.total * 2", &scalar_row_map)
905            .unwrap();
906        assert!(result.contains("B5")); // scalar at row 5
907    }
908
909    #[test]
910    fn test_column_not_found_error() {
911        let column_map = HashMap::new(); // Empty
912        let translator = FormulaTranslator::new(column_map);
913
914        // Should error on unknown column
915        let result = translator.translate_row_formula("=unknown_column + 1", 2);
916        assert!(result.is_err());
917        let err = result.unwrap_err().to_string();
918        assert!(err.contains("not found"));
919    }
920
921    #[test]
922    fn test_more_excel_functions_preserved() {
923        let column_map = HashMap::new();
924        let translator = FormulaTranslator::new(column_map);
925
926        // Test more functions
927        let functions = [
928            "ABS", "ROUND", "CEILING", "FLOOR", "MOD", "SQRT", "EXP", "LN", "LOG", "LOG10",
929        ];
930
931        for func in functions {
932            let formula = format!("={func}(10)");
933            let result = translator.translate_row_formula(&formula, 2).unwrap();
934            assert!(
935                result.to_uppercase().contains(func),
936                "Function {func} should be preserved"
937            );
938        }
939    }
940
941    #[test]
942    fn test_text_functions_preserved() {
943        let column_map = HashMap::new();
944        let translator = FormulaTranslator::new(column_map);
945
946        let functions = ["UPPER", "LOWER", "TRIM", "LEN", "LEFT", "RIGHT", "MID"];
947
948        for func in functions {
949            let formula = format!("={func}(1)"); // Using number to avoid string parsing
950            let result = translator.translate_row_formula(&formula, 2).unwrap();
951            assert!(
952                result.to_uppercase().contains(func),
953                "Function {func} should be preserved"
954            );
955        }
956    }
957
958    #[test]
959    fn test_logical_functions_preserved() {
960        let column_map = HashMap::new();
961        let translator = FormulaTranslator::new(column_map);
962
963        let functions = ["IF", "AND", "OR", "NOT", "TRUE", "FALSE", "IFERROR"];
964
965        for func in functions {
966            let formula = format!("={func}(1, 2, 3)");
967            let result = translator.translate_row_formula(&formula, 2).unwrap();
968            assert!(
969                result.to_uppercase().contains(func),
970                "Function {func} should be preserved"
971            );
972        }
973    }
974
975    #[test]
976    fn test_column_index_to_letter_extended() {
977        // Test more column letters
978        assert_eq!(FormulaTranslator::column_index_to_letter(702), "AAA");
979        assert_eq!(FormulaTranslator::column_index_to_letter(52), "BA");
980    }
981
982    #[test]
983    fn test_is_excel_function() {
984        // Test various function names
985        assert!(FormulaTranslator::is_excel_function("SUM"));
986        assert!(FormulaTranslator::is_excel_function("sum")); // lowercase
987        assert!(FormulaTranslator::is_excel_function("AVERAGE"));
988        assert!(FormulaTranslator::is_excel_function("NPV"));
989        assert!(FormulaTranslator::is_excel_function("XLOOKUP"));
990        assert!(!FormulaTranslator::is_excel_function("revenue")); // not a function
991        assert!(!FormulaTranslator::is_excel_function("my_column")); // not a function
992    }
993
994    #[test]
995    fn test_translate_sumifs_formula() {
996        let mut table_column_maps = HashMap::new();
997        let mut sales_cols = HashMap::new();
998        sales_cols.insert("amount".to_string(), "A".to_string());
999        sales_cols.insert("region".to_string(), "B".to_string());
1000        sales_cols.insert("product".to_string(), "C".to_string());
1001        table_column_maps.insert("sales_data".to_string(), sales_cols);
1002
1003        let mut table_row_counts = HashMap::new();
1004        table_row_counts.insert("sales_data".to_string(), 6);
1005
1006        let translator =
1007            FormulaTranslator::new_with_tables(HashMap::new(), table_column_maps, table_row_counts);
1008
1009        let scalar_row_map = HashMap::new();
1010
1011        // Test SUMIFS with multiple criteria
1012        let result = translator
1013            .translate_scalar_formula(
1014                "=SUMIFS(sales_data.amount, sales_data.region, 1, sales_data.product, 1)",
1015                &scalar_row_map,
1016            )
1017            .unwrap();
1018
1019        println!("SUMIFS result: {result}");
1020        assert!(result.contains("'sales_data'!A2:A7"));
1021        assert!(result.contains("'sales_data'!B2:B7"));
1022        assert!(result.contains("'sales_data'!C2:C7"));
1023    }
1024
1025    #[test]
1026    fn test_translate_percentile_formula() {
1027        let mut table_column_maps = HashMap::new();
1028        let mut dataset_cols = HashMap::new();
1029        dataset_cols.insert("values".to_string(), "A".to_string());
1030        table_column_maps.insert("dataset".to_string(), dataset_cols);
1031
1032        let mut table_row_counts = HashMap::new();
1033        table_row_counts.insert("dataset".to_string(), 10);
1034
1035        let translator =
1036            FormulaTranslator::new_with_tables(HashMap::new(), table_column_maps, table_row_counts);
1037
1038        let scalar_row_map = HashMap::new();
1039
1040        // Test PERCENTILE
1041        let result = translator
1042            .translate_scalar_formula("=PERCENTILE(dataset.values, 0.5)", &scalar_row_map)
1043            .unwrap();
1044
1045        println!("PERCENTILE result: {result}");
1046        assert!(result.contains("'dataset'!A2:A11"));
1047    }
1048
1049    #[test]
1050    fn test_translate_correl_formula() {
1051        let mut table_column_maps = HashMap::new();
1052        let mut data_cols = HashMap::new();
1053        data_cols.insert("advertising".to_string(), "A".to_string());
1054        data_cols.insert("sales".to_string(), "B".to_string());
1055        table_column_maps.insert("data_series".to_string(), data_cols);
1056
1057        let mut table_row_counts = HashMap::new();
1058        table_row_counts.insert("data_series".to_string(), 5);
1059
1060        let translator =
1061            FormulaTranslator::new_with_tables(HashMap::new(), table_column_maps, table_row_counts);
1062
1063        let scalar_row_map = HashMap::new();
1064
1065        // Test CORREL
1066        let result = translator
1067            .translate_scalar_formula(
1068                "=CORREL(data_series.advertising, data_series.sales)",
1069                &scalar_row_map,
1070            )
1071            .unwrap();
1072
1073        println!("CORREL result: {result}");
1074        assert!(result.contains("'data_series'!A2:A6"));
1075        assert!(result.contains("'data_series'!B2:B6"));
1076    }
1077}