1use 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
19pub struct StructureImpactReport {
20 pub shifted_spans: Vec<ShiftedSpan>,
22 pub absolute_ref_warnings: Vec<AbsoluteRefWarning>,
24 pub tokens_affected: u64,
26 pub tokens_unaffected: u64,
28 #[serde(skip_serializing_if = "Vec::is_empty")]
30 pub notes: Vec<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
35pub struct ShiftedSpan {
36 pub op_index: usize,
37 pub sheet_name: String,
38 pub axis: String, pub description: String,
41 pub at: u32,
42 pub count: u32,
43 pub direction: String, }
45
46#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct AbsoluteRefWarning {
49 pub warning_code: String,
51 pub cell: String,
53 pub formula: String,
55 pub token: String,
57 pub message: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
63pub struct FormulaDeltaItem {
64 pub cell: String,
66 pub before: String,
68 pub after: String,
70 pub classification: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub warning_code: Option<String>,
75}
76
77pub 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 let shifted_spans = build_shifted_spans(ops)?;
95
96 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 let (ref_sheet, coord_part) = if let Some((sp, cp)) = value.split_once('!') {
137 (extract_sheet_name(sp), cp.to_string())
138 } else {
139 (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 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 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 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 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 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 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
264fn 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 _ => {}
356 }
357 }
358 Ok(spans)
359}
360
361fn ref_touches_zone(coord_part: &str, axis: &str, at: u32, count: u32, direction: &str) -> bool {
364 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; }
380 if r > end {
381 return true; }
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
408fn 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
431fn is_single_cell_range(coord_part: &str) -> bool {
433 if let Some((start, end)) = coord_part.split_once(':') {
434 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
443fn 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
454fn 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 let end = span.at + span.count - 1;
536 if c >= span.at && c <= end {
537 col = None; } 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#[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 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"); 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"); }
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}