Skip to main content

spreadsheet_mcp/tools/
fork.rs

1use super::param_enums::{BatchMode, FillDirection, FormulaRelativeMode, ReplaceMatchMode};
2use crate::config::RecalcBackendKind;
3use crate::fork::{ChangeSummary, EditOp, StagedChange, StagedOp};
4use crate::formula::pattern::{RelativeMode, parse_base_formula, shift_formula_ast};
5use crate::model::{
6    AlignmentPatch, BordersPatch, FillPatch, FontPatch, PatternFillPatch, StylePatch, Warning,
7    WorkbookId,
8};
9use crate::recalc::RecalcBackend;
10use crate::security::sanitize_filename_component;
11use crate::state::AppState;
12use crate::tools::write_normalize::{EditBatchParamsInput, normalize_edit_batch};
13use crate::utils::make_short_random_id;
14use anyhow::{Result, anyhow, bail};
15use chrono::Utc;
16use formualizer_parse::tokenizer::Tokenizer;
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize, de};
19use std::collections::{BTreeMap, BTreeSet};
20use std::fs;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23
24fn set_recalc_needed_flag(summary: &mut ChangeSummary, recalc_needed: bool) {
25    summary
26        .flags
27        .insert("recalc_needed".to_string(), recalc_needed);
28}
29
30#[derive(Debug, Deserialize, JsonSchema)]
31pub struct CreateForkParams {
32    #[serde(alias = "workbook_id")]
33    pub workbook_or_fork_id: WorkbookId,
34}
35
36#[derive(Debug, Serialize, JsonSchema)]
37pub struct CreateForkResponse {
38    pub fork_id: String,
39    pub base_workbook: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub client_base_workbook: Option<String>,
42    pub ttl_seconds: u64,
43}
44
45pub async fn create_fork(
46    state: Arc<AppState>,
47    params: CreateForkParams,
48) -> Result<CreateForkResponse> {
49    let registry = state
50        .fork_registry()
51        .ok_or_else(|| anyhow!("fork registry not available (recalc disabled?)"))?;
52
53    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
54    let base_path = &workbook.path;
55    let config = state.config();
56    let workspace_root = &config.workspace_root;
57
58    let fork_id = registry.create_fork(base_path, workspace_root)?;
59
60    Ok(CreateForkResponse {
61        fork_id,
62        base_workbook: base_path.display().to_string(),
63        client_base_workbook: config
64            .map_path_for_client(base_path)
65            .map(|p| p.display().to_string()),
66        ttl_seconds: registry.ttl().as_secs(),
67    })
68}
69
70#[derive(Debug, Deserialize, JsonSchema)]
71pub struct EditBatchParams {
72    pub fork_id: String,
73    pub sheet_name: String,
74    pub edits: Vec<CellEdit>,
75}
76
77#[derive(Debug, Clone, Deserialize, JsonSchema)]
78pub struct CellEdit {
79    pub address: String,
80    pub value: String,
81    #[serde(default)]
82    pub is_formula: bool,
83}
84
85#[derive(Debug, Serialize, JsonSchema)]
86pub struct EditBatchResponse {
87    pub fork_id: String,
88    pub edits_applied: usize,
89    pub total_edits: usize,
90    pub recalc_needed: bool,
91    #[serde(skip_serializing_if = "Vec::is_empty")]
92    pub warnings: Vec<Warning>,
93}
94
95pub async fn edit_batch(
96    state: Arc<AppState>,
97    params: EditBatchParamsInput,
98) -> Result<EditBatchResponse> {
99    let (params, warnings) = normalize_edit_batch(params)?;
100    let registry = state
101        .fork_registry()
102        .ok_or_else(|| anyhow!("fork registry not available"))?;
103
104    let fork_ctx = registry.get_fork(&params.fork_id)?;
105    let work_path = fork_ctx.work_path.clone();
106
107    let edits_to_apply: Vec<_> = params
108        .edits
109        .iter()
110        .map(|e| EditOp {
111            timestamp: Utc::now(),
112            sheet: params.sheet_name.clone(),
113            address: e.address.clone(),
114            value: e.value.clone(),
115            is_formula: e.is_formula,
116        })
117        .collect();
118
119    let edit_count = edits_to_apply.len();
120
121    tokio::task::spawn_blocking({
122        let sheet_name = params.sheet_name.clone();
123        let edits = params.edits.clone();
124        move || {
125            let core_edits = edits
126                .into_iter()
127                .map(|edit| crate::core::types::CellEdit {
128                    address: edit.address,
129                    value: edit.value,
130                    is_formula: edit.is_formula,
131                })
132                .collect::<Vec<_>>();
133            crate::core::write::apply_edits_to_file(&work_path, &sheet_name, &core_edits)
134        }
135    })
136    .await??;
137
138    let total = registry.with_fork_mut(&params.fork_id, |ctx| {
139        ctx.edits.extend(edits_to_apply);
140        ctx.recalc_needed = true;
141        Ok(ctx.edits.len())
142    })?;
143
144    let fork_workbook_id = WorkbookId(params.fork_id.clone());
145    let _ = state.close_workbook(&fork_workbook_id);
146
147    Ok(EditBatchResponse {
148        fork_id: params.fork_id,
149        edits_applied: edit_count,
150        total_edits: total,
151        recalc_needed: true,
152        warnings,
153    })
154}
155
156fn default_clear_values() -> bool {
157    true
158}
159
160fn default_overwrite_formulas() -> bool {
161    false
162}
163
164fn default_replace_case_sensitive() -> bool {
165    true
166}
167
168#[derive(Debug, Deserialize, JsonSchema)]
169pub struct TransformBatchParams {
170    pub fork_id: String,
171    pub ops: Vec<TransformOp>,
172    #[serde(default)]
173    pub mode: Option<BatchMode>, // preview|apply (default apply)
174    pub label: Option<String>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
178#[serde(tag = "kind", rename_all = "snake_case")]
179pub enum TransformOp {
180    ClearRange {
181        sheet_name: String,
182        target: TransformTarget,
183        #[serde(default = "default_clear_values")]
184        clear_values: bool,
185        #[serde(default)]
186        clear_formulas: bool,
187    },
188    FillRange {
189        sheet_name: String,
190        target: TransformTarget,
191        value: String,
192        #[serde(default)]
193        is_formula: bool,
194        #[serde(default = "default_overwrite_formulas")]
195        overwrite_formulas: bool,
196    },
197    ReplaceInRange {
198        sheet_name: String,
199        target: TransformTarget,
200        find: String,
201        replace: String,
202        #[serde(default)]
203        match_mode: ReplaceMatchMode,
204        #[serde(default = "default_replace_case_sensitive")]
205        case_sensitive: bool,
206        #[serde(default)]
207        include_formulas: bool,
208    },
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
212#[serde(tag = "kind", rename_all = "snake_case")]
213pub enum TransformTarget {
214    Range { range: String },
215    Region { region_id: u32 },
216    Cells { cells: Vec<String> },
217}
218
219#[derive(Debug, Serialize, JsonSchema)]
220pub struct TransformBatchResponse {
221    pub fork_id: String,
222    pub mode: String,
223    pub change_id: Option<String>,
224    pub ops_applied: usize,
225    pub summary: ChangeSummary,
226}
227
228#[derive(Debug, Serialize, Deserialize)]
229struct TransformBatchStagedPayload {
230    ops: Vec<TransformOp>,
231}
232
233pub async fn transform_batch(
234    state: Arc<AppState>,
235    params: TransformBatchParams,
236) -> Result<TransformBatchResponse> {
237    let registry = state
238        .fork_registry()
239        .ok_or_else(|| anyhow!("fork registry not available"))?;
240
241    let fork_ctx = registry.get_fork(&params.fork_id)?;
242    let work_path = fork_ctx.work_path.clone();
243
244    let fork_workbook_id = WorkbookId(params.fork_id.clone());
245    let workbook = state.open_workbook(&fork_workbook_id).await?;
246
247    let mut resolved_ops = Vec::with_capacity(params.ops.len());
248    for op in &params.ops {
249        let (sheet_name, target) = match op {
250            TransformOp::ClearRange {
251                sheet_name, target, ..
252            }
253            | TransformOp::FillRange {
254                sheet_name, target, ..
255            }
256            | TransformOp::ReplaceInRange {
257                sheet_name, target, ..
258            } => (sheet_name, target),
259        };
260
261        let resolved_target = match target {
262            TransformTarget::Region { region_id } => {
263                let metrics = workbook.get_sheet_metrics(sheet_name)?;
264                let regions = metrics.detected_regions();
265                let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
266                    anyhow!(
267                        "region_id {} not found on sheet '{}'",
268                        region_id,
269                        sheet_name
270                    )
271                })?;
272                TransformTarget::Range {
273                    range: region.bounds.clone(),
274                }
275            }
276            other => other.clone(),
277        };
278
279        match op {
280            TransformOp::ClearRange {
281                sheet_name,
282                clear_values,
283                clear_formulas,
284                ..
285            } => {
286                resolved_ops.push(TransformOp::ClearRange {
287                    sheet_name: sheet_name.clone(),
288                    target: resolved_target,
289                    clear_values: *clear_values,
290                    clear_formulas: *clear_formulas,
291                });
292            }
293            TransformOp::FillRange {
294                sheet_name,
295                value,
296                is_formula,
297                overwrite_formulas,
298                ..
299            } => {
300                resolved_ops.push(TransformOp::FillRange {
301                    sheet_name: sheet_name.clone(),
302                    target: resolved_target,
303                    value: value.clone(),
304                    is_formula: *is_formula,
305                    overwrite_formulas: *overwrite_formulas,
306                });
307            }
308            TransformOp::ReplaceInRange {
309                sheet_name,
310                find,
311                replace,
312                match_mode,
313                case_sensitive,
314                include_formulas,
315                ..
316            } => {
317                resolved_ops.push(TransformOp::ReplaceInRange {
318                    sheet_name: sheet_name.clone(),
319                    target: resolved_target,
320                    find: find.clone(),
321                    replace: replace.clone(),
322                    match_mode: match_mode.clone(),
323                    case_sensitive: *case_sensitive,
324                    include_formulas: *include_formulas,
325                });
326            }
327        }
328    }
329
330    let mode = params.mode.unwrap_or_default();
331
332    if mode.is_preview() {
333        let change_id = make_short_random_id("chg", 12);
334        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
335        fs::create_dir_all(snapshot_path.parent().unwrap())?;
336        fs::copy(&work_path, &snapshot_path)?;
337
338        let snapshot_for_apply = snapshot_path.clone();
339        let apply_result = tokio::task::spawn_blocking({
340            let ops = resolved_ops.clone();
341            move || apply_transform_ops_to_file(&snapshot_for_apply, &ops)
342        })
343        .await??;
344
345        let mut summary = apply_result.summary;
346        summary.op_kinds = vec!["transform_batch".to_string()];
347        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
348
349        let staged_op = StagedOp {
350            kind: "transform_batch".to_string(),
351            payload: serde_json::to_value(TransformBatchStagedPayload {
352                ops: resolved_ops.clone(),
353            })?,
354        };
355
356        let staged = StagedChange {
357            change_id: change_id.clone(),
358            created_at: Utc::now(),
359            label: params.label.clone(),
360            ops: vec![staged_op],
361            summary: summary.clone(),
362            fork_path_snapshot: Some(snapshot_path),
363        };
364
365        registry.add_staged_change(&params.fork_id, staged)?;
366
367        Ok(TransformBatchResponse {
368            fork_id: params.fork_id,
369            mode: mode.as_str().to_string(),
370            change_id: Some(change_id),
371            ops_applied: apply_result.ops_applied,
372            summary,
373        })
374    } else {
375        let apply_result = tokio::task::spawn_blocking({
376            let ops = resolved_ops.clone();
377            let work_path = work_path.clone();
378            move || apply_transform_ops_to_file(&work_path, &ops)
379        })
380        .await??;
381
382        let mut summary = apply_result.summary;
383        summary.op_kinds = vec!["transform_batch".to_string()];
384
385        registry.with_fork_mut(&params.fork_id, |ctx| {
386            ctx.recalc_needed = true;
387            Ok(())
388        })?;
389        set_recalc_needed_flag(&mut summary, true);
390
391        let _ = state.close_workbook(&fork_workbook_id);
392
393        Ok(TransformBatchResponse {
394            fork_id: params.fork_id,
395            mode: mode.as_str().to_string(),
396            change_id: None,
397            ops_applied: apply_result.ops_applied,
398            summary,
399        })
400    }
401}
402
403#[derive(Debug, Deserialize, JsonSchema)]
404pub struct StyleBatchParams {
405    pub fork_id: String,
406    pub ops: Vec<StyleOp>,
407    #[serde(default)]
408    pub mode: Option<BatchMode>, // preview|apply (default apply)
409    pub label: Option<String>,
410}
411
412#[derive(Debug, Deserialize, JsonSchema)]
413pub struct StyleBatchParamsInput {
414    pub fork_id: String,
415    pub ops: Vec<StyleOpInput>,
416    #[serde(default)]
417    pub mode: Option<BatchMode>,
418    pub label: Option<String>,
419}
420
421#[derive(Debug, Clone)]
422pub struct StyleOpInput {
423    op: StyleOp,
424    shorthand_used: bool,
425    fill_color_used: bool,
426    color_alpha_defaulted: bool,
427}
428
429impl From<StyleOp> for StyleOpInput {
430    fn from(op: StyleOp) -> Self {
431        Self {
432            op,
433            shorthand_used: false,
434            fill_color_used: false,
435            color_alpha_defaulted: false,
436        }
437    }
438}
439
440impl<'de> Deserialize<'de> for StyleOpInput {
441    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
442    where
443        D: serde::Deserializer<'de>,
444    {
445        let mut value = serde_json::Value::deserialize(deserializer)?;
446        let Some(obj) = value.as_object_mut() else {
447            return Err(de::Error::custom("style op must be an object"));
448        };
449
450        let mut shorthand_used = false;
451        let mut fill_color_used = false;
452        let mut color_alpha_defaulted = false;
453
454        if obj.get("target").is_none()
455            && let Some(range) = obj.remove("range")
456        {
457            shorthand_used = true;
458            obj.insert(
459                "target".to_string(),
460                serde_json::json!({ "kind": "range", "range": range }),
461            );
462        }
463
464        if obj.get("patch").is_none()
465            && let Some(style) = obj.remove("style")
466        {
467            shorthand_used = true;
468            obj.insert("patch".to_string(), style);
469        }
470
471        if let Some(patch_value) = obj.remove("patch") {
472            let patch_input: StylePatchInput =
473                serde_json::from_value(patch_value).map_err(de::Error::custom)?;
474            let (patch, used_fill_color, alpha_defaulted) =
475                normalize_style_patch_input(patch_input);
476            if used_fill_color {
477                fill_color_used = true;
478            }
479            if alpha_defaulted {
480                color_alpha_defaulted = true;
481            }
482            obj.insert(
483                "patch".to_string(),
484                serde_json::to_value(patch).map_err(de::Error::custom)?,
485            );
486        }
487
488        let op = serde_json::from_value(value).map_err(de::Error::custom)?;
489        Ok(StyleOpInput {
490            op,
491            shorthand_used,
492            fill_color_used,
493            color_alpha_defaulted,
494        })
495    }
496}
497
498impl schemars::JsonSchema for StyleOpInput {
499    fn schema_name() -> std::borrow::Cow<'static, str> {
500        "StyleOp".into()
501    }
502
503    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
504        StyleOp::json_schema(generator)
505    }
506}
507
508#[derive(Debug, Clone, Deserialize)]
509struct StylePatchInput {
510    #[serde(default)]
511    pub font: Option<Option<FontPatch>>,
512    #[serde(default)]
513    pub fill: Option<Option<FillPatchInput>>,
514    #[serde(default)]
515    pub borders: Option<Option<BordersPatch>>,
516    #[serde(default)]
517    pub alignment: Option<Option<AlignmentPatch>>,
518    #[serde(default)]
519    pub number_format: Option<Option<NumberFormatPatchInput>>,
520}
521
522#[derive(Debug, Clone, Deserialize)]
523#[serde(untagged)]
524enum NumberFormatPatchInput {
525    FormatCode(String),
526    Shorthand(NumberFormatShorthandInput),
527}
528
529#[derive(Debug, Clone, Deserialize)]
530struct NumberFormatShorthandInput {
531    pub kind: NumberFormatKind,
532    #[serde(default)]
533    pub format_code: Option<String>,
534}
535
536#[derive(Debug, Clone, Deserialize)]
537#[serde(rename_all = "snake_case")]
538enum NumberFormatKind {
539    Currency,
540    Percent,
541    DateIso,
542    Accounting,
543    Integer,
544}
545
546fn number_format_kind_to_format_code(kind: &NumberFormatKind) -> &'static str {
547    match kind {
548        NumberFormatKind::Currency => "$#,##0.00",
549        NumberFormatKind::Percent => "0.00%",
550        NumberFormatKind::DateIso => "yyyy-mm-dd",
551        NumberFormatKind::Accounting => "_($* #,##0.00_)",
552        NumberFormatKind::Integer => "0",
553    }
554}
555
556#[derive(Debug, Clone, Deserialize)]
557#[serde(untagged)]
558enum FillPatchInput {
559    Canonical(FillPatch),
560    Color(FillColorPatch),
561}
562
563#[derive(Debug, Clone, Deserialize)]
564struct FillColorPatch {
565    color: String,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
569pub struct StyleOp {
570    pub sheet_name: String,
571    pub target: StyleTarget,
572    pub patch: StylePatch,
573    #[serde(default)]
574    pub op_mode: Option<crate::styles::StylePatchMode>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
578#[serde(tag = "kind", rename_all = "snake_case")]
579pub enum StyleTarget {
580    Range { range: String },
581    Region { region_id: u32 },
582    Cells { cells: Vec<String> },
583}
584
585#[derive(Debug, Serialize, JsonSchema)]
586pub struct StyleBatchResponse {
587    pub fork_id: String,
588    pub mode: String,
589    pub change_id: Option<String>,
590    pub ops_applied: usize,
591    pub summary: ChangeSummary,
592}
593
594#[derive(Debug, Deserialize, JsonSchema)]
595pub struct ColumnSizeBatchParamsInput {
596    pub fork_id: String,
597    pub sheet_name: String,
598    pub ops: Vec<ColumnSizeOpInput>,
599    pub mode: Option<BatchMode>, // preview|apply (default apply)
600    pub label: Option<String>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
604#[serde(tag = "kind", rename_all = "snake_case")]
605pub enum ColumnTarget {
606    Columns { range: String },
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
610#[serde(tag = "kind", rename_all = "snake_case")]
611pub enum ColumnSizeSpec {
612    Auto {
613        #[serde(default)]
614        min_width_chars: Option<f64>,
615        #[serde(default)]
616        max_width_chars: Option<f64>,
617    },
618    Width {
619        width_chars: f64,
620    },
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
624pub struct ColumnSizeOp {
625    pub target: ColumnTarget,
626    pub size: ColumnSizeSpec,
627}
628
629#[derive(Debug, Deserialize, JsonSchema)]
630#[serde(untagged)]
631pub enum ColumnSizeOpInput {
632    Canonical(ColumnSizeOp),
633    Shorthand { range: String, size: ColumnSizeSpec },
634}
635
636impl From<ColumnSizeOp> for ColumnSizeOpInput {
637    fn from(value: ColumnSizeOp) -> Self {
638        Self::Canonical(value)
639    }
640}
641
642#[derive(Debug, Clone)]
643struct ColumnSizeBatchParams {
644    fork_id: String,
645    sheet_name: String,
646    ops: Vec<ColumnSizeOp>,
647    mode: Option<BatchMode>,
648    label: Option<String>,
649}
650
651#[derive(Debug, Serialize, JsonSchema)]
652pub struct ColumnSizeBatchResponse {
653    pub fork_id: String,
654    pub sheet_name: String,
655    pub mode: String,
656    pub change_id: Option<String>,
657    pub ops_applied: usize,
658    pub summary: ChangeSummary,
659}
660
661#[derive(Debug, Serialize, Deserialize)]
662struct ColumnSizeBatchStagedPayload {
663    sheet_name: String,
664    ops: Vec<ColumnSizeOp>,
665}
666
667fn normalize_column_size_batch(
668    params: ColumnSizeBatchParamsInput,
669) -> Result<(ColumnSizeBatchParams, Vec<crate::model::Warning>)> {
670    let mut warnings = Vec::new();
671    let mut ops = Vec::with_capacity(params.ops.len());
672
673    for entry in params.ops {
674        match entry {
675            ColumnSizeOpInput::Canonical(op) => ops.push(op),
676            ColumnSizeOpInput::Shorthand { range, size } => {
677                warnings.push(crate::model::Warning {
678                    code: "WARN_COLUMN_SHORTHAND_TARGET".to_string(),
679                    message: "Used range shorthand; prefer target:{kind:'columns',range:'A:C'}"
680                        .to_string(),
681                });
682                ops.push(ColumnSizeOp {
683                    target: ColumnTarget::Columns { range },
684                    size,
685                });
686            }
687        }
688    }
689
690    Ok((
691        ColumnSizeBatchParams {
692            fork_id: params.fork_id,
693            sheet_name: params.sheet_name,
694            ops,
695            mode: params.mode,
696            label: params.label,
697        },
698        warnings,
699    ))
700}
701
702pub async fn column_size_batch(
703    state: Arc<AppState>,
704    params: ColumnSizeBatchParamsInput,
705) -> Result<ColumnSizeBatchResponse> {
706    let (params, warnings) = normalize_column_size_batch(params)?;
707    let warning_messages: Vec<String> = warnings
708        .into_iter()
709        .map(|warning| format!("{}: {}", warning.code, warning.message))
710        .collect();
711    let registry = state
712        .fork_registry()
713        .ok_or_else(|| anyhow!("fork registry not available"))?;
714
715    let fork_ctx = registry.get_fork(&params.fork_id)?;
716    let work_path = fork_ctx.work_path.clone();
717
718    let fork_workbook_id = WorkbookId(params.fork_id.clone());
719    let workbook = state.open_workbook(&fork_workbook_id).await?;
720    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
721
722    let mode = params.mode.unwrap_or_default();
723
724    if mode.is_preview() {
725        let change_id = make_short_random_id("chg", 12);
726        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
727        fs::create_dir_all(snapshot_path.parent().unwrap())?;
728        fs::copy(&work_path, &snapshot_path)?;
729
730        let snapshot_path_for_apply = snapshot_path.clone();
731        let apply_result = tokio::task::spawn_blocking({
732            let ops = params.ops.clone();
733            let sheet_name = params.sheet_name.clone();
734            move || apply_column_size_ops_to_file(&snapshot_path_for_apply, &sheet_name, &ops)
735        })
736        .await??;
737
738        let mut summary = apply_result.summary;
739        summary.op_kinds = vec!["column_size_batch".to_string()];
740        summary.warnings.extend(warning_messages.clone());
741        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
742
743        let staged_op = StagedOp {
744            kind: "column_size_batch".to_string(),
745            payload: serde_json::to_value(ColumnSizeBatchStagedPayload {
746                sheet_name: params.sheet_name.clone(),
747                ops: params.ops.clone(),
748            })?,
749        };
750
751        let staged = StagedChange {
752            change_id: change_id.clone(),
753            created_at: Utc::now(),
754            label: params.label.clone(),
755            ops: vec![staged_op],
756            summary: summary.clone(),
757            fork_path_snapshot: Some(snapshot_path),
758        };
759
760        registry.add_staged_change(&params.fork_id, staged)?;
761
762        Ok(ColumnSizeBatchResponse {
763            fork_id: params.fork_id,
764            sheet_name: params.sheet_name,
765            mode: mode.as_str().to_string(),
766            change_id: Some(change_id),
767            ops_applied: apply_result.ops_applied,
768            summary,
769        })
770    } else {
771        let apply_result = tokio::task::spawn_blocking({
772            let ops = params.ops.clone();
773            let sheet_name = params.sheet_name.clone();
774            let work_path = work_path.clone();
775            move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
776        })
777        .await??;
778
779        let mut summary = apply_result.summary;
780        summary.op_kinds = vec!["column_size_batch".to_string()];
781        summary.warnings.extend(warning_messages);
782        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
783
784        let _ = state.close_workbook(&fork_workbook_id);
785
786        Ok(ColumnSizeBatchResponse {
787            fork_id: params.fork_id,
788            sheet_name: params.sheet_name,
789            mode: mode.as_str().to_string(),
790            change_id: None,
791            ops_applied: apply_result.ops_applied,
792            summary,
793        })
794    }
795}
796
797#[derive(Debug, Serialize, Deserialize)]
798struct StyleBatchStagedPayload {
799    ops: Vec<StyleOp>,
800}
801
802pub fn normalize_style_batch(
803    input: StyleBatchParamsInput,
804) -> Result<(StyleBatchParams, Vec<Warning>)> {
805    let mut warnings = Vec::new();
806    let mut ops = Vec::with_capacity(input.ops.len());
807
808    for op_input in input.ops {
809        if op_input.shorthand_used {
810            warnings.push(Warning {
811                code: "WARN_STYLE_SHORTHAND".to_string(),
812                message: "Normalized style op shorthand to canonical form".to_string(),
813            });
814        }
815        if op_input.fill_color_used {
816            warnings.push(Warning {
817                code: "WARN_FILL_COLOR".to_string(),
818                message: "Normalized fill color shorthand to pattern fill".to_string(),
819            });
820        }
821        if op_input.color_alpha_defaulted {
822            warnings.push(Warning {
823                code: "WARN_COLOR_ALPHA_DEFAULT".to_string(),
824                message: "Normalized RGB hex to ARGB with default alpha".to_string(),
825            });
826        }
827        ops.push(op_input.op);
828    }
829
830    Ok((
831        StyleBatchParams {
832            fork_id: input.fork_id,
833            ops,
834            mode: input.mode,
835            label: input.label,
836        },
837        warnings,
838    ))
839}
840
841fn normalize_style_patch_input(input: StylePatchInput) -> (StylePatch, bool, bool) {
842    let mut fill_color_used = false;
843    let mut color_alpha_defaulted = false;
844    let fill = match input.fill {
845        None => None,
846        Some(None) => Some(None),
847        Some(Some(fill_input)) => {
848            let normalized = match fill_input {
849                FillPatchInput::Canonical(fill) => fill,
850                FillPatchInput::Color(color) => {
851                    fill_color_used = true;
852                    FillPatch::Pattern(PatternFillPatch {
853                        pattern_type: Some(Some("solid".to_string())),
854                        foreground_color: Some(Some(color.color)),
855                        background_color: None,
856                    })
857                }
858            };
859            Some(Some(normalized))
860        }
861    };
862
863    let number_format: Option<Option<String>> = match input.number_format {
864        None => None,
865        Some(None) => Some(None),
866        Some(Some(nf)) => match nf {
867            NumberFormatPatchInput::FormatCode(code) => Some(Some(code)),
868            NumberFormatPatchInput::Shorthand(sh) => {
869                if let Some(code) = sh.format_code {
870                    Some(Some(code))
871                } else {
872                    Some(Some(
873                        number_format_kind_to_format_code(&sh.kind).to_string(),
874                    ))
875                }
876            }
877        },
878    };
879
880    let mut patch = StylePatch {
881        font: input.font,
882        fill,
883        borders: input.borders,
884        alignment: input.alignment,
885        number_format,
886    };
887    normalize_style_patch_colors(&mut patch, &mut color_alpha_defaulted);
888
889    (patch, fill_color_used, color_alpha_defaulted)
890}
891
892fn normalize_style_patch_colors(patch: &mut StylePatch, alpha_defaulted: &mut bool) {
893    if let Some(Some(font)) = patch.font.as_mut() {
894        normalize_color_option(&mut font.color, alpha_defaulted);
895    }
896
897    if let Some(Some(fill)) = patch.fill.as_mut() {
898        normalize_fill_colors(fill, alpha_defaulted);
899    }
900
901    if let Some(Some(borders)) = patch.borders.as_mut() {
902        normalize_border_side_color(&mut borders.left, alpha_defaulted);
903        normalize_border_side_color(&mut borders.right, alpha_defaulted);
904        normalize_border_side_color(&mut borders.top, alpha_defaulted);
905        normalize_border_side_color(&mut borders.bottom, alpha_defaulted);
906        normalize_border_side_color(&mut borders.diagonal, alpha_defaulted);
907        normalize_border_side_color(&mut borders.vertical, alpha_defaulted);
908        normalize_border_side_color(&mut borders.horizontal, alpha_defaulted);
909    }
910}
911
912fn normalize_fill_colors(fill: &mut FillPatch, alpha_defaulted: &mut bool) {
913    match fill {
914        FillPatch::Pattern(pattern) => {
915            normalize_color_option(&mut pattern.foreground_color, alpha_defaulted);
916            normalize_color_option(&mut pattern.background_color, alpha_defaulted);
917        }
918        FillPatch::Gradient(gradient) => {
919            if let Some(stops) = gradient.stops.as_mut() {
920                for stop in stops {
921                    if let Some((normalized, defaulted)) =
922                        crate::styles::normalize_color_hex(&stop.color)
923                    {
924                        stop.color = normalized;
925                        if defaulted {
926                            *alpha_defaulted = true;
927                        }
928                    }
929                }
930            }
931        }
932    }
933}
934
935fn normalize_border_side_color(
936    side: &mut Option<Option<crate::model::BorderSidePatch>>,
937    alpha_defaulted: &mut bool,
938) {
939    if let Some(Some(side_patch)) = side.as_mut() {
940        normalize_color_option(&mut side_patch.color, alpha_defaulted);
941    }
942}
943
944fn normalize_color_option(value: &mut Option<Option<String>>, alpha_defaulted: &mut bool) {
945    let Some(Some(color)) = value.as_mut() else {
946        return;
947    };
948    if let Some((normalized, defaulted)) = crate::styles::normalize_color_hex(color) {
949        *color = normalized;
950        if defaulted {
951            *alpha_defaulted = true;
952        }
953    }
954}
955
956pub async fn style_batch(
957    state: Arc<AppState>,
958    params: StyleBatchParamsInput,
959) -> Result<StyleBatchResponse> {
960    let (params, warnings) = normalize_style_batch(params)?;
961    let warning_messages: Vec<String> = warnings
962        .into_iter()
963        .map(|warning| format!("{}: {}", warning.code, warning.message))
964        .collect();
965    let registry = state
966        .fork_registry()
967        .ok_or_else(|| anyhow!("fork registry not available"))?;
968
969    let fork_ctx = registry.get_fork(&params.fork_id)?;
970    let work_path = fork_ctx.work_path.clone();
971
972    // Resolve any region targets against current fork regions.
973    let fork_workbook_id = WorkbookId(params.fork_id.clone());
974    let workbook = state.open_workbook(&fork_workbook_id).await?;
975    let mut resolved_ops = Vec::with_capacity(params.ops.len());
976    for op in &params.ops {
977        let mut resolved = op.clone();
978        if let StyleTarget::Region { region_id } = &op.target {
979            let metrics = workbook.get_sheet_metrics(&op.sheet_name)?;
980            let regions = metrics.detected_regions();
981            let region = regions.iter().find(|r| r.id == *region_id).ok_or_else(|| {
982                anyhow!(
983                    "region_id {} not found on sheet '{}'",
984                    region_id,
985                    op.sheet_name
986                )
987            })?;
988            resolved.target = StyleTarget::Range {
989                range: region.bounds.clone(),
990            };
991        }
992        resolved_ops.push(resolved);
993    }
994
995    let mode = params.mode.unwrap_or_default();
996
997    if mode.is_preview() {
998        let change_id = make_short_random_id("chg", 12);
999        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1000        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1001        fs::copy(&work_path, &snapshot_path)?;
1002
1003        let snapshot_path_for_apply = snapshot_path.clone();
1004        let apply_result = tokio::task::spawn_blocking({
1005            let ops = resolved_ops.clone();
1006            move || apply_style_ops_to_file(&snapshot_path_for_apply, &ops)
1007        })
1008        .await??;
1009
1010        let mut summary = apply_result.summary;
1011        summary.op_kinds = vec!["style_batch".to_string()];
1012        summary.warnings.extend(warning_messages.clone());
1013        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1014
1015        let staged_op = StagedOp {
1016            kind: "style_batch".to_string(),
1017            payload: serde_json::to_value(StyleBatchStagedPayload {
1018                ops: resolved_ops.clone(),
1019            })?,
1020        };
1021
1022        let staged = StagedChange {
1023            change_id: change_id.clone(),
1024            created_at: Utc::now(),
1025            label: params.label.clone(),
1026            ops: vec![staged_op],
1027            summary: summary.clone(),
1028            fork_path_snapshot: Some(snapshot_path),
1029        };
1030
1031        registry.add_staged_change(&params.fork_id, staged)?;
1032
1033        Ok(StyleBatchResponse {
1034            fork_id: params.fork_id,
1035            mode: mode.as_str().to_string(),
1036            change_id: Some(change_id),
1037            ops_applied: resolved_ops.len(),
1038            summary,
1039        })
1040    } else {
1041        let apply_result = tokio::task::spawn_blocking({
1042            let ops = resolved_ops.clone();
1043            let work_path = work_path.clone();
1044            move || apply_style_ops_to_file(&work_path, &ops)
1045        })
1046        .await??;
1047
1048        let mut summary = apply_result.summary;
1049        summary.op_kinds = vec!["style_batch".to_string()];
1050        summary.warnings.extend(warning_messages);
1051        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1052
1053        let _ = state.close_workbook(&fork_workbook_id);
1054
1055        Ok(StyleBatchResponse {
1056            fork_id: params.fork_id,
1057            mode: mode.as_str().to_string(),
1058            change_id: None,
1059            ops_applied: apply_result.ops_applied,
1060            summary,
1061        })
1062    }
1063}
1064
1065#[derive(Debug, Deserialize, JsonSchema)]
1066pub struct ApplyFormulaPatternParams {
1067    pub fork_id: String,
1068    pub sheet_name: String,
1069    pub target_range: String,
1070    pub anchor_cell: String,
1071    pub base_formula: String,
1072    #[serde(default)]
1073    pub fill_direction: Option<FillDirection>, // down|right|both (default both)
1074    #[serde(default)]
1075    pub relative_mode: Option<FormulaRelativeMode>, // excel|abs_cols|abs_rows
1076    #[serde(default)]
1077    pub mode: Option<BatchMode>, // preview|apply (default apply)
1078    pub label: Option<String>,
1079}
1080
1081#[derive(Debug, Serialize, JsonSchema)]
1082pub struct ApplyFormulaPatternResponse {
1083    pub fork_id: String,
1084    pub sheet_name: String,
1085    pub target_range: String,
1086    pub mode: String,
1087    pub change_id: Option<String>,
1088    pub cells_filled: u64,
1089    pub summary: ChangeSummary,
1090}
1091
1092#[derive(Debug, Serialize, Deserialize)]
1093struct ApplyFormulaPatternStagedPayload {
1094    sheet_name: String,
1095    target_range: String,
1096    anchor_cell: String,
1097    base_formula: String,
1098    fill_direction: Option<FillDirection>,
1099    relative_mode: Option<FormulaRelativeMode>,
1100}
1101
1102pub async fn apply_formula_pattern(
1103    state: Arc<AppState>,
1104    params: ApplyFormulaPatternParams,
1105) -> Result<ApplyFormulaPatternResponse> {
1106    let registry = state
1107        .fork_registry()
1108        .ok_or_else(|| anyhow!("fork registry not available"))?;
1109
1110    let fork_ctx = registry.get_fork(&params.fork_id)?;
1111    let work_path = fork_ctx.work_path.clone();
1112
1113    let bounds = parse_range_bounds(&params.target_range)?;
1114    let (anchor_col, anchor_row) = parse_cell_ref(&params.anchor_cell)?;
1115    let fill_direction = params.fill_direction.unwrap_or_default();
1116    validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
1117
1118    let fork_workbook_id = WorkbookId(params.fork_id.clone());
1119    let workbook = state.open_workbook(&fork_workbook_id).await?;
1120    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
1121
1122    let relative_mode_param = params.relative_mode.unwrap_or_default();
1123    let relative_mode: RelativeMode = relative_mode_param.into();
1124    let mode = params.mode.unwrap_or_default();
1125
1126    if mode.is_preview() {
1127        let change_id = make_short_random_id("chg", 12);
1128        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1129        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1130        fs::copy(&work_path, &snapshot_path)?;
1131
1132        let sheet_name = params.sheet_name.clone();
1133        let target_range = params.target_range.clone();
1134        let anchor_cell = params.anchor_cell.clone();
1135        let base_formula = params.base_formula.clone();
1136        let fill_direction = Some(fill_direction);
1137        let relative_mode_param = Some(relative_mode_param);
1138        let snapshot_for_apply = snapshot_path.clone();
1139        let sheet_name_for_apply = sheet_name.clone();
1140        let target_range_for_apply = target_range.clone();
1141        let base_formula_for_apply = base_formula.clone();
1142
1143        let apply_result = tokio::task::spawn_blocking(move || {
1144            apply_formula_pattern_to_file(
1145                &snapshot_for_apply,
1146                &sheet_name_for_apply,
1147                &target_range_for_apply,
1148                anchor_col,
1149                anchor_row,
1150                &base_formula_for_apply,
1151                relative_mode,
1152            )
1153        })
1154        .await??;
1155
1156        let mut summary = apply_result.summary;
1157        summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1158        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1159
1160        let staged_op = StagedOp {
1161            kind: "apply_formula_pattern".to_string(),
1162            payload: serde_json::to_value(ApplyFormulaPatternStagedPayload {
1163                sheet_name: sheet_name.clone(),
1164                target_range: target_range.clone(),
1165                anchor_cell: anchor_cell.clone(),
1166                base_formula: base_formula.clone(),
1167                fill_direction,
1168                relative_mode: relative_mode_param,
1169            })?,
1170        };
1171
1172        let staged = StagedChange {
1173            change_id: change_id.clone(),
1174            created_at: Utc::now(),
1175            label: params.label.clone(),
1176            ops: vec![staged_op],
1177            summary: summary.clone(),
1178            fork_path_snapshot: Some(snapshot_path),
1179        };
1180
1181        registry.add_staged_change(&params.fork_id, staged)?;
1182
1183        Ok(ApplyFormulaPatternResponse {
1184            fork_id: params.fork_id,
1185            sheet_name,
1186            target_range,
1187            mode: mode.as_str().to_string(),
1188            change_id: Some(change_id),
1189            cells_filled: apply_result.cells_filled,
1190            summary,
1191        })
1192    } else {
1193        let sheet_name = params.sheet_name.clone();
1194        let target_range = params.target_range.clone();
1195        let base_formula = params.base_formula.clone();
1196        let sheet_name_for_apply = sheet_name.clone();
1197        let target_range_for_apply = target_range.clone();
1198        let base_formula_for_apply = base_formula.clone();
1199        let apply_result = tokio::task::spawn_blocking(move || {
1200            apply_formula_pattern_to_file(
1201                &work_path,
1202                &sheet_name_for_apply,
1203                &target_range_for_apply,
1204                anchor_col,
1205                anchor_row,
1206                &base_formula_for_apply,
1207                relative_mode,
1208            )
1209        })
1210        .await??;
1211
1212        let mut summary = apply_result.summary;
1213        summary.op_kinds = vec!["apply_formula_pattern".to_string()];
1214
1215        registry.with_fork_mut(&params.fork_id, |ctx| {
1216            ctx.recalc_needed = true;
1217            Ok(())
1218        })?;
1219        set_recalc_needed_flag(&mut summary, true);
1220
1221        let _ = state.close_workbook(&fork_workbook_id);
1222
1223        Ok(ApplyFormulaPatternResponse {
1224            fork_id: params.fork_id,
1225            sheet_name,
1226            target_range,
1227            mode: mode.as_str().to_string(),
1228            change_id: None,
1229            cells_filled: apply_result.cells_filled,
1230            summary,
1231        })
1232    }
1233}
1234
1235struct FormulaPatternApplyResult {
1236    cells_filled: u64,
1237    summary: ChangeSummary,
1238}
1239
1240fn apply_formula_pattern_to_file(
1241    path: &Path,
1242    sheet_name: &str,
1243    target_range: &str,
1244    anchor_col: u32,
1245    anchor_row: u32,
1246    base_formula: &str,
1247    relative_mode: RelativeMode,
1248) -> Result<FormulaPatternApplyResult> {
1249    let ast = parse_base_formula(base_formula)?;
1250    let bounds = parse_range_bounds(target_range)?;
1251
1252    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1253    let sheet = book
1254        .get_sheet_by_name_mut(sheet_name)
1255        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1256
1257    let mut cells_filled: u64 = 0;
1258    for row in bounds.min_row..=bounds.max_row {
1259        for col in bounds.min_col..=bounds.max_col {
1260            let delta_col = col as i32 - anchor_col as i32;
1261            let delta_row = row as i32 - anchor_row as i32;
1262            let shifted = shift_formula_ast(&ast, delta_col, delta_row, relative_mode)?;
1263            let shifted_for_umya = shifted.strip_prefix('=').unwrap_or(&shifted);
1264            let addr = crate::utils::cell_address(col, row);
1265            sheet
1266                .get_cell_mut(addr.as_str())
1267                .set_formula(shifted_for_umya.to_string());
1268            cells_filled += 1;
1269        }
1270    }
1271
1272    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1273
1274    let mut counts = BTreeMap::new();
1275    counts.insert("cells_filled".to_string(), cells_filled);
1276
1277    let summary = ChangeSummary {
1278        op_kinds: vec!["apply_formula_pattern".to_string()],
1279        affected_sheets: vec![sheet_name.to_string()],
1280        affected_bounds: vec![target_range.to_string()],
1281        counts,
1282        warnings: Vec::new(),
1283        ..Default::default()
1284    };
1285
1286    Ok(FormulaPatternApplyResult {
1287        cells_filled,
1288        summary,
1289    })
1290}
1291
1292fn validate_formula_pattern_bounds(
1293    bounds: &ScreenshotBounds,
1294    anchor_col: u32,
1295    anchor_row: u32,
1296    fill_direction: FillDirection,
1297) -> Result<()> {
1298    if anchor_col < bounds.min_col
1299        || anchor_col > bounds.max_col
1300        || anchor_row < bounds.min_row
1301        || anchor_row > bounds.max_row
1302    {
1303        let bounds_range = format!(
1304            "{}:{}",
1305            crate::utils::cell_address(bounds.min_col, bounds.min_row),
1306            crate::utils::cell_address(bounds.max_col, bounds.max_row)
1307        );
1308        bail!(
1309            "anchor_cell must be inside target_range (anchor {} not within {})",
1310            crate::utils::cell_address(anchor_col, anchor_row),
1311            bounds_range
1312        );
1313    }
1314
1315    if bounds.min_col != anchor_col || bounds.min_row != anchor_row {
1316        bail!("target_range must start at anchor_cell (anchor should be top-left of fill range)");
1317    }
1318
1319    match fill_direction {
1320        FillDirection::Down => {
1321            if bounds.min_col != bounds.max_col {
1322                bail!("fill_direction=down requires a single-column target_range");
1323            }
1324        }
1325        FillDirection::Right => {
1326            if bounds.min_row != bounds.max_row {
1327                bail!("fill_direction=right requires a single-row target_range");
1328            }
1329        }
1330        FillDirection::Both => {}
1331    }
1332    Ok(())
1333}
1334
1335#[derive(Debug, Deserialize, JsonSchema)]
1336pub struct StructureBatchParams {
1337    pub fork_id: String,
1338    pub ops: Vec<StructureOp>,
1339    #[serde(default)]
1340    pub mode: Option<BatchMode>, // preview|apply (default apply)
1341    pub label: Option<String>,
1342}
1343
1344#[derive(Debug, Deserialize, JsonSchema)]
1345pub struct StructureBatchParamsInput {
1346    pub fork_id: String,
1347    pub ops: Vec<StructureOpInput>,
1348    #[serde(default)]
1349    pub mode: Option<BatchMode>,
1350    pub label: Option<String>,
1351}
1352
1353#[derive(Debug, Clone)]
1354pub struct StructureOpInput {
1355    op: StructureOp,
1356    alias_used: bool,
1357}
1358
1359impl From<StructureOp> for StructureOpInput {
1360    fn from(op: StructureOp) -> Self {
1361        Self {
1362            op,
1363            alias_used: false,
1364        }
1365    }
1366}
1367
1368impl<'de> Deserialize<'de> for StructureOpInput {
1369    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1370    where
1371        D: serde::Deserializer<'de>,
1372    {
1373        let mut value = serde_json::Value::deserialize(deserializer)?;
1374        let Some(obj) = value.as_object_mut() else {
1375            return Err(de::Error::custom("structure op must be an object"));
1376        };
1377
1378        let mut alias_used = false;
1379        let kind_value = if let Some(kind) = obj.get("kind") {
1380            kind.clone()
1381        } else if let Some(op) = obj.remove("op") {
1382            alias_used = true;
1383            op
1384        } else {
1385            return Err(de::Error::custom("structure op requires 'kind' or 'op'"));
1386        };
1387
1388        let Some(kind_str) = kind_value.as_str() else {
1389            return Err(de::Error::custom("structure op kind must be a string"));
1390        };
1391
1392        let normalized_kind = if kind_str == "add_sheet" {
1393            alias_used = true;
1394            "create_sheet"
1395        } else {
1396            kind_str
1397        };
1398
1399        obj.insert(
1400            "kind".to_string(),
1401            serde_json::Value::String(normalized_kind.to_string()),
1402        );
1403
1404        let op = serde_json::from_value(value).map_err(de::Error::custom)?;
1405        Ok(StructureOpInput { op, alias_used })
1406    }
1407}
1408
1409impl schemars::JsonSchema for StructureOpInput {
1410    fn schema_name() -> std::borrow::Cow<'static, str> {
1411        "StructureOp".into()
1412    }
1413
1414    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1415        StructureOp::json_schema(generator)
1416    }
1417}
1418
1419pub fn normalize_structure_batch(
1420    input: StructureBatchParamsInput,
1421) -> Result<(StructureBatchParams, Vec<Warning>)> {
1422    let mut warnings = Vec::new();
1423    let mut ops = Vec::with_capacity(input.ops.len());
1424
1425    for op_input in input.ops {
1426        if op_input.alias_used {
1427            warnings.push(Warning {
1428                code: "WARN_ALIAS_KIND".to_string(),
1429                message: "Normalized structure op alias to canonical kind".to_string(),
1430            });
1431        }
1432        ops.push(op_input.op);
1433    }
1434
1435    Ok((
1436        StructureBatchParams {
1437            fork_id: input.fork_id,
1438            ops,
1439            mode: input.mode,
1440            label: input.label,
1441        },
1442        warnings,
1443    ))
1444}
1445
1446#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1447#[serde(tag = "kind", rename_all = "snake_case")]
1448pub enum StructureOp {
1449    InsertRows {
1450        sheet_name: String,
1451        at_row: u32,
1452        count: u32,
1453    },
1454    DeleteRows {
1455        sheet_name: String,
1456        start_row: u32,
1457        count: u32,
1458    },
1459    InsertCols {
1460        sheet_name: String,
1461        at_col: String,
1462        count: u32,
1463    },
1464    DeleteCols {
1465        sheet_name: String,
1466        start_col: String,
1467        count: u32,
1468    },
1469    RenameSheet {
1470        old_name: String,
1471        new_name: String,
1472    },
1473    CreateSheet {
1474        name: String,
1475        #[serde(default)]
1476        position: Option<u32>,
1477    },
1478    DeleteSheet {
1479        name: String,
1480    },
1481    CopyRange {
1482        sheet_name: String,
1483        #[serde(default)]
1484        dest_sheet_name: Option<String>,
1485        src_range: String,
1486        dest_anchor: String,
1487        include_styles: bool,
1488        include_formulas: bool,
1489    },
1490    MoveRange {
1491        sheet_name: String,
1492        #[serde(default)]
1493        dest_sheet_name: Option<String>,
1494        src_range: String,
1495        dest_anchor: String,
1496        include_styles: bool,
1497        include_formulas: bool,
1498    },
1499}
1500
1501fn structure_ops_require_recalc(ops: &[StructureOp]) -> bool {
1502    ops.iter().any(|op| match op {
1503        StructureOp::InsertRows { .. }
1504        | StructureOp::DeleteRows { .. }
1505        | StructureOp::InsertCols { .. }
1506        | StructureOp::DeleteCols { .. }
1507        | StructureOp::RenameSheet { .. } => true,
1508        StructureOp::CopyRange {
1509            include_formulas: true,
1510            ..
1511        }
1512        | StructureOp::MoveRange {
1513            include_formulas: true,
1514            ..
1515        } => true,
1516        _ => false,
1517    })
1518}
1519
1520#[derive(Debug, Serialize, JsonSchema)]
1521pub struct StructureBatchResponse {
1522    pub fork_id: String,
1523    pub mode: String,
1524    pub change_id: Option<String>,
1525    pub ops_applied: usize,
1526    pub summary: ChangeSummary,
1527}
1528
1529#[derive(Debug, Serialize, Deserialize)]
1530struct StructureBatchStagedPayload {
1531    ops: Vec<StructureOp>,
1532}
1533
1534pub async fn structure_batch(
1535    state: Arc<AppState>,
1536    params: StructureBatchParamsInput,
1537) -> Result<StructureBatchResponse> {
1538    let (params, warnings) = normalize_structure_batch(params)?;
1539    let alias_warnings: Vec<String> = warnings
1540        .into_iter()
1541        .map(|warning| format!("{}: {}", warning.code, warning.message))
1542        .collect();
1543    let registry = state
1544        .fork_registry()
1545        .ok_or_else(|| anyhow!("fork registry not available"))?;
1546
1547    let fork_ctx = registry.get_fork(&params.fork_id)?;
1548    let work_path = fork_ctx.work_path.clone();
1549
1550    let will_need_recalc = fork_ctx.recalc_needed || structure_ops_require_recalc(&params.ops);
1551
1552    let mode = params.mode.unwrap_or_default();
1553
1554    if mode.is_preview() {
1555        let change_id = make_short_random_id("chg", 12);
1556        let snapshot_path = stage_snapshot_path(&params.fork_id, &change_id);
1557        fs::create_dir_all(snapshot_path.parent().unwrap())?;
1558        fs::copy(&work_path, &snapshot_path)?;
1559
1560        let snapshot_for_apply = snapshot_path.clone();
1561        let ops_for_apply = params.ops.clone();
1562
1563        let apply_result = tokio::task::spawn_blocking(move || {
1564            apply_structure_ops_to_file(&snapshot_for_apply, &ops_for_apply)
1565        })
1566        .await??;
1567
1568        let mut summary = apply_result.summary;
1569        summary.op_kinds = vec!["structure_batch".to_string()];
1570        summary.warnings.extend(alias_warnings);
1571        set_recalc_needed_flag(&mut summary, fork_ctx.recalc_needed);
1572        // Best-effort preview diff size: compare current fork to preview snapshot.
1573        // This is intentionally summarized as a count to avoid large payloads.
1574        if let Ok(change_count) = tokio::task::spawn_blocking({
1575            let base_path = work_path.clone();
1576            let preview_path = snapshot_path.clone();
1577            move || {
1578                crate::core::diff::calculate_changeset(&base_path, &preview_path, None)
1579                    .map(|changes| changes.len() as u64)
1580            }
1581        })
1582        .await?
1583        {
1584            summary
1585                .counts
1586                .insert("preview_change_items".to_string(), change_count);
1587        } else {
1588            summary.warnings.push(
1589                "Preview diff computation failed; run get_changeset after applying to inspect changes."
1590                    .to_string(),
1591            );
1592        }
1593
1594        let staged_op = StagedOp {
1595            kind: "structure_batch".to_string(),
1596            payload: serde_json::to_value(StructureBatchStagedPayload {
1597                ops: params.ops.clone(),
1598            })?,
1599        };
1600
1601        let staged = StagedChange {
1602            change_id: change_id.clone(),
1603            created_at: Utc::now(),
1604            label: params.label.clone(),
1605            ops: vec![staged_op],
1606            summary: summary.clone(),
1607            fork_path_snapshot: Some(snapshot_path),
1608        };
1609
1610        registry.add_staged_change(&params.fork_id, staged)?;
1611
1612        Ok(StructureBatchResponse {
1613            fork_id: params.fork_id,
1614            mode: mode.as_str().to_string(),
1615            change_id: Some(change_id),
1616            ops_applied: apply_result.ops_applied,
1617            summary,
1618        })
1619    } else {
1620        let ops_for_apply = params.ops.clone();
1621        let apply_result = tokio::task::spawn_blocking(move || {
1622            apply_structure_ops_to_file(&work_path, &ops_for_apply)
1623        })
1624        .await??;
1625
1626        let mut summary = apply_result.summary;
1627        summary.op_kinds = vec!["structure_batch".to_string()];
1628        summary.warnings.extend(alias_warnings);
1629
1630        if will_need_recalc {
1631            registry.with_fork_mut(&params.fork_id, |ctx| {
1632                ctx.recalc_needed = true;
1633                Ok(())
1634            })?;
1635        }
1636        set_recalc_needed_flag(&mut summary, will_need_recalc);
1637
1638        let fork_workbook_id = WorkbookId(params.fork_id.clone());
1639        let _ = state.close_workbook(&fork_workbook_id);
1640
1641        Ok(StructureBatchResponse {
1642            fork_id: params.fork_id,
1643            mode: mode.as_str().to_string(),
1644            change_id: None,
1645            ops_applied: apply_result.ops_applied,
1646            summary,
1647        })
1648    }
1649}
1650
1651struct StructureApplyResult {
1652    ops_applied: usize,
1653    summary: ChangeSummary,
1654}
1655
1656fn apply_structure_ops_to_file(path: &Path, ops: &[StructureOp]) -> Result<StructureApplyResult> {
1657    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
1658
1659    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
1660    let affected_bounds: Vec<String> = Vec::new();
1661    let mut counts: BTreeMap<String, u64> = BTreeMap::new();
1662    let mut warnings: Vec<String> = vec![
1663        "Structural edits may not fully rewrite formulas/named ranges like Excel. After apply, run recalculate and review get_changeset.".to_string(),
1664    ];
1665
1666    for op in ops {
1667        match op {
1668            StructureOp::InsertRows {
1669                sheet_name,
1670                at_row,
1671                count,
1672            } => {
1673                if *at_row == 0 || *count == 0 {
1674                    bail!("insert_rows requires at_row>=1 and count>=1");
1675                }
1676                {
1677                    let sheet = book
1678                        .get_sheet_by_name_mut(sheet_name)
1679                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1680                    sheet.insert_new_row(at_row, count);
1681                }
1682                rewrite_formulas_for_sheet_row_insert(&mut book, sheet_name, *at_row, *count)?;
1683                rewrite_defined_name_formulas_for_sheet_row_insert(
1684                    &mut book, sheet_name, *at_row, *count,
1685                )?;
1686                affected_sheets.insert(sheet_name.clone());
1687                counts
1688                    .entry("rows_inserted".to_string())
1689                    .and_modify(|v| *v += *count as u64)
1690                    .or_insert(*count as u64);
1691            }
1692            StructureOp::DeleteRows {
1693                sheet_name,
1694                start_row,
1695                count,
1696            } => {
1697                if *start_row == 0 || *count == 0 {
1698                    bail!("delete_rows requires start_row>=1 and count>=1");
1699                }
1700                {
1701                    let sheet = book
1702                        .get_sheet_by_name_mut(sheet_name)
1703                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1704                    sheet.remove_row(start_row, count);
1705                }
1706                rewrite_formulas_for_sheet_row_delete(&mut book, sheet_name, *start_row, *count)?;
1707                rewrite_defined_name_formulas_for_sheet_row_delete(
1708                    &mut book, sheet_name, *start_row, *count,
1709                )?;
1710                affected_sheets.insert(sheet_name.clone());
1711                counts
1712                    .entry("rows_deleted".to_string())
1713                    .and_modify(|v| *v += *count as u64)
1714                    .or_insert(*count as u64);
1715            }
1716            StructureOp::InsertCols {
1717                sheet_name,
1718                at_col,
1719                count,
1720            } => {
1721                if at_col.trim().is_empty() || *count == 0 {
1722                    bail!("insert_cols requires at_col and count>=1");
1723                }
1724                let col_letters = normalize_col_letters(at_col)?;
1725                let root_col =
1726                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
1727                {
1728                    let sheet = book
1729                        .get_sheet_by_name_mut(sheet_name)
1730                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1731                    sheet.insert_new_column(&col_letters, count);
1732                }
1733                rewrite_formulas_for_sheet_col_insert(&mut book, sheet_name, root_col, *count)?;
1734                rewrite_defined_name_formulas_for_sheet_col_insert(
1735                    &mut book, sheet_name, root_col, *count,
1736                )?;
1737                affected_sheets.insert(sheet_name.clone());
1738                counts
1739                    .entry("cols_inserted".to_string())
1740                    .and_modify(|v| *v += *count as u64)
1741                    .or_insert(*count as u64);
1742            }
1743            StructureOp::DeleteCols {
1744                sheet_name,
1745                start_col,
1746                count,
1747            } => {
1748                if start_col.trim().is_empty() || *count == 0 {
1749                    bail!("delete_cols requires start_col and count>=1");
1750                }
1751                let col_letters = normalize_col_letters(start_col)?;
1752                let root_col =
1753                    umya_spreadsheet::helper::coordinate::column_index_from_string(&col_letters);
1754                {
1755                    let sheet = book
1756                        .get_sheet_by_name_mut(sheet_name)
1757                        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
1758                    sheet.remove_column(&col_letters, count);
1759                }
1760                rewrite_formulas_for_sheet_col_delete(&mut book, sheet_name, root_col, *count)?;
1761                rewrite_defined_name_formulas_for_sheet_col_delete(
1762                    &mut book, sheet_name, root_col, *count,
1763                )?;
1764                affected_sheets.insert(sheet_name.clone());
1765                counts
1766                    .entry("cols_deleted".to_string())
1767                    .and_modify(|v| *v += *count as u64)
1768                    .or_insert(*count as u64);
1769            }
1770            StructureOp::RenameSheet { old_name, new_name } => {
1771                let old_name = old_name.trim();
1772                let new_name = new_name.trim();
1773                if old_name.is_empty() || new_name.is_empty() {
1774                    bail!("rename_sheet requires non-empty old_name and new_name");
1775                }
1776
1777                let sheet_index = book
1778                    .get_sheet_collection_no_check()
1779                    .iter()
1780                    .position(|s| s.get_name() == old_name)
1781                    .ok_or_else(|| anyhow!("sheet '{}' not found", old_name))?;
1782                book.set_sheet_name(sheet_index, new_name.to_string())
1783                    .map_err(|e| anyhow!("failed to rename sheet '{}': {}", old_name, e))?;
1784
1785                rewrite_formulas_for_sheet_rename(&mut book, old_name, new_name)?;
1786                rewrite_defined_name_formulas_for_sheet_rename(&mut book, old_name, new_name)?;
1787
1788                affected_sheets.insert(old_name.to_string());
1789                affected_sheets.insert(new_name.to_string());
1790                counts
1791                    .entry("sheets_renamed".to_string())
1792                    .and_modify(|v| *v += 1)
1793                    .or_insert(1);
1794            }
1795            StructureOp::CreateSheet { name, position } => {
1796                let name_trimmed = name.trim();
1797                if name_trimmed.is_empty() {
1798                    bail!("create_sheet requires non-empty name");
1799                }
1800                let requested_position = *position;
1801                book.new_sheet(name_trimmed.to_string())
1802                    .map_err(|e| anyhow!("failed to create sheet '{}': {}", name_trimmed, e))?;
1803
1804                if let Some(pos) = requested_position {
1805                    let desired = pos as usize;
1806                    let len = book.get_sheet_collection_no_check().len();
1807                    if desired >= len {
1808                        warnings.push(format!(
1809                            "create_sheet position {} is out of range (sheet_count {}). Appended at end.",
1810                            desired, len
1811                        ));
1812                    } else if desired != len - 1 {
1813                        let sheets = book.get_sheet_collection_mut();
1814                        let created = sheets.remove(len - 1);
1815                        sheets.insert(desired, created);
1816                    }
1817                }
1818
1819                affected_sheets.insert(name_trimmed.to_string());
1820                counts
1821                    .entry("sheets_created".to_string())
1822                    .and_modify(|v| *v += 1)
1823                    .or_insert(1);
1824            }
1825            StructureOp::DeleteSheet { name } => {
1826                let name_trimmed = name.trim();
1827                if name_trimmed.is_empty() {
1828                    bail!("delete_sheet requires non-empty name");
1829                }
1830                if book.get_sheet_collection_no_check().len() <= 1 {
1831                    bail!("cannot delete the last remaining sheet");
1832                }
1833                book.remove_sheet_by_name(name_trimmed)
1834                    .map_err(|e| anyhow!("failed to delete sheet '{}': {}", name_trimmed, e))?;
1835                affected_sheets.insert(name_trimmed.to_string());
1836                counts
1837                    .entry("sheets_deleted".to_string())
1838                    .and_modify(|v| *v += 1)
1839                    .or_insert(1);
1840            }
1841            StructureOp::CopyRange {
1842                sheet_name,
1843                dest_sheet_name,
1844                src_range,
1845                dest_anchor,
1846                include_styles,
1847                include_formulas,
1848            } => {
1849                let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
1850                let result = copy_or_move_range(
1851                    &mut book,
1852                    sheet_name,
1853                    dest_sheet_name,
1854                    src_range,
1855                    dest_anchor,
1856                    *include_styles,
1857                    *include_formulas,
1858                    false,
1859                )?;
1860                affected_sheets.insert(sheet_name.clone());
1861                affected_sheets.insert(dest_sheet_name.to_string());
1862                counts
1863                    .entry("cells_copied".to_string())
1864                    .and_modify(|v| *v += result.cells_written)
1865                    .or_insert(result.cells_written);
1866                counts
1867                    .entry("ranges_copied".to_string())
1868                    .and_modify(|v| *v += 1)
1869                    .or_insert(1);
1870                warnings.extend(result.warnings);
1871            }
1872            StructureOp::MoveRange {
1873                sheet_name,
1874                dest_sheet_name,
1875                src_range,
1876                dest_anchor,
1877                include_styles,
1878                include_formulas,
1879            } => {
1880                let dest_sheet_name = dest_sheet_name.as_deref().unwrap_or(sheet_name);
1881                let result = copy_or_move_range(
1882                    &mut book,
1883                    sheet_name,
1884                    dest_sheet_name,
1885                    src_range,
1886                    dest_anchor,
1887                    *include_styles,
1888                    *include_formulas,
1889                    true,
1890                )?;
1891                affected_sheets.insert(sheet_name.clone());
1892                affected_sheets.insert(dest_sheet_name.to_string());
1893                counts
1894                    .entry("cells_moved".to_string())
1895                    .and_modify(|v| *v += result.cells_written)
1896                    .or_insert(result.cells_written);
1897                counts
1898                    .entry("ranges_moved".to_string())
1899                    .and_modify(|v| *v += 1)
1900                    .or_insert(1);
1901                warnings.extend(result.warnings);
1902            }
1903        }
1904    }
1905
1906    umya_spreadsheet::writer::xlsx::write(&book, path)?;
1907
1908    let summary = ChangeSummary {
1909        op_kinds: vec!["structure_batch".to_string()],
1910        affected_sheets: affected_sheets.into_iter().collect(),
1911        affected_bounds,
1912        counts,
1913        warnings,
1914        ..Default::default()
1915    };
1916
1917    Ok(StructureApplyResult {
1918        ops_applied: ops.len(),
1919        summary,
1920    })
1921}
1922
1923fn normalize_col_letters(col: &str) -> Result<String> {
1924    let letters = col.trim().to_ascii_uppercase();
1925    if letters.is_empty() || !letters.chars().all(|c| c.is_ascii_alphabetic()) {
1926        bail!("invalid column reference: {}", col);
1927    }
1928    Ok(letters)
1929}
1930
1931struct CopyMoveApplyResult {
1932    cells_written: u64,
1933    warnings: Vec<String>,
1934}
1935
1936#[allow(clippy::too_many_arguments)]
1937fn ranges_intersect(
1938    a_min_col: u32,
1939    a_min_row: u32,
1940    a_max_col: u32,
1941    a_max_row: u32,
1942    b_min_col: u32,
1943    b_min_row: u32,
1944    b_max_col: u32,
1945    b_max_row: u32,
1946) -> bool {
1947    !(a_max_col < b_min_col
1948        || b_max_col < a_min_col
1949        || a_max_row < b_min_row
1950        || b_max_row < a_min_row)
1951}
1952
1953#[allow(clippy::too_many_arguments)]
1954fn copy_or_move_range(
1955    book: &mut umya_spreadsheet::Spreadsheet,
1956    src_sheet_name: &str,
1957    dest_sheet_name: &str,
1958    src_range: &str,
1959    dest_anchor: &str,
1960    include_styles: bool,
1961    include_formulas: bool,
1962    clear_source: bool,
1963) -> Result<CopyMoveApplyResult> {
1964    let src_bounds = parse_range_bounds(src_range)?;
1965    let (dest_start_col, dest_start_row) = parse_cell_ref(dest_anchor)?;
1966
1967    let width = src_bounds.cols;
1968    let height = src_bounds.rows;
1969
1970    let dest_end_col = dest_start_col
1971        .checked_add(width.saturating_sub(1))
1972        .ok_or_else(|| anyhow!("destination range overflows column bounds"))?;
1973    let dest_end_row = dest_start_row
1974        .checked_add(height.saturating_sub(1))
1975        .ok_or_else(|| anyhow!("destination range overflows row bounds"))?;
1976
1977    let same_sheet = src_sheet_name == dest_sheet_name;
1978
1979    if same_sheet
1980        && ranges_intersect(
1981            src_bounds.min_col,
1982            src_bounds.min_row,
1983            src_bounds.max_col,
1984            src_bounds.max_row,
1985            dest_start_col,
1986            dest_start_row,
1987            dest_end_col,
1988            dest_end_row,
1989        )
1990    {
1991        let dest_range = if width == 1 && height == 1 {
1992            crate::utils::cell_address(dest_start_col, dest_start_row)
1993        } else {
1994            format!(
1995                "{}:{}",
1996                crate::utils::cell_address(dest_start_col, dest_start_row),
1997                crate::utils::cell_address(dest_end_col, dest_end_row)
1998            )
1999        };
2000        bail!(
2001            "copy/move destination overlaps source (src {}, dest {})",
2002            src_range,
2003            dest_range
2004        );
2005    }
2006
2007    let delta_col = dest_start_col as i32 - src_bounds.min_col as i32;
2008    let delta_row = dest_start_row as i32 - src_bounds.min_row as i32;
2009
2010    let mut warnings: Vec<String> = Vec::new();
2011    let mut formula_shift_failures: u64 = 0;
2012    let mut formula_value_copies: u64 = 0;
2013
2014    let (src_sheet_index, dest_sheet_index) = {
2015        let sheets = book.get_sheet_collection_no_check();
2016        let src = sheets
2017            .iter()
2018            .position(|s| s.get_name() == src_sheet_name)
2019            .ok_or_else(|| anyhow!("sheet '{}' not found", src_sheet_name))?;
2020        let dest = sheets
2021            .iter()
2022            .position(|s| s.get_name() == dest_sheet_name)
2023            .ok_or_else(|| anyhow!("sheet '{}' not found", dest_sheet_name))?;
2024        (src, dest)
2025    };
2026
2027    let sheets = book.get_sheet_collection_mut();
2028
2029    if src_sheet_index == dest_sheet_index {
2030        let sheet = &mut sheets[src_sheet_index];
2031
2032        for row in 0..height {
2033            for col in 0..width {
2034                let src_col = src_bounds.min_col + col;
2035                let src_row = src_bounds.min_row + row;
2036                let dest_col = dest_start_col + col;
2037                let dest_row = dest_start_row + row;
2038
2039                let Some(src_cell) = sheet.get_cell((src_col, src_row)) else {
2040                    sheet.remove_cell((dest_col, dest_row));
2041                    continue;
2042                };
2043
2044                let mut set_value = true;
2045                let mut dest_formula: Option<String> = None;
2046
2047                if include_formulas && src_cell.is_formula() {
2048                    let src_formula = src_cell.get_formula().to_string();
2049                    match parse_base_formula(&src_formula).and_then(|ast| {
2050                        shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2051                    }) {
2052                        Ok(shifted) => {
2053                            let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2054                            dest_formula = Some(shifted);
2055                            set_value = false;
2056                        }
2057                        Err(_) => {
2058                            dest_formula = Some(src_formula);
2059                            set_value = false;
2060                            formula_shift_failures += 1;
2061                        }
2062                    }
2063                } else if !include_formulas && src_cell.is_formula() {
2064                    formula_value_copies += 1;
2065                }
2066
2067                let src_value = src_cell.get_value().to_string();
2068                let src_style = src_cell.get_style().clone();
2069
2070                let dest_cell = sheet.get_cell_mut((dest_col, dest_row));
2071                if include_styles {
2072                    dest_cell.set_style(src_style);
2073                }
2074
2075                dest_cell.get_cell_value_mut().remove_formula();
2076                if let Some(formula) = dest_formula {
2077                    dest_cell.set_formula(formula);
2078                    dest_cell.set_formula_result_default("");
2079                }
2080                if set_value {
2081                    dest_cell.set_value(src_value);
2082                }
2083            }
2084        }
2085
2086        if clear_source {
2087            for row in 0..height {
2088                for col in 0..width {
2089                    let src_col = src_bounds.min_col + col;
2090                    let src_row = src_bounds.min_row + row;
2091                    sheet.remove_cell((src_col, src_row));
2092                }
2093            }
2094        }
2095    } else {
2096        let (src_sheet, dest_sheet) = if src_sheet_index < dest_sheet_index {
2097            let (left, right) = sheets.split_at_mut(dest_sheet_index);
2098            (&mut left[src_sheet_index], &mut right[0])
2099        } else {
2100            let (left, right) = sheets.split_at_mut(src_sheet_index);
2101            (&mut right[0], &mut left[dest_sheet_index])
2102        };
2103
2104        for row in 0..height {
2105            for col in 0..width {
2106                let src_col = src_bounds.min_col + col;
2107                let src_row = src_bounds.min_row + row;
2108                let dest_col = dest_start_col + col;
2109                let dest_row = dest_start_row + row;
2110
2111                let Some(src_cell) = src_sheet.get_cell((src_col, src_row)) else {
2112                    dest_sheet.remove_cell((dest_col, dest_row));
2113                    continue;
2114                };
2115
2116                let mut set_value = true;
2117                let mut dest_formula: Option<String> = None;
2118
2119                if include_formulas && src_cell.is_formula() {
2120                    let src_formula = src_cell.get_formula().to_string();
2121                    match parse_base_formula(&src_formula).and_then(|ast| {
2122                        shift_formula_ast(&ast, delta_col, delta_row, RelativeMode::Excel)
2123                    }) {
2124                        Ok(shifted) => {
2125                            let shifted = shifted.strip_prefix('=').unwrap_or(&shifted).to_string();
2126                            dest_formula = Some(shifted);
2127                            set_value = false;
2128                        }
2129                        Err(_) => {
2130                            dest_formula = Some(src_formula);
2131                            set_value = false;
2132                            formula_shift_failures += 1;
2133                        }
2134                    }
2135                } else if !include_formulas && src_cell.is_formula() {
2136                    formula_value_copies += 1;
2137                }
2138
2139                let src_value = src_cell.get_value().to_string();
2140                let src_style = src_cell.get_style().clone();
2141
2142                let dest_cell = dest_sheet.get_cell_mut((dest_col, dest_row));
2143                if include_styles {
2144                    dest_cell.set_style(src_style);
2145                }
2146
2147                dest_cell.get_cell_value_mut().remove_formula();
2148                if let Some(formula) = dest_formula {
2149                    dest_cell.set_formula(formula);
2150                    dest_cell.set_formula_result_default("");
2151                }
2152                if set_value {
2153                    dest_cell.set_value(src_value);
2154                }
2155            }
2156        }
2157
2158        if clear_source {
2159            for row in 0..height {
2160                for col in 0..width {
2161                    let src_col = src_bounds.min_col + col;
2162                    let src_row = src_bounds.min_row + row;
2163                    src_sheet.remove_cell((src_col, src_row));
2164                }
2165            }
2166        }
2167    }
2168
2169    if include_formulas && formula_shift_failures > 0 {
2170        warnings.push(format!(
2171            "Failed to shift {} formula(s); copied original formula text.",
2172            formula_shift_failures
2173        ));
2174    }
2175    if !include_formulas && formula_value_copies > 0 {
2176        warnings.push(format!(
2177            "Copied cached values for {} formula cell(s) (include_formulas=false); run recalculate for fresh results.",
2178            formula_value_copies
2179        ));
2180    }
2181
2182    Ok(CopyMoveApplyResult {
2183        cells_written: width as u64 * height as u64,
2184        warnings,
2185    })
2186}
2187
2188fn rewrite_formulas_for_sheet_rename(
2189    book: &mut umya_spreadsheet::Spreadsheet,
2190    old_name: &str,
2191    new_name: &str,
2192) -> Result<()> {
2193    let new_prefix = format_sheet_prefix_for_formula(new_name);
2194
2195    for sheet in book.get_sheet_collection_mut().iter_mut() {
2196        for cell in sheet.get_cell_collection_mut() {
2197            if !cell.is_formula() {
2198                continue;
2199            }
2200            let formula_text = cell.get_formula();
2201            if formula_text.is_empty() {
2202                continue;
2203            }
2204            let formula_with_equals = if formula_text.starts_with('=') {
2205                formula_text.to_string()
2206            } else {
2207                format!("={}", formula_text)
2208            };
2209
2210            let tokenizer = Tokenizer::new(&formula_with_equals)
2211                .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2212
2213            let tokens = tokenizer.items;
2214            let mut out = String::with_capacity(formula_with_equals.len());
2215            let mut cursor = 0usize;
2216
2217            for token in &tokens {
2218                if token.start > cursor {
2219                    out.push_str(&formula_with_equals[cursor..token.start]);
2220                }
2221
2222                let mut value = token.value.clone();
2223                if token.subtype == formualizer_parse::TokenSubType::Range
2224                    && value.contains('!')
2225                    && let Some((sheet_part, tail)) = value.split_once('!')
2226                    && sheet_part_matches(sheet_part, old_name)
2227                {
2228                    value = format!("{}{}", new_prefix, tail);
2229                }
2230
2231                out.push_str(&value);
2232                cursor = token.end;
2233            }
2234
2235            if cursor < formula_with_equals.len() {
2236                out.push_str(&formula_with_equals[cursor..]);
2237            }
2238
2239            let new_formula = out.strip_prefix('=').unwrap_or(&out);
2240            cell.set_formula(new_formula.to_string());
2241        }
2242    }
2243
2244    Ok(())
2245}
2246
2247fn rewrite_defined_name_formulas_for_sheet_rename(
2248    book: &mut umya_spreadsheet::Spreadsheet,
2249    old_name: &str,
2250    new_name: &str,
2251) -> Result<()> {
2252    let new_prefix = format_sheet_prefix_for_formula(new_name);
2253
2254    for defined in book.get_defined_names_mut() {
2255        let refers_to = defined.get_address();
2256        let trimmed = refers_to.trim();
2257        let had_equals = trimmed.starts_with('=');
2258        let looks_like_formula = had_equals || trimmed.contains('(');
2259        if !looks_like_formula {
2260            continue;
2261        }
2262
2263        let formula_in = if had_equals {
2264            trimmed.to_string()
2265        } else {
2266            format!("={}", trimmed)
2267        };
2268
2269        let tokenizer = Tokenizer::new(&formula_in)
2270            .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2271        let tokens = tokenizer.items;
2272
2273        let mut out = String::with_capacity(formula_in.len());
2274        let mut cursor = 0usize;
2275        let mut changed = false;
2276
2277        for token in &tokens {
2278            if token.start > cursor {
2279                out.push_str(&formula_in[cursor..token.start]);
2280            }
2281
2282            let mut value = token.value.clone();
2283            if token.subtype == formualizer_parse::TokenSubType::Range
2284                && value.contains('!')
2285                && let Some((sheet_part, tail)) = value.split_once('!')
2286                && sheet_part_matches(sheet_part, old_name)
2287            {
2288                value = format!("{}{}", new_prefix, tail);
2289                changed = true;
2290            }
2291
2292            out.push_str(&value);
2293            cursor = token.end;
2294        }
2295
2296        if cursor < formula_in.len() {
2297            out.push_str(&formula_in[cursor..]);
2298        }
2299
2300        if changed {
2301            let out_final = if had_equals {
2302                out
2303            } else {
2304                out.strip_prefix('=').unwrap_or(&out).to_string()
2305            };
2306            defined.set_address(out_final);
2307        }
2308    }
2309
2310    Ok(())
2311}
2312
2313fn rewrite_defined_name_formulas_for_sheet_col_insert(
2314    book: &mut umya_spreadsheet::Spreadsheet,
2315    sheet_name: &str,
2316    at_col: u32,
2317    count: u32,
2318) -> Result<()> {
2319    rewrite_defined_name_formulas_for_sheet_structure_change(
2320        book,
2321        sheet_name,
2322        StructureAxis::Col,
2323        StructureEdit::Insert { at: at_col, count },
2324    )
2325}
2326
2327fn rewrite_defined_name_formulas_for_sheet_col_delete(
2328    book: &mut umya_spreadsheet::Spreadsheet,
2329    sheet_name: &str,
2330    start_col: u32,
2331    count: u32,
2332) -> Result<()> {
2333    rewrite_defined_name_formulas_for_sheet_structure_change(
2334        book,
2335        sheet_name,
2336        StructureAxis::Col,
2337        StructureEdit::Delete {
2338            start: start_col,
2339            count,
2340        },
2341    )
2342}
2343
2344fn rewrite_defined_name_formulas_for_sheet_row_insert(
2345    book: &mut umya_spreadsheet::Spreadsheet,
2346    sheet_name: &str,
2347    at_row: u32,
2348    count: u32,
2349) -> Result<()> {
2350    rewrite_defined_name_formulas_for_sheet_structure_change(
2351        book,
2352        sheet_name,
2353        StructureAxis::Row,
2354        StructureEdit::Insert { at: at_row, count },
2355    )
2356}
2357
2358fn rewrite_defined_name_formulas_for_sheet_row_delete(
2359    book: &mut umya_spreadsheet::Spreadsheet,
2360    sheet_name: &str,
2361    start_row: u32,
2362    count: u32,
2363) -> Result<()> {
2364    rewrite_defined_name_formulas_for_sheet_structure_change(
2365        book,
2366        sheet_name,
2367        StructureAxis::Row,
2368        StructureEdit::Delete {
2369            start: start_row,
2370            count,
2371        },
2372    )
2373}
2374
2375fn rewrite_defined_name_formulas_for_sheet_structure_change(
2376    book: &mut umya_spreadsheet::Spreadsheet,
2377    sheet_name: &str,
2378    axis: StructureAxis,
2379    edit: StructureEdit,
2380) -> Result<()> {
2381    for defined in book.get_defined_names_mut() {
2382        let refers_to = defined.get_address();
2383        let trimmed = refers_to.trim();
2384        let had_equals = trimmed.starts_with('=');
2385        let looks_like_formula = had_equals || trimmed.contains('(');
2386        if !looks_like_formula {
2387            continue;
2388        }
2389
2390        let formula_in = if had_equals {
2391            trimmed.to_string()
2392        } else {
2393            format!("={}", trimmed)
2394        };
2395
2396        let tokenizer = Tokenizer::new(&formula_in)
2397            .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2398        let tokens = tokenizer.items;
2399
2400        let mut out = String::with_capacity(formula_in.len());
2401        let mut cursor = 0usize;
2402        let mut changed = false;
2403
2404        for token in &tokens {
2405            if token.start > cursor {
2406                out.push_str(&formula_in[cursor..token.start]);
2407            }
2408
2409            let mut value = token.value.clone();
2410            if token.subtype == formualizer_parse::TokenSubType::Range
2411                && value.contains('!')
2412                && let Some((sheet_part, coord_part)) = value.split_once('!')
2413                && sheet_part_matches(sheet_part, sheet_name)
2414            {
2415                let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
2416                value = format!("{sheet_part}!{adjusted}");
2417                changed = true;
2418            }
2419
2420            out.push_str(&value);
2421            cursor = token.end;
2422        }
2423
2424        if cursor < formula_in.len() {
2425            out.push_str(&formula_in[cursor..]);
2426        }
2427
2428        if changed {
2429            let out_final = if had_equals {
2430                out
2431            } else {
2432                out.strip_prefix('=').unwrap_or(&out).to_string()
2433            };
2434            defined.set_address(out_final);
2435        }
2436    }
2437
2438    Ok(())
2439}
2440
2441fn rewrite_formulas_for_sheet_col_insert(
2442    book: &mut umya_spreadsheet::Spreadsheet,
2443    sheet_name: &str,
2444    at_col: u32,
2445    count: u32,
2446) -> Result<()> {
2447    rewrite_formulas_for_sheet_structure_change(
2448        book,
2449        sheet_name,
2450        StructureAxis::Col,
2451        StructureEdit::Insert { at: at_col, count },
2452    )
2453}
2454
2455fn rewrite_formulas_for_sheet_col_delete(
2456    book: &mut umya_spreadsheet::Spreadsheet,
2457    sheet_name: &str,
2458    start_col: u32,
2459    count: u32,
2460) -> Result<()> {
2461    rewrite_formulas_for_sheet_structure_change(
2462        book,
2463        sheet_name,
2464        StructureAxis::Col,
2465        StructureEdit::Delete {
2466            start: start_col,
2467            count,
2468        },
2469    )
2470}
2471
2472fn rewrite_formulas_for_sheet_row_insert(
2473    book: &mut umya_spreadsheet::Spreadsheet,
2474    sheet_name: &str,
2475    at_row: u32,
2476    count: u32,
2477) -> Result<()> {
2478    rewrite_formulas_for_sheet_structure_change(
2479        book,
2480        sheet_name,
2481        StructureAxis::Row,
2482        StructureEdit::Insert { at: at_row, count },
2483    )
2484}
2485
2486fn rewrite_formulas_for_sheet_row_delete(
2487    book: &mut umya_spreadsheet::Spreadsheet,
2488    sheet_name: &str,
2489    start_row: u32,
2490    count: u32,
2491) -> Result<()> {
2492    rewrite_formulas_for_sheet_structure_change(
2493        book,
2494        sheet_name,
2495        StructureAxis::Row,
2496        StructureEdit::Delete {
2497            start: start_row,
2498            count,
2499        },
2500    )
2501}
2502
2503#[derive(Debug, Clone, Copy)]
2504enum StructureAxis {
2505    Row,
2506    Col,
2507}
2508
2509#[derive(Debug, Clone, Copy)]
2510enum StructureEdit {
2511    Insert { at: u32, count: u32 },
2512    Delete { start: u32, count: u32 },
2513}
2514
2515fn rewrite_formulas_for_sheet_structure_change(
2516    book: &mut umya_spreadsheet::Spreadsheet,
2517    sheet_name: &str,
2518    axis: StructureAxis,
2519    edit: StructureEdit,
2520) -> Result<()> {
2521    for sheet in book.get_sheet_collection_mut().iter_mut() {
2522        if sheet.get_name() == sheet_name {
2523            continue;
2524        }
2525        for cell in sheet.get_cell_collection_mut() {
2526            if !cell.is_formula() {
2527                continue;
2528            }
2529            let formula_text = cell.get_formula();
2530            if formula_text.is_empty() {
2531                continue;
2532            }
2533            let formula_with_equals = if formula_text.starts_with('=') {
2534                formula_text.to_string()
2535            } else {
2536                format!("={}", formula_text)
2537            };
2538            let tokenizer = Tokenizer::new(&formula_with_equals)
2539                .map_err(|e| anyhow!("failed to tokenize formula: {}", e.message))?;
2540            let tokens = tokenizer.items;
2541
2542            let mut out = String::with_capacity(formula_with_equals.len());
2543            let mut cursor = 0usize;
2544            let mut changed = false;
2545
2546            for token in &tokens {
2547                if token.start > cursor {
2548                    out.push_str(&formula_with_equals[cursor..token.start]);
2549                }
2550
2551                let mut value = token.value.clone();
2552                if token.subtype == formualizer_parse::TokenSubType::Range
2553                    && value.contains('!')
2554                    && let Some((sheet_part, coord_part)) = value.split_once('!')
2555                    && sheet_part_matches(sheet_part, sheet_name)
2556                {
2557                    let adjusted = adjust_ref_coord_part(coord_part, axis, edit)?;
2558                    value = format!("{sheet_part}!{adjusted}");
2559                    changed = true;
2560                }
2561
2562                out.push_str(&value);
2563                cursor = token.end;
2564            }
2565
2566            if cursor < formula_with_equals.len() {
2567                out.push_str(&formula_with_equals[cursor..]);
2568            }
2569
2570            if changed {
2571                let new_formula = out.strip_prefix('=').unwrap_or(&out);
2572                cell.set_formula(new_formula.to_string());
2573            }
2574        }
2575    }
2576    Ok(())
2577}
2578
2579fn adjust_ref_coord_part(
2580    coord_part: &str,
2581    axis: StructureAxis,
2582    edit: StructureEdit,
2583) -> Result<String> {
2584    if coord_part == "#REF!" {
2585        return Ok(coord_part.to_string());
2586    }
2587    if let Some((start, end)) = coord_part.split_once(':') {
2588        let start_adj = adjust_ref_segment(start, axis, edit)?;
2589        let end_adj = adjust_ref_segment(end, axis, edit)?;
2590        if start_adj == "#REF!" || end_adj == "#REF!" {
2591            return Ok("#REF!".to_string());
2592        }
2593        Ok(format!("{start_adj}:{end_adj}"))
2594    } else {
2595        Ok(adjust_ref_segment(coord_part, axis, edit)?)
2596    }
2597}
2598
2599fn adjust_ref_segment(segment: &str, axis: StructureAxis, edit: StructureEdit) -> Result<String> {
2600    use umya_spreadsheet::helper::coordinate::{
2601        coordinate_from_index_with_lock, index_from_coordinate, string_from_column_index,
2602    };
2603
2604    let (col, row, col_lock, row_lock) = index_from_coordinate(segment);
2605    let mut col = col;
2606    let mut row = row;
2607
2608    match axis {
2609        StructureAxis::Col => {
2610            if let Some(c) = col {
2611                col = match edit {
2612                    StructureEdit::Insert { at, count } => Some(adjust_insert(c, at, count)),
2613                    StructureEdit::Delete { start, count } => adjust_delete(c, start, count),
2614                };
2615            }
2616        }
2617        StructureAxis::Row => {
2618            if let Some(r) = row {
2619                row = match edit {
2620                    StructureEdit::Insert { at, count } => Some(adjust_insert(r, at, count)),
2621                    StructureEdit::Delete { start, count } => adjust_delete(r, start, count),
2622                };
2623            }
2624        }
2625    }
2626
2627    if col.is_none() && row.is_none() {
2628        return Ok("#REF!".to_string());
2629    }
2630
2631    match (col, row) {
2632        (Some(c), Some(r)) => Ok(coordinate_from_index_with_lock(
2633            &c,
2634            &r,
2635            &col_lock.unwrap_or(false),
2636            &row_lock.unwrap_or(false),
2637        )),
2638        (Some(c), None) => {
2639            let col_str = string_from_column_index(&c);
2640            Ok(format!(
2641                "{}{}",
2642                if col_lock.unwrap_or(false) { "$" } else { "" },
2643                col_str
2644            ))
2645        }
2646        (None, Some(r)) => Ok(format!(
2647            "{}{}",
2648            if row_lock.unwrap_or(false) { "$" } else { "" },
2649            r
2650        )),
2651        (None, None) => Ok("#REF!".to_string()),
2652    }
2653}
2654
2655fn adjust_insert(value: u32, at: u32, count: u32) -> u32 {
2656    if value >= at { value + count } else { value }
2657}
2658
2659fn adjust_delete(value: u32, start: u32, count: u32) -> Option<u32> {
2660    let end = start.saturating_add(count.saturating_sub(1));
2661    if value >= start && value <= end {
2662        None
2663    } else if value > end {
2664        Some(value - count)
2665    } else {
2666        Some(value)
2667    }
2668}
2669
2670fn sheet_part_matches(sheet_part: &str, old_name: &str) -> bool {
2671    let trimmed = sheet_part.trim();
2672    if let Some(stripped) = trimmed.strip_prefix('\'')
2673        && let Some(inner) = stripped.strip_suffix('\'')
2674    {
2675        return inner.replace("''", "'") == old_name;
2676    }
2677    trimmed == old_name
2678}
2679
2680fn format_sheet_prefix_for_formula(sheet_name: &str) -> String {
2681    if sheet_name_needs_quoting_for_formula(sheet_name) {
2682        let escaped = sheet_name.replace('\'', "''");
2683        format!("'{escaped}'!")
2684    } else {
2685        format!("{sheet_name}!")
2686    }
2687}
2688
2689fn sheet_name_needs_quoting_for_formula(name: &str) -> bool {
2690    if name.is_empty() {
2691        return false;
2692    }
2693    let bytes = name.as_bytes();
2694    if bytes[0].is_ascii_digit() {
2695        return true;
2696    }
2697    for &byte in bytes {
2698        match byte {
2699            b' ' | b'!' | b'"' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+'
2700            | b',' | b'-' | b'.' | b'/' | b':' | b';' | b'<' | b'=' | b'>' | b'?' | b'@' | b'['
2701            | b'\\' | b']' | b'^' | b'`' | b'{' | b'|' | b'}' | b'~' => return true,
2702            _ => {}
2703        }
2704    }
2705    let upper = name.to_uppercase();
2706    matches!(
2707        upper.as_str(),
2708        "TRUE" | "FALSE" | "NULL" | "REF" | "DIV" | "NAME" | "NUM" | "VALUE" | "N/A"
2709    )
2710}
2711
2712struct StyleApplyResult {
2713    ops_applied: usize,
2714    summary: ChangeSummary,
2715}
2716
2717pub(crate) fn stage_snapshot_path(fork_id: &str, change_id: &str) -> PathBuf {
2718    PathBuf::from("/tmp/mcp-staged").join(format!("{fork_id}_{change_id}.xlsx"))
2719}
2720
2721struct ColumnSizeApplyResult {
2722    ops_applied: usize,
2723    summary: ChangeSummary,
2724}
2725
2726fn parse_column_span(spec: &str) -> Result<(u32, u32)> {
2727    let raw = spec.trim();
2728    if raw.is_empty() {
2729        return Err(anyhow!("column range is empty"));
2730    }
2731
2732    let raw = raw.replace(' ', "");
2733    let (start, end) = if let Some((a, b)) = raw.split_once(':') {
2734        (a, b)
2735    } else if let Some((a, b)) = raw.split_once('-') {
2736        (a, b)
2737    } else {
2738        (raw.as_str(), raw.as_str())
2739    };
2740
2741    let start_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(start);
2742    let end_idx = umya_spreadsheet::helper::coordinate::column_index_from_string(end);
2743    if start_idx == 0 || end_idx == 0 {
2744        return Err(anyhow!("invalid column span '{spec}'"));
2745    }
2746    let (min, max) = if start_idx <= end_idx {
2747        (start_idx, end_idx)
2748    } else {
2749        (end_idx, start_idx)
2750    };
2751    Ok((min, max))
2752}
2753
2754fn apply_column_size_ops_to_file(
2755    path: &Path,
2756    sheet_name: &str,
2757    ops: &[ColumnSizeOp],
2758) -> Result<ColumnSizeApplyResult> {
2759    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2760    let sheet = book
2761        .get_sheet_by_name_mut(sheet_name)
2762        .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2763
2764    let mut affected_bounds: Vec<String> = Vec::new();
2765    let mut warnings: Vec<String> = Vec::new();
2766
2767    let mut columns_sized: u64 = 0;
2768    let mut auto_ops: u64 = 0;
2769    let mut width_ops: u64 = 0;
2770
2771    for op in ops {
2772        let ColumnTarget::Columns { range } = &op.target;
2773        let (start_col, end_col) = parse_column_span(range)?;
2774        affected_bounds.push(range.clone());
2775
2776        match &op.size {
2777            ColumnSizeSpec::Width { width_chars } => {
2778                width_ops += 1;
2779                for col in start_col..=end_col {
2780                    let col_dim = sheet.get_column_dimension_by_number_mut(&col);
2781                    col_dim.set_width(*width_chars);
2782                    col_dim.set_best_fit(false);
2783                    col_dim.set_auto_width(false);
2784                    columns_sized += 1;
2785                }
2786            }
2787            ColumnSizeSpec::Auto {
2788                min_width_chars,
2789                max_width_chars,
2790            } => {
2791                auto_ops += 1;
2792
2793                let mut saw_formula_without_cached = false;
2794                for cell in sheet.get_cell_collection() {
2795                    let col_num = *cell.get_coordinate().get_col_num();
2796                    if col_num < start_col || col_num > end_col {
2797                        continue;
2798                    }
2799                    if cell.is_formula() && cell.get_value().is_empty() {
2800                        saw_formula_without_cached = true;
2801                        break;
2802                    }
2803                }
2804                if saw_formula_without_cached {
2805                    warnings.push(
2806                        "WARN_AUTOWIDTH_FORMULA_NO_CACHED: Autosize measured empty values for some formula cells; results may be too narrow. Recalc the sheet before autosize for best results."
2807                            .to_string(),
2808                    );
2809                }
2810
2811                for col in start_col..=end_col {
2812                    sheet
2813                        .get_column_dimension_by_number_mut(&col)
2814                        .set_auto_width(true);
2815                }
2816                sheet.calculation_auto_width();
2817
2818                for col in start_col..=end_col {
2819                    let col_dim = sheet.get_column_dimension_by_number_mut(&col);
2820                    col_dim.set_auto_width(false);
2821                    col_dim.set_best_fit(true);
2822
2823                    let mut width = *col_dim.get_width();
2824                    if let Some(min_width) = min_width_chars
2825                        && width < *min_width
2826                    {
2827                        width = *min_width;
2828                    }
2829                    if let Some(max_width) = max_width_chars
2830                        && width > *max_width
2831                    {
2832                        width = *max_width;
2833                    }
2834                    col_dim.set_width(width);
2835                    columns_sized += 1;
2836                }
2837            }
2838        }
2839    }
2840
2841    umya_spreadsheet::writer::xlsx::write(&book, path)?;
2842
2843    let mut counts = BTreeMap::new();
2844    counts.insert("columns_sized".to_string(), columns_sized);
2845    counts.insert("auto_ops".to_string(), auto_ops);
2846    counts.insert("width_ops".to_string(), width_ops);
2847
2848    Ok(ColumnSizeApplyResult {
2849        ops_applied: ops.len(),
2850        summary: ChangeSummary {
2851            op_kinds: vec!["column_size_batch".to_string()],
2852            affected_sheets: vec![sheet_name.to_string()],
2853            affected_bounds,
2854            counts,
2855            warnings,
2856            ..Default::default()
2857        },
2858    })
2859}
2860
2861struct TransformApplyResult {
2862    ops_applied: usize,
2863    summary: ChangeSummary,
2864}
2865
2866fn apply_transform_ops_to_file(path: &Path, ops: &[TransformOp]) -> Result<TransformApplyResult> {
2867    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
2868
2869    let mut sheets: BTreeSet<String> = BTreeSet::new();
2870    let mut affected_bounds: Vec<String> = Vec::new();
2871
2872    let mut cells_touched: u64 = 0;
2873    let mut cells_value_cleared: u64 = 0;
2874    let mut cells_formula_cleared: u64 = 0;
2875    let mut cells_skipped_keep_formulas: u64 = 0;
2876
2877    let mut cells_value_set: u64 = 0;
2878    let mut cells_formula_set: u64 = 0;
2879    let mut cells_value_replaced: u64 = 0;
2880    let mut cells_formula_replaced: u64 = 0;
2881
2882    for op in ops {
2883        match op {
2884            TransformOp::ClearRange {
2885                sheet_name,
2886                target,
2887                clear_values,
2888                clear_formulas,
2889            } => {
2890                let sheet = book
2891                    .get_sheet_by_name_mut(sheet_name)
2892                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2893                sheets.insert(sheet_name.clone());
2894
2895                match target {
2896                    TransformTarget::Range { range } => {
2897                        let bounds = parse_range_bounds(range)?;
2898                        affected_bounds.push(range.clone());
2899
2900                        for row in bounds.min_row..=bounds.max_row {
2901                            for col in bounds.min_col..=bounds.max_col {
2902                                let exists = sheet.get_cell((col, row)).is_some();
2903                                if !exists {
2904                                    continue;
2905                                }
2906
2907                                let cell = sheet.get_cell_mut((col, row));
2908                                let was_formula = cell.is_formula();
2909                                cells_touched += 1;
2910
2911                                if *clear_formulas && was_formula {
2912                                    cell.set_formula(String::new());
2913                                    cells_formula_cleared += 1;
2914                                }
2915
2916                                if *clear_values {
2917                                    if was_formula && !*clear_formulas {
2918                                        cells_skipped_keep_formulas += 1;
2919                                    } else {
2920                                        if !cell.get_value().is_empty() {
2921                                            cells_value_cleared += 1;
2922                                        }
2923                                        cell.set_value(String::new());
2924                                    }
2925                                }
2926                            }
2927                        }
2928                    }
2929                    TransformTarget::Cells { cells } => {
2930                        affected_bounds.extend(cells.iter().cloned());
2931                        for addr in cells {
2932                            let exists = sheet.get_cell(addr.as_str()).is_some();
2933                            if !exists {
2934                                continue;
2935                            }
2936
2937                            let cell = sheet.get_cell_mut(addr.as_str());
2938                            let was_formula = cell.is_formula();
2939                            cells_touched += 1;
2940
2941                            if *clear_formulas && was_formula {
2942                                cell.set_formula(String::new());
2943                                cells_formula_cleared += 1;
2944                            }
2945
2946                            if *clear_values {
2947                                if was_formula && !*clear_formulas {
2948                                    cells_skipped_keep_formulas += 1;
2949                                } else {
2950                                    if !cell.get_value().is_empty() {
2951                                        cells_value_cleared += 1;
2952                                    }
2953                                    cell.set_value(String::new());
2954                                }
2955                            }
2956                        }
2957                    }
2958                    TransformTarget::Region { .. } => {
2959                        return Err(anyhow!(
2960                            "region_id targets must be resolved before apply_transform_ops_to_file"
2961                        ));
2962                    }
2963                }
2964            }
2965            TransformOp::FillRange {
2966                sheet_name,
2967                target,
2968                value,
2969                is_formula,
2970                overwrite_formulas,
2971            } => {
2972                let sheet = book
2973                    .get_sheet_by_name_mut(sheet_name)
2974                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
2975                sheets.insert(sheet_name.clone());
2976
2977                match target {
2978                    TransformTarget::Range { range } => {
2979                        let bounds = parse_range_bounds(range)?;
2980                        affected_bounds.push(range.clone());
2981
2982                        for row in bounds.min_row..=bounds.max_row {
2983                            for col in bounds.min_col..=bounds.max_col {
2984                                let cell = sheet.get_cell_mut((col, row));
2985                                cells_touched += 1;
2986
2987                                if !*is_formula && cell.is_formula() {
2988                                    if !*overwrite_formulas {
2989                                        cells_skipped_keep_formulas += 1;
2990                                        continue;
2991                                    }
2992                                    cell.set_formula(String::new());
2993                                    cells_formula_cleared += 1;
2994                                }
2995
2996                                if *is_formula {
2997                                    cell.set_formula(value.clone());
2998                                    cells_formula_set += 1;
2999                                } else {
3000                                    cell.set_value(value.clone());
3001                                    cells_value_set += 1;
3002                                }
3003                            }
3004                        }
3005                    }
3006                    TransformTarget::Cells { cells } => {
3007                        affected_bounds.extend(cells.iter().cloned());
3008                        for addr in cells {
3009                            let cell = sheet.get_cell_mut(addr.as_str());
3010                            cells_touched += 1;
3011
3012                            if !*is_formula && cell.is_formula() {
3013                                if !*overwrite_formulas {
3014                                    cells_skipped_keep_formulas += 1;
3015                                    continue;
3016                                }
3017                                cell.set_formula(String::new());
3018                                cells_formula_cleared += 1;
3019                            }
3020
3021                            if *is_formula {
3022                                cell.set_formula(value.clone());
3023                                cells_formula_set += 1;
3024                            } else {
3025                                cell.set_value(value.clone());
3026                                cells_value_set += 1;
3027                            }
3028                        }
3029                    }
3030                    TransformTarget::Region { .. } => {
3031                        return Err(anyhow!(
3032                            "region_id targets must be resolved before apply_transform_ops_to_file"
3033                        ));
3034                    }
3035                }
3036            }
3037            TransformOp::ReplaceInRange {
3038                sheet_name,
3039                target,
3040                find,
3041                replace,
3042                match_mode,
3043                case_sensitive,
3044                include_formulas,
3045            } => {
3046                let sheet = book
3047                    .get_sheet_by_name_mut(sheet_name)
3048                    .ok_or_else(|| anyhow!("sheet '{}' not found", sheet_name))?;
3049                sheets.insert(sheet_name.clone());
3050
3051                if *match_mode == ReplaceMatchMode::Contains && !*case_sensitive {
3052                    return Err(anyhow!(
3053                        "match_mode 'contains' requires case_sensitive=true"
3054                    ));
3055                }
3056
3057                let replace_value = |input: &str| -> Option<String> {
3058                    if *match_mode == ReplaceMatchMode::Exact {
3059                        if *case_sensitive {
3060                            (input == find).then(|| replace.clone())
3061                        } else {
3062                            input.eq_ignore_ascii_case(find).then(|| replace.clone())
3063                        }
3064                    } else if input.contains(find) {
3065                        Some(input.replace(find, replace))
3066                    } else {
3067                        None
3068                    }
3069                };
3070
3071                match target {
3072                    TransformTarget::Range { range } => {
3073                        let bounds = parse_range_bounds(range)?;
3074                        affected_bounds.push(range.clone());
3075
3076                        for row in bounds.min_row..=bounds.max_row {
3077                            for col in bounds.min_col..=bounds.max_col {
3078                                let exists = sheet.get_cell((col, row)).is_some();
3079                                if !exists {
3080                                    continue;
3081                                }
3082
3083                                let cell = sheet.get_cell_mut((col, row));
3084                                cells_touched += 1;
3085
3086                                if cell.is_formula() {
3087                                    if !*include_formulas {
3088                                        cells_skipped_keep_formulas += 1;
3089                                        continue;
3090                                    }
3091
3092                                    let formula = cell.get_formula().to_string();
3093                                    if formula.is_empty() {
3094                                        continue;
3095                                    }
3096                                    if let Some(next) = replace_value(&formula) {
3097                                        cell.set_formula(next);
3098                                        cells_formula_replaced += 1;
3099                                    }
3100                                    continue;
3101                                }
3102
3103                                let value = cell.get_value().to_string();
3104                                if value.is_empty() {
3105                                    continue;
3106                                }
3107                                if let Some(next) = replace_value(&value) {
3108                                    cell.set_value(next);
3109                                    cells_value_replaced += 1;
3110                                }
3111                            }
3112                        }
3113                    }
3114                    TransformTarget::Cells { cells } => {
3115                        affected_bounds.extend(cells.iter().cloned());
3116                        for addr in cells {
3117                            let exists = sheet.get_cell(addr.as_str()).is_some();
3118                            if !exists {
3119                                continue;
3120                            }
3121
3122                            let cell = sheet.get_cell_mut(addr.as_str());
3123                            cells_touched += 1;
3124
3125                            if cell.is_formula() {
3126                                if !*include_formulas {
3127                                    cells_skipped_keep_formulas += 1;
3128                                    continue;
3129                                }
3130
3131                                let formula = cell.get_formula().to_string();
3132                                if formula.is_empty() {
3133                                    continue;
3134                                }
3135                                if let Some(next) = replace_value(&formula) {
3136                                    cell.set_formula(next);
3137                                    cells_formula_replaced += 1;
3138                                }
3139                                continue;
3140                            }
3141
3142                            let value = cell.get_value().to_string();
3143                            if value.is_empty() {
3144                                continue;
3145                            }
3146                            if let Some(next) = replace_value(&value) {
3147                                cell.set_value(next);
3148                                cells_value_replaced += 1;
3149                            }
3150                        }
3151                    }
3152                    TransformTarget::Region { .. } => {
3153                        return Err(anyhow!(
3154                            "region_id targets must be resolved before apply_transform_ops_to_file"
3155                        ));
3156                    }
3157                }
3158            }
3159        }
3160    }
3161
3162    umya_spreadsheet::writer::xlsx::write(&book, path)?;
3163
3164    let mut counts = BTreeMap::new();
3165    counts.insert("cells_touched".to_string(), cells_touched);
3166    counts.insert("cells_value_cleared".to_string(), cells_value_cleared);
3167    counts.insert("cells_formula_cleared".to_string(), cells_formula_cleared);
3168    counts.insert(
3169        "cells_skipped_keep_formulas".to_string(),
3170        cells_skipped_keep_formulas,
3171    );
3172
3173    counts.insert("cells_value_set".to_string(), cells_value_set);
3174    counts.insert("cells_formula_set".to_string(), cells_formula_set);
3175    counts.insert("cells_value_replaced".to_string(), cells_value_replaced);
3176    counts.insert("cells_formula_replaced".to_string(), cells_formula_replaced);
3177
3178    let summary = ChangeSummary {
3179        op_kinds: vec!["transform_batch".to_string()],
3180        affected_sheets: sheets.into_iter().collect(),
3181        affected_bounds,
3182        counts,
3183        warnings: Vec::new(),
3184        ..Default::default()
3185    };
3186
3187    Ok(TransformApplyResult {
3188        ops_applied: ops.len(),
3189        summary,
3190    })
3191}
3192
3193fn apply_style_ops_to_file(path: &Path, ops: &[StyleOp]) -> Result<StyleApplyResult> {
3194    use crate::styles::{
3195        StylePatchMode, apply_style_patch, descriptor_from_style, stable_style_id,
3196    };
3197
3198    let mut book = umya_spreadsheet::reader::xlsx::read(path)?;
3199
3200    let mut sheets: BTreeSet<String> = BTreeSet::new();
3201    let mut affected_bounds: Vec<String> = Vec::new();
3202    let mut cells_touched: u64 = 0;
3203    let mut cells_style_changed: u64 = 0;
3204
3205    for op in ops {
3206        let sheet = book
3207            .get_sheet_by_name_mut(&op.sheet_name)
3208            .ok_or_else(|| anyhow!("sheet '{}' not found", op.sheet_name))?;
3209        sheets.insert(op.sheet_name.clone());
3210
3211        let op_mode = op.op_mode.unwrap_or(StylePatchMode::Merge);
3212
3213        match &op.target {
3214            StyleTarget::Range { range } => {
3215                let bounds = parse_range_bounds(range)?;
3216                affected_bounds.push(range.clone());
3217                for row in bounds.min_row..=bounds.max_row {
3218                    for col in bounds.min_col..=bounds.max_col {
3219                        let addr = crate::utils::cell_address(col, row);
3220                        let cell = sheet.get_cell_mut(addr.as_str());
3221                        let before = stable_style_id(&descriptor_from_style(cell.get_style()));
3222                        let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
3223                        cell.set_style(next_style);
3224                        let after = stable_style_id(&descriptor_from_style(cell.get_style()));
3225                        cells_touched += 1;
3226                        if before != after {
3227                            cells_style_changed += 1;
3228                        }
3229                    }
3230                }
3231            }
3232            StyleTarget::Cells { cells } => {
3233                affected_bounds.extend(cells.iter().cloned());
3234                for addr in cells {
3235                    let cell = sheet.get_cell_mut(addr.as_str());
3236                    let before = stable_style_id(&descriptor_from_style(cell.get_style()));
3237                    let next_style = apply_style_patch(cell.get_style(), &op.patch, op_mode);
3238                    cell.set_style(next_style);
3239                    let after = stable_style_id(&descriptor_from_style(cell.get_style()));
3240                    cells_touched += 1;
3241                    if before != after {
3242                        cells_style_changed += 1;
3243                    }
3244                }
3245            }
3246            StyleTarget::Region { .. } => {
3247                return Err(anyhow!(
3248                    "region_id targets must be resolved before apply_style_ops_to_file"
3249                ));
3250            }
3251        }
3252    }
3253
3254    umya_spreadsheet::writer::xlsx::write(&book, path)?;
3255
3256    let mut counts = BTreeMap::new();
3257    counts.insert("cells_touched".to_string(), cells_touched);
3258    counts.insert("cells_style_changed".to_string(), cells_style_changed);
3259
3260    let summary = ChangeSummary {
3261        op_kinds: vec!["style_batch".to_string()],
3262        affected_sheets: sheets.into_iter().collect(),
3263        affected_bounds,
3264        counts,
3265        warnings: Vec::new(),
3266        ..Default::default()
3267    };
3268
3269    Ok(StyleApplyResult {
3270        ops_applied: ops.len(),
3271        summary,
3272    })
3273}
3274
3275#[derive(Debug, Deserialize, JsonSchema)]
3276pub struct GetEditsParams {
3277    pub fork_id: String,
3278}
3279
3280#[derive(Debug, Serialize, JsonSchema)]
3281pub struct GetEditsResponse {
3282    pub fork_id: String,
3283    pub edits: Vec<EditRecord>,
3284}
3285
3286#[derive(Debug, Serialize, JsonSchema)]
3287pub struct EditRecord {
3288    pub timestamp: String,
3289    pub sheet: String,
3290    pub address: String,
3291    pub value: String,
3292    pub is_formula: bool,
3293}
3294
3295pub async fn get_edits(state: Arc<AppState>, params: GetEditsParams) -> Result<GetEditsResponse> {
3296    let registry = state
3297        .fork_registry()
3298        .ok_or_else(|| anyhow!("fork registry not available"))?;
3299
3300    let fork_ctx = registry.get_fork(&params.fork_id)?;
3301
3302    let edits: Vec<EditRecord> = fork_ctx
3303        .edits
3304        .iter()
3305        .map(|e| EditRecord {
3306            timestamp: e.timestamp.to_rfc3339(),
3307            sheet: e.sheet.clone(),
3308            address: e.address.clone(),
3309            value: e.value.clone(),
3310            is_formula: e.is_formula,
3311        })
3312        .collect();
3313
3314    Ok(GetEditsResponse {
3315        fork_id: params.fork_id,
3316        edits,
3317    })
3318}
3319
3320fn default_get_changeset_limit() -> u32 {
3321    200
3322}
3323
3324#[derive(Debug, Deserialize, JsonSchema)]
3325pub struct GetChangesetParams {
3326    pub fork_id: String,
3327    pub sheet_name: Option<String>,
3328    #[serde(default = "default_get_changeset_limit")]
3329    pub limit: u32,
3330    #[serde(default)]
3331    pub offset: u32,
3332    #[serde(default)]
3333    pub summary_only: bool,
3334    #[serde(default)]
3335    pub include_types: Option<Vec<String>>,
3336    #[serde(default)]
3337    pub exclude_types: Option<Vec<String>>,
3338    #[serde(default)]
3339    pub include_subtypes: Option<Vec<String>>,
3340    #[serde(default)]
3341    pub exclude_subtypes: Option<Vec<String>>,
3342}
3343
3344impl Default for GetChangesetParams {
3345    fn default() -> Self {
3346        Self {
3347            fork_id: String::new(),
3348            sheet_name: None,
3349            limit: default_get_changeset_limit(),
3350            offset: 0,
3351            summary_only: false,
3352            include_types: None,
3353            exclude_types: None,
3354            include_subtypes: None,
3355            exclude_subtypes: None,
3356        }
3357    }
3358}
3359
3360#[derive(Debug, Serialize, JsonSchema)]
3361pub struct ChangesetSummary {
3362    pub total_changes: u32,
3363    pub returned_changes: u32,
3364    pub truncated: bool,
3365    pub next_offset: Option<u32>,
3366    pub counts_by_kind: BTreeMap<String, u32>,
3367    pub counts_by_type: BTreeMap<String, u32>,
3368    pub counts_by_subtype: BTreeMap<String, u32>,
3369    pub affected_sheets: Vec<String>,
3370}
3371
3372#[derive(Debug, Serialize, JsonSchema)]
3373pub struct GetChangesetResponse {
3374    pub fork_id: String,
3375    pub base_workbook: String,
3376    #[serde(skip_serializing_if = "Option::is_none")]
3377    pub client_base_workbook: Option<String>,
3378    pub changes: Vec<crate::diff::Change>,
3379    pub summary: ChangesetSummary,
3380}
3381
3382fn normalize_filter(values: &Option<Vec<String>>) -> Option<BTreeSet<String>> {
3383    values.as_ref().map(|items| {
3384        items
3385            .iter()
3386            .map(|s| s.to_ascii_lowercase())
3387            .collect::<BTreeSet<_>>()
3388    })
3389}
3390
3391fn change_kind_key(change: &crate::diff::Change) -> &'static str {
3392    match change {
3393        crate::diff::Change::Cell(_) => "cell",
3394        crate::diff::Change::Table(_) => "table",
3395        crate::diff::Change::Name(_) => "name",
3396    }
3397}
3398
3399fn change_type_key(change: &crate::diff::Change) -> &'static str {
3400    use crate::diff::merge::CellDiff;
3401    match change {
3402        crate::diff::Change::Cell(cell) => match &cell.diff {
3403            CellDiff::Added { .. } => "added",
3404            CellDiff::Deleted { .. } => "deleted",
3405            CellDiff::Modified { .. } => "modified",
3406        },
3407        crate::diff::Change::Table(table) => match table {
3408            crate::diff::tables::TableDiff::TableAdded { .. } => "table_added",
3409            crate::diff::tables::TableDiff::TableDeleted { .. } => "table_deleted",
3410            crate::diff::tables::TableDiff::TableModified { .. } => "table_modified",
3411        },
3412        crate::diff::Change::Name(name) => match name {
3413            crate::diff::names::NameDiff::NameAdded { .. } => "name_added",
3414            crate::diff::names::NameDiff::NameDeleted { .. } => "name_deleted",
3415            crate::diff::names::NameDiff::NameModified { .. } => "name_modified",
3416        },
3417    }
3418}
3419
3420fn change_subtype_key(change: &crate::diff::Change) -> Option<&'static str> {
3421    use crate::diff::merge::{CellDiff, ModificationType};
3422    match change {
3423        crate::diff::Change::Cell(cell) => match &cell.diff {
3424            CellDiff::Modified { subtype, .. } => Some(match subtype {
3425                ModificationType::FormulaEdit => "formula_edit",
3426                ModificationType::RecalcResult => "recalc_result",
3427                ModificationType::ValueEdit => "value_edit",
3428                ModificationType::StyleEdit => "style_edit",
3429            }),
3430            _ => None,
3431        },
3432        _ => None,
3433    }
3434}
3435
3436fn change_sheet_name(change: &crate::diff::Change) -> Option<&str> {
3437    match change {
3438        crate::diff::Change::Cell(cell) => Some(cell.sheet.as_str()),
3439        crate::diff::Change::Table(table) => match table {
3440            crate::diff::tables::TableDiff::TableAdded { sheet, .. }
3441            | crate::diff::tables::TableDiff::TableDeleted { sheet, .. }
3442            | crate::diff::tables::TableDiff::TableModified { sheet, .. } => Some(sheet.as_str()),
3443        },
3444        crate::diff::Change::Name(name) => match name {
3445            crate::diff::names::NameDiff::NameAdded { scope_sheet, .. }
3446            | crate::diff::names::NameDiff::NameDeleted { scope_sheet, .. }
3447            | crate::diff::names::NameDiff::NameModified { scope_sheet, .. } => {
3448                scope_sheet.as_deref()
3449            }
3450        },
3451    }
3452}
3453
3454fn change_passes_filters(
3455    change: &crate::diff::Change,
3456    include_types: &Option<BTreeSet<String>>,
3457    exclude_types: &Option<BTreeSet<String>>,
3458    include_subtypes: &Option<BTreeSet<String>>,
3459    exclude_subtypes: &Option<BTreeSet<String>>,
3460) -> bool {
3461    let type_key = change_type_key(change);
3462    let subtype_key = change_subtype_key(change);
3463
3464    if let Some(include) = include_types
3465        && !include.contains(type_key)
3466    {
3467        return false;
3468    }
3469    if let Some(exclude) = exclude_types
3470        && exclude.contains(type_key)
3471    {
3472        return false;
3473    }
3474
3475    if let Some(include) = include_subtypes
3476        && subtype_key.is_none_or(|subtype| !include.contains(subtype))
3477    {
3478        return false;
3479    }
3480    if let Some(exclude) = exclude_subtypes
3481        && subtype_key.is_some_and(|subtype| exclude.contains(subtype))
3482    {
3483        return false;
3484    }
3485
3486    true
3487}
3488
3489pub async fn get_changeset(
3490    state: Arc<AppState>,
3491    params: GetChangesetParams,
3492) -> Result<GetChangesetResponse> {
3493    let registry = state
3494        .fork_registry()
3495        .ok_or_else(|| anyhow!("fork registry not available"))?;
3496
3497    let fork_ctx = registry.get_fork(&params.fork_id)?;
3498
3499    let raw_changes = tokio::task::spawn_blocking({
3500        let base_path = fork_ctx.base_path.clone();
3501        let work_path = fork_ctx.work_path.clone();
3502        let sheet_filter = params.sheet_name.clone();
3503        move || {
3504            crate::core::diff::calculate_changeset(&base_path, &work_path, sheet_filter.as_deref())
3505        }
3506    })
3507    .await??;
3508
3509    let include_types = normalize_filter(&params.include_types);
3510    let exclude_types = normalize_filter(&params.exclude_types);
3511    let include_subtypes = normalize_filter(&params.include_subtypes);
3512    let exclude_subtypes = normalize_filter(&params.exclude_subtypes);
3513
3514    let mut affected_sheets: BTreeSet<String> = BTreeSet::new();
3515    let mut counts_by_kind: BTreeMap<String, u32> = BTreeMap::new();
3516    let mut counts_by_type: BTreeMap<String, u32> = BTreeMap::new();
3517    let mut counts_by_subtype: BTreeMap<String, u32> = BTreeMap::new();
3518
3519    let mut filtered: Vec<crate::diff::Change> = Vec::new();
3520    for change in raw_changes {
3521        if !change_passes_filters(
3522            &change,
3523            &include_types,
3524            &exclude_types,
3525            &include_subtypes,
3526            &exclude_subtypes,
3527        ) {
3528            continue;
3529        }
3530
3531        *counts_by_kind
3532            .entry(change_kind_key(&change).to_string())
3533            .or_default() += 1;
3534        *counts_by_type
3535            .entry(change_type_key(&change).to_string())
3536            .or_default() += 1;
3537        if let Some(subtype) = change_subtype_key(&change) {
3538            *counts_by_subtype.entry(subtype.to_string()).or_default() += 1;
3539        }
3540        if let Some(sheet) = change_sheet_name(&change) {
3541            affected_sheets.insert(sheet.to_string());
3542        }
3543
3544        filtered.push(change);
3545    }
3546
3547    let limit = params.limit.clamp(1, 2000) as usize;
3548    let offset = params.offset as usize;
3549    let total = filtered.len();
3550
3551    let (returned_changes, changes, truncated, next_offset) = if params.summary_only {
3552        (0u32, Vec::new(), false, None)
3553    } else {
3554        let end = offset.saturating_add(limit);
3555        let truncated = end < total;
3556        let next_offset = truncated.then_some(end as u32);
3557        let changes: Vec<_> = filtered.into_iter().skip(offset).take(limit).collect();
3558        (changes.len() as u32, changes, truncated, next_offset)
3559    };
3560
3561    let summary = ChangesetSummary {
3562        total_changes: total as u32,
3563        returned_changes,
3564        truncated,
3565        next_offset,
3566        counts_by_kind,
3567        counts_by_type,
3568        counts_by_subtype,
3569        affected_sheets: affected_sheets.into_iter().collect(),
3570    };
3571
3572    Ok(GetChangesetResponse {
3573        fork_id: params.fork_id,
3574        base_workbook: fork_ctx.base_path.display().to_string(),
3575        client_base_workbook: state
3576            .config()
3577            .map_path_for_client(&fork_ctx.base_path)
3578            .map(|p| p.display().to_string()),
3579        changes,
3580        summary,
3581    })
3582}
3583
3584#[derive(Debug, Deserialize, JsonSchema)]
3585pub struct RecalculateParams {
3586    pub fork_id: String,
3587    #[serde(default = "default_timeout")]
3588    pub timeout_ms: u64,
3589    #[serde(default)]
3590    pub backend: Option<RecalcBackendKind>,
3591}
3592
3593fn default_timeout() -> u64 {
3594    30_000
3595}
3596
3597#[derive(Debug, Serialize, JsonSchema)]
3598pub struct RecalculateResponse {
3599    pub fork_id: String,
3600    pub duration_ms: u64,
3601    pub backend: String,
3602    #[serde(skip_serializing_if = "Option::is_none")]
3603    pub cells_evaluated: Option<u64>,
3604    #[serde(skip_serializing_if = "Option::is_none")]
3605    pub eval_errors: Option<Vec<String>>,
3606}
3607
3608pub async fn recalculate(
3609    state: Arc<AppState>,
3610    params: RecalculateParams,
3611) -> Result<RecalculateResponse> {
3612    let backend = state
3613        .recalc_backend(params.backend)
3614        .ok_or_else(|| anyhow!("requested recalc backend not available"))?;
3615
3616    recalculate_with_backend(state, params, backend).await
3617}
3618
3619pub async fn recalculate_with_backend(
3620    state: Arc<AppState>,
3621    params: RecalculateParams,
3622    backend: Arc<dyn RecalcBackend>,
3623) -> Result<RecalculateResponse> {
3624    let registry = state
3625        .fork_registry()
3626        .ok_or_else(|| anyhow!("fork registry not available"))?;
3627
3628    let semaphore = state
3629        .recalc_semaphore()
3630        .ok_or_else(|| anyhow!("recalc semaphore not available"))?;
3631
3632    let fork_ctx = registry.get_fork(&params.fork_id)?;
3633
3634    let _permit = semaphore
3635        .0
3636        .acquire()
3637        .await
3638        .map_err(|e| anyhow!("failed to acquire recalc permit: {}", e))?;
3639
3640    let timeout_ms = if params.timeout_ms == 0 {
3641        None
3642    } else {
3643        Some(params.timeout_ms)
3644    };
3645    let result =
3646        crate::core::recalc::execute_with_backend(&fork_ctx.work_path, timeout_ms, backend).await?;
3647
3648    registry.with_fork_mut(&params.fork_id, |ctx| {
3649        ctx.recalc_needed = false;
3650        Ok(())
3651    })?;
3652
3653    let fork_workbook_id = WorkbookId(params.fork_id.clone());
3654    let _ = state.close_workbook(&fork_workbook_id);
3655
3656    Ok(RecalculateResponse {
3657        fork_id: params.fork_id,
3658        duration_ms: result.duration_ms,
3659        backend: result.backend,
3660        cells_evaluated: result.cells_evaluated,
3661        eval_errors: result.eval_errors,
3662    })
3663}
3664
3665#[derive(Debug, Deserialize, JsonSchema)]
3666pub struct ListForksParams {}
3667
3668#[derive(Debug, Serialize, JsonSchema)]
3669pub struct ListForksResponse {
3670    pub forks: Vec<ForkSummary>,
3671}
3672
3673#[derive(Debug, Serialize, JsonSchema)]
3674pub struct ForkSummary {
3675    pub fork_id: String,
3676    pub base_path: String,
3677    #[serde(skip_serializing_if = "Option::is_none")]
3678    pub client_base_path: Option<String>,
3679    pub age_seconds: u64,
3680    pub edit_count: usize,
3681    pub recalc_needed: bool,
3682}
3683
3684pub async fn list_forks(
3685    state: Arc<AppState>,
3686    _params: ListForksParams,
3687) -> Result<ListForksResponse> {
3688    let registry = state
3689        .fork_registry()
3690        .ok_or_else(|| anyhow!("fork registry not available"))?;
3691
3692    let config = state.config();
3693    let forks: Vec<ForkSummary> = registry
3694        .list_forks()
3695        .into_iter()
3696        .map(|f| {
3697            let base_path = f.base_path;
3698            let client_base_path = if config.path_mappings.is_empty() {
3699                None
3700            } else {
3701                config
3702                    .map_path_for_client(PathBuf::from(&base_path))
3703                    .map(|p| p.display().to_string())
3704            };
3705            ForkSummary {
3706                fork_id: f.fork_id,
3707                base_path,
3708                client_base_path,
3709                age_seconds: f.created_at.elapsed().as_secs(),
3710                edit_count: f.edit_count,
3711                recalc_needed: f.recalc_needed,
3712            }
3713        })
3714        .collect();
3715
3716    Ok(ListForksResponse { forks })
3717}
3718
3719#[derive(Debug, Deserialize, JsonSchema)]
3720pub struct DiscardForkParams {
3721    pub fork_id: String,
3722}
3723
3724#[derive(Debug, Serialize, JsonSchema)]
3725pub struct DiscardForkResponse {
3726    pub fork_id: String,
3727    pub discarded: bool,
3728}
3729
3730pub async fn discard_fork(
3731    state: Arc<AppState>,
3732    params: DiscardForkParams,
3733) -> Result<DiscardForkResponse> {
3734    let registry = state
3735        .fork_registry()
3736        .ok_or_else(|| anyhow!("fork registry not available"))?;
3737
3738    registry.discard_fork(&params.fork_id)?;
3739
3740    Ok(DiscardForkResponse {
3741        fork_id: params.fork_id,
3742        discarded: true,
3743    })
3744}
3745
3746#[derive(Debug, Deserialize, JsonSchema)]
3747pub struct SaveForkParams {
3748    pub fork_id: String,
3749    /// Target path to save to. If omitted, saves to original location (requires --allow-overwrite).
3750    pub target_path: Option<String>,
3751    /// If true, discard the fork after saving. If false, fork remains active for further edits.
3752    #[serde(default = "default_drop_fork")]
3753    pub drop_fork: bool,
3754}
3755
3756fn default_drop_fork() -> bool {
3757    true
3758}
3759
3760#[derive(Debug, Serialize, JsonSchema)]
3761pub struct SaveForkResponse {
3762    pub fork_id: String,
3763    pub saved_to: String,
3764    #[serde(skip_serializing_if = "Option::is_none")]
3765    pub client_saved_to: Option<String>,
3766    pub fork_dropped: bool,
3767}
3768
3769pub async fn save_fork(state: Arc<AppState>, params: SaveForkParams) -> Result<SaveForkResponse> {
3770    let registry = state
3771        .fork_registry()
3772        .ok_or_else(|| anyhow!("fork registry not available"))?;
3773
3774    let fork_ctx = registry.get_fork(&params.fork_id)?;
3775    let config = state.config();
3776    let workspace_root = &config.workspace_root;
3777
3778    let (target, is_overwrite) = match params.target_path {
3779        Some(p) => {
3780            let resolved = config.resolve_user_path(&p);
3781            let is_overwrite = if resolved.exists() {
3782                let base_canon = fork_ctx.base_path.canonicalize().map_err(|e| {
3783                    anyhow!("failed to canonicalize base_path for overwrite check: {e}")
3784                })?;
3785                let target_canon = resolved.canonicalize().map_err(|e| {
3786                    anyhow!("failed to canonicalize target_path for overwrite check: {e}")
3787                })?;
3788                target_canon == base_canon
3789            } else {
3790                false
3791            };
3792            (resolved, is_overwrite)
3793        }
3794        None => (fork_ctx.base_path.clone(), true),
3795    };
3796
3797    if is_overwrite && !config.allow_overwrite {
3798        return Err(anyhow!(
3799            "overwriting original file is disabled. Use --allow-overwrite flag or specify a different target_path"
3800        ));
3801    }
3802
3803    let base_path = fork_ctx.base_path.clone();
3804    registry.save_fork(&params.fork_id, &target, workspace_root, params.drop_fork)?;
3805
3806    if is_overwrite {
3807        state.evict_by_path(&base_path);
3808    }
3809
3810    Ok(SaveForkResponse {
3811        fork_id: params.fork_id,
3812        saved_to: target.display().to_string(),
3813        client_saved_to: config
3814            .map_path_for_client(&target)
3815            .map(|p| p.display().to_string()),
3816        fork_dropped: params.drop_fork,
3817    })
3818}
3819
3820#[derive(Debug, Deserialize, JsonSchema)]
3821pub struct CheckpointForkParams {
3822    pub fork_id: String,
3823    pub label: Option<String>,
3824}
3825
3826#[derive(Debug, Serialize, JsonSchema)]
3827pub struct CheckpointInfo {
3828    pub checkpoint_id: String,
3829    pub created_at: String,
3830    pub label: Option<String>,
3831}
3832
3833#[derive(Debug, Serialize, JsonSchema)]
3834pub struct CheckpointForkResponse {
3835    pub fork_id: String,
3836    pub checkpoint: CheckpointInfo,
3837    pub total_checkpoints: usize,
3838}
3839
3840pub async fn checkpoint_fork(
3841    state: Arc<AppState>,
3842    params: CheckpointForkParams,
3843) -> Result<CheckpointForkResponse> {
3844    let registry = state
3845        .fork_registry()
3846        .ok_or_else(|| anyhow!("fork registry not available"))?;
3847
3848    registry.get_fork(&params.fork_id)?;
3849    let checkpoint = registry.create_checkpoint(&params.fork_id, params.label.clone())?;
3850    let total = registry.list_checkpoints(&params.fork_id)?.len();
3851
3852    Ok(CheckpointForkResponse {
3853        fork_id: params.fork_id,
3854        checkpoint: CheckpointInfo {
3855            checkpoint_id: checkpoint.checkpoint_id,
3856            created_at: checkpoint.created_at.to_rfc3339(),
3857            label: checkpoint.label,
3858        },
3859        total_checkpoints: total,
3860    })
3861}
3862
3863#[derive(Debug, Deserialize, JsonSchema)]
3864pub struct ListCheckpointsParams {
3865    pub fork_id: String,
3866}
3867
3868#[derive(Debug, Serialize, JsonSchema)]
3869pub struct ListCheckpointsResponse {
3870    pub fork_id: String,
3871    pub checkpoints: Vec<CheckpointInfo>,
3872}
3873
3874pub async fn list_checkpoints(
3875    state: Arc<AppState>,
3876    params: ListCheckpointsParams,
3877) -> Result<ListCheckpointsResponse> {
3878    let registry = state
3879        .fork_registry()
3880        .ok_or_else(|| anyhow!("fork registry not available"))?;
3881
3882    let checkpoints = registry.list_checkpoints(&params.fork_id)?;
3883    let checkpoints = checkpoints
3884        .into_iter()
3885        .map(|cp| CheckpointInfo {
3886            checkpoint_id: cp.checkpoint_id,
3887            created_at: cp.created_at.to_rfc3339(),
3888            label: cp.label,
3889        })
3890        .collect();
3891
3892    Ok(ListCheckpointsResponse {
3893        fork_id: params.fork_id,
3894        checkpoints,
3895    })
3896}
3897
3898#[derive(Debug, Deserialize, JsonSchema)]
3899pub struct RestoreCheckpointParams {
3900    pub fork_id: String,
3901    pub checkpoint_id: String,
3902}
3903
3904#[derive(Debug, Serialize, JsonSchema)]
3905pub struct RestoreCheckpointResponse {
3906    pub fork_id: String,
3907    pub restored_checkpoint: CheckpointInfo,
3908}
3909
3910pub async fn restore_checkpoint(
3911    state: Arc<AppState>,
3912    params: RestoreCheckpointParams,
3913) -> Result<RestoreCheckpointResponse> {
3914    let registry = state
3915        .fork_registry()
3916        .ok_or_else(|| anyhow!("fork registry not available"))?;
3917
3918    let checkpoint = registry.restore_checkpoint(&params.fork_id, &params.checkpoint_id)?;
3919    let fork_workbook_id = WorkbookId(params.fork_id.clone());
3920    let _ = state.close_workbook(&fork_workbook_id);
3921
3922    Ok(RestoreCheckpointResponse {
3923        fork_id: params.fork_id,
3924        restored_checkpoint: CheckpointInfo {
3925            checkpoint_id: checkpoint.checkpoint_id,
3926            created_at: checkpoint.created_at.to_rfc3339(),
3927            label: checkpoint.label,
3928        },
3929    })
3930}
3931
3932#[derive(Debug, Deserialize, JsonSchema)]
3933pub struct DeleteCheckpointParams {
3934    pub fork_id: String,
3935    pub checkpoint_id: String,
3936}
3937
3938#[derive(Debug, Serialize, JsonSchema)]
3939pub struct DeleteCheckpointResponse {
3940    pub fork_id: String,
3941    pub checkpoint_id: String,
3942    pub deleted: bool,
3943}
3944
3945pub async fn delete_checkpoint(
3946    state: Arc<AppState>,
3947    params: DeleteCheckpointParams,
3948) -> Result<DeleteCheckpointResponse> {
3949    let registry = state
3950        .fork_registry()
3951        .ok_or_else(|| anyhow!("fork registry not available"))?;
3952
3953    registry.delete_checkpoint(&params.fork_id, &params.checkpoint_id)?;
3954
3955    Ok(DeleteCheckpointResponse {
3956        fork_id: params.fork_id,
3957        checkpoint_id: params.checkpoint_id,
3958        deleted: true,
3959    })
3960}
3961
3962#[derive(Debug, Deserialize, JsonSchema)]
3963pub struct ListStagedChangesParams {
3964    pub fork_id: String,
3965}
3966
3967#[derive(Debug, Serialize, JsonSchema)]
3968pub struct StagedChangeInfo {
3969    pub change_id: String,
3970    pub created_at: String,
3971    pub label: Option<String>,
3972    pub summary: ChangeSummary,
3973}
3974
3975#[derive(Debug, Serialize, JsonSchema)]
3976pub struct ListStagedChangesResponse {
3977    pub fork_id: String,
3978    pub staged_changes: Vec<StagedChangeInfo>,
3979}
3980
3981pub async fn list_staged_changes(
3982    state: Arc<AppState>,
3983    params: ListStagedChangesParams,
3984) -> Result<ListStagedChangesResponse> {
3985    let registry = state
3986        .fork_registry()
3987        .ok_or_else(|| anyhow!("fork registry not available"))?;
3988
3989    let staged = registry.list_staged_changes(&params.fork_id)?;
3990    let staged_changes = staged
3991        .into_iter()
3992        .map(|c| StagedChangeInfo {
3993            change_id: c.change_id,
3994            created_at: c.created_at.to_rfc3339(),
3995            label: c.label,
3996            summary: c.summary,
3997        })
3998        .collect();
3999
4000    Ok(ListStagedChangesResponse {
4001        fork_id: params.fork_id,
4002        staged_changes,
4003    })
4004}
4005
4006#[derive(Debug, Deserialize, JsonSchema)]
4007pub struct ApplyStagedChangeParams {
4008    pub fork_id: String,
4009    pub change_id: String,
4010}
4011
4012#[derive(Debug, Serialize, JsonSchema)]
4013pub struct ApplyStagedChangeResponse {
4014    pub fork_id: String,
4015    pub change_id: String,
4016    pub ops_applied: usize,
4017    pub summary: ChangeSummary,
4018}
4019
4020#[derive(Debug, Deserialize)]
4021struct EditBatchStagedPayload {
4022    sheet_name: String,
4023    edits: Vec<CellEdit>,
4024}
4025
4026pub async fn apply_staged_change(
4027    state: Arc<AppState>,
4028    params: ApplyStagedChangeParams,
4029) -> Result<ApplyStagedChangeResponse> {
4030    let registry = state
4031        .fork_registry()
4032        .ok_or_else(|| anyhow!("fork registry not available"))?;
4033
4034    let staged_list = registry.list_staged_changes(&params.fork_id)?;
4035    let staged = staged_list
4036        .iter()
4037        .find(|c| c.change_id == params.change_id)
4038        .cloned()
4039        .ok_or_else(|| anyhow!("staged change not found: {}", params.change_id))?;
4040
4041    let fork_ctx = registry.get_fork(&params.fork_id)?;
4042    let work_path = fork_ctx.work_path.clone();
4043
4044    let initial_recalc_needed = fork_ctx.recalc_needed;
4045    let mut recalc_triggered = false;
4046
4047    let mut ops_applied = 0usize;
4048
4049    for op in &staged.ops {
4050        match op.kind.as_str() {
4051            "edit_batch" => {
4052                recalc_triggered = true;
4053                let payload: EditBatchStagedPayload = serde_json::from_value(op.payload.clone())
4054                    .map_err(|e| anyhow!("invalid edit_batch payload: {}", e))?;
4055
4056                let edits_to_apply: Vec<_> = payload
4057                    .edits
4058                    .iter()
4059                    .map(|e| EditOp {
4060                        timestamp: Utc::now(),
4061                        sheet: payload.sheet_name.clone(),
4062                        address: e.address.clone(),
4063                        value: e.value.clone(),
4064                        is_formula: e.is_formula,
4065                    })
4066                    .collect();
4067
4068                tokio::task::spawn_blocking({
4069                    let sheet_name = payload.sheet_name.clone();
4070                    let edits = payload.edits.clone();
4071                    let work_path = work_path.clone();
4072                    move || {
4073                        let core_edits = edits
4074                            .into_iter()
4075                            .map(|edit| crate::core::types::CellEdit {
4076                                address: edit.address,
4077                                value: edit.value,
4078                                is_formula: edit.is_formula,
4079                            })
4080                            .collect::<Vec<_>>();
4081                        crate::core::write::apply_edits_to_file(
4082                            &work_path,
4083                            &sheet_name,
4084                            &core_edits,
4085                        )
4086                    }
4087                })
4088                .await??;
4089
4090                registry.with_fork_mut(&params.fork_id, |ctx| {
4091                    ctx.edits.extend(edits_to_apply);
4092                    ctx.recalc_needed = true;
4093                    Ok(())
4094                })?;
4095
4096                ops_applied += 1;
4097            }
4098            "style_batch" => {
4099                let payload: StyleBatchStagedPayload =
4100                    serde_json::from_value(op.payload.clone())
4101                        .map_err(|e| anyhow!("invalid style_batch payload: {}", e))?;
4102
4103                tokio::task::spawn_blocking({
4104                    let ops = payload.ops.clone();
4105                    let work_path = work_path.clone();
4106                    move || apply_style_ops_to_file(&work_path, &ops)
4107                })
4108                .await??;
4109
4110                ops_applied += 1;
4111            }
4112            "column_size_batch" => {
4113                let payload: ColumnSizeBatchStagedPayload =
4114                    serde_json::from_value(op.payload.clone())
4115                        .map_err(|e| anyhow!("invalid column_size_batch payload: {}", e))?;
4116
4117                tokio::task::spawn_blocking({
4118                    let sheet_name = payload.sheet_name.clone();
4119                    let ops = payload.ops.clone();
4120                    let work_path = work_path.clone();
4121                    move || apply_column_size_ops_to_file(&work_path, &sheet_name, &ops)
4122                })
4123                .await??;
4124
4125                ops_applied += 1;
4126            }
4127            "transform_batch" => {
4128                recalc_triggered = true;
4129                let payload: TransformBatchStagedPayload =
4130                    serde_json::from_value(op.payload.clone())
4131                        .map_err(|e| anyhow!("invalid transform_batch payload: {}", e))?;
4132
4133                tokio::task::spawn_blocking({
4134                    let ops = payload.ops.clone();
4135                    let work_path = work_path.clone();
4136                    move || apply_transform_ops_to_file(&work_path, &ops)
4137                })
4138                .await??;
4139
4140                ops_applied += 1;
4141            }
4142            "apply_formula_pattern" => {
4143                recalc_triggered = true;
4144                let payload: ApplyFormulaPatternStagedPayload =
4145                    serde_json::from_value(op.payload.clone())
4146                        .map_err(|e| anyhow!("invalid apply_formula_pattern payload: {}", e))?;
4147
4148                let bounds = parse_range_bounds(&payload.target_range)?;
4149                let (anchor_col, anchor_row) = parse_cell_ref(&payload.anchor_cell)?;
4150                let fill_direction = payload.fill_direction.unwrap_or_default();
4151                validate_formula_pattern_bounds(&bounds, anchor_col, anchor_row, fill_direction)?;
4152                let relative_mode: RelativeMode = payload.relative_mode.unwrap_or_default().into();
4153
4154                tokio::task::spawn_blocking({
4155                    let sheet_name = payload.sheet_name.clone();
4156                    let target_range = payload.target_range.clone();
4157                    let base_formula = payload.base_formula.clone();
4158                    let work_path = work_path.clone();
4159                    move || {
4160                        apply_formula_pattern_to_file(
4161                            &work_path,
4162                            &sheet_name,
4163                            &target_range,
4164                            anchor_col,
4165                            anchor_row,
4166                            &base_formula,
4167                            relative_mode,
4168                        )
4169                    }
4170                })
4171                .await??;
4172
4173                ops_applied += 1;
4174            }
4175            "structure_batch" => {
4176                let payload: StructureBatchStagedPayload =
4177                    serde_json::from_value(op.payload.clone())
4178                        .map_err(|e| anyhow!("invalid structure_batch payload: {}", e))?;
4179
4180                if structure_ops_require_recalc(&payload.ops) {
4181                    recalc_triggered = true;
4182                }
4183
4184                tokio::task::spawn_blocking({
4185                    let ops = payload.ops.clone();
4186                    let work_path = work_path.clone();
4187                    move || apply_structure_ops_to_file(&work_path, &ops)
4188                })
4189                .await??;
4190
4191                ops_applied += 1;
4192            }
4193            "sheet_layout_batch" => {
4194                let payload: crate::tools::sheet_layout::SheetLayoutBatchStagedPayload =
4195                    serde_json::from_value(op.payload.clone())
4196                        .map_err(|e| anyhow!("invalid sheet_layout_batch payload: {}", e))?;
4197
4198                tokio::task::spawn_blocking({
4199                    let ops = payload.ops.clone();
4200                    let work_path = work_path.clone();
4201                    move || {
4202                        crate::tools::sheet_layout::apply_sheet_layout_ops_to_file(&work_path, &ops)
4203                    }
4204                })
4205                .await??;
4206
4207                ops_applied += 1;
4208            }
4209            "rules_batch" => {
4210                let payload: crate::tools::rules_batch::RulesBatchStagedPayload =
4211                    serde_json::from_value(op.payload.clone())
4212                        .map_err(|e| anyhow!("invalid rules_batch payload: {}", e))?;
4213
4214                tokio::task::spawn_blocking({
4215                    let ops = payload.ops.clone();
4216                    let work_path = work_path.clone();
4217                    move || crate::tools::rules_batch::apply_rules_ops_to_file(&work_path, &ops)
4218                })
4219                .await??;
4220
4221                ops_applied += 1;
4222            }
4223            other => {
4224                return Err(anyhow!("unsupported staged op kind: {}", other));
4225            }
4226        }
4227    }
4228
4229    let recalc_needed_now = initial_recalc_needed || recalc_triggered;
4230    if recalc_needed_now {
4231        registry.with_fork_mut(&params.fork_id, |ctx| {
4232            ctx.recalc_needed = true;
4233            Ok(())
4234        })?;
4235    }
4236
4237    registry.discard_staged_change(&params.fork_id, &params.change_id)?;
4238    let fork_workbook_id = WorkbookId(params.fork_id.clone());
4239    let _ = state.close_workbook(&fork_workbook_id);
4240
4241    let mut summary = staged.summary;
4242    set_recalc_needed_flag(&mut summary, recalc_needed_now);
4243
4244    Ok(ApplyStagedChangeResponse {
4245        fork_id: params.fork_id,
4246        change_id: params.change_id,
4247        ops_applied,
4248        summary,
4249    })
4250}
4251
4252#[derive(Debug, Deserialize, JsonSchema)]
4253pub struct DiscardStagedChangeParams {
4254    pub fork_id: String,
4255    pub change_id: String,
4256}
4257
4258#[derive(Debug, Serialize, JsonSchema)]
4259pub struct DiscardStagedChangeResponse {
4260    pub fork_id: String,
4261    pub change_id: String,
4262    pub discarded: bool,
4263}
4264
4265pub async fn discard_staged_change(
4266    state: Arc<AppState>,
4267    params: DiscardStagedChangeParams,
4268) -> Result<DiscardStagedChangeResponse> {
4269    let registry = state
4270        .fork_registry()
4271        .ok_or_else(|| anyhow!("fork registry not available"))?;
4272
4273    registry.discard_staged_change(&params.fork_id, &params.change_id)?;
4274
4275    Ok(DiscardStagedChangeResponse {
4276        fork_id: params.fork_id,
4277        change_id: params.change_id,
4278        discarded: true,
4279    })
4280}
4281
4282const MAX_SCREENSHOT_ROWS: u32 = 100;
4283const MAX_SCREENSHOT_COLS: u32 = 30;
4284const DEFAULT_SCREENSHOT_RANGE: &str = "A1:M40";
4285#[cfg(feature = "recalc-libreoffice")]
4286const DEFAULT_MAX_PNG_DIM_PX: u32 = 4096;
4287#[cfg(feature = "recalc-libreoffice")]
4288const DEFAULT_MAX_PNG_AREA_PX: u64 = 12_000_000;
4289
4290#[derive(Debug, Deserialize, JsonSchema)]
4291pub struct ScreenshotSheetParams {
4292    #[serde(alias = "workbook_id")]
4293    pub workbook_or_fork_id: WorkbookId,
4294    pub sheet_name: String,
4295    #[serde(default)]
4296    pub range: Option<String>,
4297}
4298
4299#[derive(Debug, Serialize, JsonSchema)]
4300pub struct ScreenshotSheetResponse {
4301    pub workbook_id: String,
4302    pub sheet_name: String,
4303    pub range: String,
4304    pub output_path: String,
4305    #[serde(skip_serializing_if = "Option::is_none")]
4306    pub client_output_path: Option<String>,
4307    pub size_bytes: u64,
4308    pub duration_ms: u64,
4309}
4310
4311pub async fn screenshot_sheet(
4312    state: Arc<AppState>,
4313    params: ScreenshotSheetParams,
4314) -> Result<ScreenshotSheetResponse> {
4315    let range = params.range.as_deref().unwrap_or(DEFAULT_SCREENSHOT_RANGE);
4316    let bounds = validate_screenshot_range(range)?;
4317
4318    let workbook = state.open_workbook(&params.workbook_or_fork_id).await?;
4319    let workbook_path = workbook.path.clone();
4320
4321    let _ = workbook.with_sheet(&params.sheet_name, |_| Ok::<_, anyhow::Error>(()))?;
4322
4323    let safe_range = sanitize_filename_component(&range.replace(':', "-"));
4324    let safe_sheet = sanitize_filename_component(&params.sheet_name).replace(' ', "_");
4325    let safe_slug = sanitize_filename_component(&workbook.slug);
4326    let filename = format!("{}_{}_{}.png", safe_slug, safe_sheet, safe_range);
4327
4328    let config = state.config();
4329    let screenshot_dir = config.screenshot_dir.clone();
4330    tokio::fs::create_dir_all(&screenshot_dir).await?;
4331    let output_path = screenshot_dir.join(&filename);
4332
4333    let semaphore = state
4334        .screenshot_semaphore()
4335        .ok_or_else(|| anyhow!("screenshot semaphore not available"))?;
4336
4337    // LibreOffice profile/macro export is not concurrency-safe. Serialize screenshot calls.
4338    let _permit = semaphore
4339        .0
4340        .acquire()
4341        .await
4342        .map_err(|e| anyhow!("failed to acquire screenshot permit: {}", e))?;
4343
4344    #[cfg(not(feature = "recalc-libreoffice"))]
4345    {
4346        let _ = workbook_path;
4347        let _ = output_path;
4348        let _ = bounds;
4349        return Err(anyhow!(
4350            "screenshot backend unavailable (build without recalc-libreoffice feature)"
4351        ));
4352    }
4353
4354    #[cfg(feature = "recalc-libreoffice")]
4355    {
4356        let executor =
4357            crate::recalc::ScreenshotExecutor::new(&crate::recalc::RecalcConfig::default());
4358        let result = executor
4359            .screenshot(
4360                &workbook_path,
4361                &output_path,
4362                &params.sheet_name,
4363                Some(range),
4364            )
4365            .await?;
4366
4367        enforce_png_pixel_limits(&result.output_path, range, &bounds).await?;
4368
4369        return Ok(ScreenshotSheetResponse {
4370            workbook_id: params.workbook_or_fork_id.0,
4371            sheet_name: params.sheet_name,
4372            range: range.to_string(),
4373            output_path: format!("file://{}", result.output_path.display()),
4374            client_output_path: config
4375                .map_path_for_client(&result.output_path)
4376                .map(|p| format!("file://{}", p.display())),
4377            size_bytes: result.size_bytes,
4378            duration_ms: result.duration_ms,
4379        });
4380    }
4381}
4382
4383#[derive(Debug, Clone, Copy)]
4384struct ScreenshotBounds {
4385    min_col: u32,
4386    max_col: u32,
4387    min_row: u32,
4388    max_row: u32,
4389    rows: u32,
4390    cols: u32,
4391}
4392
4393fn validate_screenshot_range(range: &str) -> Result<ScreenshotBounds> {
4394    let bounds = parse_range_bounds(range)?;
4395
4396    if bounds.rows > MAX_SCREENSHOT_ROWS || bounds.cols > MAX_SCREENSHOT_COLS {
4397        let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
4398        let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
4399        let total_tiles = row_tiles * col_tiles;
4400
4401        let display_limit = 50usize;
4402        let display_ranges = suggest_tiled_ranges(
4403            &bounds,
4404            MAX_SCREENSHOT_ROWS,
4405            MAX_SCREENSHOT_COLS,
4406            Some(display_limit),
4407        );
4408
4409        let mut msg = format!(
4410            "Requested range {range} is too large for a single screenshot ({} rows x {} cols; max {} x {}). \
4411Split into {} tile(s) ({} row tiles x {} col tiles). Suggested ranges: {}",
4412            bounds.rows,
4413            bounds.cols,
4414            MAX_SCREENSHOT_ROWS,
4415            MAX_SCREENSHOT_COLS,
4416            total_tiles,
4417            row_tiles,
4418            col_tiles,
4419            display_ranges.join(", ")
4420        );
4421        if total_tiles as usize > display_limit {
4422            msg.push_str(&format!(
4423                " ... and {} more.",
4424                total_tiles as usize - display_limit
4425            ));
4426        }
4427        return Err(anyhow!(msg));
4428    }
4429
4430    Ok(bounds)
4431}
4432
4433fn parse_cell_ref(cell: &str) -> Result<(u32, u32)> {
4434    use umya_spreadsheet::helper::coordinate::index_from_coordinate;
4435    let (col, row, _, _) = index_from_coordinate(cell);
4436    match (col, row) {
4437        (Some(c), Some(r)) => Ok((c, r)),
4438        _ => Err(anyhow!("Invalid cell reference: {}", cell)),
4439    }
4440}
4441
4442fn parse_range_bounds(range: &str) -> Result<ScreenshotBounds> {
4443    let parts: Vec<&str> = range.split(':').collect();
4444    if parts.is_empty() || parts.len() > 2 {
4445        return Err(anyhow!("Invalid range format. Expected 'A1' or 'A1:Z99'"));
4446    }
4447
4448    let start = parse_cell_ref(parts[0])?;
4449    let end = if parts.len() == 2 {
4450        parse_cell_ref(parts[1])?
4451    } else {
4452        start
4453    };
4454
4455    let min_col = start.0.min(end.0);
4456    let max_col = start.0.max(end.0);
4457    let min_row = start.1.min(end.1);
4458    let max_row = start.1.max(end.1);
4459
4460    let rows = max_row - min_row + 1;
4461    let cols = max_col - min_col + 1;
4462
4463    Ok(ScreenshotBounds {
4464        min_col,
4465        max_col,
4466        min_row,
4467        max_row,
4468        rows,
4469        cols,
4470    })
4471}
4472
4473fn div_ceil(n: u32, d: u32) -> u32 {
4474    n.div_ceil(d)
4475}
4476
4477fn suggest_tiled_ranges(
4478    bounds: &ScreenshotBounds,
4479    max_rows: u32,
4480    max_cols: u32,
4481    limit: Option<usize>,
4482) -> Vec<String> {
4483    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4484
4485    let mut out = Vec::new();
4486    let mut row_start = bounds.min_row;
4487    while row_start <= bounds.max_row {
4488        let row_end = (row_start + max_rows - 1).min(bounds.max_row);
4489        let mut col_start = bounds.min_col;
4490        while col_start <= bounds.max_col {
4491            let col_end = (col_start + max_cols - 1).min(bounds.max_col);
4492            let start_cell = coordinate_from_index(&col_start, &row_start);
4493            let end_cell = coordinate_from_index(&col_end, &row_end);
4494            out.push(format!("{start_cell}:{end_cell}"));
4495            if let Some(lim) = limit
4496                && out.len() >= lim
4497            {
4498                return out;
4499            }
4500            col_start = col_end + 1;
4501        }
4502        row_start = row_end + 1;
4503        if let Some(lim) = limit
4504            && out.len() >= lim
4505        {
4506            return out;
4507        }
4508    }
4509    out
4510}
4511
4512#[cfg(feature = "recalc-libreoffice")]
4513fn suggest_split_single_tile(bounds: &ScreenshotBounds) -> Vec<String> {
4514    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4515
4516    if bounds.rows >= bounds.cols && bounds.rows > 1 {
4517        let mid_row = bounds.min_row + (bounds.rows / 2) - 1;
4518        let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4519        let end1 = coordinate_from_index(&bounds.max_col, &mid_row);
4520        let start2 = coordinate_from_index(&bounds.min_col, &(mid_row + 1));
4521        let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4522        vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
4523    } else if bounds.cols > 1 {
4524        let mid_col = bounds.min_col + (bounds.cols / 2) - 1;
4525        let start1 = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4526        let end1 = coordinate_from_index(&mid_col, &bounds.max_row);
4527        let start2 = coordinate_from_index(&(mid_col + 1), &bounds.min_row);
4528        let end2 = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4529        vec![format!("{start1}:{end1}"), format!("{start2}:{end2}")]
4530    } else {
4531        vec![range_from_bounds(bounds)]
4532    }
4533}
4534
4535#[cfg(feature = "recalc-libreoffice")]
4536fn range_from_bounds(bounds: &ScreenshotBounds) -> String {
4537    use umya_spreadsheet::helper::coordinate::coordinate_from_index;
4538    let start = coordinate_from_index(&bounds.min_col, &bounds.min_row);
4539    let end = coordinate_from_index(&bounds.max_col, &bounds.max_row);
4540    format!("{start}:{end}")
4541}
4542
4543#[cfg(feature = "recalc-libreoffice")]
4544async fn enforce_png_pixel_limits(
4545    path: &std::path::Path,
4546    range: &str,
4547    bounds: &ScreenshotBounds,
4548) -> Result<()> {
4549    use image::GenericImageView;
4550    use image::ImageReader;
4551
4552    let max_dim_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_DIM_PX")
4553        .ok()
4554        .and_then(|v| v.parse::<u32>().ok())
4555        .unwrap_or(DEFAULT_MAX_PNG_DIM_PX);
4556    let max_area_px = std::env::var("SPREADSHEET_MCP_MAX_PNG_AREA_PX")
4557        .ok()
4558        .and_then(|v| v.parse::<u64>().ok())
4559        .unwrap_or(DEFAULT_MAX_PNG_AREA_PX);
4560
4561    let reader = ImageReader::open(path)
4562        .map_err(|e| anyhow!("failed to read png {}: {}", path.display(), e))?;
4563    let reader = reader
4564        .with_guessed_format()
4565        .map_err(|e| anyhow!("failed to sniff png {}: {}", path.display(), e))?;
4566    let img = reader
4567        .decode()
4568        .map_err(|e| anyhow!("failed to decode png {}: {}", path.display(), e))?;
4569    let (w, h) = img.dimensions();
4570    let area = (w as u64) * (h as u64);
4571
4572    if w > max_dim_px || h > max_dim_px || area > max_area_px {
4573        let _ = tokio::fs::remove_file(path).await;
4574
4575        let mut suggestions =
4576            suggest_tiled_ranges(bounds, MAX_SCREENSHOT_ROWS, MAX_SCREENSHOT_COLS, Some(50));
4577        let row_tiles = div_ceil(bounds.rows, MAX_SCREENSHOT_ROWS);
4578        let col_tiles = div_ceil(bounds.cols, MAX_SCREENSHOT_COLS);
4579        let total_tiles = row_tiles * col_tiles;
4580        if total_tiles == 1 {
4581            suggestions = suggest_split_single_tile(bounds);
4582        }
4583
4584        return Err(anyhow!(
4585            "Rendered PNG for range {range} is {w}x{h}px (area {area}px), exceeding limits (max_dim={max_dim_px}px, max_area={max_area_px}px). \
4586Try smaller ranges. Suggested ranges: {}",
4587            suggestions.join(", ")
4588        ));
4589    }
4590
4591    Ok(())
4592}