Skip to main content

spreadsheet_kit/cli/commands/
write.rs

1use crate::cli::{AppendRegionFooterPolicyArg, CloneMergePolicyArg, ClonePatchTargetsArg};
2use crate::config::{OutputProfile, RecalcBackendKind, ServerConfig, TransportKind};
3use crate::core::types::CellEdit;
4use crate::formula::pattern::{RelativeMode, parse_base_formula, shift_formula_ast};
5use crate::model::{
6    CommandClass, FORMULA_PARSE_FAILED_PREFIX, FormulaParseDiagnostics,
7    FormulaParseDiagnosticsBuilder, FormulaParsePolicy, GridPayload, NamedItemKind, Warning,
8    validate_formula,
9};
10use crate::runtime::stateless::StatelessRuntime;
11use crate::state::AppState;
12use crate::tools::filters::WorkbookFilter;
13use crate::tools::fork::{
14    ApplyFormulaPatternOpInput, ColumnSizeOp, ColumnSizeOpInput, CreateForkParams,
15    GridImportParams, MatrixCell, SaveForkParams, StructureBatchParamsInput, StructureOp,
16    StructureOpInput, StyleBatchParamsInput, StyleOp, StyleOpInput, TransformOp, TransformTarget,
17    apply_column_size_ops_to_file, apply_formula_pattern_ops_to_file, apply_structure_ops_to_file,
18    apply_style_ops_to_file, apply_transform_ops_to_file, create_fork, grid_import,
19    normalize_column_size_payload, normalize_structure_batch, normalize_style_batch,
20    resolve_style_ops_for_workbook, resolve_transform_ops_for_workbook, save_fork,
21};
22use crate::tools::rules_batch::{RulesOp, apply_rules_ops_to_file};
23use crate::tools::sheet_layout::{SheetLayoutOp, apply_sheet_layout_ops_to_file};
24use crate::workbook::WorkbookContext;
25use anyhow::{Context, Result, anyhow, bail};
26use regex::Regex;
27use schemars::{JsonSchema, schema_for};
28use serde::{Deserialize, Serialize, de::DeserializeOwned};
29use serde_json::Value;
30use std::collections::{BTreeMap, BTreeSet};
31use std::fs::{self, OpenOptions};
32use std::io::ErrorKind;
33use std::path::{Path, PathBuf};
34use std::sync::Arc;
35use std::thread;
36use tempfile::{Builder, TempPath};
37
38#[derive(Debug, Serialize)]
39struct CopyResponse {
40    source: String,
41    dest: String,
42    bytes_copied: u64,
43}
44
45#[derive(Debug, Serialize)]
46struct CreateWorkbookResponse {
47    path: String,
48    sheets: Vec<String>,
49    overwritten: bool,
50}
51
52#[derive(Debug, Clone, Serialize)]
53struct WritePathProvenance {
54    written_via: String,
55    #[serde(skip_serializing_if = "Vec::is_empty", default)]
56    formula_targets: Vec<String>,
57}
58
59#[derive(Debug, Serialize)]
60struct EditResponse {
61    file: String,
62    sheet: String,
63    edits_applied: usize,
64    recalc_needed: bool,
65    warnings: Vec<Warning>,
66    #[serde(skip_serializing_if = "Vec::is_empty", default)]
67    affected_cells: Vec<String>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    source_path: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    target_path: Option<String>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    changed: Option<bool>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    write_path_provenance: Option<WritePathProvenance>,
78}
79
80#[derive(Debug, Serialize)]
81struct EditDryRunResponse {
82    file: String,
83    sheet: String,
84    edits_provided: usize,
85    edits_validated: usize,
86    would_change: bool,
87    recalc_needed: bool,
88    warnings: Vec<Warning>,
89    #[serde(skip_serializing_if = "Vec::is_empty", default)]
90    affected_cells: Vec<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    write_path_provenance: Option<WritePathProvenance>,
95}
96
97#[derive(Debug, Deserialize, JsonSchema)]
98struct OpsPayload<T> {
99    ops: Vec<T>,
100}
101
102#[derive(Debug, Deserialize, JsonSchema)]
103struct ColumnSizeOpsPayload {
104    sheet_name: String,
105    ops: Vec<ColumnSizeOpInput>,
106}
107
108#[derive(Debug, Deserialize, JsonSchema)]
109#[serde(untagged)]
110enum ColumnSizeOpWithSheetInput {
111    Canonical {
112        sheet_name: String,
113        target: crate::tools::fork::ColumnTarget,
114        size: crate::tools::fork::ColumnSizeSpec,
115    },
116    Shorthand {
117        sheet_name: String,
118        range: String,
119        size: crate::tools::fork::ColumnSizeSpec,
120    },
121}
122
123impl ColumnSizeOpWithSheetInput {
124    fn sheet_name(&self) -> &str {
125        match self {
126            Self::Canonical { sheet_name, .. } | Self::Shorthand { sheet_name, .. } => sheet_name,
127        }
128    }
129
130    fn into_op_input(self) -> ColumnSizeOpInput {
131        match self {
132            Self::Canonical { target, size, .. } => {
133                ColumnSizeOpInput::Canonical(ColumnSizeOp { target, size })
134            }
135            Self::Shorthand { range, size, .. } => ColumnSizeOpInput::Shorthand { range, size },
136        }
137    }
138}
139
140const TRANSFORM_PAYLOAD_SHAPE: &str = r#"{"ops":[{"kind":"<transform_kind>",...}]}"#;
141const TRANSFORM_PAYLOAD_MINIMAL_EXAMPLE: &str = r#"{"ops":[{"kind":"fill_range","sheet_name":"Sheet1","target":{"kind":"range","range":"A1:A1"},"value":"1"}]}"#;
142const STYLE_PAYLOAD_SHAPE: &str =
143    r#"{"ops":[{"sheet_name":"...","target":{"kind":"range","range":"A1"},"patch":{...}}]}"#;
144const STYLE_PAYLOAD_MINIMAL_EXAMPLE: &str = r#"{"ops":[{"sheet_name":"Sheet1","target":{"kind":"range","range":"B2:B2"},"patch":{"font":{"bold":true}}}]}"#;
145const APPLY_FORMULA_PATTERN_PAYLOAD_SHAPE: &str = r#"{"ops":[{"sheet_name":"...","target_range":"A1:A1","anchor_cell":"A1","base_formula":"..."}]}"#;
146const APPLY_FORMULA_PATTERN_PAYLOAD_MINIMAL_EXAMPLE: &str = r#"{"ops":[{"sheet_name":"Sheet1","target_range":"C2:C4","anchor_cell":"C2","base_formula":"B2*2"}]}"#;
147const STRUCTURE_PAYLOAD_SHAPE: &str = r#"{"ops":[{"kind":"<structure_kind>",...}]}"#;
148const STRUCTURE_PAYLOAD_MINIMAL_EXAMPLE: &str =
149    r#"{"ops":[{"kind":"rename_sheet","old_name":"Summary","new_name":"Dashboard"}]}"#;
150const COLUMN_SIZE_PAYLOAD_SHAPE: &str =
151    r#"{"sheet_name":"...","ops":[{"range":"A:A","size":{"kind":"width","width_chars":12.0}}]}"#;
152const COLUMN_SIZE_PAYLOAD_ALTERNATE_SHAPE: &str =
153    r#"{"ops":[{"sheet_name":"...","range":"A:A","size":{"kind":"width","width_chars":12.0}}]}"#;
154const COLUMN_SIZE_PAYLOAD_MINIMAL_EXAMPLE: &str =
155    r#"{"sheet_name":"Sheet1","ops":[{"range":"A:A","size":{"kind":"width","width_chars":12.0}}]}"#;
156const COLUMN_SIZE_PAYLOAD_ALTERNATE_EXAMPLE: &str =
157    r#"{"ops":[{"sheet_name":"Sheet1","range":"A:A","size":{"kind":"width","width_chars":12.0}}]}"#;
158const SHEET_LAYOUT_PAYLOAD_SHAPE: &str = r#"{"ops":[{"kind":"<layout_kind>",...}]}"#;
159const SHEET_LAYOUT_PAYLOAD_MINIMAL_EXAMPLE: &str =
160    r#"{"ops":[{"kind":"freeze_panes","sheet_name":"Sheet1","freeze_rows":1,"freeze_cols":1}]}"#;
161const RULES_PAYLOAD_SHAPE: &str = r#"{"ops":[{"kind":"<rules_kind>",...}]}"#;
162const RULES_PAYLOAD_MINIMAL_EXAMPLE: &str = r#"{"ops":[{"kind":"set_data_validation","sheet_name":"Sheet1","target_range":"B2:B4","validation":{"kind":"list","formula1":"\"A,B,C\""}}]}"#;
163const EDIT_FORMULA_HINT: &str =
164    "Tip: formulas in edit shorthand use double equals, e.g. A1==SUM(B1:B5).";
165const SHELL_QUOTING_HINT: &str = "Hint: if this edit was passed as a shell argument, check quoting: double quotes let the shell expand $-style absolute references (\"$A$1\" reaches asp as \"1\"), and unquoted parentheses break the shell. Single-quote each edit, or use --edits-file (one edit per line, '-' for stdin) to bypass shell quoting.";
166
167fn load_edits_file(path: &std::path::Path) -> Result<Vec<String>> {
168    // Tolerate the @path convention used by --ops payloads.
169    let path = match path.to_str().and_then(|s| s.strip_prefix('@')) {
170        Some(stripped) => std::path::PathBuf::from(stripped),
171        None => path.to_path_buf(),
172    };
173    let path = path.as_path();
174    let raw = if path.as_os_str() == "-" {
175        use std::io::Read;
176        let mut buf = String::new();
177        std::io::stdin()
178            .read_to_string(&mut buf)
179            .context("failed to read edits from stdin")?;
180        buf
181    } else {
182        std::fs::read_to_string(path)
183            .with_context(|| format!("failed to read edits file '{}'", path.display()))?
184    };
185    Ok(raw
186        .lines()
187        .map(str::trim)
188        .filter(|line| !line.is_empty() && !line.starts_with('#'))
189        .map(str::to_string)
190        .collect())
191}
192
193#[allow(dead_code)]
194#[derive(Debug, JsonSchema)]
195struct ColumnSizeOpsPerOpPayload {
196    ops: Vec<ColumnSizeOpWithSheetInput>,
197}
198
199#[allow(dead_code)]
200#[derive(Debug, JsonSchema)]
201#[serde(untagged)]
202enum ColumnSizeOpsSchemaPayload {
203    Canonical(ColumnSizeOpsPayload),
204    PerOp(ColumnSizeOpsPerOpPayload),
205}
206
207#[derive(Debug, Clone, Copy)]
208pub enum BatchSchemaCommand {
209    Transform,
210    Style,
211    ApplyFormulaPattern,
212    Structure,
213    ColumnSize,
214    SheetLayout,
215    Rules,
216}
217
218pub fn batch_payload_schema(command: BatchSchemaCommand) -> Result<Value> {
219    let schema_value = match command {
220        BatchSchemaCommand::Transform => {
221            serde_json::to_value(schema_for!(OpsPayload<TransformOp>))?
222        }
223        BatchSchemaCommand::Style => serde_json::to_value(schema_for!(OpsPayload<StyleOpInput>))?,
224        BatchSchemaCommand::ApplyFormulaPattern => {
225            serde_json::to_value(schema_for!(OpsPayload<ApplyFormulaPatternOpInput>))?
226        }
227        BatchSchemaCommand::Structure => {
228            serde_json::to_value(schema_for!(OpsPayload<StructureOpInput>))?
229        }
230        BatchSchemaCommand::ColumnSize => {
231            serde_json::to_value(schema_for!(ColumnSizeOpsSchemaPayload))?
232        }
233        BatchSchemaCommand::SheetLayout => {
234            serde_json::to_value(schema_for!(OpsPayload<SheetLayoutOp>))?
235        }
236        BatchSchemaCommand::Rules => serde_json::to_value(schema_for!(OpsPayload<RulesOp>))?,
237    };
238
239    Ok(serde_json::json!({
240        "schema_kind": "ops_payload",
241        "schema": schema_value,
242    }))
243}
244
245pub fn batch_payload_example(command: BatchSchemaCommand) -> Result<Value> {
246    let example = match command {
247        BatchSchemaCommand::Transform => serde_json::json!({
248            "ops": [{
249                "kind": "fill_range",
250                "sheet_name": "Sheet1",
251                "target": {"kind": "range", "range": "B2:B4"},
252                "value": "0"
253            }]
254        }),
255        BatchSchemaCommand::Style => serde_json::json!({
256            "ops": [{
257                "sheet_name": "Sheet1",
258                "target": {"kind": "range", "range": "B2:B2"},
259                "patch": {"font": {"bold": true}}
260            }]
261        }),
262        BatchSchemaCommand::ApplyFormulaPattern => serde_json::json!({
263            "ops": [{
264                "sheet_name": "Sheet1",
265                "target_range": "C2:C4",
266                "anchor_cell": "C2",
267                "base_formula": "B2*2"
268            }]
269        }),
270        BatchSchemaCommand::Structure => serde_json::json!({
271            "ops": [{
272                "kind": "rename_sheet",
273                "old_name": "Summary",
274                "new_name": "Dashboard"
275            }]
276        }),
277        BatchSchemaCommand::ColumnSize => serde_json::json!({
278            "sheet_name": "Sheet1",
279            "ops": [{
280                "target": {"kind": "columns", "range": "A:A"},
281                "size": {"kind": "width", "width_chars": 12.0}
282            }]
283        }),
284        BatchSchemaCommand::SheetLayout => serde_json::json!({
285            "ops": [{
286                "kind": "freeze_panes",
287                "sheet_name": "Sheet1",
288                "freeze_rows": 1,
289                "freeze_cols": 1
290            }]
291        }),
292        BatchSchemaCommand::Rules => serde_json::json!({
293            "ops": [{
294                "kind": "set_data_validation",
295                "sheet_name": "Sheet1",
296                "target_range": "B2:B4",
297                "validation": {"kind": "list", "formula1": "\"A,B,C\""}
298            }]
299        }),
300    };
301
302    Ok(serde_json::json!({
303        "example_kind": "ops_payload",
304        "example": example,
305    }))
306}
307
308#[derive(Debug)]
309enum EditMutationMode {
310    DryRun,
311    InPlace,
312    Output { target: PathBuf, force: bool },
313}
314
315#[derive(Debug)]
316enum BatchMutationMode {
317    DryRun,
318    InPlace,
319    Output { target: PathBuf, force: bool },
320}
321
322#[derive(Debug, Serialize)]
323struct DryRunSummary {
324    operation_counts: BTreeMap<String, u64>,
325    result_counts: BTreeMap<String, u64>,
326}
327
328#[derive(Debug, Serialize)]
329struct BatchDryRunResponse {
330    op_count: usize,
331    validated_count: usize,
332    would_change: bool,
333    warnings: Vec<Warning>,
334    summary: DryRunSummary,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    write_path_provenance: Option<WritePathProvenance>,
339}
340
341#[derive(Debug, Serialize)]
342struct BatchApplyResponse {
343    op_count: usize,
344    applied_count: usize,
345    warnings: Vec<Warning>,
346    changed: bool,
347    target_path: String,
348    source_path: String,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    write_path_provenance: Option<WritePathProvenance>,
353}
354
355#[derive(Debug)]
356struct GridImportFileApplyResult {
357    summary: crate::fork::ChangeSummary,
358    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
359}
360
361pub async fn copy(source: PathBuf, dest: PathBuf) -> Result<Value> {
362    let runtime = StatelessRuntime;
363    let source = runtime.normalize_existing_file(&source)?;
364    let dest = runtime.normalize_destination_path(&dest)?;
365    let bytes_copied = runtime.copy_file(&source, &dest).with_context(|| {
366        format!(
367            "failed to copy workbook from '{}' to '{}'",
368            source.display(),
369            dest.display()
370        )
371    })?;
372
373    Ok(serde_json::to_value(CopyResponse {
374        source: source.display().to_string(),
375        dest: dest.display().to_string(),
376        bytes_copied,
377    })?)
378}
379
380pub async fn create_workbook(
381    path: PathBuf,
382    sheets: Option<Vec<String>>,
383    overwrite: bool,
384) -> Result<Value> {
385    let runtime = StatelessRuntime;
386    let path = runtime.normalize_destination_path(&path)?;
387
388    let existed = path.exists();
389    if existed {
390        if !overwrite {
391            bail!(
392                "file '{}' already exists; pass --overwrite to replace it",
393                path.display()
394            );
395        }
396        if !path.is_file() {
397            bail!("path '{}' is not a file", path.display());
398        }
399    }
400
401    let mut sheet_names = sheets.unwrap_or_else(|| vec!["Sheet1".to_string()]);
402    if sheet_names.is_empty() {
403        sheet_names.push("Sheet1".to_string());
404    }
405
406    let mut normalized_sheet_names = Vec::new();
407    for name in sheet_names {
408        let trimmed = name.trim();
409        if trimmed.is_empty() {
410            bail!("sheet names must be non-empty");
411        }
412        if normalized_sheet_names
413            .iter()
414            .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
415        {
416            bail!("duplicate sheet name '{}'", trimmed);
417        }
418        normalized_sheet_names.push(trimmed.to_string());
419    }
420
421    let mut workbook = umya_spreadsheet::new_file();
422    let first_sheet_name = normalized_sheet_names
423        .first()
424        .cloned()
425        .ok_or_else(|| anyhow!("at least one sheet is required"))?;
426    workbook
427        .get_sheet_by_name_mut("Sheet1")
428        .ok_or_else(|| anyhow!("failed to initialize workbook default sheet"))?
429        .set_name(first_sheet_name.as_str());
430
431    for sheet_name in normalized_sheet_names.iter().skip(1) {
432        workbook
433            .new_sheet(sheet_name.as_str())
434            .map_err(|err| anyhow!("failed to create sheet '{}': {}", sheet_name, err))?;
435    }
436
437    umya_spreadsheet::writer::xlsx::write(&workbook, &path)
438        .with_context(|| format!("failed to write workbook '{}'", path.display()))?;
439
440    Ok(serde_json::to_value(CreateWorkbookResponse {
441        path: path.display().to_string(),
442        sheets: normalized_sheet_names,
443        overwritten: existed,
444    })?)
445}
446
447#[allow(clippy::too_many_arguments)]
448pub async fn edit(
449    file: PathBuf,
450    sheet: String,
451    edits: Vec<String>,
452    edits_file: Option<PathBuf>,
453    dry_run: bool,
454    in_place: bool,
455    output: Option<PathBuf>,
456    force: bool,
457    formula_parse_policy: Option<FormulaParsePolicy>,
458) -> Result<Value> {
459    let mut edits = edits;
460    if let Some(path) = edits_file {
461        let mut file_edits = load_edits_file(&path)?;
462        file_edits.append(&mut edits);
463        edits = file_edits;
464    }
465    if edits.is_empty() {
466        bail!("at least one edit must be provided (positional EDIT args or --edits-file)");
467    }
468
469    let runtime = StatelessRuntime;
470    let source = runtime.normalize_existing_file(&file)?;
471    let mode = validate_edit_mode(dry_run, in_place, output, force)?;
472
473    let mut normalized_edits = Vec::with_capacity(edits.len());
474    let mut warnings = Vec::new();
475    for (idx, entry) in edits.into_iter().enumerate() {
476        let (edit, entry_warnings) = crate::core::write::normalize_shorthand_edit(&entry)
477            .with_context(|| {
478                format!(
479                    "invalid shorthand edit at index {}. {} {}",
480                    idx, EDIT_FORMULA_HINT, SHELL_QUOTING_HINT
481                )
482            })?;
483        normalized_edits.push(edit);
484        warnings.extend(entry_warnings.into_iter().map(|warning| Warning {
485            code: warning.code,
486            message: warning.message,
487        }));
488    }
489    let edits_provided = normalized_edits.len();
490
491    let policy = formula_parse_policy.unwrap_or(FormulaParsePolicy::default_for_command_class(
492        CommandClass::SingleWrite,
493    ));
494
495    let (edits_to_write, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
496        (normalized_edits, None)
497    } else {
498        let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
499        let mut valid_edits = Vec::new();
500        for edit in normalized_edits {
501            if edit.is_formula {
502                match validate_formula(&edit.value) {
503                    Ok(()) => valid_edits.push(edit),
504                    Err(err_msg) => {
505                        if policy == FormulaParsePolicy::Fail {
506                            bail!(
507                                "{}edit at {} failed: {}\n{}",
508                                FORMULA_PARSE_FAILED_PREFIX,
509                                edit.address,
510                                err_msg,
511                                SHELL_QUOTING_HINT
512                            );
513                        }
514                        builder.record_error(&sheet, &edit.address, &edit.value, &err_msg);
515                    }
516                }
517            } else {
518                valid_edits.push(edit);
519            }
520        }
521        let diagnostics = if builder.has_errors() {
522            Some(builder.build())
523        } else {
524            None
525        };
526        (valid_edits, diagnostics)
527    };
528
529    let affected_cells = edits_to_write
530        .iter()
531        .map(|edit| edit.address.clone())
532        .collect::<Vec<_>>();
533    let changed = !edits_to_write.is_empty();
534    let sheet_name = sheet;
535    let write_path_provenance = formula_write_provenance(
536        "edit",
537        edits_to_write
538            .iter()
539            .filter(|edit| edit.is_formula)
540            .map(|edit| format!("{}!{}", sheet_name, edit.address))
541            .collect(),
542    );
543
544    match mode {
545        EditMutationMode::DryRun => {
546            let _ = apply_to_temp_copy(&source, source.parent(), ".edit-", |path| {
547                runtime.apply_edits(path, &sheet_name, &edits_to_write)
548            })?;
549
550            Ok(serde_json::to_value(EditDryRunResponse {
551                file: source.display().to_string(),
552                sheet: sheet_name,
553                edits_provided,
554                edits_validated: edits_to_write.len(),
555                would_change: changed,
556                recalc_needed: false,
557                warnings,
558                affected_cells,
559                formula_parse_diagnostics,
560                write_path_provenance: write_path_provenance.clone(),
561            })?)
562        }
563        EditMutationMode::InPlace => {
564            apply_in_place_with_temp(&source, ".edit-", |path| {
565                runtime.apply_edits(path, &sheet_name, &edits_to_write)
566            })?;
567
568            Ok(serde_json::to_value(EditResponse {
569                file: source.display().to_string(),
570                sheet: sheet_name,
571                edits_applied: edits_to_write.len(),
572                recalc_needed: true,
573                warnings,
574                affected_cells,
575                source_path: None,
576                target_path: None,
577                changed: Some(changed),
578                formula_parse_diagnostics,
579                write_path_provenance: write_path_provenance.clone(),
580            })?)
581        }
582        EditMutationMode::Output { target, force } => {
583            let target = runtime.normalize_destination_path(&target)?;
584            ensure_output_path_is_distinct(&source, &target)?;
585
586            apply_to_output_with_temp(&source, &target, force, ".edit-", |path| {
587                runtime.apply_edits(path, &sheet_name, &edits_to_write)
588            })?;
589
590            Ok(serde_json::to_value(EditResponse {
591                file: target.display().to_string(),
592                sheet: sheet_name,
593                edits_applied: edits_to_write.len(),
594                recalc_needed: true,
595                warnings,
596                affected_cells,
597                source_path: Some(source.display().to_string()),
598                target_path: Some(target.display().to_string()),
599                changed: Some(changed),
600                formula_parse_diagnostics,
601                write_path_provenance: write_path_provenance.clone(),
602            })?)
603        }
604    }
605}
606
607pub async fn transform_batch(
608    file: PathBuf,
609    ops: String,
610    dry_run: bool,
611    in_place: bool,
612    output: Option<PathBuf>,
613    force: bool,
614    formula_parse_policy: Option<FormulaParsePolicy>,
615) -> Result<Value> {
616    let runtime = StatelessRuntime;
617    let source = runtime.normalize_existing_file(&file)?;
618    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
619
620    let payload: OpsPayload<TransformOp> = parse_ops_payload(
621        &ops,
622        TRANSFORM_PAYLOAD_SHAPE,
623        TRANSFORM_PAYLOAD_MINIMAL_EXAMPLE,
624    )?;
625
626    let (state, workbook_id) = runtime.open_state_for_file(&source).await?;
627    let workbook = state.open_workbook(&workbook_id).await?;
628    let resolved_ops = resolve_transform_ops_for_workbook(&workbook, &payload.ops)
629        .map_err(|error| invalid_ops_payload(error.to_string()))?;
630    let _ = state.close_workbook(&workbook_id);
631
632    let policy = formula_parse_policy.unwrap_or(FormulaParsePolicy::default_for_command_class(
633        CommandClass::BatchWrite,
634    ));
635
636    let (ops_to_apply, formula_parse_diagnostics) = if policy == FormulaParsePolicy::Off {
637        (resolved_ops, None)
638    } else {
639        let mut builder = FormulaParseDiagnosticsBuilder::new(policy);
640        let mut valid_ops = Vec::new();
641        for op in resolved_ops {
642            match &op {
643                TransformOp::FillRange {
644                    sheet_name,
645                    value,
646                    is_formula,
647                    ..
648                } if *is_formula => match validate_formula(value) {
649                    Ok(()) => valid_ops.push(op),
650                    Err(err_msg) => {
651                        if policy == FormulaParsePolicy::Fail {
652                            bail!(
653                                "{}FillRange formula failed: {}",
654                                FORMULA_PARSE_FAILED_PREFIX,
655                                err_msg
656                            );
657                        }
658                        builder.record_error(sheet_name, "FillRange", value, &err_msg);
659                    }
660                },
661                TransformOp::WriteMatrix {
662                    sheet_name,
663                    anchor,
664                    rows,
665                    overwrite_formulas,
666                } => {
667                    let mut has_errors = false;
668                    let mut valid_rows = Vec::new();
669                    let (anchor_col, anchor_row) = parse_cell_ref_for_cli(anchor)?;
670
671                    for (r_idx, row) in rows.iter().enumerate() {
672                        let mut valid_row = Vec::new();
673                        let r = anchor_row + r_idx as u32;
674                        for (c_idx, cell_opt) in row.iter().enumerate() {
675                            let c = anchor_col + c_idx as u32;
676                            if let Some(MatrixCell::Formula(f)) = cell_opt {
677                                match validate_formula(f) {
678                                    Ok(()) => valid_row.push(cell_opt.clone()),
679                                    Err(err_msg) => {
680                                        if policy == FormulaParsePolicy::Fail {
681                                            bail!(
682                                                "{}WriteMatrix formula failed at {}: {}",
683                                                FORMULA_PARSE_FAILED_PREFIX,
684                                                crate::utils::cell_address(c, r),
685                                                err_msg
686                                            );
687                                        }
688                                        builder.record_error(
689                                            sheet_name,
690                                            &crate::utils::cell_address(c, r),
691                                            f,
692                                            &err_msg,
693                                        );
694                                        has_errors = true;
695                                        valid_row.push(None);
696                                    }
697                                }
698                            } else {
699                                valid_row.push(cell_opt.clone());
700                            }
701                        }
702                        valid_rows.push(valid_row);
703                    }
704
705                    if has_errors && policy == FormulaParsePolicy::Warn {
706                        valid_ops.push(TransformOp::WriteMatrix {
707                            sheet_name: sheet_name.clone(),
708                            anchor: anchor.clone(),
709                            rows: valid_rows,
710                            overwrite_formulas: *overwrite_formulas,
711                        });
712                    } else {
713                        valid_ops.push(op);
714                    }
715                }
716                _ => valid_ops.push(op),
717            }
718        }
719        let diagnostics = if builder.has_errors() {
720            Some(builder.build())
721        } else {
722            None
723        };
724        (valid_ops, diagnostics)
725    };
726
727    let op_count = ops_to_apply.len();
728    let operation_counts = summarize_transform_operation_counts(&ops_to_apply);
729    let write_path_provenance =
730        formula_write_provenance("transform_batch", transform_formula_targets(&ops_to_apply));
731
732    match mode {
733        BatchMutationMode::DryRun => {
734            let (apply_result, _temp_path) =
735                apply_to_temp_copy(&source, source.parent(), ".transform-batch-", |path| {
736                    apply_transform_ops_to_file(path, &ops_to_apply).map_err(classify_apply_error)
737                })?;
738
739            let result_counts = apply_result.summary.counts;
740            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
741            let would_change = transform_summary_indicates_change(&result_counts);
742
743            dry_run_response(
744                op_count,
745                operation_counts,
746                result_counts,
747                warnings,
748                would_change,
749                formula_parse_diagnostics,
750                write_path_provenance.clone(),
751            )
752        }
753        BatchMutationMode::InPlace => {
754            let apply_result = apply_in_place_with_temp(&source, ".transform-batch-", |path| {
755                apply_transform_ops_to_file(path, &ops_to_apply).map_err(classify_apply_error)
756            })?;
757
758            let result_counts = apply_result.summary.counts;
759            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
760            let changed = transform_summary_indicates_change(&result_counts);
761
762            apply_response(
763                op_count,
764                apply_result.ops_applied,
765                warnings,
766                changed,
767                source.display().to_string(),
768                source.display().to_string(),
769                formula_parse_diagnostics,
770                write_path_provenance.clone(),
771            )
772        }
773        BatchMutationMode::Output { target, force } => {
774            let target = runtime.normalize_destination_path(&target)?;
775            ensure_output_path_is_distinct(&source, &target)?;
776
777            let apply_result =
778                apply_to_output_with_temp(&source, &target, force, ".transform-batch-", |path| {
779                    apply_transform_ops_to_file(path, &ops_to_apply).map_err(classify_apply_error)
780                })?;
781
782            let result_counts = apply_result.summary.counts;
783            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
784            let changed = transform_summary_indicates_change(&result_counts);
785
786            apply_response(
787                op_count,
788                apply_result.ops_applied,
789                warnings,
790                changed,
791                target.display().to_string(),
792                source.display().to_string(),
793                formula_parse_diagnostics,
794                write_path_provenance.clone(),
795            )
796        }
797    }
798}
799
800#[allow(clippy::too_many_arguments)]
801pub async fn replace_in_formulas(
802    file: PathBuf,
803    sheet: String,
804    find: String,
805    replace: String,
806    range: Option<String>,
807    regex: bool,
808    case_sensitive: bool,
809    dry_run: bool,
810    in_place: bool,
811    output: Option<PathBuf>,
812    force: bool,
813    formula_parse_policy: Option<FormulaParsePolicy>,
814) -> Result<Value> {
815    use crate::tools::fork::{ReplaceInFormulasOp, apply_replace_in_formulas_to_file};
816
817    let runtime = StatelessRuntime;
818    let source = runtime.normalize_existing_file(&file)?;
819    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
820
821    let op = ReplaceInFormulasOp {
822        sheet_name: sheet.clone(),
823        find,
824        replace,
825        range,
826        regex,
827        case_sensitive,
828    };
829
830    let policy = formula_parse_policy.unwrap_or(FormulaParsePolicy::default_for_command_class(
831        CommandClass::BatchWrite,
832    ));
833
834    match mode {
835        BatchMutationMode::DryRun => {
836            let (result, _temp_path) =
837                apply_to_temp_copy(&source, source.parent(), ".replace-in-formulas-", |path| {
838                    apply_replace_in_formulas_to_file(path, &op, policy)
839                        .map_err(classify_apply_error)
840                })?;
841
842            let warnings = warning_strings_to_cli_warnings(result.warnings.clone());
843            let would_change = result.formulas_changed > 0;
844
845            Ok(serde_json::to_value(ReplaceInFormulasDryRunResponse {
846                formulas_checked: result.formulas_checked,
847                formulas_changed: result.formulas_changed,
848                would_change,
849                recalc_needed: would_change,
850                samples: result
851                    .samples
852                    .into_iter()
853                    .map(|s| ReplaceInFormulasSampleRow {
854                        address: s.address,
855                        before: s.before,
856                        after: s.after,
857                    })
858                    .collect(),
859                warnings,
860                formula_parse_diagnostics: result.formula_parse_diagnostics,
861            })?)
862        }
863        BatchMutationMode::InPlace => {
864            let result = apply_in_place_with_temp(&source, ".replace-in-formulas-", |path| {
865                apply_replace_in_formulas_to_file(path, &op, policy).map_err(classify_apply_error)
866            })?;
867
868            let warnings = warning_strings_to_cli_warnings(result.warnings.clone());
869            let changed = result.formulas_changed > 0;
870
871            Ok(serde_json::to_value(ReplaceInFormulasApplyResponse {
872                formulas_checked: result.formulas_checked,
873                formulas_changed: result.formulas_changed,
874                changed,
875                recalc_needed: changed,
876                source_path: source.display().to_string(),
877                target_path: source.display().to_string(),
878                samples: result
879                    .samples
880                    .into_iter()
881                    .map(|s| ReplaceInFormulasSampleRow {
882                        address: s.address,
883                        before: s.before,
884                        after: s.after,
885                    })
886                    .collect(),
887                warnings,
888                formula_parse_diagnostics: result.formula_parse_diagnostics,
889            })?)
890        }
891        BatchMutationMode::Output { target, force } => {
892            let target = runtime.normalize_destination_path(&target)?;
893            ensure_output_path_is_distinct(&source, &target)?;
894
895            let result = apply_to_output_with_temp(
896                &source,
897                &target,
898                force,
899                ".replace-in-formulas-",
900                |path| {
901                    apply_replace_in_formulas_to_file(path, &op, policy)
902                        .map_err(classify_apply_error)
903                },
904            )?;
905
906            let warnings = warning_strings_to_cli_warnings(result.warnings.clone());
907            let changed = result.formulas_changed > 0;
908
909            Ok(serde_json::to_value(ReplaceInFormulasApplyResponse {
910                formulas_checked: result.formulas_checked,
911                formulas_changed: result.formulas_changed,
912                changed,
913                recalc_needed: changed,
914                source_path: source.display().to_string(),
915                target_path: target.display().to_string(),
916                samples: result
917                    .samples
918                    .into_iter()
919                    .map(|s| ReplaceInFormulasSampleRow {
920                        address: s.address,
921                        before: s.before,
922                        after: s.after,
923                    })
924                    .collect(),
925                warnings,
926                formula_parse_diagnostics: result.formula_parse_diagnostics,
927            })?)
928        }
929    }
930}
931
932#[derive(Debug, Serialize)]
933struct ReplaceInFormulasSampleRow {
934    address: String,
935    before: String,
936    after: String,
937}
938
939#[derive(Debug, Serialize)]
940struct ReplaceInFormulasDryRunResponse {
941    formulas_checked: u64,
942    formulas_changed: u64,
943    would_change: bool,
944    recalc_needed: bool,
945    samples: Vec<ReplaceInFormulasSampleRow>,
946    warnings: Vec<Warning>,
947    #[serde(skip_serializing_if = "Option::is_none")]
948    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
949}
950
951#[derive(Debug, Serialize)]
952struct ReplaceInFormulasApplyResponse {
953    formulas_checked: u64,
954    formulas_changed: u64,
955    changed: bool,
956    recalc_needed: bool,
957    source_path: String,
958    target_path: String,
959    samples: Vec<ReplaceInFormulasSampleRow>,
960    warnings: Vec<Warning>,
961    #[serde(skip_serializing_if = "Option::is_none")]
962    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
963}
964
965#[allow(clippy::too_many_arguments)]
966pub async fn range_import(
967    file: PathBuf,
968    sheet: String,
969    anchor: String,
970    from_grid: Option<String>,
971    from_csv: Option<String>,
972    header: bool,
973    clear_target: bool,
974    dry_run: bool,
975    in_place: bool,
976    output: Option<PathBuf>,
977    force: bool,
978) -> Result<Value> {
979    let runtime = StatelessRuntime;
980    let source = runtime.normalize_existing_file(&file)?;
981    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
982
983    let grid: GridPayload = match (from_grid, from_csv) {
984        (Some(grid_path), None) => {
985            let grid_raw = fs::read_to_string(&grid_path).map_err(|e| {
986                invalid_argument(format!("unable to read --from-grid '{}': {}", grid_path, e))
987            })?;
988            serde_json::from_str(&grid_raw).map_err(|e| {
989                invalid_argument(format!("invalid grid payload in '{}': {}", grid_path, e))
990            })?
991        }
992        (None, Some(csv_path)) => grid_payload_from_csv_file(&sheet, &anchor, &csv_path, header)?,
993        (Some(_), Some(_)) => {
994            return Err(invalid_argument(
995                "--from-grid and --from-csv are mutually exclusive",
996            ));
997        }
998        (None, None) => {
999            return Err(invalid_argument(
1000                "range-import requires exactly one of --from-grid or --from-csv",
1001            ));
1002        }
1003    };
1004
1005    let op_count = 1usize;
1006    let mut operation_counts = BTreeMap::new();
1007    operation_counts.insert("grid_import".to_string(), 1);
1008
1009    let formula_targets = if grid
1010        .rows
1011        .iter()
1012        .flat_map(|row| row.cells.iter())
1013        .any(|cell| cell.f.is_some())
1014    {
1015        vec![format!("{}!{}", sheet, anchor)]
1016    } else {
1017        Vec::new()
1018    };
1019    let write_path_provenance = formula_write_provenance("range_import", formula_targets);
1020
1021    match mode {
1022        BatchMutationMode::DryRun => {
1023            let (apply_result, _temp_path) =
1024                apply_to_temp_copy(&source, source.parent(), ".range-import-", |path| {
1025                    apply_grid_import_to_path(path, &sheet, &anchor, &grid, clear_target)
1026                        .map_err(classify_apply_error)
1027                })?;
1028
1029            let result_counts = apply_result.summary.counts;
1030            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1031            let would_change = grid_import_summary_indicates_change(&result_counts);
1032
1033            dry_run_response(
1034                op_count,
1035                operation_counts,
1036                result_counts,
1037                warnings,
1038                would_change,
1039                apply_result.formula_parse_diagnostics,
1040                write_path_provenance,
1041            )
1042        }
1043        BatchMutationMode::InPlace => {
1044            let apply_result = apply_in_place_with_temp(&source, ".range-import-", |path| {
1045                apply_grid_import_to_path(path, &sheet, &anchor, &grid, clear_target)
1046                    .map_err(classify_apply_error)
1047            })?;
1048
1049            let result_counts = apply_result.summary.counts;
1050            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1051            let changed = grid_import_summary_indicates_change(&result_counts);
1052
1053            apply_response(
1054                op_count,
1055                1,
1056                warnings,
1057                changed,
1058                source.display().to_string(),
1059                source.display().to_string(),
1060                apply_result.formula_parse_diagnostics,
1061                write_path_provenance,
1062            )
1063        }
1064        BatchMutationMode::Output { target, force } => {
1065            let target = runtime.normalize_destination_path(&target)?;
1066            ensure_output_path_is_distinct(&source, &target)?;
1067
1068            let apply_result =
1069                apply_to_output_with_temp(&source, &target, force, ".range-import-", |path| {
1070                    apply_grid_import_to_path(path, &sheet, &anchor, &grid, clear_target)
1071                        .map_err(classify_apply_error)
1072                })?;
1073
1074            let result_counts = apply_result.summary.counts;
1075            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1076            let changed = grid_import_summary_indicates_change(&result_counts);
1077
1078            apply_response(
1079                op_count,
1080                1,
1081                warnings,
1082                changed,
1083                target.display().to_string(),
1084                source.display().to_string(),
1085                apply_result.formula_parse_diagnostics,
1086                write_path_provenance,
1087            )
1088        }
1089    }
1090}
1091
1092pub async fn style_batch(
1093    file: PathBuf,
1094    ops: String,
1095    dry_run: bool,
1096    in_place: bool,
1097    output: Option<PathBuf>,
1098    force: bool,
1099) -> Result<Value> {
1100    let runtime = StatelessRuntime;
1101    let source = runtime.normalize_existing_file(&file)?;
1102    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1103
1104    let payload: OpsPayload<StyleOpInput> =
1105        parse_ops_payload(&ops, STYLE_PAYLOAD_SHAPE, STYLE_PAYLOAD_MINIMAL_EXAMPLE)?;
1106    let (normalized, base_warnings) = normalize_style_batch(StyleBatchParamsInput {
1107        fork_id: String::new(),
1108        ops: payload.ops,
1109        mode: None,
1110        label: None,
1111    })
1112    .map_err(|error| invalid_ops_payload(error.to_string()))?;
1113
1114    let (state, workbook_id) = runtime.open_state_for_file(&source).await?;
1115    let workbook = state.open_workbook(&workbook_id).await?;
1116    let resolved_ops = resolve_style_ops_for_workbook(&workbook, &normalized.ops)
1117        .map_err(|error| invalid_ops_payload(error.to_string()))?;
1118    let _ = state.close_workbook(&workbook_id);
1119
1120    let op_count = resolved_ops.len();
1121    let operation_counts = summarize_style_operation_counts(&resolved_ops);
1122
1123    match mode {
1124        BatchMutationMode::DryRun => {
1125            let (apply_result, _temp_path) =
1126                apply_to_temp_copy(&source, source.parent(), ".style-batch-", |path| {
1127                    apply_style_ops_to_file(path, &resolved_ops).map_err(classify_apply_error)
1128                })?;
1129
1130            let result_counts = apply_result.summary.counts;
1131            let warnings = merge_cli_warnings(
1132                base_warnings.clone(),
1133                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1134            );
1135            let would_change = style_summary_indicates_change(&result_counts);
1136
1137            dry_run_response(
1138                op_count,
1139                operation_counts,
1140                result_counts,
1141                warnings,
1142                would_change,
1143                None,
1144                None,
1145            )
1146        }
1147        BatchMutationMode::InPlace => {
1148            let apply_result = apply_in_place_with_temp(&source, ".style-batch-", |path| {
1149                apply_style_ops_to_file(path, &resolved_ops).map_err(classify_apply_error)
1150            })?;
1151
1152            let result_counts = apply_result.summary.counts;
1153            let warnings = merge_cli_warnings(
1154                base_warnings.clone(),
1155                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1156            );
1157            let changed = style_summary_indicates_change(&result_counts);
1158
1159            apply_response(
1160                op_count,
1161                apply_result.ops_applied,
1162                warnings,
1163                changed,
1164                source.display().to_string(),
1165                source.display().to_string(),
1166                None,
1167                None,
1168            )
1169        }
1170        BatchMutationMode::Output { target, force } => {
1171            let target = runtime.normalize_destination_path(&target)?;
1172            ensure_output_path_is_distinct(&source, &target)?;
1173
1174            let apply_result =
1175                apply_to_output_with_temp(&source, &target, force, ".style-batch-", |path| {
1176                    apply_style_ops_to_file(path, &resolved_ops).map_err(classify_apply_error)
1177                })?;
1178
1179            let result_counts = apply_result.summary.counts;
1180            let warnings = merge_cli_warnings(
1181                base_warnings,
1182                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1183            );
1184            let changed = style_summary_indicates_change(&result_counts);
1185
1186            apply_response(
1187                op_count,
1188                apply_result.ops_applied,
1189                warnings,
1190                changed,
1191                target.display().to_string(),
1192                source.display().to_string(),
1193                None,
1194                None,
1195            )
1196        }
1197    }
1198}
1199
1200pub async fn apply_formula_pattern(
1201    file: PathBuf,
1202    ops: String,
1203    dry_run: bool,
1204    in_place: bool,
1205    output: Option<PathBuf>,
1206    force: bool,
1207) -> Result<Value> {
1208    let runtime = StatelessRuntime;
1209    let source = runtime.normalize_existing_file(&file)?;
1210    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1211
1212    let payload: OpsPayload<ApplyFormulaPatternOpInput> = parse_ops_payload(
1213        &ops,
1214        APPLY_FORMULA_PATTERN_PAYLOAD_SHAPE,
1215        APPLY_FORMULA_PATTERN_PAYLOAD_MINIMAL_EXAMPLE,
1216    )?;
1217
1218    let op_count = payload.ops.len();
1219    let operation_counts = summarize_formula_pattern_operation_counts(&payload.ops);
1220    let write_path_provenance = formula_write_provenance(
1221        "apply_formula_pattern",
1222        apply_formula_pattern_targets(&payload.ops),
1223    );
1224
1225    match mode {
1226        BatchMutationMode::DryRun => {
1227            let (apply_result, _temp_path) = apply_to_temp_copy(
1228                &source,
1229                source.parent(),
1230                ".apply-formula-pattern-",
1231                |path| {
1232                    apply_formula_pattern_ops_to_file(path, &payload.ops)
1233                        .map_err(classify_apply_error)
1234                },
1235            )?;
1236
1237            let result_counts = apply_result.summary.counts;
1238            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1239            let would_change = formula_pattern_summary_indicates_change(&result_counts);
1240
1241            dry_run_response(
1242                op_count,
1243                operation_counts,
1244                result_counts,
1245                warnings,
1246                would_change,
1247                None,
1248                write_path_provenance.clone(),
1249            )
1250        }
1251        BatchMutationMode::InPlace => {
1252            let apply_result =
1253                apply_in_place_with_temp(&source, ".apply-formula-pattern-", |path| {
1254                    apply_formula_pattern_ops_to_file(path, &payload.ops)
1255                        .map_err(classify_apply_error)
1256                })?;
1257
1258            let result_counts = apply_result.summary.counts;
1259            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1260            let changed = formula_pattern_summary_indicates_change(&result_counts);
1261
1262            apply_response(
1263                op_count,
1264                apply_result.ops_applied,
1265                warnings,
1266                changed,
1267                source.display().to_string(),
1268                source.display().to_string(),
1269                None,
1270                write_path_provenance.clone(),
1271            )
1272        }
1273        BatchMutationMode::Output { target, force } => {
1274            let target = runtime.normalize_destination_path(&target)?;
1275            ensure_output_path_is_distinct(&source, &target)?;
1276
1277            let apply_result = apply_to_output_with_temp(
1278                &source,
1279                &target,
1280                force,
1281                ".apply-formula-pattern-",
1282                |path| {
1283                    apply_formula_pattern_ops_to_file(path, &payload.ops)
1284                        .map_err(classify_apply_error)
1285                },
1286            )?;
1287
1288            let result_counts = apply_result.summary.counts;
1289            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1290            let changed = formula_pattern_summary_indicates_change(&result_counts);
1291
1292            apply_response(
1293                op_count,
1294                apply_result.ops_applied,
1295                warnings,
1296                changed,
1297                target.display().to_string(),
1298                source.display().to_string(),
1299                None,
1300                write_path_provenance.clone(),
1301            )
1302        }
1303    }
1304}
1305
1306pub async fn check_ref_impact(
1307    file: PathBuf,
1308    ops_ref: String,
1309    show_formula_delta: bool,
1310) -> Result<Value> {
1311    let runtime = StatelessRuntime;
1312    let source = runtime.normalize_existing_file(&file)?;
1313
1314    // Load and parse the ops payload (same format as structure-batch).
1315    let payload: OpsPayload<StructureOpInput> = parse_ops_payload(
1316        &ops_ref,
1317        STRUCTURE_PAYLOAD_SHAPE,
1318        STRUCTURE_PAYLOAD_MINIMAL_EXAMPLE,
1319    )?;
1320    let (normalized, _warnings) = normalize_structure_batch(StructureBatchParamsInput {
1321        fork_id: String::new(),
1322        ops: payload.ops,
1323        mode: None,
1324        label: None,
1325        formula_parse_policy: None,
1326        impact_report: None,
1327        show_formula_delta: None,
1328    })
1329    .map_err(|error| invalid_ops_payload(error.to_string()))?;
1330
1331    // Call compute_structure_impact (read-only analysis, never mutates the file).
1332    let (impact_report, formula_delta) = crate::tools::structure_impact::compute_structure_impact(
1333        &source,
1334        &normalized.ops,
1335        show_formula_delta,
1336    )?;
1337
1338    // Build response JSON.
1339    let mut response = serde_json::to_value(&impact_report)?;
1340    if let Some(delta) = formula_delta {
1341        response["formula_delta_preview"] = serde_json::to_value(&delta)?;
1342    }
1343    response["source_path"] = Value::String(source.display().to_string());
1344
1345    Ok(response)
1346}
1347
1348#[allow(clippy::too_many_arguments)]
1349pub async fn structure_batch(
1350    file: PathBuf,
1351    ops: String,
1352    dry_run: bool,
1353    in_place: bool,
1354    output: Option<PathBuf>,
1355    force: bool,
1356    formula_parse_policy: Option<FormulaParsePolicy>,
1357    impact_report: bool,
1358    show_formula_delta: bool,
1359) -> Result<Value> {
1360    // --impact-report and --show-formula-delta require --dry-run.
1361    if (impact_report || show_formula_delta) && !dry_run {
1362        bail!(
1363            "invalid argument: --impact-report and --show-formula-delta require --dry-run. \
1364             Add --dry-run to preview structural impact without mutating the file."
1365        );
1366    }
1367
1368    let runtime = StatelessRuntime;
1369    let source = runtime.normalize_existing_file(&file)?;
1370    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1371
1372    let payload: OpsPayload<StructureOpInput> = parse_ops_payload(
1373        &ops,
1374        STRUCTURE_PAYLOAD_SHAPE,
1375        STRUCTURE_PAYLOAD_MINIMAL_EXAMPLE,
1376    )?;
1377    let (normalized, base_warnings) = normalize_structure_batch(StructureBatchParamsInput {
1378        fork_id: String::new(),
1379        ops: payload.ops,
1380        mode: None,
1381        label: None,
1382        formula_parse_policy,
1383        impact_report: None,
1384        show_formula_delta: None,
1385    })
1386    .map_err(|error| invalid_ops_payload(error.to_string()))?;
1387
1388    let policy =
1389        normalized
1390            .formula_parse_policy
1391            .unwrap_or(FormulaParsePolicy::default_for_command_class(
1392                CommandClass::BatchWrite,
1393            ));
1394
1395    let op_count = normalized.ops.len();
1396    let operation_counts = summarize_structure_operation_counts(&normalized.ops);
1397
1398    match mode {
1399        BatchMutationMode::DryRun => {
1400            let (apply_result, _temp_path) =
1401                apply_to_temp_copy(&source, source.parent(), ".structure-batch-", |path| {
1402                    apply_structure_ops_to_file(path, &normalized.ops, policy)
1403                        .map_err(classify_apply_error)
1404                })?;
1405
1406            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1407            let result_counts = apply_result.summary.counts;
1408            let warnings = merge_cli_warnings(
1409                base_warnings.clone(),
1410                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1411            );
1412            let would_change = structure_summary_indicates_change(&result_counts);
1413
1414            let mut response = dry_run_response(
1415                op_count,
1416                operation_counts,
1417                result_counts,
1418                warnings,
1419                would_change,
1420                formula_parse_diagnostics,
1421                None,
1422            )?;
1423
1424            // Attach optional impact report and formula delta preview.
1425            if impact_report || show_formula_delta {
1426                let (report, delta) = crate::tools::structure_impact::compute_structure_impact(
1427                    &source,
1428                    &normalized.ops,
1429                    show_formula_delta,
1430                )?;
1431                if impact_report {
1432                    response["impact_report"] = serde_json::to_value(&report)?;
1433                }
1434                if let Some(delta) = delta {
1435                    response["formula_delta_preview"] = serde_json::to_value(&delta)?;
1436                }
1437            }
1438
1439            Ok(response)
1440        }
1441        BatchMutationMode::InPlace => {
1442            let apply_result = apply_in_place_with_temp(&source, ".structure-batch-", |path| {
1443                apply_structure_ops_to_file(path, &normalized.ops, policy)
1444                    .map_err(classify_apply_error)
1445            })?;
1446
1447            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1448            let result_counts = apply_result.summary.counts;
1449            let warnings = merge_cli_warnings(
1450                base_warnings.clone(),
1451                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1452            );
1453            let changed = structure_summary_indicates_change(&result_counts);
1454
1455            apply_response(
1456                op_count,
1457                apply_result.ops_applied,
1458                warnings,
1459                changed,
1460                source.display().to_string(),
1461                source.display().to_string(),
1462                formula_parse_diagnostics,
1463                None,
1464            )
1465        }
1466        BatchMutationMode::Output { target, force } => {
1467            let target = runtime.normalize_destination_path(&target)?;
1468            ensure_output_path_is_distinct(&source, &target)?;
1469
1470            let apply_result =
1471                apply_to_output_with_temp(&source, &target, force, ".structure-batch-", |path| {
1472                    apply_structure_ops_to_file(path, &normalized.ops, policy)
1473                        .map_err(classify_apply_error)
1474                })?;
1475
1476            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1477            let result_counts = apply_result.summary.counts;
1478            let warnings = merge_cli_warnings(
1479                base_warnings,
1480                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1481            );
1482            let changed = structure_summary_indicates_change(&result_counts);
1483
1484            apply_response(
1485                op_count,
1486                apply_result.ops_applied,
1487                warnings,
1488                changed,
1489                target.display().to_string(),
1490                source.display().to_string(),
1491                formula_parse_diagnostics,
1492                None,
1493            )
1494        }
1495    }
1496}
1497
1498pub async fn column_size_batch(
1499    file: PathBuf,
1500    ops: String,
1501    dry_run: bool,
1502    in_place: bool,
1503    output: Option<PathBuf>,
1504    force: bool,
1505) -> Result<Value> {
1506    let runtime = StatelessRuntime;
1507    let source = runtime.normalize_existing_file(&file)?;
1508    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1509
1510    let payload: ColumnSizeOpsPayload = parse_column_size_ops_payload(&ops)?;
1511    let (normalized_ops, base_warnings) =
1512        normalize_column_size_payload(payload.sheet_name.clone(), payload.ops)
1513            .map_err(|error| invalid_ops_payload(error.to_string()))?;
1514
1515    let op_count = normalized_ops.len();
1516    let operation_counts = summarize_column_size_operation_counts(&normalized_ops);
1517
1518    match mode {
1519        BatchMutationMode::DryRun => {
1520            let sheet_name = payload.sheet_name.clone();
1521            let (apply_result, _temp_path) =
1522                apply_to_temp_copy(&source, source.parent(), ".column-size-batch-", |path| {
1523                    apply_column_size_ops_to_file(path, &sheet_name, &normalized_ops)
1524                        .map_err(classify_apply_error)
1525                })?;
1526
1527            let result_counts = apply_result.summary.counts;
1528            let warnings = merge_cli_warnings(
1529                base_warnings.clone(),
1530                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1531            );
1532            let would_change = column_size_summary_indicates_change(&result_counts);
1533
1534            dry_run_response(
1535                op_count,
1536                operation_counts,
1537                result_counts,
1538                warnings,
1539                would_change,
1540                None,
1541                None,
1542            )
1543        }
1544        BatchMutationMode::InPlace => {
1545            let sheet_name = payload.sheet_name.clone();
1546            let apply_result = apply_in_place_with_temp(&source, ".column-size-batch-", |path| {
1547                apply_column_size_ops_to_file(path, &sheet_name, &normalized_ops)
1548                    .map_err(classify_apply_error)
1549            })?;
1550
1551            let result_counts = apply_result.summary.counts;
1552            let warnings = merge_cli_warnings(
1553                base_warnings.clone(),
1554                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1555            );
1556            let changed = column_size_summary_indicates_change(&result_counts);
1557
1558            apply_response(
1559                op_count,
1560                apply_result.ops_applied,
1561                warnings,
1562                changed,
1563                source.display().to_string(),
1564                source.display().to_string(),
1565                None,
1566                None,
1567            )
1568        }
1569        BatchMutationMode::Output { target, force } => {
1570            let target = runtime.normalize_destination_path(&target)?;
1571            ensure_output_path_is_distinct(&source, &target)?;
1572
1573            let sheet_name = payload.sheet_name;
1574            let apply_result = apply_to_output_with_temp(
1575                &source,
1576                &target,
1577                force,
1578                ".column-size-batch-",
1579                |path| {
1580                    apply_column_size_ops_to_file(path, &sheet_name, &normalized_ops)
1581                        .map_err(classify_apply_error)
1582                },
1583            )?;
1584
1585            let result_counts = apply_result.summary.counts;
1586            let warnings = merge_cli_warnings(
1587                base_warnings,
1588                warning_strings_to_cli_warnings(apply_result.summary.warnings),
1589            );
1590            let changed = column_size_summary_indicates_change(&result_counts);
1591
1592            apply_response(
1593                op_count,
1594                apply_result.ops_applied,
1595                warnings,
1596                changed,
1597                target.display().to_string(),
1598                source.display().to_string(),
1599                None,
1600                None,
1601            )
1602        }
1603    }
1604}
1605
1606pub async fn sheet_layout_batch(
1607    file: PathBuf,
1608    ops: String,
1609    dry_run: bool,
1610    in_place: bool,
1611    output: Option<PathBuf>,
1612    force: bool,
1613) -> Result<Value> {
1614    let runtime = StatelessRuntime;
1615    let source = runtime.normalize_existing_file(&file)?;
1616    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1617
1618    let payload: OpsPayload<SheetLayoutOp> = parse_ops_payload(
1619        &ops,
1620        SHEET_LAYOUT_PAYLOAD_SHAPE,
1621        SHEET_LAYOUT_PAYLOAD_MINIMAL_EXAMPLE,
1622    )?;
1623
1624    let op_count = payload.ops.len();
1625    let operation_counts = summarize_sheet_layout_operation_counts(&payload.ops);
1626
1627    match mode {
1628        BatchMutationMode::DryRun => {
1629            let (apply_result, _temp_path) =
1630                apply_to_temp_copy(&source, source.parent(), ".sheet-layout-batch-", |path| {
1631                    apply_sheet_layout_ops_to_file(path, &payload.ops).map_err(classify_apply_error)
1632                })?;
1633
1634            let result_counts = apply_result.summary.counts;
1635            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1636            let would_change = sheet_layout_summary_indicates_change(&result_counts);
1637
1638            dry_run_response(
1639                op_count,
1640                operation_counts,
1641                result_counts,
1642                warnings,
1643                would_change,
1644                None,
1645                None,
1646            )
1647        }
1648        BatchMutationMode::InPlace => {
1649            let apply_result = apply_in_place_with_temp(&source, ".sheet-layout-batch-", |path| {
1650                apply_sheet_layout_ops_to_file(path, &payload.ops).map_err(classify_apply_error)
1651            })?;
1652
1653            let result_counts = apply_result.summary.counts;
1654            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1655            let changed = sheet_layout_summary_indicates_change(&result_counts);
1656
1657            apply_response(
1658                op_count,
1659                apply_result.ops_applied,
1660                warnings,
1661                changed,
1662                source.display().to_string(),
1663                source.display().to_string(),
1664                None,
1665                None,
1666            )
1667        }
1668        BatchMutationMode::Output { target, force } => {
1669            let target = runtime.normalize_destination_path(&target)?;
1670            ensure_output_path_is_distinct(&source, &target)?;
1671
1672            let apply_result = apply_to_output_with_temp(
1673                &source,
1674                &target,
1675                force,
1676                ".sheet-layout-batch-",
1677                |path| {
1678                    apply_sheet_layout_ops_to_file(path, &payload.ops).map_err(classify_apply_error)
1679                },
1680            )?;
1681
1682            let result_counts = apply_result.summary.counts;
1683            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1684            let changed = sheet_layout_summary_indicates_change(&result_counts);
1685
1686            apply_response(
1687                op_count,
1688                apply_result.ops_applied,
1689                warnings,
1690                changed,
1691                target.display().to_string(),
1692                source.display().to_string(),
1693                None,
1694                None,
1695            )
1696        }
1697    }
1698}
1699
1700pub async fn rules_batch(
1701    file: PathBuf,
1702    ops: String,
1703    dry_run: bool,
1704    in_place: bool,
1705    output: Option<PathBuf>,
1706    force: bool,
1707    formula_parse_policy: Option<FormulaParsePolicy>,
1708) -> Result<Value> {
1709    let runtime = StatelessRuntime;
1710    let source = runtime.normalize_existing_file(&file)?;
1711    let mode = validate_batch_mode(dry_run, in_place, output, force)?;
1712
1713    let payload: OpsPayload<RulesOp> =
1714        parse_ops_payload(&ops, RULES_PAYLOAD_SHAPE, RULES_PAYLOAD_MINIMAL_EXAMPLE)?;
1715
1716    let policy = formula_parse_policy.unwrap_or(FormulaParsePolicy::default_for_command_class(
1717        CommandClass::BatchWrite,
1718    ));
1719
1720    let op_count = payload.ops.len();
1721    let operation_counts = summarize_rules_operation_counts(&payload.ops);
1722
1723    match mode {
1724        BatchMutationMode::DryRun => {
1725            let (apply_result, _temp_path) =
1726                apply_to_temp_copy(&source, source.parent(), ".rules-batch-", |path| {
1727                    apply_rules_ops_to_file(path, &payload.ops, policy)
1728                        .map_err(classify_apply_error)
1729                })?;
1730
1731            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1732            let result_counts = apply_result.summary.counts;
1733            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1734            let would_change = rules_summary_indicates_change(&result_counts);
1735
1736            dry_run_response(
1737                op_count,
1738                operation_counts,
1739                result_counts,
1740                warnings,
1741                would_change,
1742                formula_parse_diagnostics,
1743                None,
1744            )
1745        }
1746        BatchMutationMode::InPlace => {
1747            let apply_result = apply_in_place_with_temp(&source, ".rules-batch-", |path| {
1748                apply_rules_ops_to_file(path, &payload.ops, policy).map_err(classify_apply_error)
1749            })?;
1750
1751            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1752            let result_counts = apply_result.summary.counts;
1753            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1754            let changed = rules_summary_indicates_change(&result_counts);
1755
1756            apply_response(
1757                op_count,
1758                apply_result.ops_applied,
1759                warnings,
1760                changed,
1761                source.display().to_string(),
1762                source.display().to_string(),
1763                formula_parse_diagnostics,
1764                None,
1765            )
1766        }
1767        BatchMutationMode::Output { target, force } => {
1768            let target = runtime.normalize_destination_path(&target)?;
1769            ensure_output_path_is_distinct(&source, &target)?;
1770
1771            let apply_result =
1772                apply_to_output_with_temp(&source, &target, force, ".rules-batch-", |path| {
1773                    apply_rules_ops_to_file(path, &payload.ops, policy)
1774                        .map_err(classify_apply_error)
1775                })?;
1776
1777            let formula_parse_diagnostics = apply_result.formula_parse_diagnostics;
1778            let result_counts = apply_result.summary.counts;
1779            let warnings = warning_strings_to_cli_warnings(apply_result.summary.warnings);
1780            let changed = rules_summary_indicates_change(&result_counts);
1781
1782            apply_response(
1783                op_count,
1784                apply_result.ops_applied,
1785                warnings,
1786                changed,
1787                target.display().to_string(),
1788                source.display().to_string(),
1789                formula_parse_diagnostics,
1790                None,
1791            )
1792        }
1793    }
1794}
1795
1796fn validate_edit_mode(
1797    dry_run: bool,
1798    in_place: bool,
1799    output: Option<PathBuf>,
1800    force: bool,
1801) -> Result<EditMutationMode> {
1802    if force && output.is_none() {
1803        return Err(invalid_argument("--force requires --output <PATH>"));
1804    }
1805
1806    if dry_run {
1807        if in_place {
1808            return Err(invalid_argument(
1809                "--dry-run cannot be combined with --in-place",
1810            ));
1811        }
1812        if output.is_some() {
1813            return Err(invalid_argument(
1814                "--dry-run cannot be combined with --output <PATH>",
1815            ));
1816        }
1817        return Ok(EditMutationMode::DryRun);
1818    }
1819
1820    if in_place && output.is_some() {
1821        return Err(invalid_argument(
1822            "--in-place cannot be combined with --output <PATH>",
1823        ));
1824    }
1825
1826    if let Some(target) = output {
1827        return Ok(EditMutationMode::Output { target, force });
1828    }
1829
1830    Ok(EditMutationMode::InPlace)
1831}
1832
1833fn validate_batch_mode(
1834    dry_run: bool,
1835    in_place: bool,
1836    output: Option<PathBuf>,
1837    force: bool,
1838) -> Result<BatchMutationMode> {
1839    if force && output.is_none() {
1840        return Err(invalid_argument("--force requires --output <PATH>"));
1841    }
1842
1843    if dry_run {
1844        if in_place {
1845            return Err(invalid_argument(
1846                "--dry-run cannot be combined with --in-place",
1847            ));
1848        }
1849        if output.is_some() {
1850            return Err(invalid_argument(
1851                "--dry-run cannot be combined with --output <PATH>",
1852            ));
1853        }
1854        return Ok(BatchMutationMode::DryRun);
1855    }
1856
1857    if in_place && output.is_some() {
1858        return Err(invalid_argument(
1859            "--in-place cannot be combined with --output <PATH>",
1860        ));
1861    }
1862
1863    if in_place {
1864        return Ok(BatchMutationMode::InPlace);
1865    }
1866
1867    if let Some(target) = output {
1868        return Ok(BatchMutationMode::Output { target, force });
1869    }
1870
1871    Err(invalid_argument(
1872        "choose exactly one mutation mode: --dry-run, --in-place, or --output <PATH>",
1873    ))
1874}
1875
1876fn parse_ops_payload_object(raw: &str, guidance: &str) -> Result<serde_json::Map<String, Value>> {
1877    let path = raw
1878        .strip_prefix('@')
1879        .ok_or_else(|| invalid_ops_payload("--ops must be provided as @<path>"))?;
1880    if path.is_empty() {
1881        return Err(invalid_ops_payload(
1882            "--ops file reference cannot be empty; expected @<path>",
1883        ));
1884    }
1885
1886    let raw_payload = fs::read_to_string(path).map_err(|error| {
1887        invalid_ops_payload(format!("unable to read ops payload '{}': {}", path, error))
1888    })?;
1889
1890    let json_value: serde_json::Value = serde_json::from_str(&raw_payload).map_err(|error| {
1891        invalid_ops_payload(format!(
1892            "ops payload is not valid JSON: {error}; {guidance}"
1893        ))
1894    })?;
1895
1896    let object = json_value.as_object().ok_or_else(|| {
1897        invalid_ops_payload(format!("ops payload must be a JSON object; {guidance}"))
1898    })?;
1899
1900    Ok(object.clone())
1901}
1902
1903fn parse_column_size_ops_payload(raw: &str) -> Result<ColumnSizeOpsPayload> {
1904    let guidance = format!(
1905        "expected top-level shape: {} OR {}; minimal valid example: {} OR {}",
1906        COLUMN_SIZE_PAYLOAD_SHAPE,
1907        COLUMN_SIZE_PAYLOAD_ALTERNATE_SHAPE,
1908        COLUMN_SIZE_PAYLOAD_MINIMAL_EXAMPLE,
1909        COLUMN_SIZE_PAYLOAD_ALTERNATE_EXAMPLE,
1910    );
1911
1912    let object = parse_ops_payload_object(raw, &guidance)?;
1913
1914    if object.contains_key("sheet_name") {
1915        let top_level_sheet = object
1916            .get("sheet_name")
1917            .and_then(Value::as_str)
1918            .map(str::to_string);
1919
1920        if let (Some(top_level_sheet), Some(ops_array)) =
1921            (top_level_sheet, object.get("ops").and_then(Value::as_array))
1922        {
1923            for (index, raw_entry) in ops_array.iter().enumerate() {
1924                if let Some(per_op_sheet) = raw_entry
1925                    .as_object()
1926                    .and_then(|entry| entry.get("sheet_name"))
1927                    .and_then(Value::as_str)
1928                    && per_op_sheet != top_level_sheet
1929                {
1930                    return Err(invalid_ops_payload(format!(
1931                        "ops payload has mixed sheet_name values between top-level and ops[{index}] ('{}' vs '{}'); {guidance}",
1932                        top_level_sheet, per_op_sheet
1933                    )));
1934                }
1935            }
1936        }
1937
1938        return serde_json::from_value(Value::Object(object)).map_err(|error| {
1939            invalid_ops_payload(format!(
1940                "ops payload does not match required schema: {error}; {guidance}"
1941            ))
1942        });
1943    }
1944
1945    let ops_value = object.get("ops").ok_or_else(|| {
1946        invalid_ops_payload(format!("ops payload must include 'ops'; {guidance}"))
1947    })?;
1948    let ops_array = ops_value.as_array().ok_or_else(|| {
1949        invalid_ops_payload(format!(
1950            "ops payload field 'ops' must be an array; {guidance}"
1951        ))
1952    })?;
1953
1954    let mut normalized_ops = Vec::with_capacity(ops_array.len());
1955    let mut inferred_sheet_name: Option<String> = None;
1956
1957    for (index, raw_entry) in ops_array.iter().enumerate() {
1958        let op_with_sheet: ColumnSizeOpWithSheetInput = serde_json::from_value(raw_entry.clone())
1959            .map_err(|error| {
1960            invalid_ops_payload(format!(
1961                "ops payload does not match required schema at ops[{index}]: {error}; {guidance}"
1962            ))
1963        })?;
1964
1965        let sheet_name = op_with_sheet.sheet_name().to_string();
1966        match &inferred_sheet_name {
1967            Some(existing) if existing != &sheet_name => {
1968                return Err(invalid_ops_payload(format!(
1969                    "ops payload has mixed sheet_name values in per-op shape; found '{}' and '{}'; {guidance}",
1970                    existing, sheet_name
1971                )));
1972            }
1973            None => inferred_sheet_name = Some(sheet_name),
1974            _ => {}
1975        }
1976
1977        normalized_ops.push(op_with_sheet.into_op_input());
1978    }
1979
1980    let sheet_name = inferred_sheet_name.ok_or_else(|| {
1981        invalid_ops_payload(format!(
1982            "ops payload must provide top-level sheet_name or per-op sheet_name values; {guidance}"
1983        ))
1984    })?;
1985
1986    Ok(ColumnSizeOpsPayload {
1987        sheet_name,
1988        ops: normalized_ops,
1989    })
1990}
1991
1992#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
1993#[serde(rename_all = "snake_case")]
1994enum AppendRegionTargetKind {
1995    DetectedRegion,
1996    Table,
1997}
1998
1999#[derive(Debug, Clone, Serialize)]
2000struct AppendFooterCandidate {
2001    row: u32,
2002    matched: bool,
2003    #[serde(skip_serializing_if = "Option::is_none")]
2004    reason: Option<String>,
2005}
2006
2007#[derive(Debug, Serialize)]
2008struct AppendRegionResponse {
2009    mode: String,
2010    file: String,
2011    #[serde(skip_serializing_if = "Option::is_none")]
2012    source_path: Option<String>,
2013    #[serde(skip_serializing_if = "Option::is_none")]
2014    target_path: Option<String>,
2015    sheet_name: String,
2016    target_kind: AppendRegionTargetKind,
2017    #[serde(skip_serializing_if = "Option::is_none")]
2018    region_id: Option<u32>,
2019    #[serde(skip_serializing_if = "Option::is_none")]
2020    table_name: Option<String>,
2021    region_bounds: String,
2022    #[serde(skip_serializing_if = "Option::is_none")]
2023    header_row: Option<u32>,
2024    footer_policy: String,
2025    insert_at_row: u32,
2026    insert_reason: String,
2027    #[serde(skip_serializing_if = "Option::is_none")]
2028    footer_row: Option<u32>,
2029    #[serde(skip_serializing_if = "Option::is_none")]
2030    footer_detection: Option<String>,
2031    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2032    footer_candidates: Vec<AppendFooterCandidate>,
2033    #[serde(skip_serializing_if = "Vec::is_empty", default)]
2034    footer_formula_targets: Vec<String>,
2035    rows_appended: u32,
2036    columns_written: u32,
2037    target_anchor: String,
2038    target_range: String,
2039    expand_adjacent_sums: bool,
2040    confidence: String,
2041    confidence_reason: String,
2042    warnings: Vec<String>,
2043    #[serde(skip_serializing_if = "Option::is_none")]
2044    would_change: Option<bool>,
2045    #[serde(skip_serializing_if = "Option::is_none")]
2046    changed: Option<bool>,
2047}
2048
2049#[derive(Debug, Clone)]
2050struct AppendRegionPlan {
2051    sheet_name: String,
2052    target_kind: AppendRegionTargetKind,
2053    region_id: Option<u32>,
2054    table_name: Option<String>,
2055    region_bounds: String,
2056    header_row: Option<u32>,
2057    footer_policy: String,
2058    insert_at_row: u32,
2059    insert_reason: String,
2060    footer_row: Option<u32>,
2061    footer_detection: Option<String>,
2062    footer_candidates: Vec<AppendFooterCandidate>,
2063    footer_formula_targets: Vec<String>,
2064    rows_appended: u32,
2065    columns_written: u32,
2066    target_anchor: String,
2067    target_range: String,
2068    confidence: String,
2069    confidence_reason: String,
2070    warnings: Vec<String>,
2071    rows: Vec<Vec<Option<MatrixCell>>>,
2072}
2073
2074struct AppendFooterScan {
2075    footer_row: Option<u32>,
2076    footer_detection: Option<String>,
2077    footer_candidates: Vec<AppendFooterCandidate>,
2078    footer_formula_targets: Vec<String>,
2079}
2080
2081struct AppendRegionTarget {
2082    sheet_name: String,
2083    target_kind: AppendRegionTargetKind,
2084    region_id: Option<u32>,
2085    table_name: Option<String>,
2086    bounds: AppendBounds,
2087    region_bounds: String,
2088    header_row: Option<u32>,
2089    headers_truncated: bool,
2090}
2091
2092#[allow(clippy::too_many_arguments)]
2093pub async fn append_region(
2094    file: PathBuf,
2095    sheet_name: String,
2096    region_id: Option<u32>,
2097    table_name: Option<String>,
2098    rows_ref: Option<String>,
2099    from_csv: Option<String>,
2100    header: bool,
2101    footer_policy: AppendRegionFooterPolicyArg,
2102    dry_run: bool,
2103    in_place: bool,
2104    output: Option<PathBuf>,
2105    force: bool,
2106) -> Result<Value> {
2107    let selected_modes = dry_run as u8 + in_place as u8 + output.is_some() as u8;
2108    if selected_modes != 1 {
2109        return Err(invalid_argument(
2110            "choose exactly one of --dry-run, --in-place, or --output <PATH>",
2111        ));
2112    }
2113    if force && output.is_none() {
2114        return Err(invalid_argument("--force requires --output <PATH>"));
2115    }
2116
2117    let runtime = StatelessRuntime;
2118    let source = runtime.normalize_existing_file(&file)?;
2119    let rows = match (rows_ref, from_csv) {
2120        (Some(rows_ref), None) => parse_append_region_rows_payload(&rows_ref)?,
2121        (None, Some(csv_path)) => parse_append_region_rows_from_csv(&csv_path, header)?,
2122        (Some(_), Some(_)) => {
2123            return Err(invalid_argument(
2124                "--rows and --from-csv are mutually exclusive",
2125            ));
2126        }
2127        (None, None) => {
2128            return Err(invalid_argument(
2129                "append-region requires exactly one of --rows or --from-csv",
2130            ));
2131        }
2132    };
2133    let plan = build_append_region_plan(
2134        &source,
2135        &sheet_name,
2136        region_id,
2137        table_name.as_deref(),
2138        footer_policy,
2139        rows,
2140    )?;
2141
2142    if dry_run {
2143        return Ok(serde_json::to_value(build_append_region_response(
2144            &plan,
2145            "dry_run",
2146            source.display().to_string(),
2147            None,
2148            Some(true),
2149            None,
2150            None,
2151        ))?);
2152    }
2153
2154    if in_place {
2155        let source_path = source.display().to_string();
2156        let ((), temp_path) =
2157            apply_to_temp_copy(&source, source.parent(), ".append-region-", |work_path| {
2158                apply_append_region_plan_to_file(work_path, &plan)
2159            })?;
2160        atomic_replace_target(temp_path, &source, true)?;
2161        return Ok(serde_json::to_value(build_append_region_response(
2162            &plan,
2163            "in_place",
2164            source_path.clone(),
2165            Some(source_path.clone()),
2166            None,
2167            Some(source_path),
2168            Some(true),
2169        ))?);
2170    }
2171
2172    let target = runtime.normalize_destination_path(
2173        output
2174            .as_ref()
2175            .expect("output required unless dry-run or in-place"),
2176    )?;
2177    ensure_output_path_is_distinct(&source, &target)?;
2178    if path_entry_exists(&target)? && !force {
2179        return Err(output_exists(format!(
2180            "output path '{}' already exists",
2181            target.display()
2182        )));
2183    }
2184
2185    let source_path = source.display().to_string();
2186    let target_path = target.display().to_string();
2187    let ((), temp_path) =
2188        apply_to_temp_copy(&source, target.parent(), ".append-region-", |work_path| {
2189            apply_append_region_plan_to_file(work_path, &plan)
2190        })?;
2191    atomic_replace_target(temp_path, &target, force)?;
2192
2193    Ok(serde_json::to_value(build_append_region_response(
2194        &plan,
2195        "output",
2196        target_path.clone(),
2197        Some(source_path),
2198        None,
2199        Some(target_path),
2200        Some(true),
2201    ))?)
2202}
2203
2204fn build_append_region_response(
2205    plan: &AppendRegionPlan,
2206    mode: &str,
2207    file: String,
2208    source_path: Option<String>,
2209    would_change: Option<bool>,
2210    target_path: Option<String>,
2211    changed: Option<bool>,
2212) -> AppendRegionResponse {
2213    AppendRegionResponse {
2214        mode: mode.to_string(),
2215        file,
2216        source_path,
2217        target_path,
2218        sheet_name: plan.sheet_name.clone(),
2219        target_kind: plan.target_kind,
2220        region_id: plan.region_id,
2221        table_name: plan.table_name.clone(),
2222        region_bounds: plan.region_bounds.clone(),
2223        header_row: plan.header_row,
2224        footer_policy: plan.footer_policy.clone(),
2225        insert_at_row: plan.insert_at_row,
2226        insert_reason: plan.insert_reason.clone(),
2227        footer_row: plan.footer_row,
2228        footer_detection: plan.footer_detection.clone(),
2229        footer_candidates: plan.footer_candidates.clone(),
2230        footer_formula_targets: plan.footer_formula_targets.clone(),
2231        rows_appended: plan.rows_appended,
2232        columns_written: plan.columns_written,
2233        target_anchor: plan.target_anchor.clone(),
2234        target_range: plan.target_range.clone(),
2235        expand_adjacent_sums: true,
2236        confidence: plan.confidence.clone(),
2237        confidence_reason: plan.confidence_reason.clone(),
2238        warnings: plan.warnings.clone(),
2239        would_change,
2240        changed,
2241    }
2242}
2243
2244fn build_append_region_plan(
2245    source: &Path,
2246    sheet_name: &str,
2247    region_id: Option<u32>,
2248    table_name: Option<&str>,
2249    footer_policy: AppendRegionFooterPolicyArg,
2250    rows: Vec<Vec<Option<MatrixCell>>>,
2251) -> Result<AppendRegionPlan> {
2252    if rows.is_empty() {
2253        return Err(invalid_argument(
2254            "append-region requires at least one row in the rows payload",
2255        ));
2256    }
2257
2258    let config = Arc::new(local_workbook_config(source));
2259    let workbook = WorkbookContext::load(&config, source)?;
2260    let target =
2261        resolve_append_region_target(&workbook, source, sheet_name, region_id, table_name)?;
2262    let bounds = target.bounds;
2263
2264    let columns_written = rows.iter().map(Vec::len).max().unwrap_or(0) as u32;
2265    if columns_written == 0 {
2266        return Err(invalid_argument(
2267            "append-region rows payload must contain at least one non-empty column",
2268        ));
2269    }
2270    let region_width = bounds.end_col - bounds.start_col + 1;
2271    if columns_written > region_width {
2272        let target_label = target
2273            .table_name
2274            .clone()
2275            .map(|name| format!("table '{}'", name))
2276            .or_else(|| target.region_id.map(|id| format!("region {}", id)))
2277            .unwrap_or_else(|| "append target".to_string());
2278        return Err(invalid_argument(format!(
2279            "rows payload is wider than {} on sheet '{}': payload columns={}, region columns={}",
2280            target_label, target.sheet_name, columns_written, region_width
2281        )));
2282    }
2283
2284    let footer_scan = detect_append_footer(
2285        source,
2286        &target.sheet_name,
2287        bounds.start_col,
2288        bounds.end_col,
2289        bounds.end_row,
2290    )?;
2291    let footer_policy_label = append_footer_policy_label(footer_policy).to_string();
2292    let (insert_at_row, insert_reason) = match footer_policy {
2293        AppendRegionFooterPolicyArg::Auto => {
2294            if let Some(row) = footer_scan.footer_row {
2295                (
2296                    row,
2297                    format!("auto policy selected detected footer row {}", row),
2298                )
2299            } else {
2300                (
2301                    bounds.end_row + 1,
2302                    format!(
2303                        "auto policy found no footer row; appending after detected region end row {}",
2304                        bounds.end_row
2305                    ),
2306                )
2307            }
2308        }
2309        AppendRegionFooterPolicyArg::BeforeFooter => {
2310            let row = footer_scan.footer_row.ok_or_else(|| {
2311                invalid_argument(
2312                    "footer policy 'before-footer' requires a detected footer/subtotal row; use --footer-policy auto or append-at-end to continue without one",
2313                )
2314            })?;
2315            (
2316                row,
2317                format!("before_footer policy selected detected footer row {}", row),
2318            )
2319        }
2320        AppendRegionFooterPolicyArg::AppendAtEnd => {
2321            if let Some(row) = footer_scan.footer_row {
2322                (
2323                    bounds.end_row + 1,
2324                    format!(
2325                        "append_at_end policy bypassed detected footer row {} and appended after region end row {}",
2326                        row, bounds.end_row
2327                    ),
2328                )
2329            } else {
2330                (
2331                    bounds.end_row + 1,
2332                    format!(
2333                        "append_at_end policy appended after detected region end row {}",
2334                        bounds.end_row
2335                    ),
2336                )
2337            }
2338        }
2339    };
2340    let target_anchor = format!(
2341        "{}{}",
2342        column_number_to_name(bounds.start_col),
2343        insert_at_row
2344    );
2345    let target_range = format_a1_range(
2346        bounds.start_col,
2347        bounds.start_col + columns_written - 1,
2348        insert_at_row,
2349        insert_at_row + rows.len() as u32 - 1,
2350    );
2351
2352    let mut warnings = Vec::new();
2353    if target.headers_truncated {
2354        warnings.push(
2355            "detected region headers were truncated; verify the append target carefully"
2356                .to_string(),
2357        );
2358    }
2359    match footer_policy {
2360        AppendRegionFooterPolicyArg::Auto if footer_scan.footer_row.is_none() => {
2361            warnings.push("no footer row detected; appending at detected region end".to_string());
2362        }
2363        AppendRegionFooterPolicyArg::AppendAtEnd if footer_scan.footer_row.is_some() => {
2364            warnings.push(format!(
2365                "footer policy '{}' ignored detected footer row {}",
2366                footer_policy_label,
2367                footer_scan.footer_row.unwrap_or_default()
2368            ));
2369        }
2370        _ => {}
2371    }
2372
2373    let (confidence, confidence_reason) = append_plan_confidence(&target, &footer_scan);
2374
2375    Ok(AppendRegionPlan {
2376        sheet_name: target.sheet_name,
2377        target_kind: target.target_kind,
2378        region_id: target.region_id,
2379        table_name: target.table_name,
2380        region_bounds: target.region_bounds,
2381        header_row: target.header_row,
2382        footer_policy: footer_policy_label,
2383        insert_at_row,
2384        insert_reason,
2385        footer_row: footer_scan.footer_row,
2386        footer_detection: footer_scan.footer_detection,
2387        footer_candidates: footer_scan.footer_candidates,
2388        footer_formula_targets: footer_scan.footer_formula_targets,
2389        rows_appended: rows.len() as u32,
2390        columns_written,
2391        target_anchor,
2392        target_range,
2393        confidence: confidence.to_string(),
2394        confidence_reason,
2395        warnings,
2396        rows,
2397    })
2398}
2399
2400fn resolve_append_region_target(
2401    workbook: &WorkbookContext,
2402    source: &Path,
2403    sheet_name: &str,
2404    region_id: Option<u32>,
2405    table_name: Option<&str>,
2406) -> Result<AppendRegionTarget> {
2407    match (region_id, table_name) {
2408        (Some(_), Some(_)) => Err(invalid_argument(
2409            "--region-id and --table-name are mutually exclusive",
2410        )),
2411        (None, None) => Err(invalid_argument(
2412            "append-region requires exactly one of --region-id or --table-name",
2413        )),
2414        (Some(region_id), None) => {
2415            let region = workbook.detected_region(sheet_name, region_id).map_err(|_| {
2416                invalid_argument(format!(
2417                    "region {} was not found on sheet '{}'; run `asp sheet-overview {} {}` to inspect detected region ids",
2418                    region_id,
2419                    sheet_name,
2420                    source.display(),
2421                    sheet_name
2422                ))
2423            })?;
2424            let bounds = parse_append_region_bounds(&region.bounds).ok_or_else(|| {
2425                invalid_argument(format!(
2426                    "detected region {} on sheet '{}' has unsupported bounds '{}'",
2427                    region_id, sheet_name, region.bounds
2428                ))
2429            })?;
2430            Ok(AppendRegionTarget {
2431                sheet_name: sheet_name.to_string(),
2432                target_kind: AppendRegionTargetKind::DetectedRegion,
2433                region_id: Some(region_id),
2434                table_name: None,
2435                bounds,
2436                region_bounds: region.bounds,
2437                header_row: region.header_row,
2438                headers_truncated: region.headers_truncated,
2439            })
2440        }
2441        (None, Some(table_name)) => resolve_append_table_target(workbook, sheet_name, table_name),
2442    }
2443}
2444
2445fn resolve_append_table_target(
2446    workbook: &WorkbookContext,
2447    sheet_name: &str,
2448    table_name: &str,
2449) -> Result<AppendRegionTarget> {
2450    let lower_name = table_name.to_ascii_lowercase();
2451    let items = workbook.named_items()?;
2452    let same_sheet = |item: &crate::model::NamedRangeDescriptor| {
2453        item.sheet_name
2454            .as_deref()
2455            .map(|item_sheet| item_sheet.eq_ignore_ascii_case(sheet_name))
2456            .unwrap_or(false)
2457    };
2458
2459    let exact_matches: Vec<_> = items
2460        .iter()
2461        .filter(|item| item.kind == NamedItemKind::Table)
2462        .filter(|item| same_sheet(item))
2463        .filter(|item| item.name.eq_ignore_ascii_case(table_name))
2464        .cloned()
2465        .collect();
2466    let candidates = if !exact_matches.is_empty() {
2467        exact_matches
2468    } else {
2469        items
2470            .into_iter()
2471            .filter(|item| item.kind == NamedItemKind::Table)
2472            .filter(|item| same_sheet(item))
2473            .filter(|item| item.name.to_ascii_lowercase().contains(&lower_name))
2474            .collect()
2475    };
2476
2477    let item = match candidates.len() {
2478        1 => candidates.into_iter().next().expect("one candidate"),
2479        0 => {
2480            return Err(invalid_argument(format!(
2481                "table '{}' was not found on sheet '{}'; run `asp named-ranges {}` to inspect available table names",
2482                table_name,
2483                sheet_name,
2484                workbook.path.display()
2485            )));
2486        }
2487        _ => {
2488            let matches = candidates
2489                .into_iter()
2490                .map(|item| item.name)
2491                .collect::<Vec<_>>()
2492                .join(", ");
2493            return Err(invalid_argument(format!(
2494                "table '{}' matched multiple tables on sheet '{}': {}",
2495                table_name, sheet_name, matches
2496            )));
2497        }
2498    };
2499
2500    let bounds = parse_append_named_item_bounds(&item.refers_to).ok_or_else(|| {
2501        invalid_argument(format!(
2502            "table '{}' on sheet '{}' has unsupported bounds '{}'",
2503            item.name, sheet_name, item.refers_to
2504        ))
2505    })?;
2506
2507    Ok(AppendRegionTarget {
2508        sheet_name: sheet_name.to_string(),
2509        target_kind: AppendRegionTargetKind::Table,
2510        region_id: None,
2511        table_name: Some(item.name.clone()),
2512        region_bounds: format_a1_range(
2513            bounds.start_col,
2514            bounds.end_col,
2515            bounds.start_row,
2516            bounds.end_row,
2517        ),
2518        header_row: Some(bounds.start_row),
2519        headers_truncated: false,
2520        bounds,
2521    })
2522}
2523
2524fn parse_append_named_item_bounds(raw: &str) -> Option<AppendBounds> {
2525    let refers_to = raw.trim().trim_start_matches('=');
2526    let range_part = refers_to
2527        .split_once('!')
2528        .map(|(_, rest)| rest)
2529        .unwrap_or(refers_to);
2530    parse_append_region_bounds(range_part)
2531}
2532
2533fn append_plan_confidence(
2534    target: &AppendRegionTarget,
2535    footer_scan: &AppendFooterScan,
2536) -> (&'static str, String) {
2537    if let Some(reason) = footer_scan.footer_detection.as_deref() {
2538        if reason.starts_with("footer keyword") {
2539            return (
2540                "high",
2541                format!("explicit footer keyword detected: {}", reason),
2542            );
2543        }
2544        return (
2545            "medium",
2546            format!("formula-derived footer signal detected: {}", reason),
2547        );
2548    }
2549
2550    if matches!(target.target_kind, AppendRegionTargetKind::Table) {
2551        return (
2552            "medium",
2553            format!(
2554                "resolved table target '{}' but found no explicit footer row",
2555                target.table_name.as_deref().unwrap_or_default()
2556            ),
2557        );
2558    }
2559
2560    if target.header_row.is_some() {
2561        return (
2562            "medium",
2563            "detected region includes a header row but no explicit footer row was found"
2564                .to_string(),
2565        );
2566    }
2567
2568    (
2569        "low",
2570        "no explicit header or footer cues were found; verify the append plan before apply"
2571            .to_string(),
2572    )
2573}
2574
2575fn append_footer_policy_label(policy: AppendRegionFooterPolicyArg) -> &'static str {
2576    match policy {
2577        AppendRegionFooterPolicyArg::Auto => "auto",
2578        AppendRegionFooterPolicyArg::BeforeFooter => "before_footer",
2579        AppendRegionFooterPolicyArg::AppendAtEnd => "append_at_end",
2580    }
2581}
2582
2583fn apply_append_region_plan_to_file(path: &Path, plan: &AppendRegionPlan) -> Result<()> {
2584    let structure_ops = vec![StructureOp::InsertRows {
2585        sheet_name: plan.sheet_name.clone(),
2586        at_row: plan.insert_at_row,
2587        count: plan.rows_appended,
2588        expand_adjacent_sums: true,
2589    }];
2590    apply_structure_ops_to_file(path, &structure_ops, FormulaParsePolicy::Warn)?;
2591
2592    let transform_ops = vec![TransformOp::WriteMatrix {
2593        sheet_name: plan.sheet_name.clone(),
2594        anchor: plan.target_anchor.clone(),
2595        rows: plan.rows.clone(),
2596        overwrite_formulas: false,
2597    }];
2598    apply_transform_ops_to_file(path, &transform_ops)?;
2599
2600    if matches!(plan.target_kind, AppendRegionTargetKind::Table)
2601        && let Some(table_name) = plan.table_name.as_deref()
2602    {
2603        expand_table_target_on_file(path, &plan.sheet_name, table_name, plan.rows_appended)?;
2604    }
2605
2606    Ok(())
2607}
2608
2609fn expand_table_target_on_file(
2610    path: &Path,
2611    sheet_name: &str,
2612    table_name: &str,
2613    appended_rows: u32,
2614) -> Result<()> {
2615    let mut book = umya_spreadsheet::reader::xlsx::read(path)
2616        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
2617    let sheet = book
2618        .get_sheet_by_name_mut(sheet_name)
2619        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", sheet_name)))?;
2620    let table = sheet
2621        .get_tables_mut()
2622        .iter_mut()
2623        .find(|table| {
2624            table.get_name().eq_ignore_ascii_case(table_name)
2625                || table.get_display_name().eq_ignore_ascii_case(table_name)
2626        })
2627        .ok_or_else(|| {
2628            invalid_argument(format!(
2629                "table '{}' was not found on sheet '{}' after append",
2630                table_name, sheet_name
2631            ))
2632        })?;
2633
2634    let start_col = *table.get_area().0.get_col_num();
2635    let start_row = *table.get_area().0.get_row_num();
2636    let end_col = *table.get_area().1.get_col_num();
2637    let end_row = *table.get_area().1.get_row_num();
2638    table.set_area(((start_col, start_row), (end_col, end_row + appended_rows)));
2639
2640    umya_spreadsheet::writer::xlsx::write(&book, path)
2641        .with_context(|| format!("failed to write workbook '{}'", path.display()))?;
2642    Ok(())
2643}
2644
2645#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
2646#[serde(rename_all = "snake_case")]
2647enum CloneHelperKind {
2648    CloneTemplateRow,
2649    CloneRowBand,
2650}
2651
2652#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
2653#[serde(rename_all = "snake_case")]
2654enum CloneAnchorKind {
2655    Before,
2656    After,
2657    InsertAt,
2658}
2659
2660#[derive(Debug, Serialize, Clone)]
2661struct CloneTemplateSummary {
2662    non_empty_cell_count: u32,
2663    formula_cell_count: u32,
2664    style_cell_count: u32,
2665    validation_cell_count: u32,
2666    merged_ranges_fully_contained: Vec<String>,
2667    merged_ranges_crossing_boundary: Vec<String>,
2668}
2669
2670#[derive(Debug, Serialize)]
2671struct CloneTemplateRowResponse {
2672    mode: String,
2673    file: String,
2674    #[serde(skip_serializing_if = "Option::is_none")]
2675    source_path: Option<String>,
2676    #[serde(skip_serializing_if = "Option::is_none")]
2677    target_path: Option<String>,
2678    sheet_name: String,
2679    helper_kind: CloneHelperKind,
2680    source_row: u32,
2681    source_row_range: String,
2682    anchor_kind: CloneAnchorKind,
2683    anchor_row: u32,
2684    insert_at_row: u32,
2685    count: u32,
2686    rows_inserted: u32,
2687    inserted_row_range: String,
2688    expand_adjacent_sums: bool,
2689    patch_target_mode: String,
2690    merge_policy: String,
2691    template_summary: CloneTemplateSummary,
2692    formula_targets: Vec<String>,
2693    likely_patch_targets: Vec<String>,
2694    adjacent_sum_targets: Vec<String>,
2695    warnings: Vec<String>,
2696    confidence: String,
2697    confidence_reason: String,
2698    #[serde(skip_serializing_if = "Option::is_none")]
2699    would_change: Option<bool>,
2700    #[serde(skip_serializing_if = "Option::is_none")]
2701    changed: Option<bool>,
2702}
2703
2704#[derive(Debug, Clone)]
2705struct CloneTemplateRowPlan {
2706    sheet_name: String,
2707    helper_kind: CloneHelperKind,
2708    source_row: u32,
2709    source_row_range: String,
2710    anchor_kind: CloneAnchorKind,
2711    anchor_row: u32,
2712    insert_at_row: u32,
2713    count: u32,
2714    rows_inserted: u32,
2715    inserted_row_range: String,
2716    expand_adjacent_sums: bool,
2717    patch_target_mode: String,
2718    merge_policy: String,
2719    template_summary: CloneTemplateSummary,
2720    formula_targets: Vec<String>,
2721    likely_patch_targets: Vec<String>,
2722    adjacent_sum_targets: Vec<String>,
2723    warnings: Vec<String>,
2724    confidence: String,
2725    confidence_reason: String,
2726    contained_merges: Vec<CloneMergeSpan>,
2727    contained_validations: Vec<CloneValidationSpec>,
2728}
2729
2730#[derive(Debug, Clone)]
2731struct CloneTemplateCellPreview {
2732    col: u32,
2733    value: String,
2734    is_formula: bool,
2735}
2736
2737#[derive(Debug, Clone)]
2738struct CloneMergeSpan {
2739    start_col: u32,
2740    end_col: u32,
2741    range: String,
2742}
2743
2744#[derive(Debug, Clone)]
2745struct CloneValidationSpec {
2746    data_validation: umya_spreadsheet::structs::DataValidation,
2747    start_col: u32,
2748    end_col: u32,
2749    start_row_offset: u32,
2750    end_row_offset: u32,
2751}
2752
2753#[derive(Debug, Clone)]
2754struct CloneTemplateCellData {
2755    col: u32,
2756    value: String,
2757    formula: Option<String>,
2758    style: umya_spreadsheet::Style,
2759}
2760
2761#[derive(Debug, Clone)]
2762struct CloneBandTemplateRow {
2763    source_row: u32,
2764    row_offset: u32,
2765    preview_cells: Vec<CloneTemplateCellPreview>,
2766    cell_data: Vec<CloneTemplateCellData>,
2767    row_dimension: Option<umya_spreadsheet::structs::Row>,
2768}
2769
2770#[derive(Debug, Clone, Serialize)]
2771struct CloneInsertedBlock {
2772    block_index: u32,
2773    row_range: String,
2774}
2775
2776#[derive(Debug, Serialize)]
2777struct CloneRowBandResponse {
2778    mode: String,
2779    file: String,
2780    #[serde(skip_serializing_if = "Option::is_none")]
2781    source_path: Option<String>,
2782    #[serde(skip_serializing_if = "Option::is_none")]
2783    target_path: Option<String>,
2784    sheet_name: String,
2785    helper_kind: CloneHelperKind,
2786    source_row_range: String,
2787    source_row_count: u32,
2788    anchor_kind: CloneAnchorKind,
2789    anchor_row: u32,
2790    insert_at_row: u32,
2791    repeat: u32,
2792    rows_inserted: u32,
2793    inserted_row_range: String,
2794    inserted_blocks: Vec<CloneInsertedBlock>,
2795    expand_adjacent_sums: bool,
2796    patch_target_mode: String,
2797    merge_policy: String,
2798    template_summary: CloneTemplateSummary,
2799    formula_targets: Vec<String>,
2800    likely_patch_targets: Vec<String>,
2801    adjacent_sum_targets: Vec<String>,
2802    warnings: Vec<String>,
2803    confidence: String,
2804    confidence_reason: String,
2805    #[serde(skip_serializing_if = "Option::is_none")]
2806    would_change: Option<bool>,
2807    #[serde(skip_serializing_if = "Option::is_none")]
2808    changed: Option<bool>,
2809}
2810
2811#[derive(Debug, Clone)]
2812struct CloneRowBandPlan {
2813    sheet_name: String,
2814    helper_kind: CloneHelperKind,
2815    source_row_range: String,
2816    source_row_count: u32,
2817    anchor_kind: CloneAnchorKind,
2818    anchor_row: u32,
2819    insert_at_row: u32,
2820    repeat: u32,
2821    rows_inserted: u32,
2822    inserted_row_range: String,
2823    inserted_blocks: Vec<CloneInsertedBlock>,
2824    expand_adjacent_sums: bool,
2825    patch_target_mode: String,
2826    merge_policy: String,
2827    template_summary: CloneTemplateSummary,
2828    formula_targets: Vec<String>,
2829    likely_patch_targets: Vec<String>,
2830    adjacent_sum_targets: Vec<String>,
2831    warnings: Vec<String>,
2832    confidence: String,
2833    confidence_reason: String,
2834    template_rows: Vec<CloneBandTemplateRow>,
2835    contained_merges: Vec<CloneBandMergeSpan>,
2836    contained_validations: Vec<CloneValidationSpec>,
2837}
2838
2839#[derive(Debug, Clone)]
2840struct CloneBandMergeSpan {
2841    start_col: u32,
2842    end_col: u32,
2843    start_row_offset: u32,
2844    end_row_offset: u32,
2845    range: String,
2846}
2847
2848#[allow(clippy::too_many_arguments)]
2849pub async fn clone_template_row(
2850    file: PathBuf,
2851    sheet_name: String,
2852    source_row: u32,
2853    before: Option<u32>,
2854    after: Option<u32>,
2855    insert_at: Option<u32>,
2856    count: u32,
2857    expand_adjacent_sums: bool,
2858    patch_targets: ClonePatchTargetsArg,
2859    merge_policy: CloneMergePolicyArg,
2860    dry_run: bool,
2861    in_place: bool,
2862    output: Option<PathBuf>,
2863    force: bool,
2864) -> Result<Value> {
2865    let selected_modes = dry_run as u8 + in_place as u8 + output.is_some() as u8;
2866    if selected_modes != 1 {
2867        return Err(invalid_argument(
2868            "choose exactly one of --dry-run, --in-place, or --output <PATH>",
2869        ));
2870    }
2871    if force && output.is_none() {
2872        return Err(invalid_argument("--force requires --output <PATH>"));
2873    }
2874
2875    let runtime = StatelessRuntime;
2876    let source = runtime.normalize_existing_file(&file)?;
2877    let plan = build_clone_template_row_plan(
2878        &source,
2879        &sheet_name,
2880        source_row,
2881        before,
2882        after,
2883        insert_at,
2884        count,
2885        expand_adjacent_sums,
2886        patch_targets,
2887        merge_policy,
2888    )?;
2889
2890    if dry_run {
2891        return Ok(serde_json::to_value(build_clone_template_row_response(
2892            &plan,
2893            "dry_run",
2894            source.display().to_string(),
2895            None,
2896            Some(true),
2897            None,
2898            None,
2899        ))?);
2900    }
2901
2902    if in_place {
2903        let source_path = source.display().to_string();
2904        let ((), temp_path) = apply_to_temp_copy(
2905            &source,
2906            source.parent(),
2907            ".clone-template-row-",
2908            |work_path| apply_clone_template_row_plan_to_file(work_path, &plan),
2909        )?;
2910        atomic_replace_target(temp_path, &source, true)?;
2911        return Ok(serde_json::to_value(build_clone_template_row_response(
2912            &plan,
2913            "in_place",
2914            source_path.clone(),
2915            Some(source_path.clone()),
2916            None,
2917            Some(source_path),
2918            Some(true),
2919        ))?);
2920    }
2921
2922    let target = runtime.normalize_destination_path(
2923        output
2924            .as_ref()
2925            .expect("output required unless dry-run or in-place"),
2926    )?;
2927    ensure_output_path_is_distinct(&source, &target)?;
2928    if path_entry_exists(&target)? && !force {
2929        return Err(output_exists(format!(
2930            "output path '{}' already exists",
2931            target.display()
2932        )));
2933    }
2934
2935    let source_path = source.display().to_string();
2936    let target_path = target.display().to_string();
2937    let ((), temp_path) = apply_to_temp_copy(
2938        &source,
2939        target.parent(),
2940        ".clone-template-row-",
2941        |work_path| apply_clone_template_row_plan_to_file(work_path, &plan),
2942    )?;
2943    atomic_replace_target(temp_path, &target, force)?;
2944
2945    Ok(serde_json::to_value(build_clone_template_row_response(
2946        &plan,
2947        "output",
2948        target_path.clone(),
2949        Some(source_path),
2950        None,
2951        Some(target_path),
2952        Some(true),
2953    ))?)
2954}
2955
2956fn build_clone_template_row_response(
2957    plan: &CloneTemplateRowPlan,
2958    mode: &str,
2959    file: String,
2960    source_path: Option<String>,
2961    would_change: Option<bool>,
2962    target_path: Option<String>,
2963    changed: Option<bool>,
2964) -> CloneTemplateRowResponse {
2965    CloneTemplateRowResponse {
2966        mode: mode.to_string(),
2967        file,
2968        source_path,
2969        target_path,
2970        sheet_name: plan.sheet_name.clone(),
2971        helper_kind: plan.helper_kind,
2972        source_row: plan.source_row,
2973        source_row_range: plan.source_row_range.clone(),
2974        anchor_kind: plan.anchor_kind,
2975        anchor_row: plan.anchor_row,
2976        insert_at_row: plan.insert_at_row,
2977        count: plan.count,
2978        rows_inserted: plan.rows_inserted,
2979        inserted_row_range: plan.inserted_row_range.clone(),
2980        expand_adjacent_sums: plan.expand_adjacent_sums,
2981        patch_target_mode: plan.patch_target_mode.clone(),
2982        merge_policy: plan.merge_policy.clone(),
2983        template_summary: plan.template_summary.clone(),
2984        formula_targets: plan.formula_targets.clone(),
2985        likely_patch_targets: plan.likely_patch_targets.clone(),
2986        adjacent_sum_targets: plan.adjacent_sum_targets.clone(),
2987        warnings: plan.warnings.clone(),
2988        confidence: plan.confidence.clone(),
2989        confidence_reason: plan.confidence_reason.clone(),
2990        would_change,
2991        changed,
2992    }
2993}
2994
2995#[allow(clippy::too_many_arguments)]
2996fn build_clone_template_row_plan(
2997    source: &Path,
2998    sheet_name: &str,
2999    source_row: u32,
3000    before: Option<u32>,
3001    after: Option<u32>,
3002    insert_at: Option<u32>,
3003    count: u32,
3004    expand_adjacent_sums: bool,
3005    patch_targets: ClonePatchTargetsArg,
3006    merge_policy: CloneMergePolicyArg,
3007) -> Result<CloneTemplateRowPlan> {
3008    if source_row == 0 {
3009        return Err(invalid_argument("--source-row must be at least 1"));
3010    }
3011    if count == 0 {
3012        return Err(invalid_argument("--count must be at least 1"));
3013    }
3014
3015    let (anchor_kind, anchor_row, insert_at_row) = resolve_clone_anchor(before, after, insert_at)?;
3016    let book = umya_spreadsheet::reader::xlsx::read(source)
3017        .with_context(|| format!("failed to read workbook '{}'", source.display()))?;
3018    let sheet = book
3019        .get_sheet_by_name(sheet_name)
3020        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", sheet_name)))?;
3021
3022    let template_cells = inspect_template_row_cells(sheet, source_row);
3023    let (contained_merges, crossing_merges) = inspect_clone_row_merges(sheet, source_row)?;
3024    let (contained_validations, crossing_validations, validation_cell_count) =
3025        inspect_clone_row_validations(sheet, source_row)?;
3026
3027    if matches!(merge_policy, CloneMergePolicyArg::Strict) && !crossing_merges.is_empty() {
3028        return Err(unsafe_clone_template(format!(
3029            "source row {} intersects merged ranges that cross the clone boundary: {}",
3030            source_row,
3031            crossing_merges.join(", ")
3032        )));
3033    }
3034
3035    let source_row_range = format!("{}:{}", source_row, source_row);
3036    let inserted_row_range = format!(
3037        "{}:{}",
3038        insert_at_row,
3039        insert_at_row + count.saturating_sub(1)
3040    );
3041
3042    let formula_targets = build_clone_formula_targets(&template_cells, insert_at_row, count);
3043    let likely_patch_targets = build_clone_patch_targets(
3044        &template_cells,
3045        &contained_validations,
3046        insert_at_row,
3047        count,
3048        patch_targets,
3049    );
3050    let adjacent_sum_targets = if expand_adjacent_sums {
3051        preview_adjacent_sum_targets(sheet, insert_at_row, count)
3052    } else {
3053        Vec::new()
3054    };
3055
3056    let non_empty_cell_count = template_cells
3057        .iter()
3058        .filter(|cell| cell.is_formula || !cell.value.trim().is_empty())
3059        .count() as u32;
3060    let formula_cell_count = template_cells.iter().filter(|cell| cell.is_formula).count() as u32;
3061    let style_cell_count = template_cells.len() as u32;
3062
3063    let mut warnings = Vec::new();
3064    if template_cells.is_empty() {
3065        warnings.push(format!(
3066            "source row {} has no materialized cells; cloning will insert blank rows",
3067            source_row
3068        ));
3069    }
3070    if !crossing_merges.is_empty() {
3071        warnings.push(format!(
3072            "merge-policy '{}' will not reproduce boundary-crossing merged ranges: {}",
3073            clone_merge_policy_label(merge_policy),
3074            crossing_merges.join(", ")
3075        ));
3076    }
3077    if !crossing_validations.is_empty() {
3078        warnings.push(format!(
3079            "row-scoped validation cloning skipped boundary-crossing validation ranges: {}",
3080            crossing_validations.join(", ")
3081        ));
3082    }
3083    if expand_adjacent_sums && adjacent_sum_targets.is_empty() {
3084        warnings.push(
3085            "no adjacent SUM footer formulas qualified for expansion below the inserted rows"
3086                .to_string(),
3087        );
3088    }
3089
3090    let (confidence, confidence_reason) = if template_cells.is_empty() {
3091        (
3092            "low",
3093            "template row has no materialized cells; verify that inserting blank rows is intended"
3094                .to_string(),
3095        )
3096    } else if !crossing_merges.is_empty() || !crossing_validations.is_empty() {
3097        (
3098            "medium",
3099            "clone can proceed, but boundary-crossing merges or validations will not be fully reproduced"
3100                .to_string(),
3101        )
3102    } else {
3103        (
3104            "high",
3105            "template row cloned cleanly with no merge or validation boundary conflicts"
3106                .to_string(),
3107        )
3108    };
3109
3110    Ok(CloneTemplateRowPlan {
3111        sheet_name: sheet_name.to_string(),
3112        helper_kind: CloneHelperKind::CloneTemplateRow,
3113        source_row,
3114        source_row_range,
3115        anchor_kind,
3116        anchor_row,
3117        insert_at_row,
3118        count,
3119        rows_inserted: count,
3120        inserted_row_range,
3121        expand_adjacent_sums,
3122        patch_target_mode: clone_patch_targets_label(patch_targets).to_string(),
3123        merge_policy: clone_merge_policy_label(merge_policy).to_string(),
3124        template_summary: CloneTemplateSummary {
3125            non_empty_cell_count,
3126            formula_cell_count,
3127            style_cell_count,
3128            validation_cell_count,
3129            merged_ranges_fully_contained: contained_merges
3130                .iter()
3131                .map(|span| span.range.clone())
3132                .collect(),
3133            merged_ranges_crossing_boundary: crossing_merges,
3134        },
3135        formula_targets,
3136        likely_patch_targets,
3137        adjacent_sum_targets,
3138        warnings,
3139        confidence: confidence.to_string(),
3140        confidence_reason,
3141        contained_merges,
3142        contained_validations,
3143    })
3144}
3145
3146fn resolve_clone_anchor(
3147    before: Option<u32>,
3148    after: Option<u32>,
3149    insert_at: Option<u32>,
3150) -> Result<(CloneAnchorKind, u32, u32)> {
3151    let selections = before.is_some() as u8 + after.is_some() as u8 + insert_at.is_some() as u8;
3152    if selections != 1 {
3153        return Err(invalid_argument(
3154            "choose exactly one of --before <ROW>, --after <ROW>, or --insert-at <ROW>",
3155        ));
3156    }
3157
3158    if let Some(row) = before {
3159        if row == 0 {
3160            return Err(invalid_argument("--before must be at least 1"));
3161        }
3162        return Ok((CloneAnchorKind::Before, row, row));
3163    }
3164    if let Some(row) = after {
3165        if row == 0 {
3166            return Err(invalid_argument("--after must be at least 1"));
3167        }
3168        return Ok((CloneAnchorKind::After, row, row + 1));
3169    }
3170    let row = insert_at.expect("one anchor row required");
3171    if row == 0 {
3172        return Err(invalid_argument("--insert-at must be at least 1"));
3173    }
3174    Ok((CloneAnchorKind::InsertAt, row, row))
3175}
3176
3177fn inspect_template_row_cells(
3178    sheet: &umya_spreadsheet::Worksheet,
3179    source_row: u32,
3180) -> Vec<CloneTemplateCellPreview> {
3181    let max_col = sheet.get_highest_column();
3182    let mut cells = Vec::new();
3183    for col in 1..=max_col {
3184        let Some(cell) = sheet.get_cell((col, source_row)) else {
3185            continue;
3186        };
3187        cells.push(CloneTemplateCellPreview {
3188            col,
3189            value: cell.get_value().to_string(),
3190            is_formula: cell.is_formula(),
3191        });
3192    }
3193    cells
3194}
3195
3196fn inspect_clone_row_merges(
3197    sheet: &umya_spreadsheet::Worksheet,
3198    source_row: u32,
3199) -> Result<(Vec<CloneMergeSpan>, Vec<String>)> {
3200    let mut contained = Vec::new();
3201    let mut crossing = Vec::new();
3202    for range in sheet.get_merge_cells() {
3203        let raw = range.get_range();
3204        let Some(bounds) = parse_append_region_bounds(&raw) else {
3205            continue;
3206        };
3207        if !(bounds.start_row..=bounds.end_row).contains(&source_row) {
3208            continue;
3209        }
3210        if bounds.start_row == source_row && bounds.end_row == source_row {
3211            contained.push(CloneMergeSpan {
3212                start_col: bounds.start_col,
3213                end_col: bounds.end_col,
3214                range: raw,
3215            });
3216        } else {
3217            crossing.push(raw);
3218        }
3219    }
3220    Ok((contained, crossing))
3221}
3222
3223fn inspect_clone_row_validations(
3224    sheet: &umya_spreadsheet::Worksheet,
3225    source_row: u32,
3226) -> Result<(Vec<CloneValidationSpec>, Vec<String>, u32)> {
3227    let mut contained = Vec::new();
3228    let mut crossing = Vec::new();
3229    let mut validation_cols = BTreeSet::new();
3230
3231    let Some(validations) = sheet.get_data_validations() else {
3232        return Ok((contained, crossing, 0));
3233    };
3234
3235    for data_validation in validations.get_data_validation_list() {
3236        for range in data_validation
3237            .get_sequence_of_references()
3238            .get_range_collection()
3239        {
3240            let raw = range.get_range();
3241            let Some(bounds) = parse_append_region_bounds(&raw) else {
3242                continue;
3243            };
3244            if !(bounds.start_row..=bounds.end_row).contains(&source_row) {
3245                continue;
3246            }
3247            for col in bounds.start_col..=bounds.end_col {
3248                validation_cols.insert(col);
3249            }
3250            if bounds.start_row == source_row && bounds.end_row == source_row {
3251                let mut clone = data_validation.clone();
3252                clone
3253                    .get_sequence_of_references_mut()
3254                    .set_sqref(format_a1_range(
3255                        bounds.start_col,
3256                        bounds.end_col,
3257                        source_row,
3258                        source_row,
3259                    ));
3260                contained.push(CloneValidationSpec {
3261                    data_validation: clone,
3262                    start_col: bounds.start_col,
3263                    end_col: bounds.end_col,
3264                    start_row_offset: 0,
3265                    end_row_offset: 0,
3266                });
3267            } else {
3268                crossing.push(raw);
3269            }
3270        }
3271    }
3272
3273    Ok((contained, crossing, validation_cols.len() as u32))
3274}
3275
3276fn build_clone_formula_targets(
3277    template_cells: &[CloneTemplateCellPreview],
3278    insert_at_row: u32,
3279    count: u32,
3280) -> Vec<String> {
3281    let formula_cols: Vec<u32> = template_cells
3282        .iter()
3283        .filter(|cell| cell.is_formula)
3284        .map(|cell| cell.col)
3285        .collect();
3286    let mut targets = Vec::new();
3287    for row in insert_at_row..(insert_at_row + count) {
3288        for col in &formula_cols {
3289            targets.push(format!("{}{}", column_number_to_name(*col), row));
3290        }
3291    }
3292    targets
3293}
3294
3295fn get_likely_input_cols_for_row(
3296    preview_cells: &[CloneTemplateCellPreview],
3297    validations: &[CloneValidationSpec],
3298    row_offset: u32,
3299    patch_targets: ClonePatchTargetsArg,
3300) -> Vec<u32> {
3301    let mut target_cols = std::collections::BTreeSet::new();
3302
3303    match patch_targets {
3304        ClonePatchTargetsArg::None => {}
3305        ClonePatchTargetsArg::AllNonFormula => {
3306            for cell in preview_cells {
3307                if !cell.is_formula {
3308                    target_cols.insert(cell.col);
3309                }
3310            }
3311        }
3312        ClonePatchTargetsArg::LikelyInputs => {
3313            for cell in preview_cells {
3314                if cell.is_formula {
3315                    continue;
3316                }
3317                if looks_like_footer_label(&cell.value) {
3318                    continue;
3319                }
3320                let is_numeric = cell.value.trim().parse::<f64>().is_ok();
3321                let has_validation = validations.iter().any(|v| {
3322                    v.start_col <= cell.col
3323                        && v.end_col >= cell.col
3324                        && v.start_row_offset <= row_offset
3325                        && v.end_row_offset >= row_offset
3326                });
3327                if is_numeric || has_validation {
3328                    target_cols.insert(cell.col);
3329                }
3330            }
3331
3332            // Add completely empty cells that have validation
3333            for v in validations {
3334                if v.start_row_offset <= row_offset && v.end_row_offset >= row_offset {
3335                    for col in v.start_col..=v.end_col {
3336                        target_cols.insert(col);
3337                    }
3338                }
3339            }
3340        }
3341    }
3342
3343    target_cols.into_iter().collect()
3344}
3345
3346fn build_clone_patch_targets(
3347    template_cells: &[CloneTemplateCellPreview],
3348    validations: &[CloneValidationSpec],
3349    insert_at_row: u32,
3350    count: u32,
3351    patch_targets: ClonePatchTargetsArg,
3352) -> Vec<String> {
3353    let target_cols = get_likely_input_cols_for_row(template_cells, validations, 0, patch_targets);
3354
3355    let mut targets = Vec::new();
3356    for row in insert_at_row..(insert_at_row + count) {
3357        for col in &target_cols {
3358            targets.push(format!("{}{}", column_number_to_name(*col), row));
3359        }
3360    }
3361    targets
3362}
3363
3364fn looks_like_footer_label(value: &str) -> bool {
3365    let text = value.trim().to_ascii_lowercase();
3366    text.starts_with("total")
3367        || text.contains("grand total")
3368        || text.contains("subtotal")
3369        || text.contains("footer")
3370}
3371
3372fn preview_adjacent_sum_targets(
3373    sheet: &umya_spreadsheet::Worksheet,
3374    insert_at_row: u32,
3375    count: u32,
3376) -> Vec<String> {
3377    let mut targets = Vec::new();
3378    let pre_shift_subtotal_row = insert_at_row;
3379    let post_shift_subtotal_row = insert_at_row + count;
3380    let sum_re = simple_sum_range_regex();
3381    let max_col = sheet.get_highest_column();
3382    for col in 1..=max_col {
3383        let Some(cell) = sheet.get_cell((col, pre_shift_subtotal_row)) else {
3384            continue;
3385        };
3386        if !cell.is_formula() {
3387            continue;
3388        }
3389        let formula_text = cell.get_formula().to_string();
3390        let formula_bare = formula_text.strip_prefix('=').unwrap_or(&formula_text);
3391        let Some(caps) = sum_re.captures(formula_bare) else {
3392            continue;
3393        };
3394        let col1 = caps.get(1).map(|m| m.as_str()).unwrap_or_default();
3395        let col2 = caps.get(3).map(|m| m.as_str()).unwrap_or_default();
3396        let row2: u32 = caps
3397            .get(4)
3398            .and_then(|m| m.as_str().parse::<u32>().ok())
3399            .unwrap_or(0);
3400        if col1 == col2 && row2 + 1 == insert_at_row {
3401            targets.push(format!(
3402                "{}{}",
3403                column_number_to_name(col),
3404                post_shift_subtotal_row
3405            ));
3406        }
3407    }
3408    targets
3409}
3410
3411fn simple_sum_range_regex() -> Regex {
3412    Regex::new(r"(?i)^SUM\(([A-Z]{1,3})(\d+):([A-Z]{1,3})(\d+)\)$").expect("valid simple sum regex")
3413}
3414
3415fn apply_clone_template_row_plan_to_file(path: &Path, plan: &CloneTemplateRowPlan) -> Result<()> {
3416    let structure_ops = vec![StructureOp::CloneRow {
3417        sheet_name: plan.sheet_name.clone(),
3418        source_row: plan.source_row,
3419        insert_at: plan.insert_at_row,
3420        count: plan.count,
3421        expand_adjacent_sums: plan.expand_adjacent_sums,
3422    }];
3423    apply_structure_ops_to_file(path, &structure_ops, FormulaParsePolicy::Warn)?;
3424    apply_clone_template_row_postprocess(path, plan)?;
3425    Ok(())
3426}
3427
3428fn apply_clone_template_row_postprocess(path: &Path, plan: &CloneTemplateRowPlan) -> Result<()> {
3429    if plan.contained_merges.is_empty() && plan.contained_validations.is_empty() {
3430        return Ok(());
3431    }
3432
3433    let mut book = umya_spreadsheet::reader::xlsx::read(path)
3434        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
3435    let sheet = book
3436        .get_sheet_by_name_mut(&plan.sheet_name)
3437        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", plan.sheet_name)))?;
3438
3439    for copy_idx in 0..plan.count {
3440        let dest_row = plan.insert_at_row + copy_idx;
3441        for merge in &plan.contained_merges {
3442            sheet.add_merge_cells(format_a1_range(
3443                merge.start_col,
3444                merge.end_col,
3445                dest_row,
3446                dest_row,
3447            ));
3448        }
3449    }
3450
3451    if !plan.contained_validations.is_empty() {
3452        if sheet.get_data_validations().is_none() {
3453            sheet.set_data_validations(umya_spreadsheet::structs::DataValidations::default());
3454        }
3455        let validations = sheet
3456            .get_data_validations_mut()
3457            .expect("data validations exist after initialization");
3458        for copy_idx in 0..plan.count {
3459            let dest_row = plan.insert_at_row + copy_idx;
3460            for spec in &plan.contained_validations {
3461                let mut clone = spec.data_validation.clone();
3462                clone
3463                    .get_sequence_of_references_mut()
3464                    .set_sqref(format_a1_range(
3465                        spec.start_col,
3466                        spec.end_col,
3467                        dest_row,
3468                        dest_row,
3469                    ));
3470                validations.add_data_validation_list(clone);
3471            }
3472        }
3473    }
3474
3475    umya_spreadsheet::writer::xlsx::write(&book, path)
3476        .with_context(|| format!("failed to write workbook '{}'", path.display()))?;
3477    Ok(())
3478}
3479
3480fn clone_patch_targets_label(mode: ClonePatchTargetsArg) -> &'static str {
3481    match mode {
3482        ClonePatchTargetsArg::LikelyInputs => "likely_inputs",
3483        ClonePatchTargetsArg::AllNonFormula => "all_non_formula",
3484        ClonePatchTargetsArg::None => "none",
3485    }
3486}
3487
3488fn clone_merge_policy_label(policy: CloneMergePolicyArg) -> &'static str {
3489    match policy {
3490        CloneMergePolicyArg::Safe => "safe",
3491        CloneMergePolicyArg::Strict => "strict",
3492    }
3493}
3494
3495#[allow(clippy::too_many_arguments)]
3496pub async fn clone_row_band(
3497    file: PathBuf,
3498    sheet_name: String,
3499    source_rows: String,
3500    before: Option<u32>,
3501    after: Option<u32>,
3502    insert_at: Option<u32>,
3503    repeat: u32,
3504    expand_adjacent_sums: bool,
3505    patch_targets: ClonePatchTargetsArg,
3506    merge_policy: CloneMergePolicyArg,
3507    dry_run: bool,
3508    in_place: bool,
3509    output: Option<PathBuf>,
3510    force: bool,
3511) -> Result<Value> {
3512    let selected_modes = dry_run as u8 + in_place as u8 + output.is_some() as u8;
3513    if selected_modes != 1 {
3514        return Err(invalid_argument(
3515            "choose exactly one of --dry-run, --in-place, or --output <PATH>",
3516        ));
3517    }
3518    if force && output.is_none() {
3519        return Err(invalid_argument("--force requires --output <PATH>"));
3520    }
3521
3522    let runtime = StatelessRuntime;
3523    let source = runtime.normalize_existing_file(&file)?;
3524    let plan = build_clone_row_band_plan(
3525        &source,
3526        &sheet_name,
3527        &source_rows,
3528        before,
3529        after,
3530        insert_at,
3531        repeat,
3532        expand_adjacent_sums,
3533        patch_targets,
3534        merge_policy,
3535    )?;
3536
3537    if dry_run {
3538        return Ok(serde_json::to_value(build_clone_row_band_response(
3539            &plan,
3540            "dry_run",
3541            source.display().to_string(),
3542            None,
3543            Some(true),
3544            None,
3545            None,
3546        ))?);
3547    }
3548
3549    if in_place {
3550        let source_path = source.display().to_string();
3551        let ((), temp_path) =
3552            apply_to_temp_copy(&source, source.parent(), ".clone-row-band-", |work_path| {
3553                apply_clone_row_band_plan_to_file(work_path, &plan)
3554            })?;
3555        atomic_replace_target(temp_path, &source, true)?;
3556        return Ok(serde_json::to_value(build_clone_row_band_response(
3557            &plan,
3558            "in_place",
3559            source_path.clone(),
3560            Some(source_path.clone()),
3561            None,
3562            Some(source_path),
3563            Some(true),
3564        ))?);
3565    }
3566
3567    let target = runtime.normalize_destination_path(
3568        output
3569            .as_ref()
3570            .expect("output required unless dry-run or in-place"),
3571    )?;
3572    ensure_output_path_is_distinct(&source, &target)?;
3573    if path_entry_exists(&target)? && !force {
3574        return Err(output_exists(format!(
3575            "output path '{}' already exists",
3576            target.display()
3577        )));
3578    }
3579
3580    let source_path = source.display().to_string();
3581    let target_path = target.display().to_string();
3582    let ((), temp_path) =
3583        apply_to_temp_copy(&source, target.parent(), ".clone-row-band-", |work_path| {
3584            apply_clone_row_band_plan_to_file(work_path, &plan)
3585        })?;
3586    atomic_replace_target(temp_path, &target, force)?;
3587
3588    Ok(serde_json::to_value(build_clone_row_band_response(
3589        &plan,
3590        "output",
3591        target_path.clone(),
3592        Some(source_path),
3593        None,
3594        Some(target_path),
3595        Some(true),
3596    ))?)
3597}
3598
3599fn build_clone_row_band_response(
3600    plan: &CloneRowBandPlan,
3601    mode: &str,
3602    file: String,
3603    source_path: Option<String>,
3604    would_change: Option<bool>,
3605    target_path: Option<String>,
3606    changed: Option<bool>,
3607) -> CloneRowBandResponse {
3608    CloneRowBandResponse {
3609        mode: mode.to_string(),
3610        file,
3611        source_path,
3612        target_path,
3613        sheet_name: plan.sheet_name.clone(),
3614        helper_kind: plan.helper_kind,
3615        source_row_range: plan.source_row_range.clone(),
3616        source_row_count: plan.source_row_count,
3617        anchor_kind: plan.anchor_kind,
3618        anchor_row: plan.anchor_row,
3619        insert_at_row: plan.insert_at_row,
3620        repeat: plan.repeat,
3621        rows_inserted: plan.rows_inserted,
3622        inserted_row_range: plan.inserted_row_range.clone(),
3623        inserted_blocks: plan.inserted_blocks.clone(),
3624        expand_adjacent_sums: plan.expand_adjacent_sums,
3625        patch_target_mode: plan.patch_target_mode.clone(),
3626        merge_policy: plan.merge_policy.clone(),
3627        template_summary: plan.template_summary.clone(),
3628        formula_targets: plan.formula_targets.clone(),
3629        likely_patch_targets: plan.likely_patch_targets.clone(),
3630        adjacent_sum_targets: plan.adjacent_sum_targets.clone(),
3631        warnings: plan.warnings.clone(),
3632        confidence: plan.confidence.clone(),
3633        confidence_reason: plan.confidence_reason.clone(),
3634        would_change,
3635        changed,
3636    }
3637}
3638
3639#[allow(clippy::too_many_arguments)]
3640fn build_clone_row_band_plan(
3641    source: &Path,
3642    sheet_name: &str,
3643    source_rows: &str,
3644    before: Option<u32>,
3645    after: Option<u32>,
3646    insert_at: Option<u32>,
3647    repeat: u32,
3648    expand_adjacent_sums: bool,
3649    patch_targets: ClonePatchTargetsArg,
3650    merge_policy: CloneMergePolicyArg,
3651) -> Result<CloneRowBandPlan> {
3652    if repeat == 0 {
3653        return Err(invalid_argument("--repeat must be at least 1"));
3654    }
3655    let (source_start_row, source_end_row) = parse_clone_row_band(source_rows)?;
3656    let source_row_count = source_end_row - source_start_row + 1;
3657    let (anchor_kind, anchor_row, insert_at_row) = resolve_clone_anchor(before, after, insert_at)?;
3658
3659    let book = umya_spreadsheet::reader::xlsx::read(source)
3660        .with_context(|| format!("failed to read workbook '{}'", source.display()))?;
3661    let sheet = book
3662        .get_sheet_by_name(sheet_name)
3663        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", sheet_name)))?;
3664
3665    let template_rows = inspect_clone_band_rows(sheet, source_start_row, source_end_row);
3666    let (contained_merges, crossing_merges) =
3667        inspect_clone_band_merges(sheet, source_start_row, source_end_row)?;
3668    let (contained_validations, crossing_validations, validation_cell_count) =
3669        inspect_clone_band_validations(sheet, source_start_row, source_end_row)?;
3670
3671    if matches!(merge_policy, CloneMergePolicyArg::Strict) && !crossing_merges.is_empty() {
3672        return Err(unsafe_clone_template(format!(
3673            "source rows {}:{} intersect merged ranges that cross the clone boundary: {}",
3674            source_start_row,
3675            source_end_row,
3676            crossing_merges.join(", ")
3677        )));
3678    }
3679
3680    let rows_inserted = source_row_count * repeat;
3681    let source_row_range = format!("{}:{}", source_start_row, source_end_row);
3682    let inserted_row_range = format!(
3683        "{}:{}",
3684        insert_at_row,
3685        insert_at_row + rows_inserted.saturating_sub(1)
3686    );
3687    let inserted_blocks = build_clone_inserted_blocks(insert_at_row, source_row_count, repeat);
3688    let formula_targets =
3689        build_clone_band_formula_targets(&template_rows, insert_at_row, source_row_count, repeat);
3690    let likely_patch_targets = build_clone_band_patch_targets(
3691        &template_rows,
3692        &contained_validations,
3693        insert_at_row,
3694        source_row_count,
3695        repeat,
3696        patch_targets,
3697    );
3698    let adjacent_sum_targets = if expand_adjacent_sums {
3699        preview_adjacent_sum_targets(sheet, insert_at_row, rows_inserted)
3700    } else {
3701        Vec::new()
3702    };
3703
3704    let non_empty_cell_count = template_rows
3705        .iter()
3706        .flat_map(|row| row.preview_cells.iter())
3707        .filter(|cell| cell.is_formula || !cell.value.trim().is_empty())
3708        .count() as u32;
3709    let formula_cell_count = template_rows
3710        .iter()
3711        .flat_map(|row| row.preview_cells.iter())
3712        .filter(|cell| cell.is_formula)
3713        .count() as u32;
3714    let style_cell_count = template_rows
3715        .iter()
3716        .map(|row| row.cell_data.len() as u32)
3717        .sum();
3718
3719    let mut warnings = Vec::new();
3720    if template_rows.iter().all(|row| row.cell_data.is_empty()) {
3721        warnings.push(format!(
3722            "source rows {}:{} have no materialized cells; cloning will insert blank rows",
3723            source_start_row, source_end_row
3724        ));
3725    }
3726    if !crossing_merges.is_empty() {
3727        warnings.push(format!(
3728            "merge-policy '{}' will not reproduce boundary-crossing merged ranges: {}",
3729            clone_merge_policy_label(merge_policy),
3730            crossing_merges.join(", ")
3731        ));
3732    }
3733    if !crossing_validations.is_empty() {
3734        warnings.push(format!(
3735            "band validation cloning skipped boundary-crossing validation ranges: {}",
3736            crossing_validations.join(", ")
3737        ));
3738    }
3739    if expand_adjacent_sums && adjacent_sum_targets.is_empty() {
3740        warnings.push(
3741            "no adjacent SUM footer formulas qualified for expansion below the inserted rows"
3742                .to_string(),
3743        );
3744    }
3745
3746    let (confidence, confidence_reason) = if template_rows
3747        .iter()
3748        .all(|row| row.cell_data.is_empty())
3749    {
3750        (
3751            "low",
3752            "source row band has no materialized cells; verify that inserting blank rows is intended"
3753                .to_string(),
3754        )
3755    } else if !crossing_merges.is_empty() || !crossing_validations.is_empty() {
3756        (
3757            "medium",
3758            "clone can proceed, but boundary-crossing merges or validations will not be fully reproduced"
3759                .to_string(),
3760        )
3761    } else {
3762        (
3763            "high",
3764            "row band cloned cleanly with no merge or validation boundary conflicts".to_string(),
3765        )
3766    };
3767
3768    Ok(CloneRowBandPlan {
3769        sheet_name: sheet_name.to_string(),
3770        helper_kind: CloneHelperKind::CloneRowBand,
3771        source_row_range,
3772        source_row_count,
3773        anchor_kind,
3774        anchor_row,
3775        insert_at_row,
3776        repeat,
3777        rows_inserted,
3778        inserted_row_range,
3779        inserted_blocks,
3780        expand_adjacent_sums,
3781        patch_target_mode: clone_patch_targets_label(patch_targets).to_string(),
3782        merge_policy: clone_merge_policy_label(merge_policy).to_string(),
3783        template_summary: CloneTemplateSummary {
3784            non_empty_cell_count,
3785            formula_cell_count,
3786            style_cell_count,
3787            validation_cell_count,
3788            merged_ranges_fully_contained: contained_merges
3789                .iter()
3790                .map(|span| span.range.clone())
3791                .collect(),
3792            merged_ranges_crossing_boundary: crossing_merges,
3793        },
3794        formula_targets,
3795        likely_patch_targets,
3796        adjacent_sum_targets,
3797        warnings,
3798        confidence: confidence.to_string(),
3799        confidence_reason,
3800        template_rows,
3801        contained_merges,
3802        contained_validations,
3803    })
3804}
3805
3806fn parse_clone_row_band(raw: &str) -> Result<(u32, u32)> {
3807    let (start, end) = raw
3808        .split_once(':')
3809        .ok_or_else(|| invalid_argument("--source-rows must use START:END notation"))?;
3810    let start_row = start
3811        .trim()
3812        .parse::<u32>()
3813        .map_err(|_| invalid_argument("--source-rows start row must be a positive integer"))?;
3814    let end_row = end
3815        .trim()
3816        .parse::<u32>()
3817        .map_err(|_| invalid_argument("--source-rows end row must be a positive integer"))?;
3818    if start_row == 0 || end_row == 0 {
3819        return Err(invalid_argument(
3820            "--source-rows values must both be at least 1",
3821        ));
3822    }
3823    if start_row > end_row {
3824        return Err(invalid_argument(
3825            "--source-rows must be an ascending contiguous range like 12:14",
3826        ));
3827    }
3828    Ok((start_row, end_row))
3829}
3830
3831fn inspect_clone_band_rows(
3832    sheet: &umya_spreadsheet::Worksheet,
3833    source_start_row: u32,
3834    source_end_row: u32,
3835) -> Vec<CloneBandTemplateRow> {
3836    let max_col = sheet.get_highest_column();
3837    let mut rows = Vec::new();
3838    for source_row in source_start_row..=source_end_row {
3839        let row_offset = source_row - source_start_row;
3840        let mut preview_cells = Vec::new();
3841        let mut cell_data = Vec::new();
3842        for col in 1..=max_col {
3843            let Some(cell) = sheet.get_cell((col, source_row)) else {
3844                continue;
3845            };
3846            let value = cell.get_value().to_string();
3847            let is_formula = cell.is_formula();
3848            preview_cells.push(CloneTemplateCellPreview {
3849                col,
3850                value: value.clone(),
3851                is_formula,
3852            });
3853            cell_data.push(CloneTemplateCellData {
3854                col,
3855                value,
3856                formula: if is_formula {
3857                    Some(cell.get_formula().to_string())
3858                } else {
3859                    None
3860                },
3861                style: cell.get_style().clone(),
3862            });
3863        }
3864        rows.push(CloneBandTemplateRow {
3865            source_row,
3866            row_offset,
3867            preview_cells,
3868            cell_data,
3869            row_dimension: sheet.get_row_dimension(&source_row).cloned(),
3870        });
3871    }
3872    rows
3873}
3874
3875fn inspect_clone_band_merges(
3876    sheet: &umya_spreadsheet::Worksheet,
3877    source_start_row: u32,
3878    source_end_row: u32,
3879) -> Result<(Vec<CloneBandMergeSpan>, Vec<String>)> {
3880    let mut contained = Vec::new();
3881    let mut crossing = Vec::new();
3882    for range in sheet.get_merge_cells() {
3883        let raw = range.get_range();
3884        let Some(bounds) = parse_append_region_bounds(&raw) else {
3885            continue;
3886        };
3887        if bounds.end_row < source_start_row || bounds.start_row > source_end_row {
3888            continue;
3889        }
3890        if bounds.start_row >= source_start_row && bounds.end_row <= source_end_row {
3891            contained.push(CloneBandMergeSpan {
3892                start_col: bounds.start_col,
3893                end_col: bounds.end_col,
3894                start_row_offset: bounds.start_row - source_start_row,
3895                end_row_offset: bounds.end_row - source_start_row,
3896                range: raw,
3897            });
3898        } else {
3899            crossing.push(raw);
3900        }
3901    }
3902    Ok((contained, crossing))
3903}
3904
3905fn inspect_clone_band_validations(
3906    sheet: &umya_spreadsheet::Worksheet,
3907    source_start_row: u32,
3908    source_end_row: u32,
3909) -> Result<(Vec<CloneValidationSpec>, Vec<String>, u32)> {
3910    let mut contained = Vec::new();
3911    let mut crossing = Vec::new();
3912    let mut validation_cells = BTreeSet::new();
3913
3914    let Some(validations) = sheet.get_data_validations() else {
3915        return Ok((contained, crossing, 0));
3916    };
3917
3918    for data_validation in validations.get_data_validation_list() {
3919        for range in data_validation
3920            .get_sequence_of_references()
3921            .get_range_collection()
3922        {
3923            let raw = range.get_range();
3924            let Some(bounds) = parse_append_region_bounds(&raw) else {
3925                continue;
3926            };
3927            if bounds.end_row < source_start_row || bounds.start_row > source_end_row {
3928                continue;
3929            }
3930            let intersect_start = bounds.start_row.max(source_start_row);
3931            let intersect_end = bounds.end_row.min(source_end_row);
3932            for row in intersect_start..=intersect_end {
3933                for col in bounds.start_col..=bounds.end_col {
3934                    validation_cells.insert((row, col));
3935                }
3936            }
3937            if bounds.start_row >= source_start_row && bounds.end_row <= source_end_row {
3938                let mut clone = data_validation.clone();
3939                clone
3940                    .get_sequence_of_references_mut()
3941                    .set_sqref(format_a1_range(
3942                        bounds.start_col,
3943                        bounds.end_col,
3944                        bounds.start_row,
3945                        bounds.end_row,
3946                    ));
3947                contained.push(CloneValidationSpec {
3948                    data_validation: clone,
3949                    start_col: bounds.start_col,
3950                    end_col: bounds.end_col,
3951                    start_row_offset: bounds.start_row - source_start_row,
3952                    end_row_offset: bounds.end_row - source_start_row,
3953                });
3954            } else {
3955                crossing.push(raw);
3956            }
3957        }
3958    }
3959
3960    Ok((contained, crossing, validation_cells.len() as u32))
3961}
3962
3963fn build_clone_inserted_blocks(
3964    insert_at_row: u32,
3965    source_row_count: u32,
3966    repeat: u32,
3967) -> Vec<CloneInsertedBlock> {
3968    (0..repeat)
3969        .map(|block_index| {
3970            let start_row = insert_at_row + block_index * source_row_count;
3971            CloneInsertedBlock {
3972                block_index,
3973                row_range: format!(
3974                    "{}:{}",
3975                    start_row,
3976                    start_row + source_row_count.saturating_sub(1)
3977                ),
3978            }
3979        })
3980        .collect()
3981}
3982
3983fn build_clone_band_formula_targets(
3984    template_rows: &[CloneBandTemplateRow],
3985    insert_at_row: u32,
3986    source_row_count: u32,
3987    repeat: u32,
3988) -> Vec<String> {
3989    let mut targets = Vec::new();
3990    for block_index in 0..repeat {
3991        let block_start = insert_at_row + block_index * source_row_count;
3992        for row in template_rows {
3993            let dest_row = block_start + row.row_offset;
3994            for cell in row.preview_cells.iter().filter(|cell| cell.is_formula) {
3995                targets.push(format!("{}{}", column_number_to_name(cell.col), dest_row));
3996            }
3997        }
3998    }
3999    targets
4000}
4001
4002fn build_clone_band_patch_targets(
4003    template_rows: &[CloneBandTemplateRow],
4004    validations: &[CloneValidationSpec],
4005    insert_at_row: u32,
4006    source_row_count: u32,
4007    repeat: u32,
4008    patch_targets: ClonePatchTargetsArg,
4009) -> Vec<String> {
4010    let mut targets = Vec::new();
4011
4012    // precompute target cols per row_offset
4013    let mut cols_by_offset = std::collections::HashMap::new();
4014    for row in template_rows {
4015        let cols = get_likely_input_cols_for_row(
4016            &row.preview_cells,
4017            validations,
4018            row.row_offset,
4019            patch_targets,
4020        );
4021        cols_by_offset.insert(row.row_offset, cols);
4022    }
4023
4024    for block_index in 0..repeat {
4025        let block_start = insert_at_row + block_index * source_row_count;
4026        for row in template_rows {
4027            let dest_row = block_start + row.row_offset;
4028            if let Some(cols) = cols_by_offset.get(&row.row_offset) {
4029                for col in cols {
4030                    targets.push(format!("{}{}", column_number_to_name(*col), dest_row));
4031                }
4032            }
4033        }
4034    }
4035    targets
4036}
4037
4038fn apply_clone_row_band_plan_to_file(path: &Path, plan: &CloneRowBandPlan) -> Result<()> {
4039    let structure_ops = vec![StructureOp::InsertRows {
4040        sheet_name: plan.sheet_name.clone(),
4041        at_row: plan.insert_at_row,
4042        count: plan.rows_inserted,
4043        expand_adjacent_sums: plan.expand_adjacent_sums,
4044    }];
4045    apply_structure_ops_to_file(path, &structure_ops, FormulaParsePolicy::Warn)?;
4046    apply_clone_row_band_postprocess(path, plan)?;
4047    Ok(())
4048}
4049
4050fn apply_clone_row_band_postprocess(path: &Path, plan: &CloneRowBandPlan) -> Result<()> {
4051    let mut book = umya_spreadsheet::reader::xlsx::read(path)
4052        .with_context(|| format!("failed to read workbook '{}'", path.display()))?;
4053    let sheet = book
4054        .get_sheet_by_name_mut(&plan.sheet_name)
4055        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", plan.sheet_name)))?;
4056
4057    for block_index in 0..plan.repeat {
4058        let block_start = plan.insert_at_row + block_index * plan.source_row_count;
4059        for row in &plan.template_rows {
4060            let dest_row = block_start + row.row_offset;
4061            if let Some(src_dim) = &row.row_dimension {
4062                let dest_dim = sheet.get_row_dimension_mut(&dest_row);
4063                dest_dim
4064                    .set_height(*src_dim.get_height())
4065                    .set_descent(*src_dim.get_descent())
4066                    .set_thick_bot(*src_dim.get_thick_bot())
4067                    .set_custom_height(*src_dim.get_custom_height())
4068                    .set_hidden(*src_dim.get_hidden())
4069                    .set_style(src_dim.get_style().clone());
4070            }
4071            for cell in &row.cell_data {
4072                let dest_cell = sheet.get_cell_mut((cell.col, dest_row));
4073                dest_cell.set_style(cell.style.clone());
4074                dest_cell.get_cell_value_mut().remove_formula();
4075                if let Some(formula) = &cell.formula {
4076                    let shifted = parse_base_formula(formula)
4077                        .and_then(|ast| {
4078                            shift_formula_ast(
4079                                &ast,
4080                                0,
4081                                dest_row as i32 - row.source_row as i32,
4082                                RelativeMode::Excel,
4083                            )
4084                        })
4085                        .ok()
4086                        .map(|value| value.strip_prefix('=').unwrap_or(&value).to_string())
4087                        .unwrap_or_else(|| formula.clone());
4088                    dest_cell.set_formula(shifted);
4089                    dest_cell.set_formula_result_default("");
4090                } else {
4091                    dest_cell.set_value(cell.value.clone());
4092                }
4093            }
4094        }
4095        for merge in &plan.contained_merges {
4096            sheet.add_merge_cells(format_a1_range(
4097                merge.start_col,
4098                merge.end_col,
4099                block_start + merge.start_row_offset,
4100                block_start + merge.end_row_offset,
4101            ));
4102        }
4103    }
4104
4105    if !plan.contained_validations.is_empty() {
4106        if sheet.get_data_validations().is_none() {
4107            sheet.set_data_validations(umya_spreadsheet::structs::DataValidations::default());
4108        }
4109        let validations = sheet
4110            .get_data_validations_mut()
4111            .expect("data validations exist after initialization");
4112        for block_index in 0..plan.repeat {
4113            let block_start = plan.insert_at_row + block_index * plan.source_row_count;
4114            for spec in &plan.contained_validations {
4115                let mut clone = spec.data_validation.clone();
4116                clone
4117                    .get_sequence_of_references_mut()
4118                    .set_sqref(format_a1_range(
4119                        spec.start_col,
4120                        spec.end_col,
4121                        block_start + spec.start_row_offset,
4122                        block_start + spec.end_row_offset,
4123                    ));
4124                validations.add_data_validation_list(clone);
4125            }
4126        }
4127    }
4128
4129    umya_spreadsheet::writer::xlsx::write(&book, path)
4130        .with_context(|| format!("failed to write workbook '{}'", path.display()))?;
4131    Ok(())
4132}
4133
4134fn parse_append_region_rows_from_csv(
4135    csv_path: &str,
4136    skip_header: bool,
4137) -> Result<Vec<Vec<Option<MatrixCell>>>> {
4138    let csv_raw = fs::read_to_string(csv_path).map_err(|e| {
4139        invalid_argument(format!("unable to read --from-csv '{}': {}", csv_path, e))
4140    })?;
4141    let mut records = parse_csv_records(&csv_raw)
4142        .map_err(|e| invalid_argument(format!("invalid CSV in '{}': {}", csv_path, e)))?;
4143
4144    if skip_header && !records.is_empty() {
4145        records.remove(0);
4146    }
4147
4148    Ok(records
4149        .into_iter()
4150        .map(|row| {
4151            row.into_iter()
4152                .map(|field| {
4153                    let value = csv_field_to_json(&field);
4154                    if value.is_null() {
4155                        None
4156                    } else {
4157                        Some(MatrixCell::Value(value))
4158                    }
4159                })
4160                .collect()
4161        })
4162        .collect())
4163}
4164
4165fn parse_append_region_rows_payload(raw_ref: &str) -> Result<Vec<Vec<Option<MatrixCell>>>> {
4166    let raw = if let Some(path) = raw_ref.strip_prefix('@') {
4167        fs::read_to_string(path)
4168            .with_context(|| format!("failed to read rows payload file '{}'", path))?
4169    } else {
4170        raw_ref.to_string()
4171    };
4172
4173    let value: Value = serde_json::from_str(&raw).map_err(|error| {
4174        invalid_argument(format!(
4175            "rows payload must be valid JSON (top-level array or object with rows array): {}",
4176            error
4177        ))
4178    })?;
4179
4180    let rows_value = if let Some(rows) = value.get("rows") {
4181        rows
4182    } else {
4183        &value
4184    };
4185    let rows = rows_value.as_array().ok_or_else(|| {
4186        invalid_argument("rows payload must be a top-level array or object with a 'rows' array")
4187    })?;
4188
4189    rows.iter()
4190        .map(|row| {
4191            let cells = row.as_array().ok_or_else(|| {
4192                invalid_argument("each appended row must be a JSON array of cell values")
4193            })?;
4194            cells.iter().map(parse_append_matrix_cell).collect()
4195        })
4196        .collect()
4197}
4198
4199fn parse_append_matrix_cell(value: &Value) -> Result<Option<MatrixCell>> {
4200    match value {
4201        Value::Null => Ok(None),
4202        Value::Object(map) if map.len() == 1 && map.contains_key("f") => {
4203            let formula = map
4204                .get("f")
4205                .and_then(Value::as_str)
4206                .ok_or_else(|| invalid_argument("formula cells must use {'f': 'FORMULA'}"))?;
4207            Ok(Some(MatrixCell::Formula(formula.to_string())))
4208        }
4209        Value::Object(map) if map.len() == 1 && map.contains_key("v") => Ok(Some(
4210            MatrixCell::Value(map.get("v").cloned().unwrap_or(Value::Null)),
4211        )),
4212        Value::Object(_) => Err(invalid_argument(
4213            "object cells must use {'v': ...} for values or {'f': 'FORMULA'} for formulas",
4214        )),
4215        other => Ok(Some(MatrixCell::Value(other.clone()))),
4216    }
4217}
4218
4219fn detect_append_footer(
4220    source: &Path,
4221    sheet_name: &str,
4222    start_col: u32,
4223    end_col: u32,
4224    region_end_row: u32,
4225) -> Result<AppendFooterScan> {
4226    let book = umya_spreadsheet::reader::xlsx::read(source)
4227        .with_context(|| format!("failed to read workbook '{}'", source.display()))?;
4228    let sheet = book
4229        .get_sheet_by_name(sheet_name)
4230        .ok_or_else(|| invalid_argument(format!("sheet '{}' was not found", sheet_name)))?;
4231
4232    let mut footer_row = None;
4233    let mut footer_detection = None;
4234    let mut footer_formula_targets = Vec::new();
4235    let mut footer_candidates = Vec::new();
4236
4237    for row in [region_end_row, region_end_row + 1] {
4238        let reason = footer_reason_for_row(sheet, start_col, end_col, row);
4239        let matched = reason.is_some();
4240        if footer_row.is_none() && matched {
4241            footer_row = Some(row);
4242            footer_detection = reason.clone();
4243            footer_formula_targets = footer_formula_targets_for_row(sheet, start_col, end_col, row);
4244        }
4245        footer_candidates.push(AppendFooterCandidate {
4246            row,
4247            matched,
4248            reason,
4249        });
4250    }
4251
4252    Ok(AppendFooterScan {
4253        footer_row,
4254        footer_detection,
4255        footer_candidates,
4256        footer_formula_targets,
4257    })
4258}
4259
4260fn footer_formula_targets_for_row(
4261    sheet: &umya_spreadsheet::Worksheet,
4262    start_col: u32,
4263    end_col: u32,
4264    row: u32,
4265) -> Vec<String> {
4266    let mut addresses = Vec::new();
4267    for col in start_col..=end_col {
4268        let Some(cell) = sheet.get_cell((col, row)) else {
4269            continue;
4270        };
4271        if !cell.get_formula().trim().is_empty() {
4272            addresses.push(format!("{}{}", column_number_to_name(col), row));
4273        }
4274    }
4275    addresses
4276}
4277
4278fn footer_reason_for_row(
4279    sheet: &umya_spreadsheet::Worksheet,
4280    start_col: u32,
4281    end_col: u32,
4282    row: u32,
4283) -> Option<String> {
4284    let mut saw_formula = false;
4285    let mut saw_non_formula_non_empty = false;
4286    let mut saw_footer_label = None;
4287    for col in start_col..=end_col {
4288        let Some(cell) = sheet.get_cell((col, row)) else {
4289            continue;
4290        };
4291        let value = cell.get_value().trim().to_string();
4292        let formula = cell.get_formula().trim().to_string();
4293        let has_formula = !formula.is_empty();
4294        if has_formula {
4295            saw_formula = true;
4296        } else if !value.is_empty() {
4297            if looks_like_footer_label(&value) {
4298                saw_footer_label = Some(value.clone());
4299            } else {
4300                saw_non_formula_non_empty = true;
4301            }
4302        }
4303    }
4304
4305    if let Some(label) = saw_footer_label
4306        && saw_formula
4307    {
4308        return Some(format!(
4309            "footer keyword '{}' and formula on row {}",
4310            label, row
4311        ));
4312    }
4313
4314    (saw_formula && !saw_non_formula_non_empty)
4315        .then(|| format!("formula-bearing summary row {}", row))
4316}
4317
4318fn local_workbook_config(source: &Path) -> ServerConfig {
4319    let workspace_root = source
4320        .parent()
4321        .unwrap_or_else(|| Path::new("."))
4322        .to_path_buf();
4323    ServerConfig {
4324        workspace_root: workspace_root.clone(),
4325        screenshot_dir: workspace_root.join("screenshots"),
4326        path_mappings: Vec::new(),
4327        cache_capacity: 8,
4328        supported_extensions: vec![
4329            "xlsx".to_string(),
4330            "xlsm".to_string(),
4331            "xls".to_string(),
4332            "xlsb".to_string(),
4333        ],
4334        single_workbook: None,
4335        enabled_tools: None,
4336        transport: TransportKind::Http,
4337        http_bind_address: "127.0.0.1:8079".parse().expect("http bind address"),
4338        recalc_enabled: false,
4339        recalc_backend: RecalcBackendKind::Auto,
4340        vba_enabled: false,
4341        max_concurrent_recalcs: 2,
4342        tool_timeout_ms: Some(30_000),
4343        max_response_bytes: Some(1_000_000),
4344        output_profile: OutputProfile::TokenDense,
4345        max_payload_bytes: Some(65_536),
4346        max_cells: Some(10_000),
4347        max_items: Some(500),
4348        allow_overwrite: false,
4349    }
4350}
4351
4352fn parse_append_region_bounds(raw: &str) -> Option<AppendBounds> {
4353    let (left, right) = raw.split_once(':').map_or((raw, raw), |(a, b)| (a, b));
4354    let (start_col, start_row) = parse_append_coord(left)?;
4355    let (end_col, end_row) = parse_append_coord(right)?;
4356    Some(AppendBounds {
4357        start_col: start_col.min(end_col),
4358        end_col: start_col.max(end_col),
4359        start_row: start_row.min(end_row),
4360        end_row: start_row.max(end_row),
4361    })
4362}
4363
4364#[derive(Debug, Clone, Copy)]
4365struct AppendBounds {
4366    start_col: u32,
4367    end_col: u32,
4368    start_row: u32,
4369    end_row: u32,
4370}
4371
4372fn parse_append_coord(raw: &str) -> Option<(u32, u32)> {
4373    let coord = raw.trim().trim_start_matches('$');
4374    if coord.is_empty() {
4375        return None;
4376    }
4377
4378    let mut letters = String::new();
4379    let mut digits = String::new();
4380    for ch in coord.chars() {
4381        if ch == '$' {
4382            continue;
4383        }
4384        if ch.is_ascii_alphabetic() {
4385            if !digits.is_empty() {
4386                return None;
4387            }
4388            letters.push(ch.to_ascii_uppercase());
4389        } else if ch.is_ascii_digit() {
4390            digits.push(ch);
4391        } else {
4392            return None;
4393        }
4394    }
4395
4396    if letters.is_empty() || digits.is_empty() {
4397        return None;
4398    }
4399
4400    let mut col = 0u32;
4401    for ch in letters.bytes() {
4402        col = col
4403            .saturating_mul(26)
4404            .saturating_add((ch - b'A' + 1) as u32);
4405    }
4406    let row = digits.parse().ok()?;
4407    (col > 0 && row > 0).then_some((col, row))
4408}
4409
4410fn column_number_to_name(mut col: u32) -> String {
4411    let mut chars = Vec::new();
4412    while col > 0 {
4413        let rem = ((col - 1) % 26) as u8;
4414        chars.push((b'A' + rem) as char);
4415        col = (col - 1) / 26;
4416    }
4417    chars.iter().rev().collect()
4418}
4419
4420fn format_a1_range(start_col: u32, end_col: u32, start_row: u32, end_row: u32) -> String {
4421    let start = format!("{}{}", column_number_to_name(start_col), start_row);
4422    let end = format!("{}{}", column_number_to_name(end_col), end_row);
4423    if start == end {
4424        start
4425    } else {
4426        format!("{}:{}", start, end)
4427    }
4428}
4429
4430fn parse_ops_payload<T: DeserializeOwned>(
4431    raw: &str,
4432    expected_shape: &str,
4433    minimal_example: &str,
4434) -> Result<T> {
4435    let guidance = format!(
4436        "expected top-level shape: {expected_shape}; minimal valid example: {minimal_example}"
4437    );
4438    let object = parse_ops_payload_object(raw, &guidance)?;
4439
4440    serde_json::from_value(Value::Object(object)).map_err(|error| {
4441        invalid_ops_payload(format!(
4442            "ops payload does not match required schema: {error}; {guidance}"
4443        ))
4444    })
4445}
4446
4447fn summarize_transform_operation_counts(ops: &[TransformOp]) -> BTreeMap<String, u64> {
4448    let mut counts = BTreeMap::new();
4449    for op in ops {
4450        let key = match op {
4451            TransformOp::ClearRange { .. } => "clear_range",
4452            TransformOp::FillRange { .. } => "fill_range",
4453            TransformOp::ReplaceInRange { .. } => "replace_in_range",
4454            TransformOp::WriteMatrix { .. } => "write_matrix",
4455        };
4456        *counts.entry(key.to_string()).or_insert(0) += 1;
4457    }
4458    counts
4459}
4460
4461fn summarize_style_operation_counts(ops: &[StyleOp]) -> BTreeMap<String, u64> {
4462    let mut counts = BTreeMap::new();
4463    counts.insert("style_ops".to_string(), ops.len() as u64);
4464    counts
4465}
4466
4467fn summarize_formula_pattern_operation_counts(
4468    ops: &[ApplyFormulaPatternOpInput],
4469) -> BTreeMap<String, u64> {
4470    let mut counts = BTreeMap::new();
4471    counts.insert("apply_formula_pattern_ops".to_string(), ops.len() as u64);
4472    counts
4473}
4474
4475fn summarize_structure_operation_counts(ops: &[StructureOp]) -> BTreeMap<String, u64> {
4476    let mut counts = BTreeMap::new();
4477    for op in ops {
4478        let key = match op {
4479            StructureOp::InsertRows { .. } => "insert_rows",
4480            StructureOp::DeleteRows { .. } => "delete_rows",
4481            StructureOp::InsertCols { .. } => "insert_cols",
4482            StructureOp::DeleteCols { .. } => "delete_cols",
4483            StructureOp::RenameSheet { .. } => "rename_sheet",
4484            StructureOp::CreateSheet { .. } => "create_sheet",
4485            StructureOp::DeleteSheet { .. } => "delete_sheet",
4486            StructureOp::CopyRange { .. } => "copy_range",
4487            StructureOp::MoveRange { .. } => "move_range",
4488            StructureOp::MergeCells { .. } => "merge_cells",
4489            StructureOp::UnmergeCells { .. } => "unmerge_cells",
4490            StructureOp::CloneRow { .. } => "clone_row",
4491        };
4492        *counts.entry(key.to_string()).or_insert(0) += 1;
4493    }
4494    counts
4495}
4496
4497fn summarize_column_size_operation_counts(ops: &[ColumnSizeOp]) -> BTreeMap<String, u64> {
4498    let mut counts = BTreeMap::new();
4499    for op in ops {
4500        let key = match op.size {
4501            crate::tools::fork::ColumnSizeSpec::Auto { .. } => "auto",
4502            crate::tools::fork::ColumnSizeSpec::Width { .. } => "width",
4503        };
4504        *counts.entry(key.to_string()).or_insert(0) += 1;
4505    }
4506    counts
4507}
4508
4509fn summarize_sheet_layout_operation_counts(ops: &[SheetLayoutOp]) -> BTreeMap<String, u64> {
4510    let mut counts = BTreeMap::new();
4511    for op in ops {
4512        let key = match op {
4513            SheetLayoutOp::FreezePanes { .. } => "freeze_panes",
4514            SheetLayoutOp::SetZoom { .. } => "set_zoom",
4515            SheetLayoutOp::SetGridlines { .. } => "set_gridlines",
4516            SheetLayoutOp::SetPageMargins { .. } => "set_page_margins",
4517            SheetLayoutOp::SetPageSetup { .. } => "set_page_setup",
4518            SheetLayoutOp::SetPrintArea { .. } => "set_print_area",
4519            SheetLayoutOp::SetPageBreaks { .. } => "set_page_breaks",
4520        };
4521        *counts.entry(key.to_string()).or_insert(0) += 1;
4522    }
4523    counts
4524}
4525
4526fn summarize_rules_operation_counts(ops: &[RulesOp]) -> BTreeMap<String, u64> {
4527    let mut counts = BTreeMap::new();
4528    for op in ops {
4529        let key = match op {
4530            RulesOp::SetDataValidation { .. } => "set_data_validation",
4531            RulesOp::AddConditionalFormat { .. } => "add_conditional_format",
4532            RulesOp::SetConditionalFormat { .. } => "set_conditional_format",
4533            RulesOp::ClearConditionalFormats { .. } => "clear_conditional_formats",
4534        };
4535        *counts.entry(key.to_string()).or_insert(0) += 1;
4536    }
4537    counts
4538}
4539
4540fn transform_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4541    const CHANGE_KEYS: &[&str] = &[
4542        "cells_value_cleared",
4543        "cells_formula_cleared",
4544        "cells_value_set",
4545        "cells_formula_set",
4546        "cells_value_replaced",
4547        "cells_formula_replaced",
4548    ];
4549    any_count_non_zero(counts, CHANGE_KEYS)
4550}
4551
4552fn style_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4553    any_count_non_zero(counts, &["cells_style_changed"])
4554}
4555
4556fn formula_pattern_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4557    any_count_non_zero(counts, &["cells_filled"])
4558}
4559
4560fn structure_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4561    any_count_non_zero(
4562        counts,
4563        &[
4564            "rows_inserted",
4565            "rows_deleted",
4566            "cols_inserted",
4567            "cols_deleted",
4568            "sheets_renamed",
4569            "sheets_created",
4570            "sheets_deleted",
4571            "cells_copied",
4572            "cells_moved",
4573            "ranges_copied",
4574            "ranges_moved",
4575        ],
4576    )
4577}
4578
4579fn column_size_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4580    any_count_non_zero(counts, &["columns_sized"])
4581}
4582
4583fn sheet_layout_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4584    any_count_non_zero(
4585        counts,
4586        &[
4587            "ops",
4588            "freeze_panes_ops",
4589            "set_zoom_ops",
4590            "set_gridlines_ops",
4591            "set_page_margins_ops",
4592            "set_page_setup_ops",
4593            "set_print_area_ops",
4594            "set_page_breaks_ops",
4595        ],
4596    )
4597}
4598
4599fn rules_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4600    any_count_non_zero(
4601        counts,
4602        &[
4603            "validations_set",
4604            "validations_replaced",
4605            "conditional_formats_added",
4606            "conditional_formats_set",
4607            "conditional_formats_replaced",
4608            "conditional_formats_cleared",
4609        ],
4610    )
4611}
4612
4613fn grid_import_summary_indicates_change(counts: &BTreeMap<String, u64>) -> bool {
4614    counts
4615        .iter()
4616        .any(|(key, value)| key != "ops" && *value > 0 && !key.starts_with("warnings_"))
4617}
4618
4619fn any_count_non_zero(counts: &BTreeMap<String, u64>, keys: &[&str]) -> bool {
4620    keys.iter()
4621        .any(|key| counts.get(*key).copied().unwrap_or(0) > 0)
4622}
4623
4624fn warning_strings_to_cli_warnings(messages: Vec<String>) -> Vec<Warning> {
4625    messages.into_iter().map(parse_warning_message).collect()
4626}
4627
4628fn merge_cli_warnings(mut left: Vec<Warning>, mut right: Vec<Warning>) -> Vec<Warning> {
4629    left.append(&mut right);
4630    left
4631}
4632
4633fn parse_warning_message(message: String) -> Warning {
4634    if let Some((code, detail)) = message.split_once(':') {
4635        let code = code.trim();
4636        let detail = detail.trim();
4637        if is_warning_code(code) && !detail.is_empty() {
4638            return Warning {
4639                code: code.to_string(),
4640                message: detail.to_string(),
4641            };
4642        }
4643    }
4644
4645    Warning {
4646        code: "WARN_INFO".to_string(),
4647        message,
4648    }
4649}
4650
4651fn is_warning_code(value: &str) -> bool {
4652    value.starts_with("WARN_")
4653        && value
4654            .chars()
4655            .all(|ch| ch.is_ascii_uppercase() || ch == '_' || ch.is_ascii_digit())
4656}
4657
4658fn formula_write_provenance(
4659    written_via: &str,
4660    formula_targets: Vec<String>,
4661) -> Option<WritePathProvenance> {
4662    if formula_targets.is_empty() {
4663        None
4664    } else {
4665        Some(WritePathProvenance {
4666            written_via: written_via.to_string(),
4667            formula_targets,
4668        })
4669    }
4670}
4671
4672fn parse_cell_ref_for_cli(cell: &str) -> Result<(u32, u32)> {
4673    let (col, row, _, _) = umya_spreadsheet::helper::coordinate::index_from_coordinate(cell);
4674    match (col, row) {
4675        (Some(c), Some(r)) if c > 0 && r > 0 => Ok((c, r)),
4676        _ => Err(invalid_ops_payload(format!(
4677            "invalid cell reference '{}' (expected A1-style reference)",
4678            cell
4679        ))),
4680    }
4681}
4682
4683fn transform_formula_targets(ops: &[TransformOp]) -> Vec<String> {
4684    ops.iter()
4685        .filter_map(|op| match op {
4686            TransformOp::FillRange {
4687                sheet_name,
4688                target,
4689                is_formula,
4690                ..
4691            } if *is_formula => Some(format!("{}!{}", sheet_name, transform_target_label(target))),
4692            TransformOp::ReplaceInRange {
4693                sheet_name,
4694                target,
4695                include_formulas,
4696                ..
4697            } if *include_formulas => {
4698                Some(format!("{}!{}", sheet_name, transform_target_label(target)))
4699            }
4700            TransformOp::WriteMatrix {
4701                sheet_name,
4702                anchor,
4703                rows,
4704                ..
4705            } if rows.iter().any(|r| {
4706                r.iter()
4707                    .any(|c| matches!(c, Some(crate::tools::fork::MatrixCell::Formula(_))))
4708            }) =>
4709            {
4710                Some(format!("{}!{}", sheet_name, anchor))
4711            }
4712            _ => None,
4713        })
4714        .collect()
4715}
4716
4717fn transform_target_label(target: &TransformTarget) -> String {
4718    match target {
4719        TransformTarget::Range { range } => range.clone(),
4720        TransformTarget::Region { region_id } => format!("region:{}", region_id),
4721        TransformTarget::Cells { cells } => {
4722            if cells.is_empty() {
4723                "cells".to_string()
4724            } else {
4725                format!("cells:{}", cells.join(","))
4726            }
4727        }
4728    }
4729}
4730
4731fn apply_formula_pattern_targets(ops: &[ApplyFormulaPatternOpInput]) -> Vec<String> {
4732    ops.iter()
4733        .map(|op| format!("{}!{}", op.sheet_name, op.target_range))
4734        .collect()
4735}
4736
4737fn dry_run_response(
4738    op_count: usize,
4739    operation_counts: BTreeMap<String, u64>,
4740    result_counts: BTreeMap<String, u64>,
4741    warnings: Vec<Warning>,
4742    would_change: bool,
4743    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4744    write_path_provenance: Option<WritePathProvenance>,
4745) -> Result<Value> {
4746    Ok(serde_json::to_value(BatchDryRunResponse {
4747        op_count,
4748        validated_count: op_count,
4749        would_change,
4750        warnings,
4751        summary: DryRunSummary {
4752            operation_counts,
4753            result_counts,
4754        },
4755        formula_parse_diagnostics,
4756        write_path_provenance,
4757    })?)
4758}
4759
4760#[allow(clippy::too_many_arguments)]
4761fn apply_response(
4762    op_count: usize,
4763    applied_count: usize,
4764    warnings: Vec<Warning>,
4765    changed: bool,
4766    target_path: String,
4767    source_path: String,
4768    formula_parse_diagnostics: Option<FormulaParseDiagnostics>,
4769    write_path_provenance: Option<WritePathProvenance>,
4770) -> Result<Value> {
4771    Ok(serde_json::to_value(BatchApplyResponse {
4772        op_count,
4773        applied_count,
4774        warnings,
4775        changed,
4776        target_path,
4777        source_path,
4778        formula_parse_diagnostics,
4779        write_path_provenance,
4780    })?)
4781}
4782
4783fn apply_in_place_with_temp<T, F>(source: &Path, temp_prefix: &str, apply_fn: F) -> Result<T>
4784where
4785    F: FnOnce(&Path) -> Result<T>,
4786{
4787    let (apply_result, temp_path) =
4788        apply_to_temp_copy(source, source.parent(), temp_prefix, apply_fn)?;
4789    atomic_replace_target(temp_path, source, true)?;
4790    Ok(apply_result)
4791}
4792
4793fn apply_to_output_with_temp<T, F>(
4794    source: &Path,
4795    target: &Path,
4796    force: bool,
4797    temp_prefix: &str,
4798    apply_fn: F,
4799) -> Result<T>
4800where
4801    F: FnOnce(&Path) -> Result<T>,
4802{
4803    let target_exists = path_entry_exists(target)?;
4804    if target_exists && !force {
4805        return Err(output_exists(format!(
4806            "output path '{}' already exists",
4807            target.display()
4808        )));
4809    }
4810
4811    let (apply_result, temp_path) =
4812        apply_to_temp_copy(source, target.parent(), temp_prefix, apply_fn)?;
4813    atomic_replace_target(temp_path, target, force)?;
4814    Ok(apply_result)
4815}
4816
4817fn apply_to_temp_copy<T, F>(
4818    source: &Path,
4819    directory: Option<&Path>,
4820    temp_prefix: &str,
4821    apply_fn: F,
4822) -> Result<(T, TempPath)>
4823where
4824    F: FnOnce(&Path) -> Result<T>,
4825{
4826    let parent = directory.ok_or_else(|| {
4827        write_failed(format!(
4828            "unable to create temp file: '{}' has no parent directory",
4829            source.display()
4830        ))
4831    })?;
4832    let temp_path = Builder::new()
4833        .prefix(temp_prefix)
4834        .suffix(".tmp.xlsx")
4835        .tempfile_in(parent)
4836        .map_err(|error| {
4837            write_failed(format!(
4838                "unable to allocate temp file in '{}': {}",
4839                parent.display(),
4840                error
4841            ))
4842        })?
4843        .into_temp_path();
4844
4845    let temp_path_ref: &Path = temp_path.as_ref();
4846
4847    fs::copy(source, temp_path_ref).map_err(|error| {
4848        write_failed(format!(
4849            "unable to stage temp workbook from '{}' to '{}': {}",
4850            source.display(),
4851            temp_path.display(),
4852            error
4853        ))
4854    })?;
4855
4856    let apply_result = apply_fn(temp_path_ref)?;
4857
4858    fsync_file(temp_path_ref)?;
4859
4860    Ok((apply_result, temp_path))
4861}
4862
4863fn atomic_replace_target(temp_path: TempPath, target: &Path, allow_overwrite: bool) -> Result<()> {
4864    if allow_overwrite {
4865        let target_exists = path_entry_exists(target)?;
4866        if target_exists && !atomic_overwrite_supported() {
4867            return Err(write_failed(
4868                "atomic overwrite is not supported on this platform",
4869            ));
4870        }
4871
4872        let temp_path_ref: &Path = temp_path.as_ref();
4873        fs::rename(temp_path_ref, target).map_err(|error| {
4874            write_failed(format!(
4875                "unable to atomically replace '{}' from '{}': {}",
4876                target.display(),
4877                temp_path.display(),
4878                error
4879            ))
4880        })?;
4881    } else {
4882        temp_path.persist_noclobber(target).map_err(|error| {
4883            if error.error.kind() == ErrorKind::AlreadyExists {
4884                output_exists(format!("output path '{}' already exists", target.display()))
4885            } else {
4886                write_failed(format!(
4887                    "unable to move staged workbook '{}' to '{}': {}",
4888                    error.path.display(),
4889                    target.display(),
4890                    error.error
4891                ))
4892            }
4893        })?;
4894    }
4895
4896    if let Some(parent) = target.parent() {
4897        fsync_directory(parent)?;
4898    }
4899
4900    Ok(())
4901}
4902
4903fn fsync_file(path: &Path) -> Result<()> {
4904    let file = OpenOptions::new()
4905        .read(true)
4906        .write(true)
4907        .open(path)
4908        .map_err(|error| {
4909            write_failed(format!(
4910                "unable to open '{}' for fsync: {}",
4911                path.display(),
4912                error
4913            ))
4914        })?;
4915    file.sync_all().map_err(|error| {
4916        write_failed(format!(
4917            "unable to fsync temp file '{}': {}",
4918            path.display(),
4919            error
4920        ))
4921    })
4922}
4923
4924#[cfg(unix)]
4925fn fsync_directory(path: &Path) -> Result<()> {
4926    let dir = fs::File::open(path).map_err(|error| {
4927        write_failed(format!(
4928            "unable to open directory '{}' for fsync: {}",
4929            path.display(),
4930            error
4931        ))
4932    })?;
4933    dir.sync_all().map_err(|error| {
4934        write_failed(format!(
4935            "unable to fsync directory '{}': {}",
4936            path.display(),
4937            error
4938        ))
4939    })
4940}
4941
4942#[cfg(not(unix))]
4943fn fsync_directory(_path: &Path) -> Result<()> {
4944    Ok(())
4945}
4946
4947fn path_entry_exists(path: &Path) -> Result<bool> {
4948    match fs::symlink_metadata(path) {
4949        Ok(_) => Ok(true),
4950        Err(error) if error.kind() == ErrorKind::NotFound => Ok(false),
4951        Err(error) => Err(write_failed(format!(
4952            "unable to inspect output path '{}': {}",
4953            path.display(),
4954            error
4955        ))),
4956    }
4957}
4958
4959fn ensure_output_path_is_distinct(source: &Path, output: &Path) -> Result<()> {
4960    let source_identity = canonical_identity_path(source)?;
4961    let output_identity = canonical_identity_path(output)?;
4962    if source_identity == output_identity {
4963        return Err(invalid_argument(
4964            "--output path resolves to the same file as input",
4965        ));
4966    }
4967    Ok(())
4968}
4969
4970fn canonical_identity_path(path: &Path) -> Result<PathBuf> {
4971    if path.exists() {
4972        return fs::canonicalize(path).with_context(|| {
4973            format!(
4974                "failed to resolve canonical identity path for '{}'",
4975                path.display()
4976            )
4977        });
4978    }
4979
4980    let parent = path.parent().unwrap_or_else(|| Path::new("."));
4981    let name = path
4982        .file_name()
4983        .ok_or_else(|| invalid_argument("output path must include a file name"))?;
4984
4985    let parent_canonical = fs::canonicalize(parent).with_context(|| {
4986        format!(
4987            "failed to resolve output parent directory '{}': {}",
4988            parent.display(),
4989            "directory does not exist or is inaccessible"
4990        )
4991    })?;
4992
4993    Ok(parent_canonical.join(name))
4994}
4995
4996#[cfg(unix)]
4997fn atomic_overwrite_supported() -> bool {
4998    true
4999}
5000
5001#[cfg(not(unix))]
5002fn atomic_overwrite_supported() -> bool {
5003    false
5004}
5005
5006fn grid_payload_from_csv_file(
5007    sheet_name: &str,
5008    anchor: &str,
5009    csv_path: &str,
5010    skip_header: bool,
5011) -> Result<GridPayload> {
5012    let csv_raw = fs::read_to_string(csv_path).map_err(|e| {
5013        invalid_argument(format!("unable to read --from-csv '{}': {}", csv_path, e))
5014    })?;
5015    let mut records = parse_csv_records(&csv_raw)
5016        .map_err(|e| invalid_argument(format!("invalid CSV in '{}': {}", csv_path, e)))?;
5017
5018    if skip_header && !records.is_empty() {
5019        records.remove(0);
5020    }
5021
5022    let rows = records
5023        .into_iter()
5024        .enumerate()
5025        .map(|(row_idx, row)| {
5026            let cells = row
5027                .into_iter()
5028                .enumerate()
5029                .map(|(col_idx, field)| crate::model::GridCell {
5030                    offset: [row_idx as u32, col_idx as u32],
5031                    v: Some(csv_field_to_json(&field)),
5032                    f: None,
5033                    fmt: None,
5034                    style: None,
5035                })
5036                .collect();
5037            crate::model::GridRow { cells }
5038        })
5039        .collect();
5040
5041    Ok(GridPayload {
5042        sheet: sheet_name.to_string(),
5043        anchor: anchor.to_string(),
5044        columns: Vec::new(),
5045        merges: Vec::new(),
5046        rows,
5047    })
5048}
5049
5050fn csv_field_to_json(field: &str) -> serde_json::Value {
5051    let trimmed = field.trim();
5052    if trimmed.is_empty() {
5053        return serde_json::Value::Null;
5054    }
5055    if trimmed.eq_ignore_ascii_case("true") {
5056        return serde_json::Value::Bool(true);
5057    }
5058    if trimmed.eq_ignore_ascii_case("false") {
5059        return serde_json::Value::Bool(false);
5060    }
5061    if let Ok(int_val) = trimmed.parse::<i64>() {
5062        return serde_json::json!(int_val);
5063    }
5064    if let Ok(float_val) = trimmed.parse::<f64>() {
5065        return serde_json::json!(float_val);
5066    }
5067    serde_json::Value::String(field.to_string())
5068}
5069
5070fn parse_csv_records(raw: &str) -> Result<Vec<Vec<String>>> {
5071    let mut records: Vec<Vec<String>> = Vec::new();
5072    let mut row: Vec<String> = Vec::new();
5073    let mut field = String::new();
5074    let mut chars = raw.chars().peekable();
5075    let mut in_quotes = false;
5076
5077    while let Some(ch) = chars.next() {
5078        if in_quotes {
5079            if ch == '"' {
5080                if matches!(chars.peek(), Some('"')) {
5081                    let _ = chars.next();
5082                    field.push('"');
5083                } else {
5084                    in_quotes = false;
5085                }
5086            } else {
5087                field.push(ch);
5088            }
5089            continue;
5090        }
5091
5092        match ch {
5093            '"' => in_quotes = true,
5094            ',' => {
5095                row.push(std::mem::take(&mut field));
5096            }
5097            '\n' => {
5098                row.push(std::mem::take(&mut field));
5099                records.push(std::mem::take(&mut row));
5100            }
5101            '\r' => {
5102                if matches!(chars.peek(), Some('\n')) {
5103                    let _ = chars.next();
5104                }
5105                row.push(std::mem::take(&mut field));
5106                records.push(std::mem::take(&mut row));
5107            }
5108            _ => field.push(ch),
5109        }
5110    }
5111
5112    if in_quotes {
5113        return Err(anyhow!("unterminated quoted field"));
5114    }
5115
5116    if !field.is_empty() || !row.is_empty() {
5117        row.push(field);
5118        records.push(row);
5119    }
5120
5121    Ok(records)
5122}
5123
5124fn apply_grid_import_to_path(
5125    path: &Path,
5126    sheet_name: &str,
5127    anchor: &str,
5128    grid: &GridPayload,
5129    clear_target: bool,
5130) -> Result<GridImportFileApplyResult> {
5131    let workspace_root = path
5132        .parent()
5133        .map(Path::to_path_buf)
5134        .unwrap_or_else(|| PathBuf::from("."));
5135
5136    let config = Arc::new(ServerConfig {
5137        workspace_root,
5138        screenshot_dir: PathBuf::from("screenshots"),
5139        path_mappings: Vec::new(),
5140        cache_capacity: 2,
5141        supported_extensions: vec!["xlsx".into(), "xlsm".into(), "xls".into(), "xlsb".into()],
5142        single_workbook: Some(path.to_path_buf()),
5143        enabled_tools: None,
5144        transport: TransportKind::Stdio,
5145        http_bind_address: "127.0.0.1:8079"
5146            .parse()
5147            .expect("hardcoded bind address is valid"),
5148        recalc_enabled: true,
5149        recalc_backend: RecalcBackendKind::Auto,
5150        vba_enabled: false,
5151        max_concurrent_recalcs: 1,
5152        tool_timeout_ms: Some(30_000),
5153        max_response_bytes: Some(1_000_000),
5154        output_profile: OutputProfile::Verbose,
5155        max_payload_bytes: Some(65_536),
5156        max_cells: Some(10_000),
5157        max_items: Some(500),
5158        allow_overwrite: true,
5159    });
5160
5161    let sheet_name = sheet_name.to_string();
5162    let anchor = anchor.to_string();
5163    let grid = grid.clone();
5164    let path_buf = path.to_path_buf();
5165
5166    let handle = thread::spawn(move || -> Result<GridImportFileApplyResult> {
5167        let state = Arc::new(AppState::new(config));
5168        let workbook_list = state.list_workbooks(WorkbookFilter::default())?;
5169        let workbook_id = workbook_list
5170            .workbooks
5171            .first()
5172            .map(|entry| entry.workbook_id.clone())
5173            .ok_or_else(|| anyhow!("no workbook found at '{}'", path_buf.display()))?;
5174
5175        let runtime = tokio::runtime::Builder::new_current_thread()
5176            .enable_all()
5177            .build()
5178            .map_err(|e| write_failed(format!("failed to create tokio runtime: {}", e)))?;
5179
5180        let (summary, formula_parse_diagnostics) = runtime.block_on(async {
5181            let fork = create_fork(
5182                state.clone(),
5183                CreateForkParams {
5184                    workbook_or_fork_id: workbook_id,
5185                },
5186            )
5187            .await?;
5188
5189            let import_response = grid_import(
5190                state.clone(),
5191                GridImportParams {
5192                    fork_id: fork.fork_id.clone(),
5193                    sheet_name,
5194                    anchor,
5195                    grid,
5196                    clear_target,
5197                    mode: None,
5198                    label: None,
5199                    formula_parse_policy: None,
5200                },
5201            )
5202            .await?;
5203
5204            let _ = save_fork(
5205                state.clone(),
5206                SaveForkParams {
5207                    fork_id: fork.fork_id,
5208                    target_path: None,
5209                    drop_fork: true,
5210                },
5211            )
5212            .await?;
5213
5214            Ok::<_, anyhow::Error>((
5215                import_response.summary,
5216                import_response.formula_parse_diagnostics,
5217            ))
5218        })?;
5219
5220        Ok(GridImportFileApplyResult {
5221            summary,
5222            formula_parse_diagnostics,
5223        })
5224    });
5225
5226    handle
5227        .join()
5228        .map_err(|_| write_failed("grid import worker thread panicked"))?
5229}
5230
5231fn classify_apply_error(error: anyhow::Error) -> anyhow::Error {
5232    let message = error.to_string();
5233    if message.starts_with(FORMULA_PARSE_FAILED_PREFIX) {
5234        return error;
5235    }
5236
5237    if error
5238        .chain()
5239        .any(|cause| cause.downcast_ref::<std::io::Error>().is_some())
5240    {
5241        write_failed(format!("failed while applying ops payload: {}", message))
5242    } else {
5243        invalid_ops_payload(message)
5244    }
5245}
5246
5247fn invalid_argument(message: impl AsRef<str>) -> anyhow::Error {
5248    anyhow!("invalid argument: {}", message.as_ref())
5249}
5250
5251fn invalid_ops_payload(message: impl AsRef<str>) -> anyhow::Error {
5252    anyhow!("invalid ops payload: {}", message.as_ref())
5253}
5254
5255fn unsafe_clone_template(message: impl AsRef<str>) -> anyhow::Error {
5256    anyhow!("unsafe clone template: {}", message.as_ref())
5257}
5258
5259fn output_exists(message: impl AsRef<str>) -> anyhow::Error {
5260    anyhow!("output exists: {}", message.as_ref())
5261}
5262
5263fn write_failed(message: impl AsRef<str>) -> anyhow::Error {
5264    anyhow!("write failed: {}", message.as_ref())
5265}
5266
5267// ── Named Range CRUD CLI ─────────────────────────────────────────────────────
5268
5269#[derive(Debug, Serialize)]
5270struct DefineNameCliResponse {
5271    file: String,
5272    name: String,
5273    refers_to: String,
5274    scope_kind: String,
5275    #[serde(skip_serializing_if = "Option::is_none")]
5276    scope_sheet_name: Option<String>,
5277    #[serde(skip_serializing_if = "Option::is_none")]
5278    source_path: Option<String>,
5279    #[serde(skip_serializing_if = "Option::is_none")]
5280    target_path: Option<String>,
5281    dry_run: bool,
5282}
5283
5284#[derive(Debug, Serialize)]
5285struct UpdateNameCliResponse {
5286    file: String,
5287    name: String,
5288    refers_to: String,
5289    scope_kind: String,
5290    #[serde(skip_serializing_if = "Option::is_none")]
5291    scope_sheet_name: Option<String>,
5292    #[serde(skip_serializing_if = "Option::is_none")]
5293    previous_refers_to: Option<String>,
5294    #[serde(skip_serializing_if = "Option::is_none")]
5295    source_path: Option<String>,
5296    #[serde(skip_serializing_if = "Option::is_none")]
5297    target_path: Option<String>,
5298    dry_run: bool,
5299}
5300
5301#[derive(Debug, Serialize)]
5302struct DeleteNameCliResponse {
5303    file: String,
5304    name: String,
5305    deleted: bool,
5306    #[serde(skip_serializing_if = "Option::is_none")]
5307    source_path: Option<String>,
5308    #[serde(skip_serializing_if = "Option::is_none")]
5309    target_path: Option<String>,
5310    dry_run: bool,
5311}
5312
5313#[allow(clippy::too_many_arguments)]
5314pub async fn define_name(
5315    file: PathBuf,
5316    name: String,
5317    refers_to: String,
5318    scope: Option<String>,
5319    scope_sheet_name: Option<String>,
5320    dry_run: bool,
5321    in_place: bool,
5322    output: Option<PathBuf>,
5323    force: bool,
5324) -> Result<Value> {
5325    use crate::tools::{define_name_in_file, parse_scope_kind};
5326
5327    let scope_kind = parse_scope_kind(scope.as_deref())?;
5328    if scope_kind == crate::model::NamedRangeScope::Sheet && scope_sheet_name.is_none() {
5329        bail!("--scope-sheet-name is required when --scope is 'sheet'");
5330    }
5331    if name.trim().is_empty() {
5332        bail!("name must not be empty");
5333    }
5334    if refers_to.trim().is_empty() {
5335        bail!("refers_to must not be empty");
5336    }
5337
5338    let runtime = StatelessRuntime;
5339    let source = runtime.normalize_existing_file(&file)?;
5340    let mode = validate_edit_mode(dry_run, in_place, output, force)?;
5341
5342    let scope_str = match scope_kind {
5343        crate::model::NamedRangeScope::Workbook => "workbook",
5344        crate::model::NamedRangeScope::Sheet => "sheet",
5345    };
5346
5347    match mode {
5348        EditMutationMode::DryRun => {
5349            // Validate only.
5350            let _ = apply_to_temp_copy(&source, source.parent(), ".defname-", |path| {
5351                define_name_in_file(
5352                    path,
5353                    &name,
5354                    &refers_to,
5355                    scope_kind,
5356                    scope_sheet_name.as_deref(),
5357                )
5358            })?;
5359            Ok(serde_json::to_value(DefineNameCliResponse {
5360                file: source.display().to_string(),
5361                name,
5362                refers_to,
5363                scope_kind: scope_str.to_string(),
5364                scope_sheet_name,
5365                source_path: None,
5366                target_path: None,
5367                dry_run: true,
5368            })?)
5369        }
5370        EditMutationMode::InPlace => {
5371            apply_in_place_with_temp(&source, ".defname-", |path| {
5372                define_name_in_file(
5373                    path,
5374                    &name,
5375                    &refers_to,
5376                    scope_kind,
5377                    scope_sheet_name.as_deref(),
5378                )
5379            })?;
5380            Ok(serde_json::to_value(DefineNameCliResponse {
5381                file: source.display().to_string(),
5382                name,
5383                refers_to,
5384                scope_kind: scope_str.to_string(),
5385                scope_sheet_name,
5386                source_path: Some(source.display().to_string()),
5387                target_path: Some(source.display().to_string()),
5388                dry_run: false,
5389            })?)
5390        }
5391        EditMutationMode::Output { target, force: f } => {
5392            apply_to_output_with_temp(&source, &target, f, ".defname-", |path| {
5393                define_name_in_file(
5394                    path,
5395                    &name,
5396                    &refers_to,
5397                    scope_kind,
5398                    scope_sheet_name.as_deref(),
5399                )
5400            })?;
5401            Ok(serde_json::to_value(DefineNameCliResponse {
5402                file: source.display().to_string(),
5403                name,
5404                refers_to,
5405                scope_kind: scope_str.to_string(),
5406                scope_sheet_name,
5407                source_path: Some(source.display().to_string()),
5408                target_path: Some(target.display().to_string()),
5409                dry_run: false,
5410            })?)
5411        }
5412    }
5413}
5414
5415#[allow(clippy::too_many_arguments)]
5416pub async fn update_name(
5417    file: PathBuf,
5418    name: String,
5419    refers_to: Option<String>,
5420    scope: Option<String>,
5421    scope_sheet_name: Option<String>,
5422    dry_run: bool,
5423    in_place: bool,
5424    output: Option<PathBuf>,
5425    force: bool,
5426) -> Result<Value> {
5427    use crate::tools::{parse_scope_kind_optional, update_name_in_file};
5428
5429    let scope_kind = parse_scope_kind_optional(scope.as_deref())?;
5430    if name.trim().is_empty() {
5431        bail!("name must not be empty");
5432    }
5433    if let Some(refers_to) = refers_to.as_ref()
5434        && refers_to.trim().is_empty()
5435    {
5436        bail!("refers_to must not be empty when provided");
5437    }
5438
5439    let runtime = StatelessRuntime;
5440    let source = runtime.normalize_existing_file(&file)?;
5441    let mode = validate_edit_mode(dry_run, in_place, output, force)?;
5442
5443    match mode {
5444        EditMutationMode::DryRun => {
5445            let (previous_refers_to, eff_scope, eff_sheet) =
5446                apply_to_temp_copy(&source, source.parent(), ".updname-", |path| {
5447                    update_name_in_file(
5448                        path,
5449                        &name,
5450                        refers_to.as_deref(),
5451                        scope_kind,
5452                        scope_sheet_name.as_deref(),
5453                    )
5454                })?
5455                .0;
5456            let scope_str = match eff_scope {
5457                crate::model::NamedRangeScope::Workbook => "workbook",
5458                crate::model::NamedRangeScope::Sheet => "sheet",
5459            };
5460            let final_refers_to = refers_to
5461                .clone()
5462                .unwrap_or_else(|| previous_refers_to.clone());
5463            Ok(serde_json::to_value(UpdateNameCliResponse {
5464                file: source.display().to_string(),
5465                name,
5466                refers_to: final_refers_to,
5467                scope_kind: scope_str.to_string(),
5468                scope_sheet_name: eff_sheet.or(scope_sheet_name),
5469                previous_refers_to: Some(previous_refers_to),
5470                source_path: None,
5471                target_path: None,
5472                dry_run: true,
5473            })?)
5474        }
5475        EditMutationMode::InPlace => {
5476            let (previous_refers_to, eff_scope, eff_sheet) =
5477                apply_in_place_with_temp(&source, ".updname-", |path| {
5478                    update_name_in_file(
5479                        path,
5480                        &name,
5481                        refers_to.as_deref(),
5482                        scope_kind,
5483                        scope_sheet_name.as_deref(),
5484                    )
5485                })?;
5486            let scope_str = match eff_scope {
5487                crate::model::NamedRangeScope::Workbook => "workbook",
5488                crate::model::NamedRangeScope::Sheet => "sheet",
5489            };
5490            let final_refers_to = refers_to
5491                .clone()
5492                .unwrap_or_else(|| previous_refers_to.clone());
5493            Ok(serde_json::to_value(UpdateNameCliResponse {
5494                file: source.display().to_string(),
5495                name,
5496                refers_to: final_refers_to,
5497                scope_kind: scope_str.to_string(),
5498                scope_sheet_name: eff_sheet.or(scope_sheet_name),
5499                previous_refers_to: Some(previous_refers_to),
5500                source_path: Some(source.display().to_string()),
5501                target_path: Some(source.display().to_string()),
5502                dry_run: false,
5503            })?)
5504        }
5505        EditMutationMode::Output { target, force: f } => {
5506            let (previous_refers_to, eff_scope, eff_sheet) =
5507                apply_to_output_with_temp(&source, &target, f, ".updname-", |path| {
5508                    update_name_in_file(
5509                        path,
5510                        &name,
5511                        refers_to.as_deref(),
5512                        scope_kind,
5513                        scope_sheet_name.as_deref(),
5514                    )
5515                })?;
5516            let scope_str = match eff_scope {
5517                crate::model::NamedRangeScope::Workbook => "workbook",
5518                crate::model::NamedRangeScope::Sheet => "sheet",
5519            };
5520            let final_refers_to = refers_to
5521                .clone()
5522                .unwrap_or_else(|| previous_refers_to.clone());
5523            Ok(serde_json::to_value(UpdateNameCliResponse {
5524                file: source.display().to_string(),
5525                name,
5526                refers_to: final_refers_to,
5527                scope_kind: scope_str.to_string(),
5528                scope_sheet_name: eff_sheet.or(scope_sheet_name),
5529                previous_refers_to: Some(previous_refers_to),
5530                source_path: Some(source.display().to_string()),
5531                target_path: Some(target.display().to_string()),
5532                dry_run: false,
5533            })?)
5534        }
5535    }
5536}
5537
5538#[allow(clippy::too_many_arguments)]
5539pub async fn delete_name(
5540    file: PathBuf,
5541    name: String,
5542    scope: Option<String>,
5543    scope_sheet_name: Option<String>,
5544    dry_run: bool,
5545    in_place: bool,
5546    output: Option<PathBuf>,
5547    force: bool,
5548) -> Result<Value> {
5549    use crate::tools::{delete_name_in_file, parse_scope_kind_optional};
5550
5551    let scope_kind = parse_scope_kind_optional(scope.as_deref())?;
5552    if name.trim().is_empty() {
5553        bail!("name must not be empty");
5554    }
5555
5556    let runtime = StatelessRuntime;
5557    let source = runtime.normalize_existing_file(&file)?;
5558    let mode = validate_edit_mode(dry_run, in_place, output, force)?;
5559
5560    match mode {
5561        EditMutationMode::DryRun => {
5562            let _ = apply_to_temp_copy(&source, source.parent(), ".delname-", |path| {
5563                delete_name_in_file(path, &name, scope_kind, scope_sheet_name.as_deref())
5564            })?;
5565            Ok(serde_json::to_value(DeleteNameCliResponse {
5566                file: source.display().to_string(),
5567                name,
5568                deleted: true,
5569                source_path: None,
5570                target_path: None,
5571                dry_run: true,
5572            })?)
5573        }
5574        EditMutationMode::InPlace => {
5575            delete_name_in_file_via_helper(
5576                &source,
5577                &name,
5578                scope_kind,
5579                scope_sheet_name.as_deref(),
5580            )?;
5581            Ok(serde_json::to_value(DeleteNameCliResponse {
5582                file: source.display().to_string(),
5583                name,
5584                deleted: true,
5585                source_path: Some(source.display().to_string()),
5586                target_path: Some(source.display().to_string()),
5587                dry_run: false,
5588            })?)
5589        }
5590        EditMutationMode::Output { target, force: f } => {
5591            apply_to_output_with_temp(&source, &target, f, ".delname-", |path| {
5592                delete_name_in_file(path, &name, scope_kind, scope_sheet_name.as_deref())
5593            })?;
5594            Ok(serde_json::to_value(DeleteNameCliResponse {
5595                file: source.display().to_string(),
5596                name,
5597                deleted: true,
5598                source_path: Some(source.display().to_string()),
5599                target_path: Some(target.display().to_string()),
5600                dry_run: false,
5601            })?)
5602        }
5603    }
5604}
5605
5606fn delete_name_in_file_via_helper(
5607    source: &Path,
5608    name: &str,
5609    scope_kind: Option<crate::model::NamedRangeScope>,
5610    scope_sheet_name: Option<&str>,
5611) -> Result<bool> {
5612    use crate::tools::delete_name_in_file;
5613    apply_in_place_with_temp(source, ".delname-", |path| {
5614        delete_name_in_file(path, name, scope_kind, scope_sheet_name)
5615    })
5616}
5617
5618pub fn parse_shorthand_for_tests(entries: Vec<String>) -> Result<(Vec<CellEdit>, Vec<Warning>)> {
5619    let mut edits = Vec::with_capacity(entries.len());
5620    let mut warnings = Vec::new();
5621    for entry in entries {
5622        let (edit, entry_warnings) = crate::core::write::normalize_shorthand_edit(&entry)?;
5623        edits.push(edit);
5624        warnings.extend(entry_warnings.into_iter().map(|warning| Warning {
5625            code: warning.code,
5626            message: warning.message,
5627        }));
5628    }
5629    Ok((edits, warnings))
5630}
5631
5632#[cfg(test)]
5633mod tests {
5634    use super::*;
5635
5636    fn with_sheet<F>(configure: F) -> umya_spreadsheet::Spreadsheet
5637    where
5638        F: FnOnce(&mut umya_spreadsheet::Worksheet),
5639    {
5640        let mut workbook = umya_spreadsheet::new_file();
5641        let sheet = workbook.get_sheet_by_name_mut("Sheet1").expect("sheet1");
5642        configure(sheet);
5643        workbook
5644    }
5645
5646    fn write_workbook_fixture<F>(name: &str, configure: F) -> (tempfile::TempDir, PathBuf)
5647    where
5648        F: FnOnce(&mut umya_spreadsheet::Worksheet),
5649    {
5650        let tempdir = tempfile::tempdir().expect("tempdir");
5651        let path = tempdir.path().join(name);
5652        let workbook = with_sheet(configure);
5653        umya_spreadsheet::writer::xlsx::write(&workbook, &path).expect("write workbook");
5654        (tempdir, path)
5655    }
5656
5657    fn seed_basic_region(sheet: &mut umya_spreadsheet::Worksheet) {
5658        sheet.get_cell_mut("A1").set_value("Name");
5659        sheet.get_cell_mut("B1").set_value("Amount");
5660        sheet.get_cell_mut("A2").set_value("Alice");
5661        sheet.get_cell_mut("B2").set_value_number(10.0);
5662        sheet.get_cell_mut("A3").set_value("Bob");
5663        sheet.get_cell_mut("B3").set_value_number(20.0);
5664    }
5665
5666    fn set_formula(
5667        sheet: &mut umya_spreadsheet::Worksheet,
5668        address: &str,
5669        formula: &str,
5670        result: &str,
5671    ) {
5672        let cell = sheet.get_cell_mut(address);
5673        cell.set_formula(formula);
5674        cell.get_cell_value_mut().set_formula_result_default(result);
5675    }
5676
5677    fn sample_append_rows() -> Vec<Vec<Option<MatrixCell>>> {
5678        vec![vec![
5679            Some(MatrixCell::Value(serde_json::json!("Cara"))),
5680            Some(MatrixCell::Value(serde_json::json!(30))),
5681        ]]
5682    }
5683
5684    fn detect_primary_region_id(path: &Path, sheet_name: &str) -> u32 {
5685        let config = Arc::new(local_workbook_config(path));
5686        let workbook = WorkbookContext::load(&config, path).expect("load workbook");
5687        let entry = workbook
5688            .get_sheet_metrics(sheet_name)
5689            .expect("sheet metrics");
5690        entry
5691            .detected_regions()
5692            .into_iter()
5693            .find(|region| region.bounds.starts_with("A1:"))
5694            .or_else(|| entry.detected_regions().into_iter().next())
5695            .expect("detected region")
5696            .id
5697    }
5698
5699    #[test]
5700    fn footer_detects_exact_total_keyword() {
5701        let workbook = with_sheet(|sheet| {
5702            sheet.get_cell_mut("A4").set_value("Total");
5703            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5704        });
5705        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5706
5707        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5708        assert!(
5709            reason
5710                .as_deref()
5711                .unwrap_or_default()
5712                .contains("footer keyword 'Total' and formula")
5713        );
5714    }
5715
5716    #[test]
5717    fn footer_detects_grand_total_keyword() {
5718        let workbook = with_sheet(|sheet| {
5719            sheet.get_cell_mut("A4").set_value("Grand Total");
5720            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5721        });
5722        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5723
5724        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5725        assert!(
5726            reason
5727                .as_deref()
5728                .unwrap_or_default()
5729                .contains("footer keyword 'Grand Total' and formula")
5730        );
5731    }
5732
5733    #[test]
5734    fn footer_detects_subtotal_keyword() {
5735        let workbook = with_sheet(|sheet| {
5736            sheet.get_cell_mut("A4").set_value("Subtotal");
5737            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5738        });
5739        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5740
5741        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5742        assert!(
5743            reason
5744                .as_deref()
5745                .unwrap_or_default()
5746                .contains("footer keyword 'Subtotal' and formula")
5747        );
5748    }
5749
5750    #[test]
5751    fn footer_detects_footer_keyword() {
5752        let workbook = with_sheet(|sheet| {
5753            sheet.get_cell_mut("A4").set_value("Footer");
5754            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5755        });
5756        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5757
5758        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5759        assert!(
5760            reason
5761                .as_deref()
5762                .unwrap_or_default()
5763                .contains("footer keyword 'Footer' and formula")
5764        );
5765    }
5766
5767    #[test]
5768    fn footer_detects_formula_summary_with_blank_label() {
5769        let workbook = with_sheet(|sheet| {
5770            set_formula(sheet, "B4", "SUM(B2:B3)", "30");
5771        });
5772        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5773
5774        assert_eq!(
5775            footer_reason_for_row(sheet, 1, 2, 4).as_deref(),
5776            Some("formula-bearing summary row 4")
5777        );
5778    }
5779
5780    #[test]
5781    fn footer_detects_sparse_late_column_formula_summary() {
5782        let workbook = with_sheet(|sheet| {
5783            set_formula(sheet, "D4", "SUM(D2:D3)", "30");
5784        });
5785        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5786
5787        assert_eq!(
5788            footer_reason_for_row(sheet, 1, 4, 4).as_deref(),
5789            Some("formula-bearing summary row 4")
5790        );
5791    }
5792
5793    #[test]
5794    fn footer_detection_trims_and_normalizes_case() {
5795        let workbook = with_sheet(|sheet| {
5796            sheet.get_cell_mut("A4").set_value("  ToTaL  ");
5797            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5798        });
5799        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5800
5801        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5802        assert!(
5803            reason
5804                .as_deref()
5805                .unwrap_or_default()
5806                .contains("footer keyword 'ToTaL' and formula")
5807        );
5808    }
5809
5810    #[test]
5811    fn footer_ignores_non_footer_total_phrase_without_formula() {
5812        let workbook = with_sheet(|sheet| {
5813            sheet.get_cell_mut("A4").set_value("Total Revenue Plan");
5814        });
5815        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5816
5817        assert!(footer_reason_for_row(sheet, 1, 2, 4).is_none());
5818    }
5819
5820    #[test]
5821    fn footer_detects_starts_with_total_with_formula() {
5822        let workbook = with_sheet(|sheet| {
5823            sheet.get_cell_mut("A4").set_value("Total Revenue Plan");
5824            set_formula(sheet, "B4", "SUM(B1:B3)", "100");
5825        });
5826        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5827
5828        let reason = footer_reason_for_row(sheet, 1, 2, 4);
5829        assert!(
5830            reason
5831                .as_deref()
5832                .unwrap_or_default()
5833                .contains("footer keyword 'Total Revenue Plan' and formula")
5834        );
5835    }
5836
5837    #[test]
5838    fn footer_ignores_last_data_row_with_formula_and_label() {
5839        let workbook = with_sheet(|sheet| {
5840            sheet.get_cell_mut("A4").set_value("Alice");
5841            set_formula(sheet, "B4", "B2+B3", "30");
5842        });
5843        let sheet = workbook.get_sheet_by_name("Sheet1").expect("sheet1");
5844
5845        assert!(footer_reason_for_row(sheet, 1, 2, 4).is_none());
5846    }
5847
5848    #[test]
5849    fn detect_append_footer_returns_none_when_no_footer_row_exists() {
5850        let (_tmp, path) = write_workbook_fixture("append-region-no-footer.xlsx", |sheet| {
5851            seed_basic_region(sheet);
5852        });
5853
5854        let detection = detect_append_footer(&path, "Sheet1", 1, 2, 3).expect("detect footer");
5855        assert_eq!(detection.footer_row, None);
5856        assert_eq!(detection.footer_detection, None);
5857        assert!(detection.footer_formula_targets.is_empty());
5858        assert_eq!(detection.footer_candidates.len(), 2);
5859        assert!(!detection.footer_candidates[0].matched);
5860        assert!(!detection.footer_candidates[1].matched);
5861    }
5862
5863    #[test]
5864    fn detect_append_footer_prefers_region_end_row_when_it_is_summary() {
5865        let (_tmp, path) = write_workbook_fixture("append-region-footer-at-end.xlsx", |sheet| {
5866            seed_basic_region(sheet);
5867            sheet.get_cell_mut("A4").set_value("Total");
5868            set_formula(sheet, "B4", "SUM(B2:B3)", "30");
5869        });
5870
5871        let detection = detect_append_footer(&path, "Sheet1", 1, 2, 4).expect("detect footer");
5872        assert_eq!(detection.footer_row, Some(4));
5873        assert_eq!(
5874            detection.footer_detection.as_deref(),
5875            Some("footer keyword 'Total' and formula on row 4")
5876        );
5877        assert_eq!(detection.footer_formula_targets, vec!["B4"]);
5878        assert!(detection.footer_candidates[0].matched);
5879    }
5880
5881    #[test]
5882    fn detect_append_footer_finds_summary_on_row_after_region_end() {
5883        let (_tmp, path) = write_workbook_fixture("append-region-footer-after-end.xlsx", |sheet| {
5884            seed_basic_region(sheet);
5885            sheet.get_cell_mut("A4").set_value("Total");
5886            set_formula(sheet, "B4", "SUM(B2:B3)", "30");
5887        });
5888
5889        let detection = detect_append_footer(&path, "Sheet1", 1, 2, 3).expect("detect footer");
5890        assert_eq!(detection.footer_row, Some(4));
5891        assert_eq!(
5892            detection.footer_detection.as_deref(),
5893            Some("footer keyword 'Total' and formula on row 4")
5894        );
5895        assert!(!detection.footer_candidates[0].matched);
5896        assert!(detection.footer_candidates[1].matched);
5897    }
5898
5899    #[test]
5900    fn build_append_region_plan_inserts_before_footer_and_sets_target_range() {
5901        let (_tmp, path) = write_workbook_fixture("append-region-plan-footer.xlsx", |sheet| {
5902            seed_basic_region(sheet);
5903            sheet.get_cell_mut("A4").set_value("Total");
5904            set_formula(sheet, "B4", "SUM(B2:B3)", "30");
5905        });
5906        let region_id = detect_primary_region_id(&path, "Sheet1");
5907
5908        let plan = build_append_region_plan(
5909            &path,
5910            "Sheet1",
5911            Some(region_id),
5912            None,
5913            AppendRegionFooterPolicyArg::Auto,
5914            sample_append_rows(),
5915        )
5916        .expect("build plan");
5917        assert_eq!(plan.target_kind, AppendRegionTargetKind::DetectedRegion);
5918        assert_eq!(plan.region_id, Some(region_id));
5919        assert_eq!(plan.footer_policy, "auto");
5920        assert_eq!(plan.footer_row, Some(4));
5921        assert_eq!(plan.insert_at_row, 4);
5922        assert_eq!(
5923            plan.insert_reason,
5924            "auto policy selected detected footer row 4"
5925        );
5926        assert_eq!(plan.footer_formula_targets, vec!["B4"]);
5927        assert_eq!(plan.target_anchor, "A4");
5928        assert_eq!(plan.target_range, "A4:B4");
5929        assert_eq!(plan.confidence, "high");
5930        assert!(plan.warnings.is_empty());
5931    }
5932
5933    #[test]
5934    fn build_append_region_plan_warns_when_no_footer_is_detected() {
5935        let (_tmp, path) = write_workbook_fixture("append-region-plan-no-footer.xlsx", |sheet| {
5936            seed_basic_region(sheet);
5937        });
5938        let region_id = detect_primary_region_id(&path, "Sheet1");
5939
5940        let plan = build_append_region_plan(
5941            &path,
5942            "Sheet1",
5943            Some(region_id),
5944            None,
5945            AppendRegionFooterPolicyArg::Auto,
5946            sample_append_rows(),
5947        )
5948        .expect("build plan");
5949        assert_eq!(plan.footer_row, None);
5950        assert!(plan.insert_at_row >= 4);
5951        assert_eq!(plan.confidence, "low");
5952        assert!(
5953            plan.warnings
5954                .iter()
5955                .any(|warning| warning.contains("no footer row detected"))
5956        );
5957    }
5958
5959    #[test]
5960    fn build_append_region_plan_does_not_treat_formula_data_row_as_footer() {
5961        let (_tmp, path) =
5962            write_workbook_fixture("append-region-plan-formula-data-row.xlsx", |sheet| {
5963                sheet.get_cell_mut("A1").set_value("Name");
5964                sheet.get_cell_mut("B1").set_value("Amount");
5965                sheet.get_cell_mut("A2").set_value("Alice");
5966                sheet.get_cell_mut("B2").set_value_number(10.0);
5967                sheet.get_cell_mut("A3").set_value("Bob");
5968                set_formula(sheet, "B3", "B2*2", "20");
5969            });
5970        let region_id = detect_primary_region_id(&path, "Sheet1");
5971
5972        let plan = build_append_region_plan(
5973            &path,
5974            "Sheet1",
5975            Some(region_id),
5976            None,
5977            AppendRegionFooterPolicyArg::Auto,
5978            sample_append_rows(),
5979        )
5980        .expect("build plan");
5981        assert_eq!(plan.footer_row, None);
5982        assert_eq!(plan.insert_at_row, 4);
5983    }
5984
5985    #[test]
5986    fn build_append_region_plan_table_target_does_not_treat_formula_data_row_as_footer() {
5987        let (_tmp, path) =
5988            write_workbook_fixture("append-region-plan-table-formula-data-row.xlsx", |sheet| {
5989                sheet.get_cell_mut("A1").set_value("Name");
5990                sheet.get_cell_mut("B1").set_value("Amount");
5991                sheet.get_cell_mut("A2").set_value("Alice");
5992                sheet.get_cell_mut("B2").set_value_number(10.0);
5993                sheet.get_cell_mut("A3").set_value("Bob");
5994                set_formula(sheet, "B3", "B2*2", "20");
5995                let mut table = umya_spreadsheet::structs::Table::new("SalesTable", ("A1", "B3"));
5996                table.set_display_name("SalesTable");
5997                sheet.add_table(table);
5998            });
5999
6000        let plan = build_append_region_plan(
6001            &path,
6002            "Sheet1",
6003            None,
6004            Some("SalesTable"),
6005            AppendRegionFooterPolicyArg::Auto,
6006            sample_append_rows(),
6007        )
6008        .expect("build plan");
6009        assert_eq!(plan.footer_row, None);
6010        assert_eq!(plan.insert_at_row, 4);
6011    }
6012
6013    #[test]
6014    fn build_append_region_plan_before_footer_fails_for_formula_data_row() {
6015        let (_tmp, path) = write_workbook_fixture(
6016            "append-region-plan-formula-data-row-before-footer.xlsx",
6017            |sheet| {
6018                sheet.get_cell_mut("A1").set_value("Name");
6019                sheet.get_cell_mut("B1").set_value("Amount");
6020                sheet.get_cell_mut("A2").set_value("Alice");
6021                sheet.get_cell_mut("B2").set_value_number(10.0);
6022                sheet.get_cell_mut("A3").set_value("Bob");
6023                set_formula(sheet, "B3", "B2*2", "20");
6024            },
6025        );
6026        let region_id = detect_primary_region_id(&path, "Sheet1");
6027
6028        let error = build_append_region_plan(
6029            &path,
6030            "Sheet1",
6031            Some(region_id),
6032            None,
6033            AppendRegionFooterPolicyArg::BeforeFooter,
6034            sample_append_rows(),
6035        )
6036        .expect_err("before-footer should fail for calculated data rows");
6037        assert!(
6038            error
6039                .to_string()
6040                .contains("requires a detected footer/subtotal row")
6041        );
6042    }
6043
6044    #[test]
6045    fn build_append_region_plan_append_at_end_ignores_detected_footer() {
6046        let (_tmp, path) =
6047            write_workbook_fixture("append-region-plan-append-at-end.xlsx", |sheet| {
6048                seed_basic_region(sheet);
6049                sheet.get_cell_mut("A4").set_value("Total");
6050                set_formula(sheet, "B4", "SUM(B2:B3)", "30");
6051            });
6052        let region_id = detect_primary_region_id(&path, "Sheet1");
6053
6054        let plan = build_append_region_plan(
6055            &path,
6056            "Sheet1",
6057            Some(region_id),
6058            None,
6059            AppendRegionFooterPolicyArg::AppendAtEnd,
6060            sample_append_rows(),
6061        )
6062        .expect("build plan");
6063        assert_eq!(plan.footer_row, Some(4));
6064        assert_eq!(plan.insert_at_row, 5);
6065        assert!(
6066            plan.insert_reason
6067                .contains("append_at_end policy bypassed detected footer row 4")
6068        );
6069        assert!(
6070            plan.warnings
6071                .iter()
6072                .any(|warning| warning.contains("ignored detected footer row 4"))
6073        );
6074    }
6075
6076    #[test]
6077    fn build_append_region_plan_before_footer_requires_detected_footer() {
6078        let (_tmp, path) =
6079            write_workbook_fixture("append-region-plan-before-footer.xlsx", |sheet| {
6080                seed_basic_region(sheet);
6081            });
6082        let region_id = detect_primary_region_id(&path, "Sheet1");
6083
6084        let error = build_append_region_plan(
6085            &path,
6086            "Sheet1",
6087            Some(region_id),
6088            None,
6089            AppendRegionFooterPolicyArg::BeforeFooter,
6090            sample_append_rows(),
6091        )
6092        .expect_err("before-footer should fail without a footer row");
6093        assert!(
6094            error
6095                .to_string()
6096                .contains("footer policy 'before-footer' requires a detected footer/subtotal row")
6097        );
6098    }
6099
6100    #[test]
6101    fn build_append_region_plan_resolves_table_target() {
6102        let (_tmp, path) = write_workbook_fixture("append-region-plan-table.xlsx", |sheet| {
6103            sheet.get_cell_mut("A1").set_value("Name");
6104            sheet.get_cell_mut("B1").set_value("Amount");
6105            sheet.get_cell_mut("A2").set_value("Alice");
6106            sheet.get_cell_mut("B2").set_value_number(10.0);
6107            sheet.get_cell_mut("A3").set_value("Bob");
6108            sheet.get_cell_mut("B3").set_value_number(20.0);
6109            let mut table = umya_spreadsheet::structs::Table::new("SalesTable", ("A1", "B3"));
6110            table.set_display_name("SalesTable");
6111            sheet.add_table(table);
6112        });
6113
6114        let plan = build_append_region_plan(
6115            &path,
6116            "Sheet1",
6117            None,
6118            Some("SalesTable"),
6119            AppendRegionFooterPolicyArg::Auto,
6120            sample_append_rows(),
6121        )
6122        .expect("build plan");
6123        assert_eq!(plan.target_kind, AppendRegionTargetKind::Table);
6124        assert_eq!(plan.table_name.as_deref(), Some("SalesTable"));
6125        assert_eq!(plan.header_row, Some(1));
6126        assert_eq!(plan.region_bounds, "A1:B3");
6127    }
6128
6129    #[test]
6130    fn build_append_region_plan_rejects_payload_wider_than_region() {
6131        let (_tmp, path) = write_workbook_fixture("append-region-plan-too-wide.xlsx", |sheet| {
6132            seed_basic_region(sheet);
6133        });
6134        let region_id = detect_primary_region_id(&path, "Sheet1");
6135        let rows = vec![vec![
6136            Some(MatrixCell::Value(serde_json::json!("Cara"))),
6137            Some(MatrixCell::Value(serde_json::json!(30))),
6138            Some(MatrixCell::Value(serde_json::json!("extra"))),
6139        ]];
6140
6141        let error = build_append_region_plan(
6142            &path,
6143            "Sheet1",
6144            Some(region_id),
6145            None,
6146            AppendRegionFooterPolicyArg::Auto,
6147            rows,
6148        )
6149        .expect_err("payload wider than region should fail");
6150        assert!(
6151            error
6152                .to_string()
6153                .contains("rows payload is wider than region 0")
6154                || error
6155                    .to_string()
6156                    .contains("rows payload is wider than region ")
6157        );
6158    }
6159
6160    #[test]
6161    fn build_append_region_plan_rejects_zero_column_payload() {
6162        let (_tmp, path) =
6163            write_workbook_fixture("append-region-plan-empty-columns.xlsx", |sheet| {
6164                seed_basic_region(sheet);
6165            });
6166        let region_id = detect_primary_region_id(&path, "Sheet1");
6167
6168        let error = build_append_region_plan(
6169            &path,
6170            "Sheet1",
6171            Some(region_id),
6172            None,
6173            AppendRegionFooterPolicyArg::Auto,
6174            vec![Vec::new()],
6175        )
6176        .expect_err("zero-column payload should fail");
6177        assert!(
6178            error
6179                .to_string()
6180                .contains("append-region rows payload must contain at least one non-empty column")
6181        );
6182    }
6183
6184    #[test]
6185    fn build_clone_template_row_plan_reports_targets_and_adjacent_sum_candidates() {
6186        let (_tmp, path) = write_workbook_fixture("clone-template-row-plan.xlsx", |sheet| {
6187            sheet.get_cell_mut("A1").set_value("Item");
6188            sheet.get_cell_mut("B1").set_value("Input");
6189            sheet.get_cell_mut("C1").set_value("Calc");
6190            sheet.get_cell_mut("A2").set_value("Alpha");
6191            sheet.get_cell_mut("B2").set_value_number(10.0);
6192            set_formula(sheet, "C2", "B2*2", "20");
6193            sheet.get_cell_mut("A3").set_value("Total");
6194            set_formula(sheet, "C3", "SUM(C2:C2)", "20");
6195        });
6196
6197        let plan = build_clone_template_row_plan(
6198            &path,
6199            "Sheet1",
6200            2,
6201            None,
6202            Some(2),
6203            None,
6204            2,
6205            true,
6206            ClonePatchTargetsArg::LikelyInputs,
6207            CloneMergePolicyArg::Safe,
6208        )
6209        .expect("build plan");
6210        assert_eq!(plan.anchor_kind, CloneAnchorKind::After);
6211        assert_eq!(plan.insert_at_row, 3);
6212        assert_eq!(plan.inserted_row_range, "3:4");
6213        assert_eq!(plan.formula_targets, vec!["C3", "C4"]);
6214        assert_eq!(plan.likely_patch_targets, vec!["B3", "B4"]);
6215        assert_eq!(plan.adjacent_sum_targets, vec!["C5"]);
6216        assert_eq!(plan.confidence, "high");
6217    }
6218
6219    #[test]
6220    fn build_clone_template_row_plan_strict_merge_policy_fails_for_crossing_merge() {
6221        let (_tmp, path) =
6222            write_workbook_fixture("clone-template-row-strict-merge.xlsx", |sheet| {
6223                sheet.get_cell_mut("A1").set_value("Header");
6224                sheet.get_cell_mut("A2").set_value("Alpha");
6225                sheet.get_cell_mut("B2").set_value_number(10.0);
6226                sheet.add_merge_cells("A1:A2");
6227            });
6228
6229        let error = build_clone_template_row_plan(
6230            &path,
6231            "Sheet1",
6232            2,
6233            Some(3),
6234            None,
6235            None,
6236            1,
6237            false,
6238            ClonePatchTargetsArg::LikelyInputs,
6239            CloneMergePolicyArg::Strict,
6240        )
6241        .expect_err("strict merge policy should fail");
6242        assert!(error.to_string().contains("unsafe clone template"));
6243    }
6244
6245    #[test]
6246    fn apply_clone_template_row_plan_preserves_horizontal_merges_and_row_validations() {
6247        let (_tmp, path) = write_workbook_fixture("clone-template-row-apply.xlsx", |sheet| {
6248            sheet.get_cell_mut("A1").set_value("Name");
6249            sheet.get_cell_mut("B1").set_value("Input");
6250            sheet.get_cell_mut("C1").set_value("Calc");
6251            sheet.get_cell_mut("A2").set_value("Alpha");
6252            sheet.get_cell_mut("B2").set_value_number(10.0);
6253            set_formula(sheet, "C2", "B2*2", "20");
6254            sheet.add_merge_cells("A2:B2");
6255
6256            let mut dv = umya_spreadsheet::structs::DataValidation::default();
6257            dv.set_type(umya_spreadsheet::structs::DataValidationValues::List);
6258            dv.get_sequence_of_references_mut().set_sqref("B2:B2");
6259            dv.set_formula1("\"A,B,C\"");
6260            sheet.set_data_validations(umya_spreadsheet::structs::DataValidations::default());
6261            sheet
6262                .get_data_validations_mut()
6263                .unwrap()
6264                .add_data_validation_list(dv);
6265        });
6266
6267        let plan = build_clone_template_row_plan(
6268            &path,
6269            "Sheet1",
6270            2,
6271            Some(3),
6272            None,
6273            None,
6274            2,
6275            false,
6276            ClonePatchTargetsArg::AllNonFormula,
6277            CloneMergePolicyArg::Safe,
6278        )
6279        .expect("build plan");
6280        apply_clone_template_row_plan_to_file(&path, &plan).expect("apply plan");
6281
6282        let book = umya_spreadsheet::reader::xlsx::read(&path).expect("read workbook");
6283        let sheet = book.get_sheet_by_name("Sheet1").expect("sheet1");
6284        assert_eq!(sheet.get_cell("A3").expect("A3").get_value(), "Alpha");
6285        assert_eq!(sheet.get_cell("B4").expect("B4").get_value(), "10");
6286        let merge_ranges: Vec<String> = sheet
6287            .get_merge_cells()
6288            .iter()
6289            .map(|range| range.get_range())
6290            .collect();
6291        assert!(merge_ranges.contains(&"A3:B3".to_string()));
6292        assert!(merge_ranges.contains(&"A4:B4".to_string()));
6293        let validations = sheet.get_data_validations().expect("validations");
6294        let sqrefs: Vec<String> = validations
6295            .get_data_validation_list()
6296            .iter()
6297            .map(|dv| dv.get_sequence_of_references().get_sqref())
6298            .collect();
6299        assert!(sqrefs.iter().any(|sqref| sqref.contains("B3")));
6300        assert!(sqrefs.iter().any(|sqref| sqref.contains("B4")));
6301    }
6302
6303    #[test]
6304    fn build_clone_row_band_plan_reports_inserted_blocks_and_targets() {
6305        let (_tmp, path) = write_workbook_fixture("clone-row-band-plan.xlsx", |sheet| {
6306            sheet.get_cell_mut("A1").set_value("Item");
6307            sheet.get_cell_mut("B1").set_value("Input");
6308            sheet.get_cell_mut("C1").set_value("Calc");
6309            sheet.get_cell_mut("A2").set_value("Alpha");
6310            sheet.get_cell_mut("B2").set_value_number(10.0);
6311            set_formula(sheet, "C2", "B2*2", "20");
6312            sheet.get_cell_mut("A3").set_value("Beta");
6313            sheet.get_cell_mut("B3").set_value_number(20.0);
6314            set_formula(sheet, "C3", "B3*2", "40");
6315            sheet.get_cell_mut("A4").set_value("Total");
6316            set_formula(sheet, "C4", "SUM(C2:C3)", "60");
6317        });
6318
6319        let plan = build_clone_row_band_plan(
6320            &path,
6321            "Sheet1",
6322            "2:3",
6323            None,
6324            Some(3),
6325            None,
6326            2,
6327            true,
6328            ClonePatchTargetsArg::LikelyInputs,
6329            CloneMergePolicyArg::Safe,
6330        )
6331        .expect("build plan");
6332        assert_eq!(plan.helper_kind, CloneHelperKind::CloneRowBand);
6333        assert_eq!(plan.source_row_count, 2);
6334        assert_eq!(plan.rows_inserted, 4);
6335        assert_eq!(plan.inserted_row_range, "4:7");
6336        assert_eq!(plan.inserted_blocks.len(), 2);
6337        assert_eq!(plan.inserted_blocks[0].row_range, "4:5");
6338        assert_eq!(plan.inserted_blocks[1].row_range, "6:7");
6339        assert_eq!(plan.formula_targets, vec!["C4", "C5", "C6", "C7"]);
6340        assert_eq!(plan.likely_patch_targets, vec!["B4", "B5", "B6", "B7"]);
6341        assert_eq!(plan.adjacent_sum_targets, vec!["C8"]);
6342    }
6343
6344    #[test]
6345    fn build_clone_row_band_plan_strict_merge_policy_fails_for_crossing_merge() {
6346        let (_tmp, path) = write_workbook_fixture("clone-row-band-strict-merge.xlsx", |sheet| {
6347            sheet.get_cell_mut("A1").set_value("Header");
6348            sheet.get_cell_mut("A2").set_value("Alpha");
6349            sheet.get_cell_mut("A3").set_value("Beta");
6350            sheet.add_merge_cells("A1:A2");
6351        });
6352
6353        let error = build_clone_row_band_plan(
6354            &path,
6355            "Sheet1",
6356            "2:3",
6357            Some(4),
6358            None,
6359            None,
6360            1,
6361            false,
6362            ClonePatchTargetsArg::LikelyInputs,
6363            CloneMergePolicyArg::Strict,
6364        )
6365        .expect_err("strict merge policy should fail");
6366        assert!(error.to_string().contains("unsafe clone template"));
6367    }
6368
6369    #[test]
6370    fn apply_clone_row_band_plan_preserves_contained_merges_validations_and_row_heights() {
6371        let (_tmp, path) = write_workbook_fixture("clone-row-band-apply.xlsx", |sheet| {
6372            sheet.get_cell_mut("A1").set_value("Name");
6373            sheet.get_cell_mut("B1").set_value("Input");
6374            sheet.get_cell_mut("C1").set_value("Calc");
6375            sheet.get_cell_mut("A2").set_value("Alpha");
6376            sheet.get_cell_mut("B2").set_value_number(10.0);
6377            set_formula(sheet, "C2", "B2*2", "20");
6378            sheet.get_cell_mut("A3").set_value("Beta");
6379            sheet.get_cell_mut("B3").set_value_number(20.0);
6380            set_formula(sheet, "C3", "B3*2", "40");
6381            sheet.add_merge_cells("A2:A3");
6382            sheet
6383                .get_row_dimension_mut(&2)
6384                .set_height(28.0)
6385                .set_custom_height(true);
6386            sheet
6387                .get_row_dimension_mut(&3)
6388                .set_height(32.0)
6389                .set_custom_height(true);
6390
6391            let mut dv = umya_spreadsheet::structs::DataValidation::default();
6392            dv.set_type(umya_spreadsheet::structs::DataValidationValues::List);
6393            dv.get_sequence_of_references_mut().set_sqref("B2:B3");
6394            dv.set_formula1("\"A,B,C\"");
6395            sheet.set_data_validations(umya_spreadsheet::structs::DataValidations::default());
6396            sheet
6397                .get_data_validations_mut()
6398                .unwrap()
6399                .add_data_validation_list(dv);
6400        });
6401
6402        let plan = build_clone_row_band_plan(
6403            &path,
6404            "Sheet1",
6405            "2:3",
6406            Some(4),
6407            None,
6408            None,
6409            2,
6410            false,
6411            ClonePatchTargetsArg::AllNonFormula,
6412            CloneMergePolicyArg::Safe,
6413        )
6414        .expect("build plan");
6415        apply_clone_row_band_plan_to_file(&path, &plan).expect("apply plan");
6416
6417        let book = umya_spreadsheet::reader::xlsx::read(&path).expect("read workbook");
6418        let sheet = book.get_sheet_by_name("Sheet1").expect("sheet1");
6419        assert_eq!(sheet.get_cell("A4").expect("A4").get_value(), "Alpha");
6420        assert_eq!(sheet.get_cell("A5").expect("A5").get_value(), "Beta");
6421        assert_eq!(
6422            sheet
6423                .get_cell("C4")
6424                .expect("C4")
6425                .get_formula()
6426                .replace(' ', ""),
6427            "B4*2"
6428        );
6429        assert_eq!(
6430            sheet
6431                .get_cell("C7")
6432                .expect("C7")
6433                .get_formula()
6434                .replace(' ', ""),
6435            "B7*2"
6436        );
6437        let merge_ranges: Vec<String> = sheet
6438            .get_merge_cells()
6439            .iter()
6440            .map(|range| range.get_range())
6441            .collect();
6442        assert!(merge_ranges.contains(&"A4:A5".to_string()));
6443        assert!(merge_ranges.contains(&"A6:A7".to_string()));
6444        assert_eq!(
6445            sheet.get_row_dimension(&4).map(|row| *row.get_height()),
6446            Some(28.0)
6447        );
6448        assert_eq!(
6449            sheet.get_row_dimension(&5).map(|row| *row.get_height()),
6450            Some(32.0)
6451        );
6452        let validations = sheet.get_data_validations().expect("validations");
6453        let sqrefs: Vec<String> = validations
6454            .get_data_validation_list()
6455            .iter()
6456            .map(|dv| dv.get_sequence_of_references().get_sqref())
6457            .collect();
6458        assert!(
6459            sqrefs
6460                .iter()
6461                .any(|sqref| sqref.contains("B4") && sqref.contains("B5"))
6462        );
6463        assert!(
6464            sqrefs
6465                .iter()
6466                .any(|sqref| sqref.contains("B6") && sqref.contains("B7"))
6467        );
6468    }
6469
6470    #[test]
6471    fn apply_clone_row_band_shifts_formulas_correctly_for_internal_external_and_absolute_refs() {
6472        let (_tmp, path) = write_workbook_fixture("clone-row-band-formulas.xlsx", |sheet| {
6473            sheet.get_cell_mut("A1").set_value("Rate");
6474            sheet.get_cell_mut("Z1").set_value_number(0.05);
6475
6476            // Row 2
6477            set_formula(sheet, "A2", "A1", ""); // External relative
6478            set_formula(sheet, "B2", "$Z$1", ""); // Absolute
6479            sheet.get_cell_mut("C2").set_value_number(100.0);
6480
6481            // Row 3
6482            set_formula(sheet, "A3", "A2", ""); // Internal relative
6483            set_formula(sheet, "B3", "$Z$1", ""); // Absolute
6484            set_formula(sheet, "D3", "SUM(C2:C3)", ""); // Internal range
6485        });
6486
6487        let plan = build_clone_row_band_plan(
6488            &path,
6489            "Sheet1",
6490            "2:3",
6491            None,
6492            Some(3), // after row 3 -> inserts at row 4
6493            None,
6494            1, // repeat 1 time -> range 4:5
6495            false,
6496            ClonePatchTargetsArg::None,
6497            CloneMergePolicyArg::Safe,
6498        )
6499        .expect("build plan");
6500
6501        apply_clone_row_band_plan_to_file(&path, &plan).expect("apply plan");
6502
6503        let book = umya_spreadsheet::reader::xlsx::read(&path).expect("read workbook");
6504        let sheet = book.get_sheet_by_name("Sheet1").expect("sheet1");
6505
6506        // Row 4 (cloned from Row 2, offset +2)
6507        assert_eq!(
6508            sheet
6509                .get_cell("A4")
6510                .expect("A4")
6511                .get_formula()
6512                .replace(' ', ""),
6513            "A3"
6514        );
6515        assert_eq!(
6516            sheet
6517                .get_cell("B4")
6518                .expect("B4")
6519                .get_formula()
6520                .replace(' ', ""),
6521            "$Z$1"
6522        );
6523
6524        // Row 5 (cloned from Row 3, offset +2)
6525        assert_eq!(
6526            sheet
6527                .get_cell("A5")
6528                .expect("A5")
6529                .get_formula()
6530                .replace(' ', ""),
6531            "A4"
6532        );
6533        assert_eq!(
6534            sheet
6535                .get_cell("B5")
6536                .expect("B5")
6537                .get_formula()
6538                .replace(' ', ""),
6539            "$Z$1"
6540        );
6541        assert_eq!(
6542            sheet
6543                .get_cell("D5")
6544                .expect("D5")
6545                .get_formula()
6546                .replace(' ', ""),
6547            "SUM(C4:C5)"
6548        );
6549    }
6550
6551    #[test]
6552    fn apply_clone_row_band_safe_policy_drops_crossing_merges_but_keeps_contained_merges() {
6553        let (_tmp, path) = write_workbook_fixture("clone-row-band-safe-merges.xlsx", |sheet| {
6554            sheet.get_cell_mut("A1").set_value("A1");
6555            sheet.get_cell_mut("A2").set_value("A2");
6556            sheet.get_cell_mut("A3").set_value("A3");
6557            sheet.get_cell_mut("A4").set_value("A4");
6558
6559            sheet.add_merge_cells("A2:A3"); // Fully contained in 2:3
6560            sheet.add_merge_cells("B3:B4"); // Crossing bottom boundary
6561            sheet.add_merge_cells("C1:C2"); // Crossing top boundary
6562        });
6563
6564        let plan = build_clone_row_band_plan(
6565            &path,
6566            "Sheet1",
6567            "2:3",
6568            None,
6569            Some(3), // after row 3 -> inserts at row 4
6570            None,
6571            1, // repeat 1 time -> range 4:5
6572            false,
6573            ClonePatchTargetsArg::None,
6574            CloneMergePolicyArg::Safe,
6575        )
6576        .expect("build plan");
6577
6578        apply_clone_row_band_plan_to_file(&path, &plan).expect("apply plan");
6579
6580        let book = umya_spreadsheet::reader::xlsx::read(&path).expect("read workbook");
6581        let sheet = book.get_sheet_by_name("Sheet1").expect("sheet1");
6582
6583        let merge_ranges: Vec<String> = sheet
6584            .get_merge_cells()
6585            .iter()
6586            .map(|range| range.get_range())
6587            .collect();
6588
6589        // Original merges should still exist (or be properly expanded)
6590        assert!(merge_ranges.contains(&"A2:A3".to_string()));
6591        assert!(merge_ranges.contains(&"C1:C2".to_string()));
6592        // B3:B4 crosses the insertion boundary at row 4, so inserting 2 rows expands it to B3:B6
6593        assert!(merge_ranges.contains(&"B3:B6".to_string()));
6594
6595        // Cloned fully contained merge should exist
6596        assert!(merge_ranges.contains(&"A4:A5".to_string()));
6597
6598        // Cloned crossing merges should NOT exist
6599        // shifted B3:B4 (+2 rows) = B5:B6 (does not exist, though B3:B6 covers the area)
6600        // shifted C1:C2 (+2 rows) = C3:C4
6601        assert!(!merge_ranges.contains(&"C3:C4".to_string()));
6602    }
6603
6604    #[test]
6605    fn build_clone_template_row_plan_identifies_likely_inputs_correctly() {
6606        let (_tmp, path) = write_workbook_fixture("clone-likely-inputs.xlsx", |sheet| {
6607            sheet.get_cell_mut("A1").set_value("Expense"); // String label, skip
6608            sheet.get_cell_mut("B1").set_value_number(150.0); // Numeric, include
6609            // C1 is completely empty, no validation
6610            sheet.get_cell_mut("D1").set_value_number(200.0); // Numeric, include
6611            set_formula(sheet, "E1", "B1+D1", "350"); // Formula, skip
6612
6613            // F1 is a string but has data validation, include
6614            sheet.get_cell_mut("F1").set_value("Select...");
6615            let mut dv = umya_spreadsheet::structs::DataValidation::default();
6616            dv.set_type(umya_spreadsheet::structs::DataValidationValues::List);
6617            dv.get_sequence_of_references_mut().set_sqref("F1:F1");
6618            dv.set_formula1("\"A,B,C\"");
6619            sheet.set_data_validations(umya_spreadsheet::structs::DataValidations::default());
6620            sheet
6621                .get_data_validations_mut()
6622                .unwrap()
6623                .add_data_validation_list(dv);
6624
6625            // G1 is completely empty but has data validation, include
6626            let mut dv2 = umya_spreadsheet::structs::DataValidation::default();
6627            dv2.set_type(umya_spreadsheet::structs::DataValidationValues::List);
6628            dv2.get_sequence_of_references_mut().set_sqref("G1:G1");
6629            dv2.set_formula1("\"X,Y,Z\"");
6630            sheet
6631                .get_data_validations_mut()
6632                .unwrap()
6633                .add_data_validation_list(dv2);
6634        });
6635
6636        let plan = build_clone_template_row_plan(
6637            &path,
6638            "Sheet1",
6639            1,
6640            None,
6641            Some(1),
6642            None,
6643            1,
6644            false,
6645            ClonePatchTargetsArg::LikelyInputs,
6646            CloneMergePolicyArg::Safe,
6647        )
6648        .expect("build plan");
6649
6650        let expected_targets = vec!["B2", "D2", "F2", "G2"];
6651        assert_eq!(plan.likely_patch_targets, expected_targets);
6652    }
6653}