1use crate::error::{ForgeError, ForgeResult};
4use regex::Regex;
5use std::collections::HashMap;
6
7pub struct FormulaTranslator {
9 column_map: HashMap<String, String>,
11 table_column_maps: HashMap<String, HashMap<String, String>>,
13 table_row_counts: HashMap<String, usize>,
15}
16
17impl FormulaTranslator {
18 #[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 #[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 #[allow(clippy::too_many_lines)] pub fn translate_row_formula(&self, formula: &str, excel_row: u32) -> ForgeResult<String> {
57 let formula_body = formula.strip_prefix('=').unwrap_or(formula);
59
60 let mut result = formula_body.to_string();
61
62 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 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 if result[..full_match.start()].ends_with('\'')
108 || result[full_match.end()..].starts_with('!')
109 {
110 return None;
111 }
112
113 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 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 if result[..full_match.start()].ends_with('\'')
150 || result[full_match.end()..].starts_with('!')
151 {
152 return None;
153 }
154
155 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 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 if Self::is_excel_function(var_name) {
177 continue;
178 }
179
180 let start = match_obj.start();
182 let end = match_obj.end();
183 if start > 0 && result[..start].ends_with('!') {
184 continue;
185 }
186
187 if start > 0 && result[..start].ends_with('\'') {
189 continue;
190 }
191 if end < result.len() && result[end..].starts_with('!') {
192 continue;
193 }
194
195 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 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 let is_number = var_name.parse::<f64>().is_ok();
208
209 if !looks_like_cell_ref && !is_number {
210 return Err(ForgeError::Export(format!(
212 "Column '{var_name}' not found in table"
213 )));
214 }
215 }
217 }
218
219 Ok(format!("={result}"))
220 }
221
222 #[allow(clippy::too_many_lines)] fn is_excel_function(word: &str) -> bool {
225 let upper = word.to_uppercase();
226 matches!(
227 upper.as_str(),
228 "SUM"
230 | "AVERAGE"
231 | "MAX"
232 | "MIN"
233 | "COUNT"
234 | "COUNTA"
235 | "PRODUCT"
236 | "MEDIAN"
237 | "SUMIF"
239 | "SUMIFS"
240 | "COUNTIF"
241 | "COUNTIFS"
242 | "AVERAGEIF"
243 | "AVERAGEIFS"
244 | "MAXIFS"
245 | "MINIFS"
246 | "IF"
248 | "AND"
249 | "OR"
250 | "NOT"
251 | "XOR"
252 | "TRUE"
253 | "FALSE"
254 | "IFERROR"
255 | "IFNA"
256 | "CHOOSE"
257 | "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 | "SIN"
279 | "COS"
280 | "TAN"
281 | "ASIN"
282 | "ACOS"
283 | "ATAN"
284 | "SINH"
285 | "COSH"
286 | "TANH"
287 | "RADIANS"
288 | "DEGREES"
289 | "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 | "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 | "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 | "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 | "VLOOKUP"
354 | "HLOOKUP"
355 | "XLOOKUP"
356 | "INDEX"
357 | "MATCH"
358 | "OFFSET"
359 | "INDIRECT"
360 | "ADDRESS"
361 | "ROW"
362 | "COLUMN"
363 | "ROWS"
364 | "COLUMNS"
365 | "UNIQUE"
367 | "COUNTUNIQUE"
368 | "SORT"
369 | "FILTER"
370 | "SEQUENCE"
371 | "RANDARRAY"
372 | "VAR"
374 | "VARP"
375 | "STDEV"
376 | "STDEVP"
377 | "CORREL"
378 | "PERCENTILE"
379 | "QUARTILE"
380 | "LARGE"
381 | "SMALL"
382 | "RANK"
383 | "LET"
385 | "LAMBDA"
386 | "SWITCH"
387 | "IFS"
388 )
389 }
390
391 #[allow(clippy::too_many_lines)] pub fn translate_scalar_formula(
407 &self,
408 formula: &str,
409 scalar_row_map: &HashMap<String, u32>,
410 ) -> ForgeResult<String> {
411 let formula_body = formula.strip_prefix('=').unwrap_or(formula);
413
414 let mut result = formula_body.to_string();
415
416 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 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 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 for (range, replacement) in indexed_replacements.into_iter().rev() {
446 result.replace_range(range, &replacement);
447 }
448
449 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 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 for (range, replacement) in agg_replacements.into_iter().rev() {
480 result.replace_range(range, &replacement);
481 }
482
483 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 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 if result[..full_match.start()].ends_with('\'')
500 || result[full_match.end()..].starts_with('!')
501 {
502 return None;
503 }
504
505 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 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 for (range, replacement) in general_replacements.into_iter().rev() {
527 result.replace_range(range, &replacement);
528 }
529
530 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 if result[full_match.range()].contains('!') {
545 return None;
546 }
547
548 if !self.table_column_maps.contains_key(table_name) {
550 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 let replacement = format!("'{table_name}'!{col_letter}2");
568 Some((full_match.range(), replacement))
569 })
570 .collect();
571
572 for (range, replacement) in simple_replacements.into_iter().rev() {
574 result.replace_range(range, &replacement);
575 }
576
577 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 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 for (range, replacement) in scalar_replacements.into_iter().rev() {
602 result.replace_range(range, &replacement);
603 }
604
605 Ok(format!("={result}"))
606 }
607
608 #[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 #[allow(clippy::cast_possible_truncation)] 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 let result = translator
660 .translate_row_formula("=revenue - cogs", 2)
661 .unwrap();
662 assert_eq!(result, "=A2 - B2");
663
664 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(); 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 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 let result = translator
736 .translate_row_formula("=NPV(0.1, cashflow)", 2)
737 .unwrap();
738 assert!(result.contains("NPV"));
739 assert!(result.contains("A2")); let result = translator
743 .translate_row_formula("=XNPV(0.1, cashflow, 45000)", 2)
744 .unwrap();
745 assert!(result.contains("XNPV"));
746
747 let result = translator
749 .translate_row_formula("=PMT(0.05, 12, 1000)", 2)
750 .unwrap();
751 assert!(result.contains("PMT"));
752
753 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 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 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 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 #[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 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 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 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")); }
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 let result = translator
904 .translate_scalar_formula("=metrics.total * 2", &scalar_row_map)
905 .unwrap();
906 assert!(result.contains("B5")); }
908
909 #[test]
910 fn test_column_not_found_error() {
911 let column_map = HashMap::new(); let translator = FormulaTranslator::new(column_map);
913
914 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 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)"); 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 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 assert!(FormulaTranslator::is_excel_function("SUM"));
986 assert!(FormulaTranslator::is_excel_function("sum")); 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")); assert!(!FormulaTranslator::is_excel_function("my_column")); }
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 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 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 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}