Skip to main content

spreadsheet_kit/model/
diagnostics.rs

1use formualizer_parse::parser::ParserError;
2use formualizer_parse::tokenizer::{
3    RecoveryAction, TokenDiagnostic, TokenStream, TokenSubType, TokenType,
4};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9const MAX_GROUPS: usize = 50;
10const MAX_SAMPLE_ADDRESSES: usize = 5;
11const FORMULA_PREVIEW_MAX_BYTES: usize = 80;
12
13pub const FORMULA_PARSE_FAILED: &str = "FORMULA_PARSE_FAILED";
14pub const FORMULA_PARSE_FAILED_PREFIX: &str = "formula parse failed: ";
15
16#[derive(
17    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, clap::ValueEnum, Default,
18)]
19#[serde(rename_all = "snake_case")]
20pub enum FormulaParsePolicy {
21    /// Abort on any formula parse failure.
22    Fail,
23    /// Continue but collect diagnostics.
24    #[default]
25    Warn,
26    /// Skip silently.
27    Off,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(rename_all = "snake_case")]
32pub enum CommandClass {
33    SingleWrite,
34    BatchWrite,
35    ReadAnalysis,
36}
37
38impl FormulaParsePolicy {
39    pub fn default_for_command_class(class: CommandClass) -> Self {
40        match class {
41            CommandClass::SingleWrite => FormulaParsePolicy::Fail,
42            CommandClass::BatchWrite | CommandClass::ReadAnalysis => FormulaParsePolicy::Warn,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct FormulaParseErrorGroup {
49    pub error_code: String,
50    pub error_message: String,
51    pub sheet_name: String,
52    pub formula_preview: String,
53    pub count: usize,
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub sample_addresses: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59pub struct FormulaParseDiagnostics {
60    pub policy: FormulaParsePolicy,
61    pub total_errors: usize,
62    pub groups_truncated: bool,
63    #[serde(skip_serializing_if = "Vec::is_empty")]
64    pub groups: Vec<FormulaParseErrorGroup>,
65}
66
67pub struct FormulaParseDiagnosticsBuilder {
68    policy: FormulaParsePolicy,
69    groups: BTreeMap<(String, String, String), GroupAccumulator>,
70    total_errors: usize,
71}
72
73struct GroupAccumulator {
74    error_code: String,
75    error_message: String,
76    formula_preview: String,
77    count: usize,
78    sample_addresses: Vec<String>,
79}
80
81impl FormulaParseDiagnosticsBuilder {
82    pub fn new(policy: FormulaParsePolicy) -> Self {
83        Self {
84            policy,
85            groups: BTreeMap::new(),
86            total_errors: 0,
87        }
88    }
89
90    pub fn record_error(&mut self, sheet: &str, address: &str, formula: &str, error: &str) {
91        let formula_preview = truncate_formula_preview(formula);
92        let normalized_formula = normalize_formula_for_grouping(formula);
93        let normalized_error = normalize_error_for_grouping(error);
94        let key = (sheet.to_string(), normalized_error, normalized_formula);
95
96        self.total_errors += 1;
97
98        let group = self.groups.entry(key).or_insert_with(|| GroupAccumulator {
99            error_code: FORMULA_PARSE_FAILED.to_string(),
100            error_message: error.to_string(),
101            formula_preview,
102            count: 0,
103            sample_addresses: Vec::new(),
104        });
105
106        group.count += 1;
107        if group.sample_addresses.len() < MAX_SAMPLE_ADDRESSES {
108            group.sample_addresses.push(address.to_string());
109        }
110    }
111
112    pub fn build(self) -> FormulaParseDiagnostics {
113        let groups_truncated = self.groups.len() > MAX_GROUPS;
114        let groups = self
115            .groups
116            .into_iter()
117            .take(MAX_GROUPS)
118            .map(
119                |((sheet_name, _error_message, _normalized_key), group)| FormulaParseErrorGroup {
120                    error_code: group.error_code,
121                    error_message: group.error_message,
122                    sheet_name,
123                    formula_preview: group.formula_preview,
124                    count: group.count,
125                    sample_addresses: group.sample_addresses,
126                },
127            )
128            .collect();
129
130        FormulaParseDiagnostics {
131            policy: self.policy,
132            total_errors: self.total_errors,
133            groups_truncated,
134            groups,
135        }
136    }
137
138    pub fn is_empty(&self) -> bool {
139        self.total_errors == 0
140    }
141
142    pub fn has_errors(&self) -> bool {
143        self.total_errors > 0
144    }
145}
146
147fn truncate_formula_preview(formula: &str) -> String {
148    if formula.len() <= FORMULA_PREVIEW_MAX_BYTES {
149        return formula.to_string();
150    }
151
152    let mut end = FORMULA_PREVIEW_MAX_BYTES;
153    while end > 0 && !formula.is_char_boundary(end) {
154        end -= 1;
155    }
156
157    let mut result = formula[..end].to_string();
158    result.push('…');
159    result
160}
161
162/// Normalize a formula for grouping by replacing cell/range references with
163/// `$REF`. This collapses formulas that differ only in cell addresses (e.g.
164/// `=IF(C4="",...)` and `=IF(C5="",...)`) into the same group key.
165fn normalize_formula_for_grouping(formula: &str) -> String {
166    if let Ok(stream) = TokenStream::new(formula) {
167        let mut out = String::with_capacity(formula.len());
168        if formula.starts_with('=') {
169            out.push('=');
170        }
171        for span in &stream.spans {
172            if span.token_type == TokenType::Operand && span.subtype == TokenSubType::Range {
173                out.push_str("$REF");
174            } else if let Some(val) = stream.source().get(span.start..span.end) {
175                out.push_str(val);
176            }
177        }
178        return truncate_formula_preview(&out);
179    }
180
181    // Fallback for unparsable formulas: use a simple regex-style substitution
182    // to replace cell-like references (e.g. A1, $C$10, Sheet1!B2:C5).
183    normalize_refs_regex(formula)
184}
185
186/// Regex-free cell reference normalization for malformed formulas.
187/// Replaces patterns like A1, $B$2, C10, AA100 with $REF.
188fn normalize_refs_regex(formula: &str) -> String {
189    let bytes = formula.as_bytes();
190    let mut out = String::with_capacity(formula.len());
191    let mut i = 0;
192
193    while i < bytes.len() {
194        // Skip dollar signs that prefix column/row references
195        let start = i;
196        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_alphabetic() {
197            // possible absolute ref like $A$1
198        }
199
200        // Try to match a cell reference: optional $, 1-3 alpha, optional $, 1+ digit
201        let mut j = i;
202        // skip leading $
203        if j < bytes.len() && bytes[j] == b'$' {
204            j += 1;
205        }
206        // require 1-3 alpha chars (column)
207        let col_start = j;
208        while j < bytes.len() && bytes[j].is_ascii_alphabetic() && j - col_start < 4 {
209            j += 1;
210        }
211        let col_len = j - col_start;
212        if (1..=3).contains(&col_len) {
213            // skip optional $ before row
214            if j < bytes.len() && bytes[j] == b'$' {
215                j += 1;
216            }
217            // require 1+ digits (row)
218            let row_start = j;
219            while j < bytes.len() && bytes[j].is_ascii_digit() {
220                j += 1;
221            }
222            let row_len = j - row_start;
223            if row_len >= 1 {
224                // Ensure this isn't part of a larger identifier (e.g. function name)
225                let preceded_by_alpha = start > 0
226                    && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'_');
227                let followed_by_alpha =
228                    j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_');
229                if !preceded_by_alpha && !followed_by_alpha {
230                    out.push_str("$REF");
231                    i = j;
232                    continue;
233                }
234            }
235        }
236
237        out.push(bytes[i] as char);
238        i += 1;
239    }
240
241    truncate_formula_preview(&out)
242}
243
244/// Normalize an error message for grouping by replacing numeric position/range
245/// fields with placeholders.
246fn normalize_error_for_grouping(error: &str) -> String {
247    let mut out = String::with_capacity(error.len());
248    let bytes = error.as_bytes();
249    let mut i = 0;
250
251    while i < bytes.len() {
252        // Normalize "position <digits>" -> "position N"
253        if i + 9 <= bytes.len() && &bytes[i..i + 9] == b"position " {
254            out.push_str("position ");
255            i += 9;
256            if i < bytes.len() && bytes[i].is_ascii_digit() {
257                out.push('N');
258                while i < bytes.len() && bytes[i].is_ascii_digit() {
259                    i += 1;
260                }
261                continue;
262            }
263        }
264
265        // Normalize "bytes <digits>..<digits>" -> "bytes N..N"
266        if i + 6 <= bytes.len() && &bytes[i..i + 6] == b"bytes " {
267            let mut cursor = i + 6;
268            if cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
269                out.push_str("bytes N");
270                while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
271                    cursor += 1;
272                }
273                if cursor + 2 <= bytes.len() && &bytes[cursor..cursor + 2] == b".." {
274                    cursor += 2;
275                    out.push_str("..N");
276                    while cursor < bytes.len() && bytes[cursor].is_ascii_digit() {
277                        cursor += 1;
278                    }
279                }
280                i = cursor;
281                continue;
282            }
283        }
284
285        out.push(bytes[i] as char);
286        i += 1;
287    }
288
289    out
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct FormulaParseFailure {
294    pub parser_message: String,
295    pub parser_position: Option<usize>,
296    pub tokenizer: Option<TokenizerRecovery>,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct TokenizerRecovery {
301    pub message: String,
302    pub recovery: RecoveryAction,
303    pub span_start: usize,
304    pub span_end: usize,
305}
306
307impl FormulaParseFailure {
308    fn from_parser_error(formula: &str, err: &ParserError) -> Self {
309        let tokenizer = first_tokenizer_recovery(formula);
310        Self {
311            parser_message: err.message.clone(),
312            parser_position: err.position,
313            tokenizer,
314        }
315    }
316
317    fn render_message(&self) -> String {
318        let parser_message = match self.parser_position {
319            Some(pos) => format!("parse error at position {pos}: {}", self.parser_message),
320            None => format!("parse error: {}", self.parser_message),
321        };
322
323        if let Some(tokenizer) = &self.tokenizer {
324            format!(
325                "{parser_message} (tokenizer recovery {:?} at bytes {}..{}: {})",
326                tokenizer.recovery, tokenizer.span_start, tokenizer.span_end, tokenizer.message
327            )
328        } else {
329            parser_message
330        }
331    }
332}
333
334impl std::fmt::Display for FormulaParseFailure {
335    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
336        f.write_str(&self.render_message())
337    }
338}
339
340impl std::error::Error for FormulaParseFailure {}
341
342fn first_tokenizer_recovery(formula: &str) -> Option<TokenizerRecovery> {
343    let stream = TokenStream::new_best_effort(formula);
344    let diagnostic = stream.diagnostics_ref().first()?;
345    Some(tokenizer_recovery_from_diagnostic(diagnostic))
346}
347
348fn tokenizer_recovery_from_diagnostic(diagnostic: &TokenDiagnostic) -> TokenizerRecovery {
349    TokenizerRecovery {
350        message: diagnostic.message.clone(),
351        recovery: diagnostic.recovery,
352        span_start: diagnostic.span.start,
353        span_end: diagnostic.span.end,
354    }
355}
356
357fn normalize_formula_input(formula: &str) -> String {
358    let trimmed = formula.trim();
359    if trimmed.starts_with('=') {
360        trimmed.to_string()
361    } else {
362        format!("={trimmed}")
363    }
364}
365
366pub fn validate_formula_detailed(formula: &str) -> Result<(), FormulaParseFailure> {
367    let formula_in = normalize_formula_input(formula);
368    formualizer_parse::parse(&formula_in)
369        .map(|_| ())
370        .map_err(|err| FormulaParseFailure::from_parser_error(&formula_in, &err))
371}
372
373pub fn format_formula_parse_failure(formula: &str, err: &ParserError) -> String {
374    let formula_in = normalize_formula_input(formula);
375    FormulaParseFailure::from_parser_error(&formula_in, err).to_string()
376}
377
378/// Validate a single formula string using the project's formula parser.
379/// Returns Ok(()) if valid, Err(error_message) if invalid.
380pub fn validate_formula(formula: &str) -> Result<(), String> {
381    validate_formula_detailed(formula).map_err(|err| err.to_string())
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_policy_default_is_warn() {
390        assert_eq!(FormulaParsePolicy::default(), FormulaParsePolicy::Warn);
391    }
392
393    #[test]
394    fn test_policy_default_for_single_write() {
395        assert_eq!(
396            FormulaParsePolicy::default_for_command_class(CommandClass::SingleWrite),
397            FormulaParsePolicy::Fail
398        );
399    }
400
401    #[test]
402    fn test_policy_default_for_batch_write() {
403        assert_eq!(
404            FormulaParsePolicy::default_for_command_class(CommandClass::BatchWrite),
405            FormulaParsePolicy::Warn
406        );
407    }
408
409    #[test]
410    fn test_policy_default_for_read_analysis() {
411        assert_eq!(
412            FormulaParsePolicy::default_for_command_class(CommandClass::ReadAnalysis),
413            FormulaParsePolicy::Warn
414        );
415    }
416
417    #[test]
418    fn test_policy_serde_roundtrip() {
419        let cases = [
420            (FormulaParsePolicy::Fail, "fail"),
421            (FormulaParsePolicy::Warn, "warn"),
422            (FormulaParsePolicy::Off, "off"),
423        ];
424
425        for (policy, expected) in cases {
426            let serialized = serde_json::to_string(&policy).expect("serialize policy");
427            assert_eq!(serialized, format!("\"{expected}\""));
428
429            let deserialized: FormulaParsePolicy =
430                serde_json::from_str(&serialized).expect("deserialize policy");
431            assert_eq!(deserialized, policy);
432        }
433    }
434
435    #[test]
436    fn test_empty_builder() {
437        let builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
438        assert!(builder.is_empty());
439        assert!(!builder.has_errors());
440
441        let diagnostics = builder.build();
442        assert_eq!(diagnostics.policy, FormulaParsePolicy::Warn);
443        assert_eq!(diagnostics.total_errors, 0);
444        assert!(!diagnostics.groups_truncated);
445        assert!(diagnostics.groups.is_empty());
446    }
447
448    #[test]
449    fn test_single_error_group() {
450        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
451        builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
452
453        let diagnostics = builder.build();
454        assert_eq!(diagnostics.total_errors, 1);
455        assert_eq!(diagnostics.groups.len(), 1);
456        let group = &diagnostics.groups[0];
457        assert_eq!(group.count, 1);
458        assert_eq!(group.error_code, FORMULA_PARSE_FAILED);
459    }
460
461    #[test]
462    fn test_grouping_same_key() {
463        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
464        builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
465        builder.record_error("Sheet1", "A2", "=SUM(A:A)", "unexpected token");
466        builder.record_error("Sheet1", "A3", "=SUM(A:A)", "unexpected token");
467
468        let diagnostics = builder.build();
469        assert_eq!(diagnostics.groups.len(), 1);
470        let group = &diagnostics.groups[0];
471        assert_eq!(group.count, 3);
472        assert_eq!(group.sample_addresses, vec!["A1", "A2", "A3"]);
473    }
474
475    #[test]
476    fn test_grouping_different_sheets() {
477        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
478        builder.record_error("A", "A1", "=SUM(A:A)", "unexpected token");
479        builder.record_error("B", "A1", "=SUM(A:A)", "unexpected token");
480
481        let diagnostics = builder.build();
482        assert_eq!(diagnostics.groups.len(), 2);
483        assert_eq!(diagnostics.groups[0].sheet_name, "A");
484        assert_eq!(diagnostics.groups[1].sheet_name, "B");
485    }
486
487    #[test]
488    fn test_grouping_different_messages() {
489        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
490        builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
491        builder.record_error("Sheet1", "B1", "=SUM(A:A)", "unknown function");
492
493        let diagnostics = builder.build();
494        assert_eq!(diagnostics.groups.len(), 2);
495    }
496
497    #[test]
498    fn test_sample_address_cap_at_5() {
499        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
500
501        for i in 1..=8 {
502            builder.record_error("Sheet1", &format!("A{i}"), "=SUM(A:A)", "unexpected token");
503        }
504
505        let diagnostics = builder.build();
506        let group = &diagnostics.groups[0];
507        assert_eq!(group.count, 8);
508        assert_eq!(group.sample_addresses.len(), 5);
509        assert_eq!(group.sample_addresses, vec!["A1", "A2", "A3", "A4", "A5"]);
510    }
511
512    #[test]
513    fn test_deterministic_ordering() {
514        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
515        builder.record_error("C", "A1", "=1", "err");
516        builder.record_error("A", "A1", "=1", "err");
517        builder.record_error("B", "A1", "=1", "err");
518
519        let diagnostics = builder.build();
520        let sheets: Vec<&str> = diagnostics
521            .groups
522            .iter()
523            .map(|group| group.sheet_name.as_str())
524            .collect();
525        assert_eq!(sheets, vec!["A", "B", "C"]);
526    }
527
528    #[test]
529    fn test_groups_truncated_at_50() {
530        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
531
532        // Use structurally distinct formulas (different function names) so they
533        // don't collapse under reference normalization.
534        for i in 0..60 {
535            builder.record_error(
536                "Sheet1",
537                "A1",
538                &format!("=FUNC{i}(A1)"),
539                &format!("error variant {i}"),
540            );
541        }
542
543        let diagnostics = builder.build();
544        assert_eq!(diagnostics.total_errors, 60);
545        assert_eq!(diagnostics.groups.len(), 50);
546        assert!(diagnostics.groups_truncated);
547    }
548
549    #[test]
550    fn test_formula_preview_truncation() {
551        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
552        let formula = format!("={}", "A".repeat(119));
553        assert_eq!(formula.len(), 120);
554
555        builder.record_error("Sheet1", "A1", &formula, "unexpected token");
556        let diagnostics = builder.build();
557
558        let preview = &diagnostics.groups[0].formula_preview;
559        assert!(preview.ends_with('…'));
560        assert_ne!(preview, &formula);
561        assert!(preview.len() <= FORMULA_PREVIEW_MAX_BYTES + '…'.len_utf8());
562    }
563
564    #[test]
565    fn test_diagnostics_json_structure() {
566        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
567        builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
568        let diagnostics = builder.build();
569
570        let value = serde_json::to_value(diagnostics).expect("serialize diagnostics");
571        assert_eq!(value["policy"], serde_json::json!("warn"));
572        assert_eq!(value["total_errors"], serde_json::json!(1));
573        assert_eq!(value["groups_truncated"], serde_json::json!(false));
574        assert!(value["groups"].is_array());
575
576        let group = &value["groups"][0];
577        assert_eq!(group["error_code"], serde_json::json!(FORMULA_PARSE_FAILED));
578        assert_eq!(
579            group["error_message"],
580            serde_json::json!("unexpected token")
581        );
582        assert_eq!(group["sheet_name"], serde_json::json!("Sheet1"));
583        assert_eq!(group["formula_preview"], serde_json::json!("=SUM(A:A)"));
584        assert_eq!(group["count"], serde_json::json!(1));
585        assert!(group["sample_addresses"].is_array());
586    }
587
588    #[test]
589    fn test_has_errors_after_record() {
590        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
591        builder.record_error("Sheet1", "A1", "=SUM(A:A)", "unexpected token");
592
593        assert!(builder.has_errors());
594        assert!(!builder.is_empty());
595    }
596
597    #[test]
598    fn test_validate_formula_valid() {
599        assert!(validate_formula("SUM(A1:A10)").is_ok());
600        assert!(validate_formula("=SUM(A1:A10)").is_ok());
601        assert!(validate_formula("A1+B1").is_ok());
602        assert!(validate_formula("IF(A1>0,1,0)").is_ok());
603    }
604
605    #[test]
606    fn test_validate_formula_invalid() {
607        assert!(validate_formula("SUM(A1:A10").is_err()); // unclosed paren
608        assert!(validate_formula("SUM(A1:A10))").is_err()); // extra closing paren
609    }
610
611    #[test]
612    fn test_validate_formula_detailed_includes_recovery_context() {
613        let err = validate_formula_detailed("SUM(A1:A10")
614            .expect_err("unterminated formula should return parse diagnostics");
615        let rendered = err.to_string();
616        assert!(rendered.contains("parse error"));
617        assert!(rendered.contains("tokenizer recovery"));
618        assert!(rendered.contains("bytes "));
619    }
620
621    #[test]
622    fn test_normalize_error_for_grouping_normalizes_bytes_ranges() {
623        let n1 = normalize_error_for_grouping(
624            "parse error at position 14 (tokenizer recovery UnmatchedOpener at bytes 1..9: x)",
625        );
626        let n2 = normalize_error_for_grouping(
627            "parse error at position 29 (tokenizer recovery UnmatchedOpener at bytes 7..15: x)",
628        );
629        assert_eq!(n1, n2);
630    }
631
632    #[test]
633    fn test_grouping_normalizes_cell_references() {
634        // Formulas that differ only in cell references should group together.
635        // This is the exact scenario from the Production_Readiness workbook.
636        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
637        builder.record_error(
638            "Assessments",
639            "D4",
640            "=IF(C4=\"\",\"\",IF(C4=\"N/A\",\"\",0))",
641            "parse error at position 42",
642        );
643        builder.record_error(
644            "Assessments",
645            "D5",
646            "=IF(C5=\"\",\"\",IF(C5=\"N/A\",\"\",0))",
647            "parse error at position 42",
648        );
649        builder.record_error(
650            "Assessments",
651            "D10",
652            "=IF(C10=\"\",\"\",IF(C10=\"N/A\",\"\",0))",
653            "parse error at position 42",
654        );
655
656        let diagnostics = builder.build();
657        // All three should collapse into ONE group (same structure, same error)
658        assert_eq!(diagnostics.total_errors, 3);
659        assert_eq!(diagnostics.groups.len(), 1);
660
661        let group = &diagnostics.groups[0];
662        assert_eq!(group.count, 3);
663        assert_eq!(group.sample_addresses, vec!["D4", "D5", "D10"]);
664        // formula_preview should show the first formula encountered (human-readable, not normalized)
665        assert!(group.formula_preview.contains("C4"));
666    }
667
668    #[test]
669    fn test_grouping_different_structure_not_collapsed() {
670        // Formulas with genuinely different structure should NOT collapse.
671        let mut builder = FormulaParseDiagnosticsBuilder::new(FormulaParsePolicy::Warn);
672        builder.record_error("Sheet1", "A1", "=SUM(A1:A10)", "unexpected token");
673        builder.record_error("Sheet1", "A2", "=AVERAGE(B1:B10)", "unexpected token");
674
675        let diagnostics = builder.build();
676        assert_eq!(diagnostics.groups.len(), 2);
677    }
678
679    #[test]
680    fn test_normalize_formula_for_grouping() {
681        // Direct unit test for the normalization function
682        let n1 = normalize_formula_for_grouping("=IF(C4=\"\",\"\",0)");
683        let n2 = normalize_formula_for_grouping("=IF(C5=\"\",\"\",0)");
684        let n3 = normalize_formula_for_grouping("=IF(C100=\"\",\"\",0)");
685        assert_eq!(n1, n2);
686        assert_eq!(n2, n3);
687
688        // Different structure should differ
689        let n4 = normalize_formula_for_grouping("=SUM(A1:A10)");
690        let n5 = normalize_formula_for_grouping("=AVERAGE(A1:A10)");
691        assert_ne!(n4, n5);
692
693        // Unparsable formula falls back to truncated preview
694        let bad = normalize_formula_for_grouping("=SUM(A1:A10))");
695        assert!(!bad.is_empty());
696    }
697}