Skip to main content

spreadsheet_kit/tools/
structure_impact.rs

1//! Pure planning functions for structure-batch impact reports and formula delta previews.
2//!
3//! These functions analyse a workbook + structure ops **without** mutating any file.
4
5use crate::tools::fork::StructureOp;
6use anyhow::Result;
7use formualizer_parse::tokenizer::Tokenizer;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeSet;
11use std::path::Path;
12
13// ──────────────────────────────────────────────────────────────────
14//  Public types (additive – never break existing callers)
15// ──────────────────────────────────────────────────────────────────
16
17/// Machine-readable impact report for a set of structure operations.
18#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19pub struct StructureImpactReport {
20    /// Per-operation shifted row/col spans.
21    pub shifted_spans: Vec<ShiftedSpan>,
22    /// Absolute-reference-risk warnings for `$`-anchored refs crossing insertion/deletion zones.
23    pub absolute_ref_warnings: Vec<AbsoluteRefWarning>,
24    /// Number of formula tokens affected (would be rewritten).
25    pub tokens_affected: u64,
26    /// Number of formula tokens unaffected (outside the zone).
27    pub tokens_unaffected: u64,
28    /// Informational notes (e.g. single-cell range no-expansion).
29    #[serde(skip_serializing_if = "Vec::is_empty")]
30    pub notes: Vec<String>,
31}
32
33/// Describes a row/column span that would be shifted by a structure op.
34#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35pub struct ShiftedSpan {
36    pub op_index: usize,
37    pub sheet_name: String,
38    pub axis: String, // "row" | "col"
39    /// Human-readable description, e.g. "rows 5..∞ shift +3".
40    pub description: String,
41    pub at: u32,
42    pub count: u32,
43    pub direction: String, // "insert" | "delete"
44}
45
46/// A warning about an absolute reference (`$A$5`) that crosses a structural edit boundary.
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct AbsoluteRefWarning {
49    /// Warning code: `ABSOLUTE_REF_CROSS_INSERT` or `ABSOLUTE_REF_CROSS_DELETE`.
50    pub warning_code: String,
51    /// Sheet + cell owning the formula, e.g. `"Calc!B2"`.
52    pub cell: String,
53    /// The original formula text.
54    pub formula: String,
55    /// The specific token that is at risk.
56    pub token: String,
57    /// Human-readable explanation.
58    pub message: String,
59}
60
61/// A before/after formula delta item for preview.
62#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct FormulaDeltaItem {
64    /// Sheet + cell, e.g. `"Sheet1!C4"`.
65    pub cell: String,
66    /// Formula text before the structure edit.
67    pub before: String,
68    /// Predicted formula text after the structure edit.
69    pub after: String,
70    /// Classification: `"shifted"`, `"deleted_ref"`, or `"unchanged"`.
71    pub classification: String,
72    /// Optional warning code.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub warning_code: Option<String>,
75}
76
77// ──────────────────────────────────────────────────────────────────
78//  Public entry point
79// ──────────────────────────────────────────────────────────────────
80
81/// Build an impact report (and optional formula delta preview) by analysing the workbook
82/// on disk without mutating it.
83///
84/// * `impact_report` is always computed when called.
85/// * `formula_delta_preview` is only computed when `include_formula_delta` is true.
86pub fn compute_structure_impact(
87    path: &Path,
88    ops: &[StructureOp],
89    include_formula_delta: bool,
90) -> Result<(StructureImpactReport, Option<Vec<FormulaDeltaItem>>)> {
91    let book = umya_spreadsheet::reader::xlsx::read(path)?;
92
93    // 1. Build shifted spans from ops.
94    let shifted_spans = build_shifted_spans(ops)?;
95
96    // 2. Collect all formula cells across sheets.
97    let mut tokens_affected: u64 = 0;
98    let mut tokens_unaffected: u64 = 0;
99    let mut absolute_ref_warnings: Vec<AbsoluteRefWarning> = Vec::new();
100    let mut notes: Vec<String> = Vec::new();
101    let mut formula_deltas: Vec<FormulaDeltaItem> = Vec::new();
102
103    for sheet in book.get_sheet_collection() {
104        let sheet_name = sheet.get_name().to_string();
105        for cell in sheet.get_cell_collection() {
106            if !cell.is_formula() {
107                continue;
108            }
109            let formula_text = cell.get_formula();
110            if formula_text.is_empty() {
111                continue;
112            }
113            let cell_address = cell.get_coordinate().get_coordinate().to_string();
114            let full_cell = format!("{}!{}", sheet_name, cell_address);
115
116            let formula_with_equals = if formula_text.starts_with('=') {
117                formula_text.to_string()
118            } else {
119                format!("={}", formula_text)
120            };
121
122            let tokens = match Tokenizer::new(&formula_with_equals) {
123                Ok(tok) => tok.items,
124                Err(_) => continue,
125            };
126
127            let mut cell_affected = false;
128
129            for token in &tokens {
130                if token.subtype != formualizer_parse::TokenSubType::Range {
131                    continue;
132                }
133                let value = &token.value;
134
135                // Determine which sheet this reference targets.
136                let (ref_sheet, coord_part) = if let Some((sp, cp)) = value.split_once('!') {
137                    (extract_sheet_name(sp), cp.to_string())
138                } else {
139                    // Unqualified ref → belongs to the same sheet as the cell.
140                    (sheet_name.clone(), value.clone())
141                };
142
143                let relevant_spans: Vec<&ShiftedSpan> = shifted_spans
144                    .iter()
145                    .filter(|span| span.sheet_name == ref_sheet)
146                    .collect();
147                if relevant_spans.is_empty() {
148                    continue;
149                }
150
151                // Count each token at most once as affected/unaffected, even when multiple
152                // structure spans target the same sheet in a single batch.
153                let mut token_affected = false;
154                for span in &relevant_spans {
155                    if ref_touches_zone(
156                        &coord_part,
157                        &span.axis,
158                        span.at,
159                        span.count,
160                        &span.direction,
161                    ) {
162                        token_affected = true;
163                        cell_affected = true;
164
165                        // Check for absolute refs at risk.
166                        if has_absolute_component(&coord_part, &span.axis) {
167                            let warning_code = if span.direction == "insert" {
168                                "ABSOLUTE_REF_CROSS_INSERT"
169                            } else {
170                                "ABSOLUTE_REF_CROSS_DELETE"
171                            };
172                            absolute_ref_warnings.push(AbsoluteRefWarning {
173                                warning_code: warning_code.to_string(),
174                                cell: full_cell.clone(),
175                                formula: formula_text.to_string(),
176                                token: value.clone(),
177                                message: format!(
178                                    "Absolute reference '{}' in {} crosses {} zone at {}={}",
179                                    value, full_cell, span.direction, span.axis, span.at
180                                ),
181                            });
182                        }
183                    }
184                }
185
186                if token_affected {
187                    tokens_affected += 1;
188                    // Check single-cell range non-expansion.
189                    if is_single_cell_range(&coord_part) {
190                        notes.push(format!(
191                            "Single-cell range '{}' in {} will not expand on insert (like SUM(K54:K54))",
192                            value, full_cell
193                        ));
194                    }
195                } else {
196                    tokens_unaffected += 1;
197                }
198            }
199
200            // Formula delta preview.
201            if include_formula_delta && cell_affected {
202                let predicted =
203                    simulate_formula_after(&formula_with_equals, &sheet_name, &shifted_spans);
204                let before_clean = formula_text.to_string();
205                let after_clean = predicted
206                    .strip_prefix('=')
207                    .unwrap_or(&predicted)
208                    .to_string();
209
210                let classification = if after_clean.contains("#REF!") {
211                    "deleted_ref"
212                } else if before_clean == after_clean {
213                    "unchanged"
214                } else {
215                    "shifted"
216                };
217
218                let warning_code = if classification == "deleted_ref" {
219                    Some("DELETED_REF".to_string())
220                } else {
221                    None
222                };
223
224                formula_deltas.push(FormulaDeltaItem {
225                    cell: full_cell,
226                    before: before_clean,
227                    after: after_clean,
228                    classification: classification.to_string(),
229                    warning_code,
230                });
231            }
232        }
233    }
234
235    // Deduplicate notes.
236    let notes: Vec<String> = {
237        let mut seen = BTreeSet::new();
238        notes
239            .into_iter()
240            .filter(|n| seen.insert(n.clone()))
241            .collect()
242    };
243
244    let report = StructureImpactReport {
245        shifted_spans,
246        absolute_ref_warnings,
247        tokens_affected,
248        tokens_unaffected,
249        notes,
250    };
251
252    // Cap formula delta preview to a reasonable sample (50 items).
253    let delta = if include_formula_delta {
254        let mut deltas = formula_deltas;
255        deltas.truncate(50);
256        Some(deltas)
257    } else {
258        None
259    };
260
261    Ok((report, delta))
262}
263
264// ──────────────────────────────────────────────────────────────────
265//  Internal helpers
266// ──────────────────────────────────────────────────────────────────
267
268fn build_shifted_spans(ops: &[StructureOp]) -> Result<Vec<ShiftedSpan>> {
269    let mut spans = Vec::new();
270    for (idx, op) in ops.iter().enumerate() {
271        match op {
272            StructureOp::InsertRows {
273                sheet_name,
274                at_row,
275                count,
276                ..
277            } => {
278                spans.push(ShiftedSpan {
279                    op_index: idx,
280                    sheet_name: sheet_name.clone(),
281                    axis: "row".to_string(),
282                    description: format!("rows {}..∞ shift +{}", at_row, count),
283                    at: *at_row,
284                    count: *count,
285                    direction: "insert".to_string(),
286                });
287            }
288            StructureOp::DeleteRows {
289                sheet_name,
290                start_row,
291                count,
292            } => {
293                spans.push(ShiftedSpan {
294                    op_index: idx,
295                    sheet_name: sheet_name.clone(),
296                    axis: "row".to_string(),
297                    description: format!(
298                        "rows {}..{} deleted, rows {}..∞ shift -{}",
299                        start_row,
300                        start_row + count - 1,
301                        start_row + count,
302                        count
303                    ),
304                    at: *start_row,
305                    count: *count,
306                    direction: "delete".to_string(),
307                });
308            }
309            StructureOp::InsertCols {
310                sheet_name,
311                at_col,
312                count,
313            } => {
314                let col_letters = at_col.trim().to_uppercase();
315                let col_index =
316                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
317                spans.push(ShiftedSpan {
318                    op_index: idx,
319                    sheet_name: sheet_name.clone(),
320                    axis: "col".to_string(),
321                    description: format!("cols {}..∞ shift +{}", col_letters, count),
322                    at: col_index,
323                    count: *count,
324                    direction: "insert".to_string(),
325                });
326            }
327            StructureOp::DeleteCols {
328                sheet_name,
329                start_col,
330                count,
331            } => {
332                let col_letters = start_col.trim().to_uppercase();
333                let col_index =
334                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
335                let end_col_index = col_index + count - 1;
336                let end_col_letters =
337                    umya_spreadsheet::helper::coordinate::string_from_column_index(&end_col_index);
338                let next_col_index = col_index + count;
339                let next_col_letters =
340                    umya_spreadsheet::helper::coordinate::string_from_column_index(&next_col_index);
341                spans.push(ShiftedSpan {
342                    op_index: idx,
343                    sheet_name: sheet_name.clone(),
344                    axis: "col".to_string(),
345                    description: format!(
346                        "cols {}..{} deleted, cols {}..∞ shift -{}",
347                        col_letters, end_col_letters, next_col_letters, count
348                    ),
349                    at: col_index,
350                    count: *count,
351                    direction: "delete".to_string(),
352                });
353            }
354            // Non-row/col ops don't produce shifted spans (rename, create, delete sheet, etc.)
355            _ => {}
356        }
357    }
358    Ok(spans)
359}
360
361/// Check whether a cell reference (the coordinate part, e.g. `$A$5` or `B2:C10`)
362/// intersects the affected zone.
363fn ref_touches_zone(coord_part: &str, axis: &str, at: u32, count: u32, direction: &str) -> bool {
364    // Parse potential range A1:B2 or single cell.
365    let parts: Vec<&str> = coord_part.split(':').collect();
366    for part in &parts {
367        let (col_idx, row_idx, _, _) =
368            umya_spreadsheet::helper::coordinate::index_from_coordinate(part);
369        match axis {
370            "row" => {
371                if let Some(r) = row_idx {
372                    if direction == "insert" && r >= at {
373                        return true;
374                    }
375                    if direction == "delete" {
376                        let end = at + count - 1;
377                        if r >= at && r <= end {
378                            return true; // in deleted zone
379                        }
380                        if r > end {
381                            return true; // will be shifted
382                        }
383                    }
384                }
385            }
386            "col" => {
387                if let Some(c) = col_idx {
388                    if direction == "insert" && c >= at {
389                        return true;
390                    }
391                    if direction == "delete" {
392                        let end = at + count - 1;
393                        if c >= at && c <= end {
394                            return true;
395                        }
396                        if c > end {
397                            return true;
398                        }
399                    }
400                }
401            }
402            _ => {}
403        }
404    }
405    false
406}
407
408/// Check whether a coordinate part has an absolute `$` component on the relevant axis.
409fn has_absolute_component(coord_part: &str, axis: &str) -> bool {
410    let parts: Vec<&str> = coord_part.split(':').collect();
411    for part in &parts {
412        let (_, _, col_lock, row_lock) =
413            umya_spreadsheet::helper::coordinate::index_from_coordinate(part);
414        match axis {
415            "row" => {
416                if row_lock == Some(true) {
417                    return true;
418                }
419            }
420            "col" => {
421                if col_lock == Some(true) {
422                    return true;
423                }
424            }
425            _ => {}
426        }
427    }
428    false
429}
430
431/// Check if coord_part is a single-cell range like K54:K54.
432fn is_single_cell_range(coord_part: &str) -> bool {
433    if let Some((start, end)) = coord_part.split_once(':') {
434        // Normalize by removing $ for comparison.
435        let clean_start = start.replace('$', "").to_uppercase();
436        let clean_end = end.replace('$', "").to_uppercase();
437        clean_start == clean_end
438    } else {
439        false
440    }
441}
442
443/// Extract sheet name from a potentially quoted sheet prefix like `'My Sheet'` or `Sheet1`.
444fn extract_sheet_name(raw: &str) -> String {
445    let trimmed = raw.trim();
446    if let Some(stripped) = trimmed.strip_prefix('\'')
447        && let Some(inner) = stripped.strip_suffix('\'')
448    {
449        return inner.replace("''", "'");
450    }
451    trimmed.to_string()
452}
453
454/// Simulate what a formula would look like after applying the given shifted spans.
455/// This is a best-effort prediction using the same token-level approach as the real rewriter.
456fn simulate_formula_after(
457    formula_with_equals: &str,
458    cell_sheet: &str,
459    spans: &[ShiftedSpan],
460) -> String {
461    let tokens = match Tokenizer::new(formula_with_equals) {
462        Ok(tok) => tok.items,
463        Err(_) => return formula_with_equals.to_string(),
464    };
465
466    let mut out = String::with_capacity(formula_with_equals.len());
467    let mut cursor = 0usize;
468
469    for token in &tokens {
470        if token.start > cursor {
471            out.push_str(&formula_with_equals[cursor..token.start]);
472        }
473
474        let mut value = token.value.clone();
475        if token.subtype == formualizer_parse::TokenSubType::Range {
476            let (ref_sheet, mut coord_part, prefix) = if let Some((sp, cp)) = value.split_once('!')
477            {
478                (extract_sheet_name(sp), cp.to_string(), format!("{}!", sp))
479            } else {
480                (cell_sheet.to_string(), value.clone(), String::new())
481            };
482
483            for span in spans {
484                if span.sheet_name != ref_sheet {
485                    continue;
486                }
487                coord_part = simulate_adjust_coord(&coord_part, span);
488            }
489            value = format!("{}{}", prefix, coord_part);
490        }
491
492        out.push_str(&value);
493        cursor = token.end;
494    }
495
496    if cursor < formula_with_equals.len() {
497        out.push_str(&formula_with_equals[cursor..]);
498    }
499
500    out
501}
502
503fn simulate_adjust_coord(coord_part: &str, span: &ShiftedSpan) -> String {
504    if coord_part == "#REF!" {
505        return coord_part.to_string();
506    }
507    if let Some((start, end)) = coord_part.split_once(':') {
508        let start_adj = simulate_adjust_segment(start, span);
509        let end_adj = simulate_adjust_segment(end, span);
510        if start_adj == "#REF!" || end_adj == "#REF!" {
511            return "#REF!".to_string();
512        }
513        format!("{}:{}", start_adj, end_adj)
514    } else {
515        simulate_adjust_segment(coord_part, span)
516    }
517}
518
519fn simulate_adjust_segment(segment: &str, span: &ShiftedSpan) -> String {
520    use umya_spreadsheet::helper::coordinate::{
521        coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
522    };
523
524    let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
525    let mut col = col;
526    let mut row = row;
527
528    match span.axis.as_str() {
529        "col" => {
530            if let Some(c) = col {
531                if span.direction == "insert" {
532                    col = Some(if c >= span.at { c + span.count } else { c });
533                } else {
534                    // delete
535                    let end = span.at + span.count - 1;
536                    if c >= span.at && c <= end {
537                        col = None; // deleted
538                    } else if c > end {
539                        col = Some(c - span.count);
540                    }
541                }
542            }
543        }
544        "row" => {
545            if let Some(r) = row {
546                if span.direction == "insert" {
547                    row = Some(if r >= span.at { r + span.count } else { r });
548                } else {
549                    let end = span.at + span.count - 1;
550                    if r >= span.at && r <= end {
551                        row = None;
552                    } else if r > end {
553                        row = Some(r - span.count);
554                    }
555                }
556            }
557        }
558        _ => {}
559    }
560
561    if col.is_none() && row.is_none() {
562        return "#REF!".to_string();
563    }
564
565    match (col, row) {
566        (Some(c), Some(r)) => coordinate_from_index_with_lock(
567            &c,
568            &r,
569            &col_lock.unwrap_or(false),
570            &row_lock.unwrap_or(false),
571        ),
572        (Some(c), None) => {
573            let col_str = string_from_column_index(&c);
574            format!(
575                "{}{}",
576                if col_lock.unwrap_or(false) { "$" } else { "" },
577                col_str
578            )
579        }
580        (None, Some(r)) => {
581            format!("{}{}", if row_lock.unwrap_or(false) { "$" } else { "" }, r)
582        }
583        (None, None) => "#REF!".to_string(),
584    }
585}
586
587// ──────────────────────────────────────────────────────────────────
588//  Tests
589// ──────────────────────────────────────────────────────────────────
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    fn create_test_workbook(
596        setup: impl FnOnce(&mut umya_spreadsheet::Spreadsheet),
597    ) -> tempfile::TempDir {
598        let dir = tempfile::tempdir().unwrap();
599        let path = dir.path().join("test.xlsx");
600        let mut book = umya_spreadsheet::new_file();
601        setup(&mut book);
602        umya_spreadsheet::writer::xlsx::write(&book, &path).unwrap();
603        dir
604    }
605
606    fn wb_path(dir: &tempfile::TempDir) -> std::path::PathBuf {
607        dir.path().join("test.xlsx")
608    }
609
610    #[test]
611    fn shifted_spans_for_insert_rows() {
612        let ops = vec![StructureOp::InsertRows {
613            sheet_name: "Sheet1".to_string(),
614            at_row: 5,
615            count: 3,
616            expand_adjacent_sums: false,
617        }];
618        let spans = build_shifted_spans(&ops).unwrap();
619        assert_eq!(spans.len(), 1);
620        assert_eq!(spans[0].axis, "row");
621        assert_eq!(spans[0].at, 5);
622        assert_eq!(spans[0].count, 3);
623        assert_eq!(spans[0].direction, "insert");
624        assert!(spans[0].description.contains("shift +3"));
625    }
626
627    #[test]
628    fn shifted_spans_for_delete_cols() {
629        let ops = vec![StructureOp::DeleteCols {
630            sheet_name: "Data".to_string(),
631            start_col: "C".to_string(),
632            count: 2,
633        }];
634        let spans = build_shifted_spans(&ops).unwrap();
635        assert_eq!(spans.len(), 1);
636        assert_eq!(spans[0].axis, "col");
637        assert_eq!(spans[0].direction, "delete");
638        assert!(spans[0].description.contains("deleted"));
639    }
640
641    #[test]
642    fn impact_report_detects_affected_formulas() {
643        let tmp = create_test_workbook(|book| {
644            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
645            sheet.get_cell_mut("A1").set_value_number(10);
646            sheet.get_cell_mut("A2").set_value_number(20);
647            sheet.get_cell_mut("B1").set_formula("A1+A2".to_string());
648            sheet.get_cell_mut("C1").set_formula("$A$5".to_string());
649        });
650
651        let ops = vec![StructureOp::InsertRows {
652            sheet_name: "Sheet1".to_string(),
653            at_row: 2,
654            count: 1,
655            expand_adjacent_sums: false,
656        }];
657
658        let (report, _) = compute_structure_impact(&wb_path(&tmp), &ops, false).unwrap();
659        assert_eq!(report.shifted_spans.len(), 1);
660        // A2 reference in B1 formula should be affected (row 2 >= at_row 2).
661        assert!(report.tokens_affected > 0);
662    }
663
664    #[test]
665    fn impact_report_flags_absolute_ref_crossing_insert() {
666        let tmp = create_test_workbook(|book| {
667            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
668            sheet
669                .get_cell_mut("A1")
670                .set_formula("$A$5+$A$10".to_string());
671        });
672
673        let ops = vec![StructureOp::InsertRows {
674            sheet_name: "Sheet1".to_string(),
675            at_row: 3,
676            count: 2,
677            expand_adjacent_sums: false,
678        }];
679
680        let (report, _) = compute_structure_impact(&wb_path(&tmp), &ops, false).unwrap();
681        assert!(
682            !report.absolute_ref_warnings.is_empty(),
683            "should flag absolute refs crossing insert zone"
684        );
685        assert!(
686            report
687                .absolute_ref_warnings
688                .iter()
689                .any(|w| w.warning_code == "ABSOLUTE_REF_CROSS_INSERT")
690        );
691    }
692
693    #[test]
694    fn formula_delta_preview_shows_before_after() {
695        let tmp = create_test_workbook(|book| {
696            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
697            sheet.get_cell_mut("A1").set_value_number(10);
698            sheet.get_cell_mut("A5").set_value_number(50);
699            sheet.get_cell_mut("B1").set_formula("A5*2".to_string());
700        });
701
702        let ops = vec![StructureOp::InsertRows {
703            sheet_name: "Sheet1".to_string(),
704            at_row: 3,
705            count: 2,
706            expand_adjacent_sums: false,
707        }];
708
709        let (_report, delta) = compute_structure_impact(&wb_path(&tmp), &ops, true).unwrap();
710        let delta = delta.expect("delta should be present");
711        assert!(!delta.is_empty(), "should have at least one delta");
712
713        let b1_delta = delta.iter().find(|d| d.cell == "Sheet1!B1");
714        assert!(b1_delta.is_some(), "B1 should have a delta");
715        let item = b1_delta.unwrap();
716        assert_eq!(item.before, "A5*2");
717        assert_eq!(item.after, "A7*2"); // row 5 shifted by +2
718        assert_eq!(item.classification, "shifted");
719    }
720
721    #[test]
722    fn token_counts_do_not_double_count_across_multiple_spans() {
723        let tmp = create_test_workbook(|book| {
724            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
725            sheet.get_cell_mut("B1").set_formula("A5*2".to_string());
726        });
727
728        let ops = vec![
729            StructureOp::InsertRows {
730                sheet_name: "Sheet1".to_string(),
731                at_row: 2,
732                count: 1,
733                expand_adjacent_sums: false,
734            },
735            StructureOp::InsertRows {
736                sheet_name: "Sheet1".to_string(),
737                at_row: 4,
738                count: 1,
739                expand_adjacent_sums: false,
740            },
741        ];
742
743        let (report, delta) = compute_structure_impact(&wb_path(&tmp), &ops, true).unwrap();
744        assert_eq!(
745            report.tokens_affected, 1,
746            "single range token should count once"
747        );
748        assert_eq!(report.tokens_unaffected, 0);
749
750        let delta = delta.expect("delta should be present");
751        let item = delta
752            .iter()
753            .find(|d| d.cell == "Sheet1!B1")
754            .expect("B1 delta");
755        assert_eq!(item.before, "A5*2");
756        assert_eq!(item.after, "A7*2"); // sequential +1 at row>=2 then +1 at row>=4
757    }
758
759    #[test]
760    fn no_mutation_occurs_during_preview() {
761        let tmp = create_test_workbook(|book| {
762            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
763            sheet.get_cell_mut("A1").set_value_number(42);
764            sheet.get_cell_mut("B1").set_formula("A1*2".to_string());
765        });
766
767        let before_bytes = std::fs::read(wb_path(&tmp)).unwrap();
768
769        let ops = vec![StructureOp::InsertRows {
770            sheet_name: "Sheet1".to_string(),
771            at_row: 1,
772            count: 5,
773            expand_adjacent_sums: false,
774        }];
775
776        let _ = compute_structure_impact(&wb_path(&tmp), &ops, true).unwrap();
777
778        let after_bytes = std::fs::read(wb_path(&tmp)).unwrap();
779        assert_eq!(
780            before_bytes, after_bytes,
781            "preview must not mutate the file"
782        );
783    }
784
785    #[test]
786    fn single_cell_range_noted() {
787        let tmp = create_test_workbook(|book| {
788            let sheet = book.get_sheet_by_name_mut("Sheet1").unwrap();
789            sheet
790                .get_cell_mut("B1")
791                .set_formula("SUM(K54:K54)".to_string());
792        });
793
794        let ops = vec![StructureOp::InsertRows {
795            sheet_name: "Sheet1".to_string(),
796            at_row: 50,
797            count: 1,
798            expand_adjacent_sums: false,
799        }];
800
801        let (report, _) = compute_structure_impact(&wb_path(&tmp), &ops, false).unwrap();
802        assert!(
803            report.notes.iter().any(|n| n.contains("Single-cell range")),
804            "should note single-cell range non-expansion"
805        );
806    }
807}