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 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 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 let (impact_report, formula_delta) = crate::tools::structure_impact::compute_structure_impact(
1333 &source,
1334 &normalized.ops,
1335 show_formula_delta,
1336 )?;
1337
1338 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 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 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(®ion.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 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 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#[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 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 set_formula(sheet, "A2", "A1", ""); set_formula(sheet, "B2", "$Z$1", ""); sheet.get_cell_mut("C2").set_value_number(100.0);
6480
6481 set_formula(sheet, "A3", "A2", ""); set_formula(sheet, "B3", "$Z$1", ""); set_formula(sheet, "D3", "SUM(C2:C3)", ""); });
6486
6487 let plan = build_clone_row_band_plan(
6488 &path,
6489 "Sheet1",
6490 "2:3",
6491 None,
6492 Some(3), None,
6494 1, 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 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 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"); sheet.add_merge_cells("B3:B4"); sheet.add_merge_cells("C1:C2"); });
6563
6564 let plan = build_clone_row_band_plan(
6565 &path,
6566 "Sheet1",
6567 "2:3",
6568 None,
6569 Some(3), None,
6571 1, 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 assert!(merge_ranges.contains(&"A2:A3".to_string()));
6591 assert!(merge_ranges.contains(&"C1:C2".to_string()));
6592 assert!(merge_ranges.contains(&"B3:B6".to_string()));
6594
6595 assert!(merge_ranges.contains(&"A4:A5".to_string()));
6597
6598 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"); sheet.get_cell_mut("B1").set_value_number(150.0); sheet.get_cell_mut("D1").set_value_number(200.0); set_formula(sheet, "E1", "B1+D1", "350"); 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 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}