Skip to main content

spreadsheet_kit/cli/
mod.rs

1pub mod commands;
2pub mod errors;
3pub mod output;
4
5use crate::model::FormulaParsePolicy;
6use anyhow::Result;
7use clap::{Args, Parser, Subcommand, ValueEnum};
8use serde_json::Value;
9use std::ffi::OsString;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Copy, ValueEnum)]
13pub enum OutputFormat {
14    Json,
15    Csv,
16}
17
18#[derive(Debug, Clone, Copy, ValueEnum)]
19pub enum TableReadFormat {
20    Json,
21    Values,
22    Csv,
23}
24
25#[derive(Debug, Clone, Copy, ValueEnum)]
26pub enum RangeValuesFormatArg {
27    Json,
28    Values,
29    Csv,
30    Dense,
31    Rows,
32}
33
34#[derive(Debug, Clone, Copy, ValueEnum)]
35pub enum SheetPageFormatArg {
36    #[value(name = "full")]
37    Full,
38    #[value(name = "compact")]
39    Compact,
40    #[value(name = "values_only")]
41    ValuesOnly,
42}
43
44#[derive(Debug, Clone, Copy, ValueEnum)]
45pub enum TableSampleModeArg {
46    First,
47    Last,
48    Distributed,
49}
50
51#[derive(Debug, Clone, Copy, ValueEnum)]
52pub enum OutputShape {
53    Canonical,
54    Compact,
55}
56
57#[derive(Debug, Clone, Copy, ValueEnum)]
58pub enum FindValueMode {
59    Value,
60    Label,
61}
62
63#[derive(Debug, Clone, Copy, ValueEnum)]
64pub enum LabelDirectionArg {
65    Right,
66    Below,
67    Any,
68}
69
70#[derive(Debug, Clone, Copy, ValueEnum)]
71pub enum FormulaSort {
72    Complexity,
73    Count,
74}
75
76#[derive(Debug, Clone, Copy, ValueEnum)]
77pub enum TraceDirectionArg {
78    Precedents,
79    Dependents,
80}
81
82#[derive(Debug, Clone, Copy, ValueEnum)]
83pub enum LayoutModeArg {
84    Values,
85    Formulas,
86}
87
88#[derive(Debug, Clone, Copy, ValueEnum)]
89pub enum LayoutRenderArg {
90    Json,
91    Ascii,
92    Both,
93}
94
95#[derive(Debug, Clone, Copy, ValueEnum)]
96pub enum AppendRegionFooterPolicyArg {
97    Auto,
98    BeforeFooter,
99    AppendAtEnd,
100}
101
102#[derive(Debug, Clone, Copy, ValueEnum)]
103pub enum ClonePatchTargetsArg {
104    LikelyInputs,
105    AllNonFormula,
106    None,
107}
108
109#[derive(Debug, Clone, Copy, ValueEnum)]
110pub enum CloneMergePolicyArg {
111    Safe,
112    Strict,
113}
114
115#[derive(Debug, Subcommand)]
116pub enum SheetportManifestCommands {
117    #[command(
118        about = "Discover candidate SheetPort ports from workbook structure",
119        after_long_help = "Examples:\n  agent-spreadsheet sheetport manifest candidates deal_model.xlsx\n  agent-spreadsheet sheetport manifest candidates deal_model.xlsx --sheet-filter Assumptions"
120    )]
121    Candidates {
122        #[arg(value_name = "FILE", help = "Path to the workbook")]
123        file: PathBuf,
124        #[arg(long, value_name = "SHEET", help = "Optional sheet filter")]
125        sheet_filter: Option<String>,
126    },
127    #[command(about = "Print the canonical SheetPort JSON schema")]
128    Schema,
129    #[command(
130        about = "Validate a SheetPort manifest",
131        after_long_help = "Example:\n  agent-spreadsheet sheetport manifest validate manifest.yaml"
132    )]
133    Validate {
134        #[arg(value_name = "MANIFEST", help = "Path to the YAML manifest")]
135        manifest: PathBuf,
136    },
137    #[command(
138        about = "Normalize a SheetPort manifest for deterministic diffs",
139        after_long_help = "Examples:\n  agent-spreadsheet sheetport manifest normalize manifest.yaml\n  agent-spreadsheet sheetport manifest normalize manifest.yaml --output manifest.normalized.yaml"
140    )]
141    Normalize {
142        #[arg(value_name = "MANIFEST", help = "Path to the YAML manifest")]
143        manifest: PathBuf,
144        #[arg(long, value_name = "PATH", help = "Write normalized YAML to this file")]
145        output: Option<PathBuf>,
146    },
147}
148
149#[derive(Debug, Subcommand)]
150pub enum SheetportCommands {
151    #[command(about = "Manifest lifecycle helpers", subcommand)]
152    Manifest(SheetportManifestCommands),
153    #[command(
154        about = "Bind-check a workbook against a SheetPort manifest",
155        after_long_help = "Example:\n  agent-spreadsheet sheetport bind-check deal_model.xlsx manifest.yaml"
156    )]
157    BindCheck {
158        #[arg(value_name = "FILE", help = "Path to the workbook")]
159        file: PathBuf,
160        #[arg(value_name = "MANIFEST", help = "Path to the YAML manifest")]
161        manifest: PathBuf,
162    },
163    #[command(
164        about = "Execute a SheetPort manifest with JSON inputs",
165        after_long_help = "Examples:\n  agent-spreadsheet sheetport run data.xlsx manifest.yaml --inputs '{\"loan\": 10000}'\n  agent-spreadsheet sheetport run data.xlsx manifest.yaml"
166    )]
167    Run {
168        #[arg(value_name = "FILE", help = "Path to the workbook")]
169        file: PathBuf,
170        #[arg(value_name = "MANIFEST", help = "Path to the YAML manifest")]
171        manifest: PathBuf,
172        #[arg(long, help = "JSON string or @file containing input arguments")]
173        inputs: Option<String>,
174        #[arg(long, help = "Seed for deterministic RNG evaluation")]
175        rng_seed: Option<u64>,
176        #[arg(long, help = "Freeze volatile functions (e.g. NOW(), RAND())")]
177        freeze_volatile: bool,
178    },
179}
180
181#[derive(Debug, Subcommand)]
182pub enum SessionCommands {
183    #[command(about = "Start a new session tracking a base workbook file")]
184    Start {
185        #[arg(long, value_name = "FILE", help = "Path to the base workbook")]
186        base: PathBuf,
187        #[arg(long, value_name = "LABEL", help = "Human-readable session label")]
188        label: Option<String>,
189        #[arg(
190            long,
191            value_name = "PATH",
192            help = "Workspace root directory (default: cwd)"
193        )]
194        workspace: Option<PathBuf>,
195    },
196    #[command(about = "View the event timeline for a session")]
197    Log {
198        #[arg(long, value_name = "ID", help = "Session identifier")]
199        session: String,
200        #[arg(long, value_name = "OP_ID", help = "Show events since this op_id")]
201        since: Option<String>,
202        #[arg(
203            long,
204            value_name = "KIND",
205            help = "Filter by operation kind prefix (e.g. structure)"
206        )]
207        kind: Option<String>,
208        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
209        workspace: Option<PathBuf>,
210    },
211    #[command(about = "List branches in a session")]
212    Branches {
213        #[arg(long, value_name = "ID", help = "Session identifier")]
214        session: String,
215        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
216        workspace: Option<PathBuf>,
217    },
218    #[command(about = "Switch to a different branch")]
219    Switch {
220        #[arg(long, value_name = "ID", help = "Session identifier")]
221        session: String,
222        #[arg(long, value_name = "NAME", help = "Branch name to switch to")]
223        branch: String,
224        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
225        workspace: Option<PathBuf>,
226    },
227    #[command(about = "Set HEAD to a specific event (time-travel)")]
228    Checkout {
229        #[arg(long, value_name = "ID", help = "Session identifier")]
230        session: String,
231        #[arg(value_name = "OP_ID", help = "Event identifier to checkout")]
232        op_id: String,
233        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
234        workspace: Option<PathBuf>,
235    },
236    #[command(about = "Move HEAD back one event (branch-local undo)")]
237    Undo {
238        #[arg(long, value_name = "ID", help = "Session identifier")]
239        session: String,
240        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
241        workspace: Option<PathBuf>,
242    },
243    #[command(about = "Move HEAD forward one event (branch-local redo)")]
244    Redo {
245        #[arg(long, value_name = "ID", help = "Session identifier")]
246        session: String,
247        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
248        workspace: Option<PathBuf>,
249    },
250    #[command(about = "Create a new branch forking from a given event")]
251    Fork {
252        #[arg(long, value_name = "ID", help = "Session identifier")]
253        session: String,
254        #[arg(
255            long,
256            value_name = "OP_ID",
257            help = "Fork from this event (default: current HEAD)"
258        )]
259        from: Option<String>,
260        #[arg(long, value_name = "LABEL", help = "Human-readable branch label")]
261        label: Option<String>,
262        #[arg(value_name = "NAME", help = "New branch name")]
263        branch_name: String,
264        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
265        workspace: Option<PathBuf>,
266    },
267    #[command(
268        about = "Stage an operation (compute dry-run impact without advancing HEAD)",
269        after_long_help = "Canonical session payload contract:\n  • Every payload must include a top-level kind field.\n  • transform.write_matrix is a flat object with sheet_name/anchor/rows.\n  • Batch families use an ops array envelope.\n\nExamples:\n  asp session op --session sess_abc123 --ops @write_matrix.json\n\n  write_matrix.json\n  {\n    \"kind\": \"transform.write_matrix\",\n    \"sheet_name\": \"Sheet1\",\n    \"anchor\": \"B7\",\n    \"rows\": [[\"Revenue\", 100]]\n  }\n\n  asp session op --session sess_abc123 --ops @structure_ops.json\n\n  structure_ops.json\n  {\n    \"kind\": \"structure.insert_rows\",\n    \"ops\": [{ \"sheet_name\": \"Sheet1\", \"at\": 12, \"count\": 2 }]\n  }"
270    )]
271    Op {
272        #[arg(long, value_name = "ID", help = "Session identifier")]
273        session: String,
274        #[arg(
275            long,
276            value_name = "OPS_REF",
277            help = "Ops payload file reference (@path)"
278        )]
279        ops: String,
280        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
281        workspace: Option<PathBuf>,
282    },
283    #[command(about = "Apply a staged operation (compare-and-swap against current HEAD)")]
284    Apply {
285        #[arg(long, value_name = "ID", help = "Session identifier")]
286        session: String,
287        #[arg(value_name = "STAGED_ID", help = "Staged operation identifier")]
288        staged_id: String,
289        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
290        workspace: Option<PathBuf>,
291    },
292    #[command(about = "Compile the current HEAD into a standalone Excel file")]
293    Materialize {
294        #[arg(long, value_name = "ID", help = "Session identifier")]
295        session: String,
296        #[arg(long, value_name = "PATH", help = "Output file path")]
297        output: PathBuf,
298        #[arg(long, help = "Allow overwriting existing output file")]
299        force: bool,
300        #[arg(long, value_name = "PATH", help = "Workspace root directory")]
301        workspace: Option<PathBuf>,
302    },
303}
304
305#[derive(Debug, Subcommand)]
306pub enum DiscoverabilityCommands {
307    #[command(about = "Schema/example target for transform-batch payloads")]
308    TransformBatch,
309    #[command(about = "Schema/example target for style-batch payloads")]
310    StyleBatch,
311    #[command(about = "Schema/example target for apply-formula-pattern payloads")]
312    ApplyFormulaPattern,
313    #[command(about = "Schema/example target for structure-batch payloads")]
314    StructureBatch,
315    #[command(about = "Schema/example target for column-size-batch payloads")]
316    ColumnSizeBatch,
317    #[command(about = "Schema/example target for sheet-layout-batch payloads")]
318    SheetLayoutBatch,
319    #[command(about = "Schema/example target for rules-batch payloads")]
320    RulesBatch,
321    #[command(about = "Schema/example target for event-sourced session op payloads")]
322    SessionOp {
323        #[arg(
324            value_name = "KIND",
325            help = "Exact session op kind, e.g. transform.write_matrix"
326        )]
327        kind: String,
328    },
329}
330
331#[derive(Debug, Args, Clone)]
332struct SurfaceLeafArgs {
333    #[arg(
334        trailing_var_arg = true,
335        allow_hyphen_values = true,
336        num_args = 0..,
337        value_name = "ARGS"
338    )]
339    args: Vec<OsString>,
340}
341
342#[derive(Debug, Subcommand)]
343enum SurfaceReadCommands {
344    #[command(about = "List workbook sheets with basic summary metadata")]
345    Sheets(SurfaceLeafArgs),
346    #[command(about = "Inspect one sheet and detect structured regions")]
347    Overview(SurfaceLeafArgs),
348    #[command(about = "Read raw values for one or more A1 ranges")]
349    Values(SurfaceLeafArgs),
350    #[command(about = "Export a range to a specific format")]
351    Export(SurfaceLeafArgs),
352    #[command(about = "Inspect detail snapshots for targeted A1 cells/ranges")]
353    Cells(SurfaceLeafArgs),
354    #[command(about = "Read one sheet page with deterministic continuation")]
355    Page(SurfaceLeafArgs),
356    #[command(about = "Read a table-like region as json, values, or csv")]
357    Table(SurfaceLeafArgs),
358    #[command(about = "List workbook named ranges and table/formula named items")]
359    Names(SurfaceLeafArgs),
360    #[command(about = "Describe workbook-level metadata and sheet counts")]
361    Workbook(SurfaceLeafArgs),
362    #[command(about = "Render a range with layout metadata")]
363    Layout(SurfaceLeafArgs),
364}
365
366#[derive(Debug, Subcommand)]
367enum SurfaceAnalyzeCommands {
368    #[command(about = "Find cells matching a text query by value or label")]
369    FindValue(SurfaceLeafArgs),
370    #[command(about = "Find formulas containing a text query with pagination")]
371    FindFormula(SurfaceLeafArgs),
372    #[command(about = "Summarize formulas on a sheet by complexity or frequency")]
373    FormulaMap(SurfaceLeafArgs),
374    #[command(about = "Trace formula precedents or dependents from one origin cell")]
375    FormulaTrace(SurfaceLeafArgs),
376    #[command(about = "Scan workbook formulas for volatile functions")]
377    ScanVolatiles(SurfaceLeafArgs),
378    #[command(about = "Compute per-sheet statistics for density and column types")]
379    SheetStatistics(SurfaceLeafArgs),
380    #[command(about = "Profile table headers, types, and column distributions")]
381    TableProfile(SurfaceLeafArgs),
382    #[command(about = "Analyze structural operation impact without mutation")]
383    RefImpact(SurfaceLeafArgs),
384}
385
386#[derive(Debug, Subcommand)]
387enum SurfaceWriteFormulaCommands {
388    #[command(about = "Find and replace text in formula bodies (not values)")]
389    Replace(SurfaceLeafArgs),
390}
391
392#[derive(Debug, Subcommand)]
393enum SurfaceWriteNameCommands {
394    #[command(about = "Define a new named range in a workbook")]
395    Define(SurfaceLeafArgs),
396    #[command(about = "Update an existing named range")]
397    Update(SurfaceLeafArgs),
398    #[command(about = "Delete a named range from a workbook")]
399    Delete(SurfaceLeafArgs),
400}
401
402#[derive(Debug, Subcommand)]
403enum SurfaceWriteBatchCommands {
404    #[command(about = "Apply stateless transform operations from an @ops payload")]
405    Transform(SurfaceLeafArgs),
406    #[command(about = "Apply stateless style operations from an @ops payload")]
407    Style(SurfaceLeafArgs),
408    #[command(about = "Apply stateless formula pattern operations from an @ops payload")]
409    FormulaPattern(SurfaceLeafArgs),
410    #[command(about = "Apply stateless structure operations from an @ops payload")]
411    Structure(SurfaceLeafArgs),
412    #[command(about = "Apply stateless column sizing operations from an @ops payload")]
413    ColumnSize(SurfaceLeafArgs),
414    #[command(about = "Apply stateless sheet layout operations from an @ops payload")]
415    SheetLayout(SurfaceLeafArgs),
416    #[command(
417        about = "Apply stateless data validation and conditional format operations from an @ops payload"
418    )]
419    Rules(SurfaceLeafArgs),
420}
421
422#[derive(Debug, Subcommand)]
423enum SurfaceWriteCommands {
424    #[command(about = "Apply one or more shorthand cell edits to a sheet")]
425    Cells(SurfaceLeafArgs),
426    #[command(about = "Import range data from grid JSON or CSV")]
427    Import(SurfaceLeafArgs),
428    #[command(about = "Append rows into a detected region with footer-aware insertion")]
429    Append(SurfaceLeafArgs),
430    #[command(about = "Clone one template row into inserted rows with preview-first planning")]
431    CloneTemplateRow(SurfaceLeafArgs),
432    #[command(about = "Clone a contiguous template row band with preview-first planning")]
433    CloneRowBand(SurfaceLeafArgs),
434    #[command(subcommand, about = "Formula-only mutation helpers")]
435    Formulas(SurfaceWriteFormulaCommands),
436    #[command(subcommand, about = "Named range mutation helpers")]
437    Name(SurfaceWriteNameCommands),
438    #[command(subcommand, about = "Stateless batch mutation surfaces")]
439    Batch(SurfaceWriteBatchCommands),
440}
441
442#[derive(Debug, Subcommand)]
443enum SurfaceWorkbookCommands {
444    #[command(about = "Create a new workbook at a destination path")]
445    Create(SurfaceLeafArgs),
446    #[command(about = "Copy a workbook to a new path for safe edits")]
447    Copy(SurfaceLeafArgs),
448    #[command(about = "Recalculate workbook formulas")]
449    Recalculate(SurfaceLeafArgs),
450}
451
452#[derive(Debug, Subcommand)]
453enum SurfaceVerifyCommands {
454    #[command(about = "Compare two workbook states and verify target deltas plus error provenance")]
455    Proof(SurfaceLeafArgs),
456    #[command(about = "Diff two workbook versions with summary-first, paged details")]
457    Diff(SurfaceLeafArgs),
458}
459
460#[derive(Debug, Subcommand)]
461enum SurfaceDiscoverabilityBatchCommands {
462    #[command(about = "Schema/example target for transform batch payloads")]
463    Transform,
464    #[command(about = "Schema/example target for style batch payloads")]
465    Style,
466    #[command(about = "Schema/example target for formula pattern batch payloads")]
467    FormulaPattern,
468    #[command(about = "Schema/example target for structure batch payloads")]
469    Structure,
470    #[command(about = "Schema/example target for column size batch payloads")]
471    ColumnSize,
472    #[command(about = "Schema/example target for sheet layout batch payloads")]
473    SheetLayout,
474    #[command(about = "Schema/example target for rules batch payloads")]
475    Rules,
476}
477
478#[derive(Debug, Subcommand)]
479enum SurfaceDiscoverabilityWriteCommands {
480    #[command(subcommand, about = "Batch payload targets")]
481    Batch(SurfaceDiscoverabilityBatchCommands),
482}
483
484#[derive(Debug, Subcommand)]
485enum SurfaceDiscoverabilitySessionCommands {
486    #[command(about = "Schema/example target for event-sourced session op payloads")]
487    Op {
488        #[arg(
489            value_name = "KIND",
490            help = "Exact session op kind, e.g. transform.write_matrix"
491        )]
492        kind: String,
493    },
494}
495
496#[derive(Debug, Subcommand)]
497enum SurfaceDiscoverabilityCommands {
498    #[command(subcommand, about = "Discoverability targets for write payloads")]
499    Write(SurfaceDiscoverabilityWriteCommands),
500    #[command(subcommand, about = "Discoverability targets for session payloads")]
501    Session(SurfaceDiscoverabilitySessionCommands),
502}
503
504#[derive(Debug, Subcommand)]
505enum SurfaceCommands {
506    #[command(subcommand, about = "Read workbook data and structure")]
507    Read(SurfaceReadCommands),
508    #[command(subcommand, about = "Analyze workbook contents and formulas")]
509    Analyze(SurfaceAnalyzeCommands),
510    #[command(subcommand, about = "Write and mutate workbook contents")]
511    Write(SurfaceWriteCommands),
512    #[command(subcommand, about = "Workbook-level file operations")]
513    Workbook(SurfaceWorkbookCommands),
514    #[command(subcommand, about = "Verification and review workflows")]
515    Verify(SurfaceVerifyCommands),
516    #[command(about = "Print canonical JSON schema for a command or payload target")]
517    Schema {
518        #[command(subcommand)]
519        command: SurfaceDiscoverabilityCommands,
520    },
521    #[command(about = "Print a copy-pastable canonical example for a command or payload target")]
522    Example {
523        #[command(subcommand)]
524        command: SurfaceDiscoverabilityCommands,
525    },
526    #[command(about = "Event-sourced session management", subcommand, hide = false)]
527    Session(Box<SessionCommands>),
528    #[command(about = "SheetPort manifest lifecycle and execution commands")]
529    Sheetport {
530        #[command(subcommand)]
531        command: SheetportCommands,
532    },
533}
534
535#[derive(Debug, Parser)]
536#[command(
537    name = "asp",
538    version,
539    about = "Stateless spreadsheet CLI for reads, writes, and verification workflows",
540    long_about = "Stateless spreadsheet CLI for AI and automation workflows.\n\nPrimary command: asp\nCompatibility alias: agent-spreadsheet\n\nVerify install:\n  asp --version\n  asp --help\n\nPrimary groups:\n  • read      -> workbook extraction and inspection\n  • analyze   -> search, profiling, and diagnostics\n  • write     -> direct edits, workflow helpers, and batch mutations\n  • workbook  -> file-level create/copy/recalculate flows\n  • verify    -> proof and diff review surfaces\n  • session   -> event-sourced stateful editing\n  • sheetport -> manifest lifecycle and execution\n\nDiscoverability:\n  • asp schema write batch transform\n  • asp example write batch transform\n  • asp schema session op transform.write_matrix\n\nTip: global --output-format csv is currently unsupported and returns an error. Use --output-format json, or command-level CSV options such as asp read table --table-format csv."
541)]
542struct SurfaceCli {
543    #[arg(
544        long = "output-format",
545        value_enum,
546        default_value_t = OutputFormat::Json,
547        global = true,
548        help = "Output format (csv is currently unsupported globally; use json or command-specific CSV options like asp read table --table-format csv)"
549    )]
550    output_format: OutputFormat,
551
552    #[arg(
553        long,
554        value_enum,
555        default_value_t = OutputShape::Canonical,
556        global = true,
557        help = "Output shape (canonical keeps full schema; compact applies command-specific projections while preserving stable payload contracts for range-values/read-table/sheet-page; formula-trace compact omits per-layer highlights while preserving continuation fields)"
558    )]
559    shape: OutputShape,
560
561    #[arg(
562        long,
563        global = true,
564        help = "Emit compact JSON without pretty-printing (default behavior)"
565    )]
566    compact: bool,
567
568    #[arg(long, global = true, help = "Suppress non-fatal warnings")]
569    quiet: bool,
570
571    #[command(subcommand)]
572    command: SurfaceCommands,
573}
574
575#[derive(Debug, Parser)]
576#[command(
577    name = "asp",
578    version,
579    about = "Stateless spreadsheet CLI for reads, edits, and diffs",
580    long_about = "Stateless spreadsheet CLI for AI and automation workflows.\n\nPrimary command: asp\nCompatibility alias: agent-spreadsheet\n\nVerify install:\n  asp --version\n  asp --help\n\nCommon workflows:\n  • Inspect a workbook: list-sheets → sheet-overview → table-profile\n  • Deterministic pagination loops: sheet-page (--format + next_start_row) and read-table (--limit/--offset + next_offset)\n  • Find labels or values: find-value --mode label|value\n  • Discover payload contracts: schema <target> / example <target>\n  • Stateless batch writes: transform/style/formula/structure/column/layout/rules via --ops @ops.json + one mode (--dry-run|--in-place|--output)\n  • Copy → edit → recalculate → diff for safe what-if changes\n  • SheetPort manifest loop: sheetport manifest candidates → draft/edit YAML → sheetport manifest validate → sheetport bind-check → sheetport run\n\nTip: global --output-format csv is currently unsupported and returns an error. Use --output-format json, or command-level CSV options such as read-table --table-format csv."
581)]
582pub struct Cli {
583    #[arg(
584        long = "output-format",
585        value_enum,
586        default_value_t = OutputFormat::Json,
587        global = true,
588        help = "Output format (csv is currently unsupported globally; use json or command-specific CSV options like read-table --table-format csv)"
589    )]
590    pub output_format: OutputFormat,
591
592    #[arg(
593        long,
594        value_enum,
595        default_value_t = OutputShape::Canonical,
596        global = true,
597        help = "Output shape (canonical keeps full schema; compact applies command-specific projections while preserving stable payload contracts for range-values/read-table/sheet-page; formula-trace compact omits per-layer highlights while preserving continuation fields)"
598    )]
599    pub shape: OutputShape,
600
601    #[arg(
602        long,
603        global = true,
604        help = "Emit compact JSON without pretty-printing (default behavior)"
605    )]
606    pub compact: bool,
607
608    #[arg(long, global = true, help = "Suppress non-fatal warnings")]
609    pub quiet: bool,
610
611    #[command(subcommand)]
612    pub command: Commands,
613}
614
615#[derive(Debug, Subcommand)]
616pub enum Commands {
617    #[command(about = "List workbook sheets with basic summary metadata")]
618    ListSheets {
619        #[arg(value_name = "FILE", help = "Path to the workbook (.xlsx/.xlsm)")]
620        file: PathBuf,
621        #[arg(
622            long,
623            value_name = "ID",
624            help = "Read from a session's materialized state instead of the file"
625        )]
626        session: Option<String>,
627        #[arg(
628            long = "session-workspace",
629            value_name = "PATH",
630            help = "Workspace root for session resolution"
631        )]
632        session_workspace: Option<PathBuf>,
633    },
634    #[command(about = "Inspect one sheet and detect structured regions")]
635    SheetOverview {
636        #[arg(value_name = "FILE", help = "Path to the workbook")]
637        file: PathBuf,
638        #[arg(
639            value_name = "SHEET",
640            help = "Exact sheet name (quote names with spaces)"
641        )]
642        sheet: String,
643        #[arg(
644            long,
645            value_name = "ID",
646            help = "Read from a session's materialized state instead of the file"
647        )]
648        session: Option<String>,
649        #[arg(
650            long = "session-workspace",
651            value_name = "PATH",
652            help = "Workspace root for session resolution"
653        )]
654        session_workspace: Option<PathBuf>,
655    },
656    #[command(
657        about = "Read raw values for one or more A1 ranges",
658        after_long_help = "Examples:\n  agent-spreadsheet range-values data.xlsx Sheet1 A1:C20\n  agent-spreadsheet range-values data.xlsx \"Q1 Actuals\" A1:B5 D10:E20\n  agent-spreadsheet range-values data.xlsx Sheet1 A1:C20 --include-formulas\n\nDense default:\n  range-values defaults to dense JSON encoding optimized for agent consumption:\n  dictionary + row_runs + optional sparse formulas.\n\nFormula semantics:\n  By default, range-values returns resolved values only.\n  Use --include-formulas to include formulas in the response (sparse list in dense mode, matrix in json mode).\n\nShape behavior:\n  range-values keeps a stable top-level shape in both canonical and compact modes (no single-range flattening).\n\nRelated:\n  Use inspect-cells when you need formula + value + style metadata in one response."
659    )]
660    RangeValues {
661        #[arg(value_name = "FILE", help = "Path to the workbook")]
662        file: PathBuf,
663        #[arg(value_name = "SHEET", help = "Sheet name containing the ranges")]
664        sheet: String,
665        #[arg(
666            value_name = "RANGE",
667            help = "One or more A1 ranges (for example A1:C10)"
668        )]
669        ranges: Vec<String>,
670        #[arg(
671            long,
672            value_enum,
673            value_name = "FORMAT",
674            help = "Output payload format (dense default, or json/values/csv explicitly)"
675        )]
676        format: Option<RangeValuesFormatArg>,
677        #[arg(
678            long = "include-formulas",
679            value_name = "BOOL",
680            num_args = 0..=1,
681            default_missing_value = "true",
682            help = "Include formulas (sparse list in dense mode, matrix in json mode)"
683        )]
684        include_formulas: Option<bool>,
685        #[arg(
686            long,
687            value_name = "ID",
688            help = "Read from a session's materialized state instead of the file"
689        )]
690        session: Option<String>,
691        #[arg(
692            long = "session-workspace",
693            value_name = "PATH",
694            help = "Workspace root for session resolution"
695        )]
696        session_workspace: Option<PathBuf>,
697    },
698    #[command(
699        about = "Export a range to a specific format (e.g., csv, grid)",
700        after_long_help = "Examples:\n  agent-spreadsheet range-export data.xlsx Sheet1 A1:C20 --format csv --output data.csv\n  agent-spreadsheet range-export data.xlsx Sheet1 A1:C20 --format csv --output -"
701    )]
702    RangeExport {
703        #[arg(value_name = "FILE", help = "Path to the workbook")]
704        file: PathBuf,
705        #[arg(value_name = "SHEET", help = "Sheet name containing the range")]
706        sheet: String,
707        #[arg(value_name = "RANGE", help = "A1 range (for example A1:C10)")]
708        range: String,
709        #[arg(long, help = "Output format (e.g. csv, grid)", default_value = "json")]
710        format: String,
711        #[arg(long, help = "Output path or '-' for stdout")]
712        output: Option<String>,
713        #[arg(
714            long = "include-formulas",
715            value_name = "BOOL",
716            num_args = 0..=1,
717            default_missing_value = "true",
718            help = "Include parsed formulas in formula cells alongside evaluated values (JSON only)"
719        )]
720        include_formulas: Option<bool>,
721        #[arg(
722            long,
723            value_name = "ID",
724            help = "Read from a session's materialized state instead of the file"
725        )]
726        session: Option<String>,
727        #[arg(
728            long = "session-workspace",
729            value_name = "PATH",
730            help = "Workspace root for session resolution"
731        )]
732        session_workspace: Option<PathBuf>,
733    },
734    #[command(
735        about = "Import range data from grid JSON or CSV",
736        after_long_help = "Examples:\n  agent-spreadsheet range-import data.xlsx Sheet1 --anchor B7 --from-grid region.json\n  agent-spreadsheet range-import data.xlsx Sheet1 --anchor B7 --from-csv data.csv --in-place"
737    )]
738    RangeImport {
739        #[arg(value_name = "FILE", help = "Path to the workbook")]
740        file: PathBuf,
741        #[arg(value_name = "SHEET", help = "Sheet name to import into")]
742        sheet: String,
743        #[arg(long, help = "Anchor cell for import (e.g. B7)")]
744        anchor: String,
745        #[arg(long, help = "Path to the grid JSON file to import")]
746        from_grid: Option<String>,
747        #[arg(long, help = "Path to the CSV file to import")]
748        from_csv: Option<String>,
749        #[arg(long, help = "Skip first CSV row when importing --from-csv")]
750        header: bool,
751        #[arg(long, help = "Clear the target area before import")]
752        clear_target: bool,
753        #[arg(long, help = "Validate ops without mutating files")]
754        dry_run: bool,
755        #[arg(long, help = "Apply imports by atomically replacing the source file")]
756        in_place: bool,
757        #[arg(long, help = "Apply imports to this output path")]
758        output: Option<PathBuf>,
759        #[arg(long, help = "Allow overwriting --output when it already exists")]
760        force: bool,
761    },
762    #[command(
763        about = "Inspect detail snapshots for targeted A1 cells/ranges (detail view, default max 25 cells)",
764        after_long_help = "Examples:
765  agent-spreadsheet inspect-cells data.xlsx Sheet1 A1:C3
766  agent-spreadsheet inspect-cells data.xlsx \"Q1 Actuals\" D4 D7:F8
767  agent-spreadsheet inspect-cells data.xlsx Sheet1 B2,C4 --include-empty
768  agent-spreadsheet inspect-cells data.xlsx Sheet1 A1:J10 --budget 100
769
770inspect-cells is a detail view for formula/value/cached/style triage and enforces a small per-request cell budget.
771Use --budget to raise the limit for rect-style reads (up to 200).
772For broader discovery, use sheet-page, range-values, or layout-page."
773    )]
774    InspectCells {
775        #[arg(value_name = "FILE", help = "Path to the workbook")]
776        file: PathBuf,
777        #[arg(value_name = "SHEET", help = "Sheet name containing the targets")]
778        sheet: String,
779        #[arg(
780            value_name = "TARGET",
781            value_delimiter = ',',
782            num_args = 1..,
783            help = "One or more A1 cells/ranges (e.g. B2, A1:C3, D7:F8)"
784        )]
785        targets: Vec<String>,
786        #[arg(long, help = "Include empty cells in the response")]
787        include_empty: bool,
788        #[arg(
789            long,
790            value_name = "N",
791            help = "Override the per-request cell budget (default 25, max 200)"
792        )]
793        budget: Option<u32>,
794        #[arg(
795            long,
796            value_name = "ID",
797            help = "Read from a session's materialized state instead of the file"
798        )]
799        session: Option<String>,
800        #[arg(
801            long = "session-workspace",
802            value_name = "PATH",
803            help = "Workspace root for session resolution"
804        )]
805        session_workspace: Option<PathBuf>,
806    },
807    #[command(
808        about = "Read one sheet page with deterministic continuation",
809        after_long_help = "Examples:\n  agent-spreadsheet sheet-page data.xlsx Sheet1 --format compact --page-size 200\n  agent-spreadsheet sheet-page data.xlsx Sheet1 --format compact --page-size 200 --start-row 201\n  agent-spreadsheet sheet-page data.xlsx Sheet1 --format full --columns A,C:E --include-styles\n\nMachine contract:\n  - Inspect the top-level format field first.\n  - format=full: consume top-level rows/header_row/next_start_row.\n  - format=compact: consume compact.headers/compact.header_row/compact.rows plus next_start_row.\n  - format=values_only: consume values_only.rows plus next_start_row.\n  - Global --shape compact preserves the active sheet-page branch (no flattening).\n\nPagination loop:\n  1) Run without --start-row.\n  2) If next_start_row is present, pass it to --start-row for the next request.\n  3) Stop when next_start_row is omitted.\n\nMachine continuation example:\n  Request page 1, read next_start_row, then request page 2 with --start-row <next_start_row>."
810    )]
811    SheetPage {
812        #[arg(value_name = "FILE", help = "Path to the workbook")]
813        file: PathBuf,
814        #[arg(value_name = "SHEET", help = "Sheet to page through")]
815        sheet: String,
816        #[arg(long, value_name = "ROW", help = "1-based starting row")]
817        start_row: Option<u32>,
818        #[arg(
819            long = "page-size",
820            value_name = "N",
821            help = "Rows per page (must be at least 1)"
822        )]
823        page_size: Option<u32>,
824        #[arg(
825            long,
826            value_name = "COLUMNS",
827            value_delimiter = ',',
828            help = "Column selectors by letter/range, e.g. A,C,E:G"
829        )]
830        columns: Option<Vec<String>>,
831        #[arg(
832            long = "columns-by-header",
833            value_name = "HEADERS",
834            value_delimiter = ',',
835            help = "Column selectors by header text (case-insensitive)"
836        )]
837        columns_by_header: Option<Vec<String>>,
838        #[arg(
839            long = "include-formulas",
840            value_name = "BOOL",
841            num_args = 0..=1,
842            default_missing_value = "true",
843            help = "Include formulas (default true)"
844        )]
845        include_formulas: Option<bool>,
846        #[arg(
847            long = "include-styles",
848            value_name = "BOOL",
849            num_args = 0..=1,
850            default_missing_value = "true",
851            help = "Include style metadata (default false)"
852        )]
853        include_styles: Option<bool>,
854        #[arg(
855            long = "include-header",
856            value_name = "BOOL",
857            num_args = 0..=1,
858            default_missing_value = "true",
859            help = "Include header row (default true)"
860        )]
861        include_header: Option<bool>,
862        #[arg(
863            long,
864            value_enum,
865            value_name = "FORMAT",
866            required = true,
867            help = "Page output format: full, compact, or values_only"
868        )]
869        format: SheetPageFormatArg,
870        #[arg(
871            long,
872            value_name = "ID",
873            help = "Read from a session's materialized state instead of the file"
874        )]
875        session: Option<String>,
876        #[arg(
877            long = "session-workspace",
878            value_name = "PATH",
879            help = "Workspace root for session resolution"
880        )]
881        session_workspace: Option<PathBuf>,
882    },
883    #[command(
884        about = "Read a table-like region as json, values, or csv",
885        after_long_help = "Examples:\n  agent-spreadsheet read-table data.xlsx --sheet Sheet1 --table-format values\n  agent-spreadsheet read-table data.xlsx --sheet Sheet1 --table-format csv --limit 50 --offset 0\n  agent-spreadsheet read-table data.xlsx --table-name SalesTable --sample-mode distributed --limit 20\n\nPagination loop:\n  Repeat with --offset set to next_offset until next_offset is omitted."
886    )]
887    ReadTable {
888        #[arg(value_name = "FILE", help = "Path to the workbook")]
889        file: PathBuf,
890        #[arg(long, value_name = "SHEET", help = "Restrict read to a specific sheet")]
891        sheet: Option<String>,
892        #[arg(long, value_name = "RANGE", help = "Optional A1 range override")]
893        range: Option<String>,
894        #[arg(long, value_name = "NAME", help = "Read from a named Excel table")]
895        table_name: Option<String>,
896        #[arg(long, value_name = "ID", help = "Read from a detected region id")]
897        region_id: Option<u32>,
898        #[arg(
899            long,
900            value_name = "LIMIT",
901            help = "Maximum rows to return (must be at least 1)"
902        )]
903        limit: Option<u32>,
904        #[arg(long, value_name = "OFFSET", help = "Row offset for pagination")]
905        offset: Option<u32>,
906        #[arg(
907            long = "sample-mode",
908            value_enum,
909            value_name = "MODE",
910            help = "Sampling mode: first, last, or distributed"
911        )]
912        sample_mode: Option<TableSampleModeArg>,
913        #[arg(
914            long = "filters-json",
915            value_name = "JSON",
916            help = "Inline JSON array of filters (mutually exclusive with --filters-file)"
917        )]
918        filters_json: Option<String>,
919        #[arg(
920            long = "filters-file",
921            value_name = "PATH",
922            help = "Path to JSON array of filters (mutually exclusive with --filters-json)"
923        )]
924        filters_file: Option<PathBuf>,
925        #[arg(
926            long = "table-format",
927            value_enum,
928            value_name = "FORMAT",
929            help = "Output format for this command"
930        )]
931        table_format: Option<TableReadFormat>,
932        #[arg(
933            long,
934            value_name = "ID",
935            help = "Read from a session's materialized state instead of the file"
936        )]
937        session: Option<String>,
938        #[arg(
939            long = "session-workspace",
940            value_name = "PATH",
941            help = "Workspace root for session resolution"
942        )]
943        session_workspace: Option<PathBuf>,
944    },
945    #[command(
946        about = "Find cells matching a text query by value or label",
947        after_long_help = "Examples:\n  agent-spreadsheet find-value data.xlsx Revenue --mode value\n  agent-spreadsheet find-value data.xlsx \"Net Income\" --sheet \"Q1 Actuals\" --mode label --label-direction below\n\nLabel mode behavior:\n  - QUERY is matched against label cells.\n  - Result value is taken from an adjacent cell, not from the label itself.\n  - --label-direction any (default) checks right first, then below."
948    )]
949    FindValue {
950        #[arg(value_name = "FILE", help = "Path to the workbook")]
951        file: PathBuf,
952        #[arg(value_name = "QUERY", help = "Text to search for")]
953        query: String,
954        #[arg(long, value_name = "SHEET", help = "Limit search to one sheet")]
955        sheet: Option<String>,
956        #[arg(
957            long,
958            value_enum,
959            value_name = "MODE",
960            help = "Search mode: value or label"
961        )]
962        mode: Option<FindValueMode>,
963        #[arg(
964            long = "label-direction",
965            value_enum,
966            value_name = "DIR",
967            help = "For --mode label, read the value from right, below, or any (default: any)"
968        )]
969        label_direction: Option<LabelDirectionArg>,
970        #[arg(
971            long,
972            value_name = "ID",
973            help = "Read from a session's materialized state instead of the file"
974        )]
975        session: Option<String>,
976        #[arg(
977            long = "session-workspace",
978            value_name = "PATH",
979            help = "Workspace root for session resolution"
980        )]
981        session_workspace: Option<PathBuf>,
982    },
983    #[command(
984        about = "List workbook named ranges and table/formula named items",
985        after_long_help = "Examples:\n  agent-spreadsheet named-ranges data.xlsx\n  agent-spreadsheet named-ranges data.xlsx --sheet \"Q1 Actuals\" --name-prefix Sales"
986    )]
987    NamedRanges {
988        #[arg(value_name = "FILE", help = "Path to the workbook")]
989        file: PathBuf,
990        #[arg(long, value_name = "SHEET", help = "Optional sheet name filter")]
991        sheet: Option<String>,
992        #[arg(
993            long = "name-prefix",
994            value_name = "PREFIX",
995            help = "Optional case-insensitive prefix filter for item names"
996        )]
997        name_prefix: Option<String>,
998        #[arg(
999            long,
1000            value_name = "ID",
1001            help = "Read from a session's materialized state instead of the file"
1002        )]
1003        session: Option<String>,
1004        #[arg(
1005            long = "session-workspace",
1006            value_name = "PATH",
1007            help = "Workspace root for session resolution"
1008        )]
1009        session_workspace: Option<PathBuf>,
1010    },
1011    #[command(
1012        about = "Define a new named range in a workbook",
1013        after_long_help = "Examples:\n  agent-spreadsheet define-name data.xlsx MyRange 'Sheet1!$A$1:$B$10'\n  agent-spreadsheet define-name data.xlsx SheetLocal 'Sheet1!$A$1' --scope sheet --scope-sheet-name Sheet1 --in-place"
1014    )]
1015    DefineName {
1016        #[arg(value_name = "FILE", help = "Path to the workbook")]
1017        file: PathBuf,
1018        #[arg(value_name = "NAME", help = "Name to define")]
1019        name: String,
1020        #[arg(value_name = "REFERS_TO", help = "Range or formula the name refers to")]
1021        refers_to: String,
1022        #[arg(
1023            long,
1024            value_name = "SCOPE",
1025            help = "Scope: workbook (default) or sheet"
1026        )]
1027        scope: Option<String>,
1028        #[arg(
1029            long = "scope-sheet-name",
1030            value_name = "SHEET",
1031            help = "Sheet name when scope is 'sheet'"
1032        )]
1033        scope_sheet_name: Option<String>,
1034        #[arg(long, help = "Validate without mutating files")]
1035        dry_run: bool,
1036        #[arg(long, help = "Apply by atomically replacing the source file")]
1037        in_place: bool,
1038        #[arg(long, value_name = "PATH", help = "Apply to this output path")]
1039        output: Option<PathBuf>,
1040        #[arg(long, help = "Allow overwriting --output when it already exists")]
1041        force: bool,
1042    },
1043    #[command(
1044        about = "Update an existing named range",
1045        after_long_help = "Examples:\n  agent-spreadsheet update-name data.xlsx MyRange 'Sheet1!$A$1:$C$20' --in-place\n  agent-spreadsheet update-name data.xlsx SheetLocal --scope sheet --scope-sheet-name Sheet1 --in-place\n\nNote: REFERS_TO is optional. Omit it to update scope metadata only."
1046    )]
1047    UpdateName {
1048        #[arg(value_name = "FILE", help = "Path to the workbook")]
1049        file: PathBuf,
1050        #[arg(value_name = "NAME", help = "Name to update")]
1051        name: String,
1052        #[arg(
1053            value_name = "REFERS_TO",
1054            help = "Optional new range or formula the name refers to"
1055        )]
1056        refers_to: Option<String>,
1057        #[arg(long, value_name = "SCOPE", help = "Scope filter: workbook or sheet")]
1058        scope: Option<String>,
1059        #[arg(
1060            long = "scope-sheet-name",
1061            value_name = "SHEET",
1062            help = "Sheet name to disambiguate"
1063        )]
1064        scope_sheet_name: Option<String>,
1065        #[arg(long, help = "Validate without mutating files")]
1066        dry_run: bool,
1067        #[arg(long, help = "Apply by atomically replacing the source file")]
1068        in_place: bool,
1069        #[arg(long, value_name = "PATH", help = "Apply to this output path")]
1070        output: Option<PathBuf>,
1071        #[arg(long, help = "Allow overwriting --output when it already exists")]
1072        force: bool,
1073    },
1074    #[command(
1075        about = "Delete a named range from a workbook",
1076        after_long_help = "Examples:\n  agent-spreadsheet delete-name data.xlsx MyRange --in-place\n  agent-spreadsheet delete-name data.xlsx SheetLocal --scope sheet --scope-sheet-name Sheet1 --in-place"
1077    )]
1078    DeleteName {
1079        #[arg(value_name = "FILE", help = "Path to the workbook")]
1080        file: PathBuf,
1081        #[arg(value_name = "NAME", help = "Name to delete")]
1082        name: String,
1083        #[arg(long, value_name = "SCOPE", help = "Scope filter: workbook or sheet")]
1084        scope: Option<String>,
1085        #[arg(
1086            long = "scope-sheet-name",
1087            value_name = "SHEET",
1088            help = "Sheet name to disambiguate"
1089        )]
1090        scope_sheet_name: Option<String>,
1091        #[arg(long, help = "Validate without mutating files")]
1092        dry_run: bool,
1093        #[arg(long, help = "Apply by atomically replacing the source file")]
1094        in_place: bool,
1095        #[arg(long, value_name = "PATH", help = "Apply to this output path")]
1096        output: Option<PathBuf>,
1097        #[arg(long, help = "Allow overwriting --output when it already exists")]
1098        force: bool,
1099    },
1100    #[command(
1101        about = "Find formulas containing a text query with pagination",
1102        after_long_help = "Examples:\n  agent-spreadsheet find-formula data.xlsx SUM(\n  agent-spreadsheet find-formula data.xlsx VLOOKUP --sheet \"Q1 Actuals\" --limit 25 --offset 50\n\nRelated:\n  Use inspect-cells for per-cell formula/value/cached/style snapshots in a target range."
1103    )]
1104    FindFormula {
1105        #[arg(value_name = "FILE", help = "Path to the workbook")]
1106        file: PathBuf,
1107        #[arg(value_name = "QUERY", help = "Text to search for within formulas")]
1108        query: String,
1109        #[arg(long, value_name = "SHEET", help = "Optional sheet name filter")]
1110        sheet: Option<String>,
1111        #[arg(
1112            long,
1113            value_name = "N",
1114            help = "Maximum matches to return (must be at least 1)"
1115        )]
1116        limit: Option<u32>,
1117        #[arg(long, value_name = "N", help = "Match offset for continuation")]
1118        offset: Option<u32>,
1119    },
1120    #[command(
1121        about = "Scan workbook formulas for volatile functions",
1122        after_long_help = "Examples:\n  agent-spreadsheet scan-volatiles data.xlsx\n  agent-spreadsheet scan-volatiles data.xlsx --sheet \"Q1 Actuals\" --limit 10 --offset 10"
1123    )]
1124    ScanVolatiles {
1125        #[arg(value_name = "FILE", help = "Path to the workbook")]
1126        file: PathBuf,
1127        #[arg(long, value_name = "SHEET", help = "Optional sheet name filter")]
1128        sheet: Option<String>,
1129        #[arg(
1130            long,
1131            value_name = "N",
1132            help = "Maximum entries to return (must be at least 1)"
1133        )]
1134        limit: Option<u32>,
1135        #[arg(long, value_name = "N", help = "Entry offset for continuation")]
1136        offset: Option<u32>,
1137        #[arg(
1138            long = "formula-parse-policy",
1139            value_enum,
1140            value_name = "POLICY",
1141            help = "Formula parse policy: fail, warn (default), or off"
1142        )]
1143        formula_parse_policy: Option<FormulaParsePolicy>,
1144    },
1145    #[command(
1146        about = "Compute per-sheet statistics for density and column types",
1147        after_long_help = "Examples:\n  agent-spreadsheet sheet-statistics data.xlsx Sheet1\n  agent-spreadsheet sheet-statistics data.xlsx \"Q1 Actuals\""
1148    )]
1149    SheetStatistics {
1150        #[arg(value_name = "FILE", help = "Path to the workbook")]
1151        file: PathBuf,
1152        #[arg(value_name = "SHEET", help = "Sheet to summarize")]
1153        sheet: String,
1154    },
1155    #[command(
1156        about = "Summarize formulas on a sheet by complexity or frequency",
1157        after_long_help = "Examples:\n  agent-spreadsheet formula-map data.xlsx Sheet1\n  agent-spreadsheet formula-map data.xlsx \"Q1 Actuals\" --sort-by count --limit 25"
1158    )]
1159    FormulaMap {
1160        #[arg(value_name = "FILE", help = "Path to the workbook")]
1161        file: PathBuf,
1162        #[arg(value_name = "SHEET", help = "Sheet to analyze")]
1163        sheet: String,
1164        #[arg(long, value_name = "LIMIT", help = "Maximum groups to return")]
1165        limit: Option<u32>,
1166        #[arg(
1167            long,
1168            value_enum,
1169            value_name = "ORDER",
1170            help = "Sort groups by complexity or count"
1171        )]
1172        sort_by: Option<FormulaSort>,
1173        #[arg(
1174            long = "formula-parse-policy",
1175            value_enum,
1176            value_name = "POLICY",
1177            help = "Formula parse policy: fail, warn (default), or off"
1178        )]
1179        formula_parse_policy: Option<FormulaParsePolicy>,
1180    },
1181    #[command(
1182        about = "Trace formula precedents or dependents from one origin cell",
1183        after_long_help = "Examples:\n  agent-spreadsheet formula-trace data.xlsx Sheet1 C2 precedents --depth 2\n  agent-spreadsheet formula-trace data.xlsx Sheet1 C2 dependents --page-size 25\n  agent-spreadsheet formula-trace data.xlsx Sheet1 C2 precedents --cursor-depth 1 --cursor-offset 25\n\nContinuation:\n  Reuse next_cursor.depth/next_cursor.offset as --cursor-depth/--cursor-offset to continue paged traces.\n\nRelated:\n  Use inspect-cells for a local per-cell triage view that includes formula/value/cached/style metadata."
1184    )]
1185    FormulaTrace {
1186        #[arg(value_name = "FILE", help = "Path to the workbook")]
1187        file: PathBuf,
1188        #[arg(value_name = "SHEET", help = "Sheet containing the origin cell")]
1189        sheet: String,
1190        #[arg(value_name = "CELL", help = "Origin cell in A1 notation")]
1191        cell: String,
1192        #[arg(
1193            value_name = "DIRECTION",
1194            help = "Trace direction: precedents or dependents"
1195        )]
1196        direction: TraceDirectionArg,
1197        #[arg(
1198            long,
1199            value_name = "DEPTH",
1200            help = "Trace depth (must be between 1 and 5)"
1201        )]
1202        depth: Option<u32>,
1203        #[arg(
1204            long = "page-size",
1205            value_name = "N",
1206            help = "Page size for trace edges (must be between 5 and 200)"
1207        )]
1208        page_size: Option<usize>,
1209        #[arg(
1210            long = "cursor-depth",
1211            value_name = "DEPTH",
1212            help = "Continuation cursor depth (must be paired with --cursor-offset)"
1213        )]
1214        cursor_depth: Option<u32>,
1215        #[arg(
1216            long = "cursor-offset",
1217            value_name = "OFFSET",
1218            help = "Continuation cursor offset (must be paired with --cursor-depth)"
1219        )]
1220        cursor_offset: Option<usize>,
1221        #[arg(
1222            long = "formula-parse-policy",
1223            value_enum,
1224            value_name = "POLICY",
1225            help = "Formula parse policy: fail, warn (default), or off"
1226        )]
1227        formula_parse_policy: Option<FormulaParsePolicy>,
1228        #[arg(
1229            long,
1230            value_name = "ID",
1231            help = "Read from a session's materialized state instead of the file"
1232        )]
1233        session: Option<String>,
1234        #[arg(
1235            long = "session-workspace",
1236            value_name = "PATH",
1237            help = "Workspace root for session resolution"
1238        )]
1239        session_workspace: Option<PathBuf>,
1240    },
1241    #[command(about = "Describe workbook-level metadata and sheet counts")]
1242    Describe {
1243        #[arg(value_name = "FILE", help = "Path to the workbook")]
1244        file: PathBuf,
1245        #[arg(
1246            long,
1247            value_name = "ID",
1248            help = "Read from a session's materialized state instead of the file"
1249        )]
1250        session: Option<String>,
1251        #[arg(
1252            long = "session-workspace",
1253            value_name = "PATH",
1254            help = "Workspace root for session resolution"
1255        )]
1256        session_workspace: Option<PathBuf>,
1257    },
1258    #[command(
1259        about = "Profile table headers, types, and column distributions",
1260        after_long_help = "Examples:\n  agent-spreadsheet table-profile data.xlsx\n  agent-spreadsheet table-profile data.xlsx --sheet \"Q1 Actuals\""
1261    )]
1262    TableProfile {
1263        #[arg(value_name = "FILE", help = "Path to the workbook")]
1264        file: PathBuf,
1265        #[arg(long, value_name = "SHEET", help = "Optional sheet to profile")]
1266        sheet: Option<String>,
1267        #[arg(
1268            long,
1269            value_name = "ID",
1270            help = "Read from a session's materialized state instead of the file"
1271        )]
1272        session: Option<String>,
1273        #[arg(
1274            long = "session-workspace",
1275            value_name = "PATH",
1276            help = "Workspace root for session resolution"
1277        )]
1278        session_workspace: Option<PathBuf>,
1279    },
1280    #[command(
1281        about = "Render a range with layout: column widths, borders, bold/italic, alignment",
1282        after_long_help = "Examples:\n  agent-spreadsheet layout-page data.xlsx Sheet1 --range A1:F30\n  agent-spreadsheet layout-page data.xlsx Sheet1 --range A1:H40 --render both\n  agent-spreadsheet layout-page data.xlsx Sheet1 --range B2:G20 --mode formulas\n  agent-spreadsheet layout-page data.xlsx Sheet1 --range B2:G20 --render ascii\n\nThe JSON output (default) includes per-column widths, merged cell spans, and per-cell style metadata.\nThe ASCII render gives a proportional grid with box-drawing borders and bold/italic markers.\n\nCLI notes:\n  --render ascii prints the grid directly (plain text) instead of JSON.\n  Empty edge columns are trimmed by default; use --skip-empty-columns-trim to keep them.\n\nLimits: 80 rows × 25 columns. Ranges exceeding these are silently capped."
1283    )]
1284    LayoutPage {
1285        #[arg(value_name = "FILE", help = "Path to the workbook")]
1286        file: PathBuf,
1287        #[arg(value_name = "SHEET", help = "Sheet name")]
1288        sheet: String,
1289        #[arg(
1290            value_name = "RANGE",
1291            help = "A1 range to render (default: A1:T50); equivalent to --range"
1292        )]
1293        range_positional: Option<String>,
1294        #[arg(
1295            long,
1296            value_name = "RANGE",
1297            help = "A1 range to render (default: A1:T50)"
1298        )]
1299        range: Option<String>,
1300        #[arg(
1301            long,
1302            value_enum,
1303            value_name = "MODE",
1304            help = "Cell content: values (default) or formulas"
1305        )]
1306        mode: Option<LayoutModeArg>,
1307        #[arg(
1308            long = "max-col-width",
1309            value_name = "N",
1310            help = "Maximum column width in character units before truncating (default: 20)"
1311        )]
1312        max_col_width: Option<u32>,
1313        #[arg(
1314            long = "fit-columns",
1315            help = "Set each column width to the longest rendered cell so truncation is avoided (default off)"
1316        )]
1317        fit_columns: bool,
1318        #[arg(
1319            long = "skip-empty-columns-trim",
1320            help = "Disable default trimming of empty edge columns"
1321        )]
1322        skip_empty_columns_trim: bool,
1323        #[arg(
1324            long,
1325            value_enum,
1326            value_name = "RENDER",
1327            help = "Output format: json (default), ascii, or both"
1328        )]
1329        render: Option<LayoutRenderArg>,
1330        #[arg(
1331            long,
1332            value_name = "ID",
1333            help = "Read from a session's materialized state instead of the file"
1334        )]
1335        session: Option<String>,
1336        #[arg(
1337            long = "session-workspace",
1338            value_name = "PATH",
1339            help = "Workspace root for session resolution"
1340        )]
1341        session_workspace: Option<PathBuf>,
1342    },
1343    #[command(
1344        about = "Create a new workbook at a destination path",
1345        after_long_help = "Examples:
1346  agent-spreadsheet create-workbook new.xlsx
1347  agent-spreadsheet create-workbook model.xlsx --sheets Inputs,Calc,Output
1348  agent-spreadsheet create-workbook model.xlsx --overwrite"
1349    )]
1350    CreateWorkbook {
1351        #[arg(value_name = "PATH", help = "Destination workbook path")]
1352        path: PathBuf,
1353        #[arg(
1354            long,
1355            value_name = "SHEETS",
1356            value_delimiter = ',',
1357            help = "Comma-separated sheet names (default: Sheet1)"
1358        )]
1359        sheets: Option<Vec<String>>,
1360        #[arg(long, help = "Overwrite destination file when it exists")]
1361        overwrite: bool,
1362    },
1363    #[command(about = "Copy a workbook to a new path for safe edits")]
1364    Copy {
1365        #[arg(value_name = "SOURCE", help = "Original workbook path")]
1366        source: PathBuf,
1367        #[arg(value_name = "DEST", help = "Destination workbook path")]
1368        dest: PathBuf,
1369    },
1370    #[command(
1371        about = "Apply one or more shorthand cell edits to a sheet",
1372        after_long_help = r#"Examples:
1373  agent-spreadsheet edit workbook.xlsx Sheet1 A1=42 'B2==SUM(A1:A10)'
1374  agent-spreadsheet edit workbook.xlsx Sheet1 --dry-run A1=42 'B2==SUM(A1:A10)'
1375  agent-spreadsheet edit workbook.xlsx Sheet1 --output edited.xlsx --force A1=42 'B2==SUM(A1:A10)'
1376  agent-spreadsheet edit workbook.xlsx Sheet1 --edits-file edits.txt --output edited.xlsx --force
1377  printf '%s\n' 'B13==-MIN(B9,B12)' 'C13==-MIN(C9,C12)' | agent-spreadsheet edit workbook.xlsx Sheet1 --edits-file - --output edited.xlsx --force
1378
1379Mode selection:
1380  Default behavior (no mode flags): in-place edit of the source workbook.
1381  Optional explicit modes: --dry-run, --in-place, or --output <PATH>.
1382
1383Formula shorthand:
1384  Use double equals for formulas, e.g. C2==SUM(A1:A10).
1385  Single equals writes a literal value/text, e.g. C2=SUM(A1:A10).
1386
1387Shell quoting (positional edits):
1388  Single-quote every edit that contains parentheses, spaces, or $:
1389  unquoted ( breaks the shell, and double quotes let the shell expand
1390  $-style absolute references (e.g. "$A$1" becomes "1").
1391  Prefer --edits-file (one edit per line, '-' for stdin) for formula
1392  batches; file/stdin edits bypass shell quoting entirely.
1393
1394Cache note:
1395  Formula edits (values starting with =) clear cached results.
1396  Run recalculate to refresh computed values.
1397
1398Diagnostics note:
1399  Formula writes include write_path_provenance (written_via + formula_targets)."#
1400    )]
1401    Edit {
1402        #[arg(value_name = "FILE", help = "Workbook path to modify")]
1403        file: PathBuf,
1404        #[arg(value_name = "SHEET", help = "Target sheet name")]
1405        sheet: String,
1406        #[arg(long, help = "Validate edits without mutating any workbook")]
1407        dry_run: bool,
1408        #[arg(long, help = "Apply edits by atomically replacing the source file")]
1409        in_place: bool,
1410        #[arg(long, value_name = "PATH", help = "Apply edits to this output path")]
1411        output: Option<PathBuf>,
1412        #[arg(long, help = "Allow overwriting --output when it already exists")]
1413        force: bool,
1414        #[arg(
1415            value_name = "EDIT",
1416            help = "Edit operations like A1=42 or 'B2==SUM(A1:A10)' (single-quote formulas containing parentheses or $)"
1417        )]
1418        edits: Vec<String>,
1419        #[arg(
1420            long = "edits-file",
1421            value_name = "PATH",
1422            help = "Read edits from a file, one edit per line ('-' reads stdin). Blank lines and lines starting with # are ignored. Avoids shell quoting issues with $ and parentheses."
1423        )]
1424        edits_file: Option<PathBuf>,
1425        #[arg(
1426            long = "formula-parse-policy",
1427            value_enum,
1428            value_name = "POLICY",
1429            help = "Formula parse policy: fail (default for edit), warn, or off"
1430        )]
1431        formula_parse_policy: Option<FormulaParsePolicy>,
1432    },
1433    #[command(
1434        about = "Append rows into a detected region with footer-aware insertion",
1435        after_long_help = "Examples:\n  asp append-region workbook.xlsx --sheet Sheet1 --region-id 0 --rows @rows.json --dry-run\n  asp append-region workbook.xlsx --sheet Sheet1 --table-name SalesTable --from-csv rows.csv --header --footer-policy before-footer --output updated.xlsx --force\n\nTarget selection:\n  Use exactly one of --region-id or --table-name.\n  --region-id comes from `asp sheet-overview`.\n  --table-name resolves an existing sheet table by name.\n\nInput payloads:\n  Use exactly one of --rows or --from-csv.\n  --rows accepts a top-level JSON array of rows, or an object with a rows array.\n  Cells may be raw JSON scalars/null, {'v': ...} value cells, or {'f': 'FORMULA'} formula cells.\n  --from-csv imports CSV rows and treats empty fields as blanks; use --header to skip the first CSV row.\n\nFooter policies:\n  - auto (default): insert before a detected footer row when found, else append at the region end\n  - before-footer: require a detected footer/subtotal row and fail when none is found\n  - append-at-end: always append after the detected region end, even when a footer row is present\n\nBehavior:\n  - resolves a detected region or table target\n  - reports footer candidates, policy choice, and formula footer targets in dry-run output\n  - writes the appended matrix into inserted rows\n  - expands adjacent SUM footers below the insertion band when rows are inserted before them"
1436    )]
1437    AppendRegion {
1438        #[arg(value_name = "FILE", help = "Workbook path to update")]
1439        file: PathBuf,
1440        #[arg(
1441            long = "sheet",
1442            value_name = "SHEET",
1443            help = "Sheet containing the detected region or table"
1444        )]
1445        sheet_name: String,
1446        #[arg(
1447            long = "region-id",
1448            value_name = "ID",
1449            help = "Detected region id from `asp sheet-overview`"
1450        )]
1451        region_id: Option<u32>,
1452        #[arg(
1453            long = "table-name",
1454            value_name = "NAME",
1455            help = "Sheet table name to append into instead of a detected region id"
1456        )]
1457        table_name: Option<String>,
1458        #[arg(
1459            long,
1460            value_name = "ROWS_REF",
1461            help = "Rows payload as @file or inline JSON"
1462        )]
1463        rows: Option<String>,
1464        #[arg(
1465            long = "from-csv",
1466            value_name = "PATH",
1467            help = "CSV file to append as rows"
1468        )]
1469        from_csv: Option<String>,
1470        #[arg(long, help = "Skip first CSV row when importing --from-csv")]
1471        header: bool,
1472        #[arg(
1473            long = "footer-policy",
1474            value_enum,
1475            default_value = "auto",
1476            value_name = "POLICY",
1477            help = "Footer handling policy: auto, before-footer, or append-at-end"
1478        )]
1479        footer_policy: AppendRegionFooterPolicyArg,
1480        #[arg(long, help = "Preview insertion plan without mutating files")]
1481        dry_run: bool,
1482        #[arg(long, help = "Apply by atomically replacing the source file")]
1483        in_place: bool,
1484        #[arg(long, value_name = "PATH", help = "Apply append to this output path")]
1485        output: Option<PathBuf>,
1486        #[arg(long, help = "Allow overwriting --output when it already exists")]
1487        force: bool,
1488    },
1489    #[command(
1490        about = "Clone one template row into inserted rows with preview-first planning",
1491        after_long_help = "Examples:\n  asp clone-template-row workbook.xlsx --sheet Sheet1 --source-row 12 --after 12 --count 2 --dry-run\n  asp clone-template-row workbook.xlsx --sheet Sheet1 --source-row 8 --before 20 --patch-targets all-non-formula --output updated.xlsx --force\n\nAnchor selection:\n  Use exactly one of --before, --after, or --insert-at.\n\nBehavior:\n  - clones a single template row using the existing row-clone structure path\n  - reports formula targets, patch targets, merge-boundary warnings, and confidence metadata in dry-run output\n  - merge-policy safe warns on boundary-crossing merges; strict fails instead"
1492    )]
1493    CloneTemplateRow {
1494        #[arg(value_name = "FILE", help = "Workbook path to update")]
1495        file: PathBuf,
1496        #[arg(
1497            long = "sheet",
1498            value_name = "SHEET",
1499            help = "Sheet containing the template row"
1500        )]
1501        sheet_name: String,
1502        #[arg(long = "source-row", value_name = "ROW", help = "1-based row to clone")]
1503        source_row: u32,
1504        #[arg(long, value_name = "ROW", help = "Insert before this 1-based row")]
1505        before: Option<u32>,
1506        #[arg(long, value_name = "ROW", help = "Insert after this 1-based row")]
1507        after: Option<u32>,
1508        #[arg(
1509            long = "insert-at",
1510            value_name = "ROW",
1511            help = "Raw 1-based insertion row"
1512        )]
1513        insert_at: Option<u32>,
1514        #[arg(
1515            long,
1516            value_name = "N",
1517            default_value_t = 1,
1518            help = "Number of row copies to insert"
1519        )]
1520        count: u32,
1521        #[arg(
1522            long = "expand-adjacent-sums",
1523            help = "Expand adjacent SUM footer formulas below the inserted rows"
1524        )]
1525        expand_adjacent_sums: bool,
1526        #[arg(
1527            long = "patch-targets",
1528            value_enum,
1529            default_value = "likely-inputs",
1530            value_name = "MODE",
1531            help = "Patch target mode: likely-inputs, all-non-formula, or none"
1532        )]
1533        patch_targets: ClonePatchTargetsArg,
1534        #[arg(
1535            long = "merge-policy",
1536            value_enum,
1537            default_value = "safe",
1538            value_name = "POLICY",
1539            help = "Merge handling policy: safe warns, strict fails"
1540        )]
1541        merge_policy: CloneMergePolicyArg,
1542        #[arg(long, help = "Preview clone plan without mutating files")]
1543        dry_run: bool,
1544        #[arg(long, help = "Apply by atomically replacing the source file")]
1545        in_place: bool,
1546        #[arg(long, value_name = "PATH", help = "Apply clone to this output path")]
1547        output: Option<PathBuf>,
1548        #[arg(long, help = "Allow overwriting --output when it already exists")]
1549        force: bool,
1550    },
1551    #[command(
1552        about = "Clone a contiguous template row band with preview-first planning",
1553        after_long_help = "Examples:\n  asp clone-row-band workbook.xlsx --sheet Sheet1 --source-rows 12:14 --after 14 --repeat 2 --dry-run\n  asp clone-row-band workbook.xlsx --sheet Sheet1 --source-rows 20:22 --before 30 --patch-targets all-non-formula --output updated.xlsx --force\n\nAnchor selection:\n  Use exactly one of --before, --after, or --insert-at.\n\nBehavior:\n  - clones a contiguous source row band using row insertion plus stamped template cells\n  - reports inserted blocks, formula targets, patch targets, merge-boundary warnings, and confidence metadata in dry-run output\n  - merge-policy safe warns on boundary-crossing merges; strict fails instead"
1554    )]
1555    CloneRowBand {
1556        #[arg(value_name = "FILE", help = "Workbook path to update")]
1557        file: PathBuf,
1558        #[arg(
1559            long = "sheet",
1560            value_name = "SHEET",
1561            help = "Sheet containing the source row band"
1562        )]
1563        sheet_name: String,
1564        #[arg(
1565            long = "source-rows",
1566            value_name = "START:END",
1567            help = "Contiguous 1-based source row band"
1568        )]
1569        source_rows: String,
1570        #[arg(long, value_name = "ROW", help = "Insert before this 1-based row")]
1571        before: Option<u32>,
1572        #[arg(long, value_name = "ROW", help = "Insert after this 1-based row")]
1573        after: Option<u32>,
1574        #[arg(
1575            long = "insert-at",
1576            value_name = "ROW",
1577            help = "Raw 1-based insertion row"
1578        )]
1579        insert_at: Option<u32>,
1580        #[arg(
1581            long,
1582            value_name = "N",
1583            default_value_t = 1,
1584            help = "Number of times to repeat the row band"
1585        )]
1586        repeat: u32,
1587        #[arg(
1588            long = "expand-adjacent-sums",
1589            help = "Expand adjacent SUM footer formulas below the inserted rows"
1590        )]
1591        expand_adjacent_sums: bool,
1592        #[arg(
1593            long = "patch-targets",
1594            value_enum,
1595            default_value = "likely-inputs",
1596            value_name = "MODE",
1597            help = "Patch target mode: likely-inputs, all-non-formula, or none"
1598        )]
1599        patch_targets: ClonePatchTargetsArg,
1600        #[arg(
1601            long = "merge-policy",
1602            value_enum,
1603            default_value = "safe",
1604            value_name = "POLICY",
1605            help = "Merge handling policy: safe warns, strict fails"
1606        )]
1607        merge_policy: CloneMergePolicyArg,
1608        #[arg(long, help = "Preview clone plan without mutating files")]
1609        dry_run: bool,
1610        #[arg(long, help = "Apply by atomically replacing the source file")]
1611        in_place: bool,
1612        #[arg(long, value_name = "PATH", help = "Apply clone to this output path")]
1613        output: Option<PathBuf>,
1614        #[arg(long, help = "Allow overwriting --output when it already exists")]
1615        force: bool,
1616    },
1617    #[command(
1618        about = "Apply stateless transform operations from an @ops payload",
1619        after_long_help = r#"Examples:
1620  agent-spreadsheet transform-batch workbook.xlsx --ops @ops.json --dry-run
1621  agent-spreadsheet transform-batch workbook.xlsx --ops @ops.json --in-place
1622  agent-spreadsheet transform-batch workbook.xlsx --ops @ops.json --output transformed.xlsx --force
1623
1624Mode selection:
1625  Choose exactly one of --dry-run, --in-place, or --output <PATH>.
1626
1627Payload examples (`--ops @transform_ops.json`):
1628  Minimal:
1629    {"ops":[{"kind":"fill_range","sheet_name":"Sheet1","target":{"kind":"range","range":"B2:B4"},"value":"0"}]}
1630  Advanced:
1631    {"ops":[{"kind":"replace_in_range","sheet_name":"Sheet1","target":{"kind":"region","region_id":1},"find":"N/A","replace":"","match_mode":"contains","case_sensitive":false,"include_formulas":true}]}
1632
1633Required envelope:
1634  Top-level object with an `ops` array.
1635  Each op requires a `kind` discriminator and command-specific required fields.
1636
1637Cache note:
1638  Formula writes (FillRange with is_formula, ReplaceInRange with include_formulas) clear cached results.
1639  Run recalculate to refresh computed values.
1640
1641Diagnostics note:
1642  Formula writes include write_path_provenance (written_via + formula_targets)."#
1643    )]
1644    TransformBatch {
1645        #[arg(
1646            value_name = "FILE",
1647            help = "Workbook path to transform",
1648            required_unless_present = "print_schema"
1649        )]
1650        file: Option<PathBuf>,
1651        #[arg(
1652            long,
1653            value_name = "OPS_REF",
1654            help = "Ops payload file reference (@path)",
1655            required_unless_present = "print_schema"
1656        )]
1657        ops: Option<String>,
1658        #[arg(long, help = "Validate ops and report summary without mutating files")]
1659        dry_run: bool,
1660        #[arg(
1661            long,
1662            help = "Apply transforms by atomically replacing the source file"
1663        )]
1664        in_place: bool,
1665        #[arg(
1666            long,
1667            value_name = "PATH",
1668            help = "Apply transforms to this output path"
1669        )]
1670        output: Option<PathBuf>,
1671        #[arg(long, help = "Allow overwriting --output when it already exists")]
1672        force: bool,
1673        #[arg(
1674            long = "print-schema",
1675            hide = true,
1676            help = "Print the full JSON schema for the --ops payload and exit"
1677        )]
1678        print_schema: bool,
1679        #[arg(
1680            long = "formula-parse-policy",
1681            value_enum,
1682            value_name = "POLICY",
1683            help = "Formula parse policy: fail, warn (default for transform-batch), or off"
1684        )]
1685        formula_parse_policy: Option<FormulaParsePolicy>,
1686    },
1687    #[command(
1688        about = "Apply stateless style operations from an @ops payload",
1689        after_long_help = r#"Examples:
1690  agent-spreadsheet style-batch workbook.xlsx --ops @style_ops.json --dry-run
1691  agent-spreadsheet style-batch workbook.xlsx --ops @style_ops.json --output styled.xlsx --force
1692
1693Payload examples (`--ops @style_ops.json`):
1694  Minimal:
1695    {"ops":[{"sheet_name":"Sheet1","target":{"kind":"range","range":"B2:B2"},"patch":{"font":{"bold":true}}}]}
1696  Advanced:
1697    {"ops":[{"sheet_name":"Sheet1","target":{"kind":"cells","cells":["B2","B3"]},"patch":{"number_format":"$#,##0.00","alignment":{"horizontal":"right"}},"op_mode":"merge"}]}
1698
1699Required envelope:
1700  Top-level object with an `ops` array.
1701  Style ops require `sheet_name`, `target`, and `patch` (no top-level op `kind`)."#
1702    )]
1703    StyleBatch {
1704        #[arg(
1705            value_name = "FILE",
1706            help = "Workbook path to style",
1707            required_unless_present = "print_schema"
1708        )]
1709        file: Option<PathBuf>,
1710        #[arg(
1711            long,
1712            value_name = "OPS_REF",
1713            help = "Ops payload file reference (@path)",
1714            required_unless_present = "print_schema"
1715        )]
1716        ops: Option<String>,
1717        #[arg(long, help = "Validate ops and report summary without mutating files")]
1718        dry_run: bool,
1719        #[arg(long, help = "Apply style ops by atomically replacing the source file")]
1720        in_place: bool,
1721        #[arg(
1722            long,
1723            value_name = "PATH",
1724            help = "Apply style ops to this output path"
1725        )]
1726        output: Option<PathBuf>,
1727        #[arg(long, help = "Allow overwriting --output when it already exists")]
1728        force: bool,
1729        #[arg(
1730            long = "print-schema",
1731            hide = true,
1732            help = "Print the full JSON schema for the --ops payload and exit"
1733        )]
1734        print_schema: bool,
1735    },
1736    #[command(
1737        about = "Apply stateless formula pattern operations from an @ops payload",
1738        after_long_help = r#"Examples:
1739  agent-spreadsheet apply-formula-pattern workbook.xlsx --ops @formula_ops.json --in-place
1740  agent-spreadsheet apply-formula-pattern workbook.xlsx --ops @formula_ops.json --dry-run
1741
1742Payload examples (`--ops @formula_ops.json`):
1743  Minimal:
1744    {"ops":[{"sheet_name":"Sheet1","target_range":"C2:C4","anchor_cell":"C2","base_formula":"B2*2"}]}
1745  Advanced:
1746    {"ops":[{"sheet_name":"Sheet1","target_range":"C2:E4","anchor_cell":"C2","base_formula":"B2*2","fill_direction":"both","relative_mode":"excel"}]}
1747
1748Required envelope:
1749  Top-level object with an `ops` array.
1750  Each op requires `sheet_name`, `target_range`, `anchor_cell`, and `base_formula`.
1751  `relative_mode` valid values: excel|abs_cols|abs_rows.
1752
1753Cache note:
1754  Updated formula cells clear cached results. Run recalculate to refresh computed values.
1755
1756Diagnostics note:
1757  Formula writes include write_path_provenance (written_via + formula_targets)."#
1758    )]
1759    ApplyFormulaPattern {
1760        #[arg(
1761            value_name = "FILE",
1762            help = "Workbook path to update",
1763            required_unless_present = "print_schema"
1764        )]
1765        file: Option<PathBuf>,
1766        #[arg(
1767            long,
1768            value_name = "OPS_REF",
1769            help = "Ops payload file reference (@path)",
1770            required_unless_present = "print_schema"
1771        )]
1772        ops: Option<String>,
1773        #[arg(long, help = "Validate ops and report summary without mutating files")]
1774        dry_run: bool,
1775        #[arg(
1776            long,
1777            help = "Apply formula pattern ops by atomically replacing the source file"
1778        )]
1779        in_place: bool,
1780        #[arg(
1781            long,
1782            value_name = "PATH",
1783            help = "Apply formula pattern ops to this output path"
1784        )]
1785        output: Option<PathBuf>,
1786        #[arg(long, help = "Allow overwriting --output when it already exists")]
1787        force: bool,
1788        #[arg(
1789            long = "print-schema",
1790            hide = true,
1791            help = "Print the full JSON schema for the --ops payload and exit"
1792        )]
1793        print_schema: bool,
1794    },
1795    #[command(
1796        about = "Apply stateless structure operations from an @ops payload",
1797        after_long_help = r#"Examples:
1798  agent-spreadsheet structure-batch workbook.xlsx --ops @structure_ops.json --dry-run
1799  agent-spreadsheet structure-batch workbook.xlsx --ops @structure_ops.json --output structured.xlsx
1800
1801Payload examples (`--ops @structure_ops.json`):
1802  Minimal:
1803    {"ops":[{"kind":"rename_sheet","old_name":"Summary","new_name":"Dashboard"}]}
1804  Advanced:
1805    {"ops":[{"kind":"copy_range","sheet_name":"Sheet1","dest_sheet_name":"Summary","src_range":"A1:C4","dest_anchor":"A1","include_styles":true,"include_formulas":true}]}
1806
1807Required envelope:
1808  Top-level object with an `ops` array.
1809  Each op requires a `kind` discriminator and kind-specific required fields.
1810
1811Cache note:
1812  Structural operations that rewrite formula references (row/column insert/delete, sheet rename,
1813  copy/move) clear cached formula results. Run recalculate to refresh computed values."#
1814    )]
1815    StructureBatch {
1816        #[arg(
1817            value_name = "FILE",
1818            help = "Workbook path to update",
1819            required_unless_present = "print_schema"
1820        )]
1821        file: Option<PathBuf>,
1822        #[arg(
1823            long,
1824            value_name = "OPS_REF",
1825            help = "Ops payload file reference (@path)",
1826            required_unless_present = "print_schema"
1827        )]
1828        ops: Option<String>,
1829        #[arg(long, help = "Validate ops and report summary without mutating files")]
1830        dry_run: bool,
1831        #[arg(
1832            long,
1833            help = "Apply structure ops by atomically replacing the source file"
1834        )]
1835        in_place: bool,
1836        #[arg(
1837            long,
1838            value_name = "PATH",
1839            help = "Apply structure ops to this output path"
1840        )]
1841        output: Option<PathBuf>,
1842        #[arg(long, help = "Allow overwriting --output when it already exists")]
1843        force: bool,
1844        #[arg(
1845            long = "print-schema",
1846            hide = true,
1847            help = "Print the full JSON schema for the --ops payload and exit"
1848        )]
1849        print_schema: bool,
1850        #[arg(
1851            long = "formula-parse-policy",
1852            value_enum,
1853            value_name = "POLICY",
1854            help = "Formula parse policy: fail, warn (default for structure-batch), or off"
1855        )]
1856        formula_parse_policy: Option<FormulaParsePolicy>,
1857        #[arg(
1858            long = "impact-report",
1859            help = "Include a structural impact report (shifted spans, absolute-ref warnings). Requires --dry-run."
1860        )]
1861        impact_report: bool,
1862        #[arg(
1863            long = "show-formula-delta",
1864            help = "Include before/after formula delta preview samples. Requires --dry-run."
1865        )]
1866        show_formula_delta: bool,
1867    },
1868    #[command(
1869        about = "Analyze structural operation impact without mutation (preflight ref-risk check)",
1870        after_long_help = r#"Examples:
1871  agent-spreadsheet check-ref-impact workbook.xlsx --ops @structure_ops.json
1872  agent-spreadsheet check-ref-impact workbook.xlsx --ops @structure_ops.json --show-formula-delta
1873
1874Payload format is the same as structure-batch --ops.
1875This command is read-only: it never modifies the workbook.
1876
1877Output includes:
1878  - shifted_spans: which rows/cols shift and by how much
1879  - absolute_ref_warnings: $-anchored references that cross insertion/deletion boundaries
1880  - tokens_affected / tokens_unaffected counts
1881  - optional formula_delta_preview (before/after formula samples)"#
1882    )]
1883    CheckRefImpact {
1884        #[arg(value_name = "FILE", help = "Path to the workbook")]
1885        file: PathBuf,
1886        #[arg(
1887            long,
1888            value_name = "OPS_REF",
1889            help = "Ops payload file reference (@path) \u{2014} same format as structure-batch"
1890        )]
1891        ops: String,
1892        #[arg(
1893            long = "show-formula-delta",
1894            help = "Include before/after formula delta preview samples"
1895        )]
1896        show_formula_delta: bool,
1897    },
1898    #[command(
1899        about = "Apply stateless column sizing operations from an @ops payload",
1900        after_long_help = r#"Examples:
1901  agent-spreadsheet column-size-batch workbook.xlsx --ops @column_size_ops.json --in-place
1902  agent-spreadsheet column-size-batch workbook.xlsx --ops @column_size_ops.json --output columns.xlsx
1903
1904Payload examples (`--ops @column_size_ops.json`):
1905  Minimal:
1906    {"sheet_name":"Sheet1","ops":[{"range":"A:A","size":{"kind":"width","width_chars":12.0}}]}
1907  Advanced:
1908    {"sheet_name":"Sheet1","ops":[{"target":{"kind":"columns","range":"A:C"},"size":{"kind":"auto","min_width_chars":8.0,"max_width_chars":24.0}}]}
1909
1910Required envelope:
1911  Preferred: top-level object with `sheet_name` and `ops`.
1912  Also accepted: top-level `ops` where each op includes `sheet_name`.
1913  Each op requires `size.kind`; canonical form also includes `target.kind:"columns"`."#
1914    )]
1915    ColumnSizeBatch {
1916        #[arg(
1917            value_name = "FILE",
1918            help = "Workbook path to update",
1919            required_unless_present = "print_schema"
1920        )]
1921        file: Option<PathBuf>,
1922        #[arg(
1923            long,
1924            value_name = "OPS_REF",
1925            help = "Ops payload file reference (@path)",
1926            required_unless_present = "print_schema"
1927        )]
1928        ops: Option<String>,
1929        #[arg(long, help = "Validate ops and report summary without mutating files")]
1930        dry_run: bool,
1931        #[arg(
1932            long,
1933            help = "Apply column sizing ops by atomically replacing the source file"
1934        )]
1935        in_place: bool,
1936        #[arg(
1937            long,
1938            value_name = "PATH",
1939            help = "Apply column sizing ops to this output path"
1940        )]
1941        output: Option<PathBuf>,
1942        #[arg(long, help = "Allow overwriting --output when it already exists")]
1943        force: bool,
1944        #[arg(
1945            long = "print-schema",
1946            hide = true,
1947            help = "Print the full JSON schema for the --ops payload and exit"
1948        )]
1949        print_schema: bool,
1950    },
1951    #[command(
1952        about = "Apply stateless sheet layout operations from an @ops payload",
1953        after_long_help = r#"Examples:
1954  agent-spreadsheet sheet-layout-batch workbook.xlsx --ops @layout_ops.json --dry-run
1955  agent-spreadsheet sheet-layout-batch workbook.xlsx --ops @layout_ops.json --in-place
1956
1957Payload examples (`--ops @layout_ops.json`):
1958  Minimal:
1959    {"ops":[{"kind":"freeze_panes","sheet_name":"Sheet1","freeze_rows":1,"freeze_cols":1}]}
1960  Advanced:
1961    {"ops":[{"kind":"set_page_setup","sheet_name":"Sheet1","orientation":"landscape","fit_to_width":1,"fit_to_height":1}]}
1962
1963Required envelope:
1964  Top-level object with an `ops` array.
1965  Each op requires a `kind` discriminator plus kind-specific required fields."#
1966    )]
1967    SheetLayoutBatch {
1968        #[arg(
1969            value_name = "FILE",
1970            help = "Workbook path to update",
1971            required_unless_present = "print_schema"
1972        )]
1973        file: Option<PathBuf>,
1974        #[arg(
1975            long,
1976            value_name = "OPS_REF",
1977            help = "Ops payload file reference (@path)",
1978            required_unless_present = "print_schema"
1979        )]
1980        ops: Option<String>,
1981        #[arg(long, help = "Validate ops and report summary without mutating files")]
1982        dry_run: bool,
1983        #[arg(
1984            long,
1985            help = "Apply sheet layout ops by atomically replacing the source file"
1986        )]
1987        in_place: bool,
1988        #[arg(
1989            long,
1990            value_name = "PATH",
1991            help = "Apply sheet layout ops to this output path"
1992        )]
1993        output: Option<PathBuf>,
1994        #[arg(long, help = "Allow overwriting --output when it already exists")]
1995        force: bool,
1996        #[arg(
1997            long = "print-schema",
1998            hide = true,
1999            help = "Print the full JSON schema for the --ops payload and exit"
2000        )]
2001        print_schema: bool,
2002    },
2003    #[command(
2004        about = "Apply stateless data validation and conditional format operations from an @ops payload",
2005        after_long_help = r##"Examples:
2006  agent-spreadsheet rules-batch workbook.xlsx --ops @rules_ops.json --dry-run
2007  agent-spreadsheet rules-batch workbook.xlsx --ops @rules_ops.json --output ruled.xlsx --force
2008
2009Payload examples (`--ops @rules_ops.json`):
2010  Minimal:
2011    {"ops":[{"kind":"set_data_validation","sheet_name":"Sheet1","target_range":"B2:B4","validation":{"kind":"list","formula1":"\"A,B,C\""}}]}
2012  Advanced:
2013    {"ops":[{"kind":"set_conditional_format","sheet_name":"Sheet1","target_range":"C2:C10","rule":{"kind":"expression","formula":"C2>100"},"style":{"fill_color":"#FFF2CC","bold":true}}]}
2014
2015Required envelope:
2016  Top-level object with an `ops` array.
2017  Each op requires a `kind` discriminator and kind-specific required fields.
2018
2019Note:
2020  Data-validation and conditional-format formulas are rule-level (not cell-level) and do not affect
2021  cell formula caches. No recalculate is needed after rules-batch operations."##
2022    )]
2023    RulesBatch {
2024        #[arg(
2025            value_name = "FILE",
2026            help = "Workbook path to update",
2027            required_unless_present = "print_schema"
2028        )]
2029        file: Option<PathBuf>,
2030        #[arg(
2031            long,
2032            value_name = "OPS_REF",
2033            help = "Ops payload file reference (@path)",
2034            required_unless_present = "print_schema"
2035        )]
2036        ops: Option<String>,
2037        #[arg(long, help = "Validate ops and report summary without mutating files")]
2038        dry_run: bool,
2039        #[arg(long, help = "Apply rules ops by atomically replacing the source file")]
2040        in_place: bool,
2041        #[arg(
2042            long,
2043            value_name = "PATH",
2044            help = "Apply rules ops to this output path"
2045        )]
2046        output: Option<PathBuf>,
2047        #[arg(long, help = "Allow overwriting --output when it already exists")]
2048        force: bool,
2049        #[arg(
2050            long = "print-schema",
2051            hide = true,
2052            help = "Print the full JSON schema for the --ops payload and exit"
2053        )]
2054        print_schema: bool,
2055        #[arg(
2056            long = "formula-parse-policy",
2057            value_enum,
2058            value_name = "POLICY",
2059            help = "Formula parse policy: fail, warn (default for rules-batch), or off"
2060        )]
2061        formula_parse_policy: Option<FormulaParsePolicy>,
2062    },
2063    #[command(
2064        about = "SheetPort manifest lifecycle and execution commands",
2065        after_long_help = "Examples:\n  agent-spreadsheet sheetport manifest candidates model.xlsx\n  agent-spreadsheet sheetport manifest validate manifest.yaml\n  agent-spreadsheet sheetport bind-check model.xlsx manifest.yaml\n  agent-spreadsheet sheetport run model.xlsx manifest.yaml --inputs @inputs.json"
2066    )]
2067    Sheetport {
2068        #[command(subcommand)]
2069        command: SheetportCommands,
2070    },
2071    #[command(
2072        about = "Find and replace text in formula bodies (not values)",
2073        after_long_help = r#"Examples:
2074  agent-spreadsheet replace-in-formulas data.xlsx Sheet1 --find '$64' --replace '$65' --dry-run
2075  agent-spreadsheet replace-in-formulas data.xlsx Sheet1 --find 'SUM' --replace 'SUMIFS' --in-place
2076  agent-spreadsheet replace-in-formulas data.xlsx Sheet1 --find 'Sheet1!' --replace 'Sheet2!' --range A1:Z100 --output fixed.xlsx
2077  agent-spreadsheet replace-in-formulas data.xlsx Sheet1 --find '(?i)old_name' --replace 'new_name' --regex --in-place
2078
2079Mode selection:
2080  Choose exactly one of --dry-run, --in-place, or --output <PATH>.
2081
2082Behavior:
2083  Only formula-bearing cells are considered. Literal values are never touched.
2084  When --range is omitted, the used range of the sheet is scanned.
2085  Output includes a count of changed formulas and sample diffs (address, before, after).
2086
2087Regex mode:
2088  Use --regex for regular expression patterns. Capture groups are supported in --replace (e.g. $1).
2089
2090Formula parse policy:
2091  After replacement, each new formula is validated. Policy controls behavior on malformed results:
2092    warn (default) => report diagnostics and skip invalid replacements
2093    fail => reject and error
2094    off => skip validation"#
2095    )]
2096    ReplaceInFormulas {
2097        #[arg(value_name = "FILE", help = "Workbook path to update")]
2098        file: PathBuf,
2099        #[arg(
2100            value_name = "SHEET",
2101            help = "Sheet name containing formulas to update"
2102        )]
2103        sheet: String,
2104        #[arg(long, help = "Text or pattern to find in formula bodies")]
2105        find: String,
2106        #[arg(long, help = "Replacement text")]
2107        replace: String,
2108        #[arg(
2109            long,
2110            value_name = "RANGE",
2111            help = "Optional A1 range to scope replacement (default: used range)"
2112        )]
2113        range: Option<String>,
2114        #[arg(long, help = "Interpret --find as a regular expression")]
2115        regex: bool,
2116        #[arg(long, help = "Case-sensitive matching (default: true)")]
2117        case_sensitive: Option<bool>,
2118        #[arg(long, help = "Validate ops and report summary without mutating files")]
2119        dry_run: bool,
2120        #[arg(
2121            long,
2122            help = "Apply replacement by atomically replacing the source file"
2123        )]
2124        in_place: bool,
2125        #[arg(
2126            long,
2127            value_name = "PATH",
2128            help = "Apply replacement to this output path"
2129        )]
2130        output: Option<PathBuf>,
2131        #[arg(long, help = "Allow overwriting --output when it already exists")]
2132        force: bool,
2133        #[arg(
2134            long = "formula-parse-policy",
2135            value_enum,
2136            value_name = "POLICY",
2137            help = "Formula parse policy: warn (default), fail, or off"
2138        )]
2139        formula_parse_policy: Option<FormulaParsePolicy>,
2140    },
2141    #[command(
2142        about = "Recalculate workbook formulas",
2143        after_long_help = "Examples:\n  asp recalculate data.xlsx\n  asp recalculate data.xlsx --output /tmp/recalced.xlsx\n  asp recalculate data.xlsx --output /tmp/recalced.xlsx --force\n\nDefault (no flags): recalculate the file in-place.\n--output <PATH>: copy source to output, recalculate the copy, leave source unchanged.\n--force: allow overwriting an existing --output file."
2144    )]
2145    Recalculate {
2146        #[arg(value_name = "FILE", help = "Workbook path to recalculate")]
2147        file: PathBuf,
2148        #[arg(
2149            long,
2150            value_name = "PATH",
2151            help = "Recalculate into this output path (source stays unchanged)"
2152        )]
2153        output: Option<PathBuf>,
2154        #[arg(long, help = "Allow overwriting --output when it already exists")]
2155        force: bool,
2156        #[arg(
2157            long = "ignore-sheets",
2158            value_name = "SHEETS",
2159            value_delimiter = ',',
2160            help = "Comma-separated sheet names to exclude from changed-cells summary"
2161        )]
2162        ignore_sheets: Option<Vec<String>>,
2163        #[arg(
2164            long = "changed-cells",
2165            help = "Include a summary of cells whose values changed after recalculation"
2166        )]
2167        changed_cells: bool,
2168    },
2169    #[command(
2170        about = "Compare two workbook states and verify target deltas plus error provenance",
2171        after_long_help = "Examples:\n  asp verify baseline.xlsx candidate.xlsx --targets Summary!B2\n  asp verify baseline.xlsx candidate.xlsx --targets Sheet1!C2,Summary!B2 --named-ranges\n  asp verify baseline.xlsx candidate.xlsx --sheet Summary --errors-only\n  asp verify baseline.xlsx candidate.xlsx --targets Sheet1!C2,Summary!B2 --targets-only\n\nBehavior:\n  - target_deltas compares the exact Sheet!A1 cells you request\n  - each target delta includes a classification such as unchanged, direct_edit, recalc_result, formula_shift, or new_error\n  - new_errors reports error cells present only in the current workbook\n  - resolved_errors reports baseline error cells that no longer error in the current workbook\n  - preexisting_errors reports error cells that existed in both baseline and current\n  - --sheet scopes error and named-range scans to one sheet; explicit --targets remain exact\n  - --errors-only returns only error provenance output\n  - --targets-only returns only target proof output\n  - --named-ranges adds added/removed/changed named range deltas in default verify mode"
2172    )]
2173    Verify {
2174        #[arg(value_name = "BASELINE", help = "Baseline workbook path")]
2175        baseline: PathBuf,
2176        #[arg(value_name = "CURRENT", help = "Current workbook path")]
2177        current: PathBuf,
2178        #[arg(
2179            long = "targets",
2180            value_name = "SHEET!CELL",
2181            value_delimiter = ',',
2182            help = "One or more Sheet!A1 targets to compare (comma-separated)"
2183        )]
2184        targets: Option<Vec<String>>,
2185        #[arg(
2186            long = "sheet",
2187            value_name = "SHEET",
2188            help = "Limit error and named-range scanning to one sheet"
2189        )]
2190        sheet_name: Option<String>,
2191        #[arg(
2192            long = "named-ranges",
2193            help = "Include added/removed/changed named range deltas"
2194        )]
2195        named_ranges: bool,
2196        #[arg(
2197            long,
2198            help = "Return only error provenance output (no target or named-range deltas)"
2199        )]
2200        errors_only: bool,
2201        #[arg(long, help = "Return only target proof output (requires --targets)")]
2202        targets_only: bool,
2203    },
2204    #[command(
2205        about = "Diff two workbook versions with summary-first, paged details",
2206        after_long_help = "Examples:\n  asp diff baseline.xlsx candidate.xlsx\n  asp diff baseline.xlsx candidate.xlsx --details --limit 200 --offset 0\n  asp diff baseline.xlsx candidate.xlsx --sheet \"GL Data\" --range A1:P200\n  asp diff baseline.xlsx candidate.xlsx --exclude-recalc-result\n\nBehavior:\n  - summary output now includes grouped change buckets and subtype counts\n  - recalc_result changes are counted separately from direct edits\n  - --exclude-recalc-result suppresses cached-value churn so direct edits are easier to review"
2207    )]
2208    Diff {
2209        #[arg(value_name = "ORIGINAL", help = "Baseline workbook path")]
2210        original: PathBuf,
2211        #[arg(value_name = "MODIFIED", help = "Modified workbook path")]
2212        modified: PathBuf,
2213        #[arg(long, help = "Limit diff to one sheet name")]
2214        sheet: Option<String>,
2215        #[arg(
2216            long,
2217            value_name = "SHEETS",
2218            value_delimiter = ',',
2219            help = "Limit diff to multiple sheet names (comma-separated)"
2220        )]
2221        sheets: Option<Vec<String>>,
2222        #[arg(
2223            long,
2224            value_name = "A1_RANGE",
2225            help = "Optional A1 range filter (e.g. A1:C100)"
2226        )]
2227        range: Option<String>,
2228        #[arg(
2229            long,
2230            help = "Include paged change items; default output is summary-only"
2231        )]
2232        details: bool,
2233        #[arg(
2234            long = "exclude-recalc-result",
2235            help = "Exclude recalc_result cell changes from summary and details"
2236        )]
2237        exclude_recalc_result: bool,
2238        #[arg(
2239            long,
2240            default_value_t = 200,
2241            help = "Page size for --details (1..2000)"
2242        )]
2243        limit: u32,
2244        #[arg(long, default_value_t = 0, help = "Offset for --details pagination")]
2245        offset: u32,
2246    },
2247    #[command(
2248        about = "Print canonical JSON schema for a command or payload target",
2249        after_long_help = "Examples:\n  asp schema transform-batch\n  asp schema structure-batch\n  asp schema session-op transform.write_matrix"
2250    )]
2251    Schema {
2252        #[command(subcommand)]
2253        command: DiscoverabilityCommands,
2254    },
2255    #[command(
2256        about = "Print a copy-pastable canonical example for a command or payload target",
2257        after_long_help = "Examples:\n  asp example transform-batch\n  asp example rules-batch\n  asp example session-op structure.clone_row"
2258    )]
2259    Example {
2260        #[command(subcommand)]
2261        command: DiscoverabilityCommands,
2262    },
2263    #[command(
2264        about = "Event-sourced session management (start, navigate, stage, apply, materialize)",
2265        subcommand,
2266        after_long_help = "Session commands provide event-sourced workbook editing with undo/redo, branching, staged apply, and payload discovery.\n\nWorkflow:\n  1. asp session start --base model.xlsx\n  2. asp example session-op transform.write_matrix\n  3. asp session op --session <id> --ops @edits.json\n  4. asp session apply --session <id> <staged_id>\n  5. asp session materialize --session <id> --output result.xlsx\n\nDiscoverability:\n  • asp schema session-op transform.write_matrix\n  • asp example session-op transform.write_matrix"
2267    )]
2268    Session(Box<SessionCommands>),
2269    #[command(
2270        about = "[Deprecated] Execute a SheetPort manifest with JSON inputs",
2271        after_long_help = "Use `agent-spreadsheet sheetport run ...` for new workflows.\n\nExamples:\n  agent-spreadsheet run-manifest data.xlsx manifest.yaml --inputs '{\"loan\": 10000}'\n  agent-spreadsheet sheetport run data.xlsx manifest.yaml --inputs @inputs.json"
2272    )]
2273    RunManifest {
2274        #[arg(value_name = "FILE", help = "Path to the workbook")]
2275        file: PathBuf,
2276        #[arg(value_name = "MANIFEST", help = "Path to the YAML manifest")]
2277        manifest: PathBuf,
2278        #[arg(long, help = "JSON string or @file containing input arguments")]
2279        inputs: Option<String>,
2280        #[arg(long, help = "Seed for deterministic RNG evaluation")]
2281        rng_seed: Option<u64>,
2282        #[arg(long, help = "Freeze volatile functions (e.g. NOW(), RAND())")]
2283        freeze_volatile: bool,
2284    },
2285}
2286
2287pub async fn run_command(command: Commands) -> Result<Value> {
2288    match command {
2289        Commands::ListSheets {
2290            file,
2291            session,
2292            session_workspace,
2293        } => {
2294            let (resolved, _guard) =
2295                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2296            commands::read::list_sheets(resolved).await
2297        }
2298        Commands::SheetOverview {
2299            file,
2300            sheet,
2301            session,
2302            session_workspace,
2303        } => {
2304            let (resolved, _guard) =
2305                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2306            commands::read::sheet_overview(resolved, sheet).await
2307        }
2308        Commands::RangeValues {
2309            file,
2310            sheet,
2311            ranges,
2312            format,
2313            include_formulas,
2314            session,
2315            session_workspace,
2316        } => {
2317            let (resolved, _guard) =
2318                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2319            commands::read::range_values(resolved, sheet, ranges, format, include_formulas).await
2320        }
2321        Commands::RangeExport {
2322            file,
2323            sheet,
2324            range,
2325            format,
2326            output,
2327            include_formulas,
2328            session,
2329            session_workspace,
2330        } => {
2331            let (resolved, _guard) =
2332                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2333            commands::read::range_export(resolved, sheet, range, format, output, include_formulas)
2334                .await
2335        }
2336        Commands::RangeImport {
2337            file,
2338            sheet,
2339            anchor,
2340            from_grid,
2341            from_csv,
2342            header,
2343            clear_target,
2344            dry_run,
2345            in_place,
2346            output,
2347            force,
2348        } => {
2349            commands::write::range_import(
2350                file,
2351                sheet,
2352                anchor,
2353                from_grid,
2354                from_csv,
2355                header,
2356                clear_target,
2357                dry_run,
2358                in_place,
2359                output,
2360                force,
2361            )
2362            .await
2363        }
2364        Commands::InspectCells {
2365            file,
2366            sheet,
2367            targets,
2368            include_empty,
2369            budget,
2370            session,
2371            session_workspace,
2372        } => {
2373            let (resolved, _guard) =
2374                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2375            commands::read::inspect_cells(resolved, sheet, targets, include_empty, budget).await
2376        }
2377        Commands::SheetPage {
2378            file,
2379            sheet,
2380            start_row,
2381            page_size,
2382            columns,
2383            columns_by_header,
2384            include_formulas,
2385            include_styles,
2386            include_header,
2387            format,
2388            session,
2389            session_workspace,
2390        } => {
2391            let (resolved, _guard) =
2392                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2393            commands::read::sheet_page(
2394                resolved,
2395                sheet,
2396                start_row,
2397                page_size,
2398                columns,
2399                columns_by_header,
2400                include_formulas,
2401                include_styles,
2402                include_header,
2403                format,
2404            )
2405            .await
2406        }
2407        Commands::ReadTable {
2408            file,
2409            sheet,
2410            range,
2411            table_name,
2412            region_id,
2413            limit,
2414            offset,
2415            sample_mode,
2416            filters_json,
2417            filters_file,
2418            table_format,
2419            session,
2420            session_workspace,
2421        } => {
2422            let (resolved, _guard) =
2423                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2424            commands::read::read_table(
2425                resolved,
2426                sheet,
2427                range,
2428                table_name,
2429                region_id,
2430                limit,
2431                offset,
2432                sample_mode,
2433                filters_json,
2434                filters_file,
2435                table_format,
2436            )
2437            .await
2438        }
2439        Commands::FindValue {
2440            file,
2441            query,
2442            sheet,
2443            mode,
2444            label_direction,
2445            session,
2446            session_workspace,
2447        } => {
2448            let (resolved, _guard) =
2449                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2450            commands::read::find_value(resolved, query, sheet, mode, label_direction).await
2451        }
2452        Commands::NamedRanges {
2453            file,
2454            sheet,
2455            name_prefix,
2456            session,
2457            session_workspace,
2458        } => {
2459            let (resolved, _guard) =
2460                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2461            commands::read::named_ranges(resolved, sheet, name_prefix).await
2462        }
2463        Commands::DefineName {
2464            file,
2465            name,
2466            refers_to,
2467            scope,
2468            scope_sheet_name,
2469            dry_run,
2470            in_place,
2471            output,
2472            force,
2473        } => {
2474            commands::write::define_name(
2475                file,
2476                name,
2477                refers_to,
2478                scope,
2479                scope_sheet_name,
2480                dry_run,
2481                in_place,
2482                output,
2483                force,
2484            )
2485            .await
2486        }
2487        Commands::UpdateName {
2488            file,
2489            name,
2490            refers_to,
2491            scope,
2492            scope_sheet_name,
2493            dry_run,
2494            in_place,
2495            output,
2496            force,
2497        } => {
2498            commands::write::update_name(
2499                file,
2500                name,
2501                refers_to,
2502                scope,
2503                scope_sheet_name,
2504                dry_run,
2505                in_place,
2506                output,
2507                force,
2508            )
2509            .await
2510        }
2511        Commands::DeleteName {
2512            file,
2513            name,
2514            scope,
2515            scope_sheet_name,
2516            dry_run,
2517            in_place,
2518            output,
2519            force,
2520        } => {
2521            commands::write::delete_name(
2522                file,
2523                name,
2524                scope,
2525                scope_sheet_name,
2526                dry_run,
2527                in_place,
2528                output,
2529                force,
2530            )
2531            .await
2532        }
2533        Commands::FindFormula {
2534            file,
2535            query,
2536            sheet,
2537            limit,
2538            offset,
2539        } => commands::read::find_formula(file, query, sheet, limit, offset).await,
2540        Commands::ScanVolatiles {
2541            file,
2542            sheet,
2543            limit,
2544            offset,
2545            formula_parse_policy,
2546        } => commands::read::scan_volatiles(file, sheet, limit, offset, formula_parse_policy).await,
2547        Commands::SheetStatistics { file, sheet } => {
2548            commands::read::sheet_statistics(file, sheet).await
2549        }
2550        Commands::FormulaMap {
2551            file,
2552            sheet,
2553            limit,
2554            sort_by,
2555            formula_parse_policy,
2556        } => commands::read::formula_map(file, sheet, limit, sort_by, formula_parse_policy).await,
2557        Commands::FormulaTrace {
2558            file,
2559            sheet,
2560            cell,
2561            direction,
2562            depth,
2563            page_size,
2564            cursor_depth,
2565            cursor_offset,
2566            formula_parse_policy,
2567            session,
2568            session_workspace,
2569        } => {
2570            let (resolved, _guard) =
2571                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2572            commands::read::formula_trace(
2573                resolved,
2574                sheet,
2575                cell,
2576                direction,
2577                depth,
2578                page_size,
2579                cursor_depth,
2580                cursor_offset,
2581                formula_parse_policy,
2582            )
2583            .await
2584        }
2585        Commands::Describe {
2586            file,
2587            session,
2588            session_workspace,
2589        } => {
2590            let (resolved, _guard) =
2591                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2592            commands::read::describe(resolved).await
2593        }
2594        Commands::TableProfile {
2595            file,
2596            sheet,
2597            session,
2598            session_workspace,
2599        } => {
2600            let (resolved, _guard) =
2601                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2602            commands::read::table_profile(resolved, sheet).await
2603        }
2604        Commands::LayoutPage {
2605            file,
2606            sheet,
2607            range_positional,
2608            range,
2609            mode,
2610            max_col_width,
2611            fit_columns,
2612            skip_empty_columns_trim,
2613            render,
2614            session,
2615            session_workspace,
2616        } => {
2617            let (resolved, _guard) =
2618                commands::read::resolve_file_or_session(file, session, session_workspace)?;
2619            commands::read::layout_page(
2620                resolved,
2621                sheet,
2622                range.or(range_positional),
2623                mode,
2624                max_col_width,
2625                fit_columns,
2626                skip_empty_columns_trim,
2627                render,
2628            )
2629            .await
2630        }
2631        Commands::CreateWorkbook {
2632            path,
2633            sheets,
2634            overwrite,
2635        } => commands::write::create_workbook(path, sheets, overwrite).await,
2636        Commands::Copy { source, dest } => commands::write::copy(source, dest).await,
2637        Commands::Edit {
2638            file,
2639            sheet,
2640            dry_run,
2641            in_place,
2642            output,
2643            force,
2644            edits,
2645            edits_file,
2646            formula_parse_policy,
2647        } => {
2648            commands::write::edit(
2649                file,
2650                sheet,
2651                edits,
2652                edits_file,
2653                dry_run,
2654                in_place,
2655                output,
2656                force,
2657                formula_parse_policy,
2658            )
2659            .await
2660        }
2661        Commands::AppendRegion {
2662            file,
2663            sheet_name,
2664            region_id,
2665            table_name,
2666            rows,
2667            from_csv,
2668            header,
2669            footer_policy,
2670            dry_run,
2671            in_place,
2672            output,
2673            force,
2674        } => {
2675            commands::write::append_region(
2676                file,
2677                sheet_name,
2678                region_id,
2679                table_name,
2680                rows,
2681                from_csv,
2682                header,
2683                footer_policy,
2684                dry_run,
2685                in_place,
2686                output,
2687                force,
2688            )
2689            .await
2690        }
2691        Commands::CloneTemplateRow {
2692            file,
2693            sheet_name,
2694            source_row,
2695            before,
2696            after,
2697            insert_at,
2698            count,
2699            expand_adjacent_sums,
2700            patch_targets,
2701            merge_policy,
2702            dry_run,
2703            in_place,
2704            output,
2705            force,
2706        } => {
2707            commands::write::clone_template_row(
2708                file,
2709                sheet_name,
2710                source_row,
2711                before,
2712                after,
2713                insert_at,
2714                count,
2715                expand_adjacent_sums,
2716                patch_targets,
2717                merge_policy,
2718                dry_run,
2719                in_place,
2720                output,
2721                force,
2722            )
2723            .await
2724        }
2725        Commands::CloneRowBand {
2726            file,
2727            sheet_name,
2728            source_rows,
2729            before,
2730            after,
2731            insert_at,
2732            repeat,
2733            expand_adjacent_sums,
2734            patch_targets,
2735            merge_policy,
2736            dry_run,
2737            in_place,
2738            output,
2739            force,
2740        } => {
2741            commands::write::clone_row_band(
2742                file,
2743                sheet_name,
2744                source_rows,
2745                before,
2746                after,
2747                insert_at,
2748                repeat,
2749                expand_adjacent_sums,
2750                patch_targets,
2751                merge_policy,
2752                dry_run,
2753                in_place,
2754                output,
2755                force,
2756            )
2757            .await
2758        }
2759        Commands::TransformBatch {
2760            file,
2761            ops,
2762            dry_run,
2763            in_place,
2764            output,
2765            force,
2766            print_schema,
2767            formula_parse_policy,
2768        } => {
2769            if print_schema {
2770                commands::write::batch_payload_schema(
2771                    commands::write::BatchSchemaCommand::Transform,
2772                )
2773            } else {
2774                let file = file.ok_or_else(|| {
2775                    anyhow::anyhow!("invalid argument: transform-batch requires <FILE>")
2776                })?;
2777                let ops = ops.ok_or_else(|| {
2778                    anyhow::anyhow!("invalid argument: transform-batch requires --ops @<path>")
2779                })?;
2780                commands::write::transform_batch(
2781                    file,
2782                    ops,
2783                    dry_run,
2784                    in_place,
2785                    output,
2786                    force,
2787                    formula_parse_policy,
2788                )
2789                .await
2790            }
2791        }
2792        Commands::StyleBatch {
2793            file,
2794            ops,
2795            dry_run,
2796            in_place,
2797            output,
2798            force,
2799            print_schema,
2800        } => {
2801            if print_schema {
2802                commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Style)
2803            } else {
2804                let file = file.ok_or_else(|| {
2805                    anyhow::anyhow!("invalid argument: style-batch requires <FILE>")
2806                })?;
2807                let ops = ops.ok_or_else(|| {
2808                    anyhow::anyhow!("invalid argument: style-batch requires --ops @<path>")
2809                })?;
2810                commands::write::style_batch(file, ops, dry_run, in_place, output, force).await
2811            }
2812        }
2813        Commands::ApplyFormulaPattern {
2814            file,
2815            ops,
2816            dry_run,
2817            in_place,
2818            output,
2819            force,
2820            print_schema,
2821        } => {
2822            if print_schema {
2823                commands::write::batch_payload_schema(
2824                    commands::write::BatchSchemaCommand::ApplyFormulaPattern,
2825                )
2826            } else {
2827                let file = file.ok_or_else(|| {
2828                    anyhow::anyhow!("invalid argument: apply-formula-pattern requires <FILE>")
2829                })?;
2830                let ops = ops.ok_or_else(|| {
2831                    anyhow::anyhow!(
2832                        "invalid argument: apply-formula-pattern requires --ops @<path>"
2833                    )
2834                })?;
2835                commands::write::apply_formula_pattern(file, ops, dry_run, in_place, output, force)
2836                    .await
2837            }
2838        }
2839        Commands::StructureBatch {
2840            file,
2841            ops,
2842            dry_run,
2843            in_place,
2844            output,
2845            force,
2846            print_schema,
2847            formula_parse_policy,
2848            impact_report,
2849            show_formula_delta,
2850        } => {
2851            if print_schema {
2852                commands::write::batch_payload_schema(
2853                    commands::write::BatchSchemaCommand::Structure,
2854                )
2855            } else {
2856                let file = file.ok_or_else(|| {
2857                    anyhow::anyhow!("invalid argument: structure-batch requires <FILE>")
2858                })?;
2859                let ops = ops.ok_or_else(|| {
2860                    anyhow::anyhow!("invalid argument: structure-batch requires --ops @<path>")
2861                })?;
2862                commands::write::structure_batch(
2863                    file,
2864                    ops,
2865                    dry_run,
2866                    in_place,
2867                    output,
2868                    force,
2869                    formula_parse_policy,
2870                    impact_report,
2871                    show_formula_delta,
2872                )
2873                .await
2874            }
2875        }
2876        Commands::CheckRefImpact {
2877            file,
2878            ops,
2879            show_formula_delta,
2880        } => commands::write::check_ref_impact(file, ops, show_formula_delta).await,
2881        Commands::ColumnSizeBatch {
2882            file,
2883            ops,
2884            dry_run,
2885            in_place,
2886            output,
2887            force,
2888            print_schema,
2889        } => {
2890            if print_schema {
2891                commands::write::batch_payload_schema(
2892                    commands::write::BatchSchemaCommand::ColumnSize,
2893                )
2894            } else {
2895                let file = file.ok_or_else(|| {
2896                    anyhow::anyhow!("invalid argument: column-size-batch requires <FILE>")
2897                })?;
2898                let ops = ops.ok_or_else(|| {
2899                    anyhow::anyhow!("invalid argument: column-size-batch requires --ops @<path>")
2900                })?;
2901                commands::write::column_size_batch(file, ops, dry_run, in_place, output, force)
2902                    .await
2903            }
2904        }
2905        Commands::SheetLayoutBatch {
2906            file,
2907            ops,
2908            dry_run,
2909            in_place,
2910            output,
2911            force,
2912            print_schema,
2913        } => {
2914            if print_schema {
2915                commands::write::batch_payload_schema(
2916                    commands::write::BatchSchemaCommand::SheetLayout,
2917                )
2918            } else {
2919                let file = file.ok_or_else(|| {
2920                    anyhow::anyhow!("invalid argument: sheet-layout-batch requires <FILE>")
2921                })?;
2922                let ops = ops.ok_or_else(|| {
2923                    anyhow::anyhow!("invalid argument: sheet-layout-batch requires --ops @<path>")
2924                })?;
2925                commands::write::sheet_layout_batch(file, ops, dry_run, in_place, output, force)
2926                    .await
2927            }
2928        }
2929        Commands::RulesBatch {
2930            file,
2931            ops,
2932            dry_run,
2933            in_place,
2934            output,
2935            force,
2936            print_schema,
2937            formula_parse_policy,
2938        } => {
2939            if print_schema {
2940                commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Rules)
2941            } else {
2942                let file = file.ok_or_else(|| {
2943                    anyhow::anyhow!("invalid argument: rules-batch requires <FILE>")
2944                })?;
2945                let ops = ops.ok_or_else(|| {
2946                    anyhow::anyhow!("invalid argument: rules-batch requires --ops @<path>")
2947                })?;
2948                commands::write::rules_batch(
2949                    file,
2950                    ops,
2951                    dry_run,
2952                    in_place,
2953                    output,
2954                    force,
2955                    formula_parse_policy,
2956                )
2957                .await
2958            }
2959        }
2960        Commands::Sheetport { command } => match command {
2961            SheetportCommands::Manifest(manifest_command) => match manifest_command {
2962                SheetportManifestCommands::Candidates { file, sheet_filter } => {
2963                    commands::read::sheetport_manifest_candidates(file, sheet_filter).await
2964                }
2965                SheetportManifestCommands::Schema => commands::read::sheetport_manifest_schema(),
2966                SheetportManifestCommands::Validate { manifest } => {
2967                    commands::read::sheetport_manifest_validate(manifest)
2968                }
2969                SheetportManifestCommands::Normalize { manifest, output } => {
2970                    commands::read::sheetport_manifest_normalize(manifest, output)
2971                }
2972            },
2973            SheetportCommands::BindCheck { file, manifest } => {
2974                commands::read::sheetport_bind_check(file, manifest).await
2975            }
2976            SheetportCommands::Run {
2977                file,
2978                manifest,
2979                inputs,
2980                rng_seed,
2981                freeze_volatile,
2982            } => {
2983                commands::read::sheetport_run(file, manifest, inputs, rng_seed, freeze_volatile)
2984                    .await
2985            }
2986        },
2987        Commands::ReplaceInFormulas {
2988            file,
2989            sheet,
2990            find,
2991            replace,
2992            range,
2993            regex,
2994            case_sensitive,
2995            dry_run,
2996            in_place,
2997            output,
2998            force,
2999            formula_parse_policy,
3000        } => {
3001            commands::write::replace_in_formulas(
3002                file,
3003                sheet,
3004                find,
3005                replace,
3006                range,
3007                regex,
3008                case_sensitive.unwrap_or(true),
3009                dry_run,
3010                in_place,
3011                output,
3012                force,
3013                formula_parse_policy,
3014            )
3015            .await
3016        }
3017        Commands::Recalculate {
3018            file,
3019            output,
3020            force,
3021            ignore_sheets,
3022            changed_cells,
3023        } => commands::recalc::recalculate(file, output, force, ignore_sheets, changed_cells).await,
3024        Commands::Verify {
3025            baseline,
3026            current,
3027            targets,
3028            sheet_name,
3029            named_ranges,
3030            errors_only,
3031            targets_only,
3032        } => {
3033            commands::verify::verify(
3034                baseline,
3035                current,
3036                targets,
3037                sheet_name,
3038                named_ranges,
3039                errors_only,
3040                targets_only,
3041            )
3042            .await
3043        }
3044        Commands::Diff {
3045            original,
3046            modified,
3047            sheet,
3048            sheets,
3049            range,
3050            details,
3051            limit,
3052            offset,
3053            exclude_recalc_result,
3054        } => {
3055            commands::diff::diff(commands::diff::DiffCommandArgs {
3056                original,
3057                modified,
3058                sheet,
3059                sheets,
3060                range,
3061                details,
3062                limit,
3063                offset,
3064                exclude_recalc_result,
3065            })
3066            .await
3067        }
3068        Commands::Schema { command } => run_schema_command(command),
3069        Commands::Example { command } => run_example_command(command),
3070        Commands::Session(command) => match *command {
3071            SessionCommands::Start {
3072                base,
3073                label,
3074                workspace,
3075            } => commands::session::session_start(base, label, workspace).await,
3076            SessionCommands::Log {
3077                session,
3078                since,
3079                kind,
3080                workspace,
3081            } => commands::session::session_log(session, workspace, since, kind).await,
3082            SessionCommands::Branches { session, workspace } => {
3083                commands::session::session_branches(session, workspace).await
3084            }
3085            SessionCommands::Switch {
3086                session,
3087                branch,
3088                workspace,
3089            } => commands::session::session_switch(session, branch, workspace).await,
3090            SessionCommands::Checkout {
3091                session,
3092                op_id,
3093                workspace,
3094            } => commands::session::session_checkout(session, op_id, workspace).await,
3095            SessionCommands::Undo { session, workspace } => {
3096                commands::session::session_undo(session, workspace).await
3097            }
3098            SessionCommands::Redo { session, workspace } => {
3099                commands::session::session_redo(session, workspace).await
3100            }
3101            SessionCommands::Fork {
3102                session,
3103                from,
3104                label,
3105                branch_name,
3106                workspace,
3107            } => {
3108                commands::session::session_fork(session, from, label, branch_name, workspace).await
3109            }
3110            SessionCommands::Op {
3111                session,
3112                ops,
3113                workspace,
3114            } => commands::session::session_op_stage(session, ops, workspace).await,
3115            SessionCommands::Apply {
3116                session,
3117                staged_id,
3118                workspace,
3119            } => commands::session::session_apply(session, staged_id, workspace).await,
3120            SessionCommands::Materialize {
3121                session,
3122                output,
3123                force,
3124                workspace,
3125            } => commands::session::session_materialize(session, output, workspace, force).await,
3126        },
3127        Commands::RunManifest {
3128            file,
3129            manifest,
3130            inputs,
3131            rng_seed,
3132            freeze_volatile,
3133        } => commands::read::sheetport_run(file, manifest, inputs, rng_seed, freeze_volatile).await,
3134    }
3135}
3136
3137fn run_schema_command(command: DiscoverabilityCommands) -> Result<Value> {
3138    match command {
3139        DiscoverabilityCommands::TransformBatch => {
3140            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Transform)
3141        }
3142        DiscoverabilityCommands::StyleBatch => {
3143            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Style)
3144        }
3145        DiscoverabilityCommands::ApplyFormulaPattern => commands::write::batch_payload_schema(
3146            commands::write::BatchSchemaCommand::ApplyFormulaPattern,
3147        ),
3148        DiscoverabilityCommands::StructureBatch => {
3149            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Structure)
3150        }
3151        DiscoverabilityCommands::ColumnSizeBatch => {
3152            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::ColumnSize)
3153        }
3154        DiscoverabilityCommands::SheetLayoutBatch => {
3155            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::SheetLayout)
3156        }
3157        DiscoverabilityCommands::RulesBatch => {
3158            commands::write::batch_payload_schema(commands::write::BatchSchemaCommand::Rules)
3159        }
3160        DiscoverabilityCommands::SessionOp { kind } => {
3161            commands::session::session_payload_schema(kind)
3162        }
3163    }
3164}
3165
3166fn run_example_command(command: DiscoverabilityCommands) -> Result<Value> {
3167    match command {
3168        DiscoverabilityCommands::TransformBatch => {
3169            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::Transform)
3170        }
3171        DiscoverabilityCommands::StyleBatch => {
3172            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::Style)
3173        }
3174        DiscoverabilityCommands::ApplyFormulaPattern => commands::write::batch_payload_example(
3175            commands::write::BatchSchemaCommand::ApplyFormulaPattern,
3176        ),
3177        DiscoverabilityCommands::StructureBatch => {
3178            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::Structure)
3179        }
3180        DiscoverabilityCommands::ColumnSizeBatch => {
3181            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::ColumnSize)
3182        }
3183        DiscoverabilityCommands::SheetLayoutBatch => {
3184            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::SheetLayout)
3185        }
3186        DiscoverabilityCommands::RulesBatch => {
3187            commands::write::batch_payload_example(commands::write::BatchSchemaCommand::Rules)
3188        }
3189        DiscoverabilityCommands::SessionOp { kind } => {
3190            commands::session::session_payload_example(kind)
3191        }
3192    }
3193}
3194
3195fn first_subcommand_index(argv: &[OsString]) -> Option<usize> {
3196    let mut expect_global_value = false;
3197
3198    for (index, arg) in argv.iter().enumerate().skip(1) {
3199        let token = arg.to_string_lossy();
3200
3201        if expect_global_value {
3202            expect_global_value = false;
3203            continue;
3204        }
3205
3206        match token.as_ref() {
3207            "--output-format" | "--shape" | "--format" => {
3208                expect_global_value = true;
3209                continue;
3210            }
3211            "--compact" | "--quiet" => continue,
3212            _ => {}
3213        }
3214
3215        if token.starts_with("--output-format=")
3216            || token.starts_with("--shape=")
3217            || token.starts_with("--format=")
3218        {
3219            continue;
3220        }
3221
3222        if token.starts_with('-') {
3223            continue;
3224        }
3225
3226        return Some(index);
3227    }
3228
3229    None
3230}
3231
3232fn is_legacy_output_format(value: &str) -> bool {
3233    matches!(value, "json" | "csv")
3234}
3235
3236fn normalize_legacy_global_format_argv(argv: Vec<OsString>) -> Vec<OsString> {
3237    if argv.len() <= 1 {
3238        return argv;
3239    }
3240
3241    let first_subcommand_index = first_subcommand_index(&argv);
3242    let first_subcommand_name = first_subcommand_index
3243        .map(|index| argv[index].to_string_lossy().into_owned())
3244        .unwrap_or_default();
3245    let second_subcommand_name = first_subcommand_index.and_then(|index| {
3246        argv.get(index + 1)
3247            .map(|value| value.to_string_lossy().into_owned())
3248    });
3249    let preserve_sheet_page_format = first_subcommand_name == "sheet-page"
3250        || first_subcommand_name == "range-export"
3251        || first_subcommand_name == "range-values"
3252        || (first_subcommand_name == "read"
3253            && matches!(
3254                second_subcommand_name.as_deref(),
3255                Some("page") | Some("export") | Some("values")
3256            ));
3257
3258    let mut normalized = Vec::with_capacity(argv.len());
3259    normalized.push(argv[0].clone());
3260
3261    let mut index = 1usize;
3262    while index < argv.len() {
3263        let token = argv[index].to_string_lossy();
3264        let can_rewrite_here = !preserve_sheet_page_format
3265            || first_subcommand_index
3266                .map(|subcommand_index| index < subcommand_index)
3267                .unwrap_or(true);
3268
3269        if can_rewrite_here && token == "--format" && index + 1 < argv.len() {
3270            let value = argv[index + 1].to_string_lossy();
3271            if is_legacy_output_format(value.as_ref()) {
3272                normalized.push(OsString::from("--output-format"));
3273                normalized.push(argv[index + 1].clone());
3274                index += 2;
3275                continue;
3276            }
3277        }
3278
3279        if can_rewrite_here
3280            && let Some(value) = token.strip_prefix("--format=")
3281            && is_legacy_output_format(value)
3282        {
3283            normalized.push(OsString::from(format!("--output-format={value}")));
3284            index += 1;
3285            continue;
3286        }
3287
3288        normalized.push(argv[index].clone());
3289        index += 1;
3290    }
3291
3292    normalized
3293}
3294
3295#[derive(Debug)]
3296enum ResolvedSurfaceCommand {
3297    Command(Commands),
3298    Schema(DiscoverabilityCommands),
3299    Example(DiscoverabilityCommands),
3300}
3301
3302fn flat_to_canonical_command(flat: &str) -> Option<&'static str> {
3303    match flat {
3304        "list-sheets" => Some("read sheets"),
3305        "sheet-overview" => Some("read overview"),
3306        "range-values" => Some("read values"),
3307        "range-export" => Some("read export"),
3308        "inspect-cells" => Some("read cells"),
3309        "sheet-page" => Some("read page"),
3310        "read-table" => Some("read table"),
3311        "named-ranges" => Some("read names"),
3312        "describe" => Some("read workbook"),
3313        "layout-page" => Some("read layout"),
3314        "find-value" => Some("analyze find-value"),
3315        "find-formula" => Some("analyze find-formula"),
3316        "formula-map" => Some("analyze formula-map"),
3317        "formula-trace" => Some("analyze formula-trace"),
3318        "scan-volatiles" => Some("analyze scan-volatiles"),
3319        "sheet-statistics" => Some("analyze sheet-statistics"),
3320        "table-profile" => Some("analyze table-profile"),
3321        "check-ref-impact" => Some("analyze ref-impact"),
3322        "edit" => Some("write cells"),
3323        "range-import" => Some("write import"),
3324        "append-region" => Some("write append"),
3325        "clone-template-row" => Some("write clone-template-row"),
3326        "clone-row-band" => Some("write clone-row-band"),
3327        "replace-in-formulas" => Some("write formulas replace"),
3328        "transform-batch" => Some("write batch transform"),
3329        "style-batch" => Some("write batch style"),
3330        "apply-formula-pattern" => Some("write batch formula-pattern"),
3331        "structure-batch" => Some("write batch structure"),
3332        "column-size-batch" => Some("write batch column-size"),
3333        "sheet-layout-batch" => Some("write batch sheet-layout"),
3334        "rules-batch" => Some("write batch rules"),
3335        "define-name" => Some("write name define"),
3336        "update-name" => Some("write name update"),
3337        "delete-name" => Some("write name delete"),
3338        "create-workbook" => Some("workbook create"),
3339        "copy" => Some("workbook copy"),
3340        "recalculate" => Some("workbook recalculate"),
3341        "verify" => Some("verify proof"),
3342        "diff" => Some("verify diff"),
3343        "run-manifest" => Some("sheetport run"),
3344        _ => None,
3345    }
3346}
3347
3348fn flat_to_nested_tokens(flat: &str) -> Option<&'static [&'static str]> {
3349    match flat {
3350        "list-sheets" => Some(&["read", "sheets"]),
3351        "sheet-overview" => Some(&["read", "overview"]),
3352        "range-values" => Some(&["read", "values"]),
3353        "range-export" => Some(&["read", "export"]),
3354        "inspect-cells" => Some(&["read", "cells"]),
3355        "sheet-page" => Some(&["read", "page"]),
3356        "read-table" => Some(&["read", "table"]),
3357        "named-ranges" => Some(&["read", "names"]),
3358        "describe" => Some(&["read", "workbook"]),
3359        "layout-page" => Some(&["read", "layout"]),
3360        "find-value" => Some(&["analyze", "find-value"]),
3361        "find-formula" => Some(&["analyze", "find-formula"]),
3362        "formula-map" => Some(&["analyze", "formula-map"]),
3363        "formula-trace" => Some(&["analyze", "formula-trace"]),
3364        "scan-volatiles" => Some(&["analyze", "scan-volatiles"]),
3365        "sheet-statistics" => Some(&["analyze", "sheet-statistics"]),
3366        "table-profile" => Some(&["analyze", "table-profile"]),
3367        "check-ref-impact" => Some(&["analyze", "ref-impact"]),
3368        "edit" => Some(&["write", "cells"]),
3369        "range-import" => Some(&["write", "import"]),
3370        "append-region" => Some(&["write", "append"]),
3371        "clone-template-row" => Some(&["write", "clone-template-row"]),
3372        "clone-row-band" => Some(&["write", "clone-row-band"]),
3373        "replace-in-formulas" => Some(&["write", "formulas", "replace"]),
3374        "transform-batch" => Some(&["write", "batch", "transform"]),
3375        "style-batch" => Some(&["write", "batch", "style"]),
3376        "apply-formula-pattern" => Some(&["write", "batch", "formula-pattern"]),
3377        "structure-batch" => Some(&["write", "batch", "structure"]),
3378        "column-size-batch" => Some(&["write", "batch", "column-size"]),
3379        "sheet-layout-batch" => Some(&["write", "batch", "sheet-layout"]),
3380        "rules-batch" => Some(&["write", "batch", "rules"]),
3381        "define-name" => Some(&["write", "name", "define"]),
3382        "update-name" => Some(&["write", "name", "update"]),
3383        "delete-name" => Some(&["write", "name", "delete"]),
3384        "create-workbook" => Some(&["workbook", "create"]),
3385        "copy" => Some(&["workbook", "copy"]),
3386        "recalculate" => Some(&["workbook", "recalculate"]),
3387        "verify" => Some(&["verify", "proof"]),
3388        "diff" => Some(&["verify", "diff"]),
3389        "run-manifest" => Some(&["sheetport", "run"]),
3390        _ => None,
3391    }
3392}
3393
3394fn legacy_discoverability_tokens(target: &str) -> Option<&'static [&'static str]> {
3395    match target {
3396        "transform-batch" => Some(&["write", "batch", "transform"]),
3397        "style-batch" => Some(&["write", "batch", "style"]),
3398        "apply-formula-pattern" => Some(&["write", "batch", "formula-pattern"]),
3399        "structure-batch" => Some(&["write", "batch", "structure"]),
3400        "column-size-batch" => Some(&["write", "batch", "column-size"]),
3401        "sheet-layout-batch" => Some(&["write", "batch", "sheet-layout"]),
3402        "rules-batch" => Some(&["write", "batch", "rules"]),
3403        _ => None,
3404    }
3405}
3406
3407fn canonical_leaf_path_to_flat(tokens: &[String]) -> Option<&'static str> {
3408    match tokens {
3409        [a, b] if a == "read" && b == "sheets" => Some("list-sheets"),
3410        [a, b] if a == "read" && b == "overview" => Some("sheet-overview"),
3411        [a, b] if a == "read" && b == "values" => Some("range-values"),
3412        [a, b] if a == "read" && b == "export" => Some("range-export"),
3413        [a, b] if a == "read" && b == "cells" => Some("inspect-cells"),
3414        [a, b] if a == "read" && b == "page" => Some("sheet-page"),
3415        [a, b] if a == "read" && b == "table" => Some("read-table"),
3416        [a, b] if a == "read" && b == "names" => Some("named-ranges"),
3417        [a, b] if a == "read" && b == "workbook" => Some("describe"),
3418        [a, b] if a == "read" && b == "layout" => Some("layout-page"),
3419        [a, b] if a == "analyze" && b == "find-value" => Some("find-value"),
3420        [a, b] if a == "analyze" && b == "find-formula" => Some("find-formula"),
3421        [a, b] if a == "analyze" && b == "formula-map" => Some("formula-map"),
3422        [a, b] if a == "analyze" && b == "formula-trace" => Some("formula-trace"),
3423        [a, b] if a == "analyze" && b == "scan-volatiles" => Some("scan-volatiles"),
3424        [a, b] if a == "analyze" && b == "sheet-statistics" => Some("sheet-statistics"),
3425        [a, b] if a == "analyze" && b == "table-profile" => Some("table-profile"),
3426        [a, b] if a == "analyze" && b == "ref-impact" => Some("check-ref-impact"),
3427        [a, b] if a == "write" && b == "cells" => Some("edit"),
3428        [a, b] if a == "write" && b == "import" => Some("range-import"),
3429        [a, b] if a == "write" && b == "append" => Some("append-region"),
3430        [a, b] if a == "write" && b == "clone-template-row" => Some("clone-template-row"),
3431        [a, b] if a == "write" && b == "clone-row-band" => Some("clone-row-band"),
3432        [a, b] if a == "workbook" && b == "create" => Some("create-workbook"),
3433        [a, b] if a == "workbook" && b == "copy" => Some("copy"),
3434        [a, b] if a == "workbook" && b == "recalculate" => Some("recalculate"),
3435        [a, b] if a == "verify" && b == "proof" => Some("verify"),
3436        [a, b] if a == "verify" && b == "diff" => Some("diff"),
3437        [a, b, c] if a == "write" && b == "formulas" && c == "replace" => {
3438            Some("replace-in-formulas")
3439        }
3440        [a, b, c] if a == "write" && b == "name" && c == "define" => Some("define-name"),
3441        [a, b, c] if a == "write" && b == "name" && c == "update" => Some("update-name"),
3442        [a, b, c] if a == "write" && b == "name" && c == "delete" => Some("delete-name"),
3443        [a, b, c] if a == "write" && b == "batch" && c == "transform" => Some("transform-batch"),
3444        [a, b, c] if a == "write" && b == "batch" && c == "style" => Some("style-batch"),
3445        [a, b, c] if a == "write" && b == "batch" && c == "formula-pattern" => {
3446            Some("apply-formula-pattern")
3447        }
3448        [a, b, c] if a == "write" && b == "batch" && c == "structure" => Some("structure-batch"),
3449        [a, b, c] if a == "write" && b == "batch" && c == "column-size" => {
3450            Some("column-size-batch")
3451        }
3452        [a, b, c] if a == "write" && b == "batch" && c == "sheet-layout" => {
3453            Some("sheet-layout-batch")
3454        }
3455        [a, b, c] if a == "write" && b == "batch" && c == "rules" => Some("rules-batch"),
3456        _ => None,
3457    }
3458}
3459
3460fn rewrite_flat_surface_text(text: &str) -> String {
3461    let mut rewritten = text.replace("agent-spreadsheet", "asp");
3462
3463    let replacements = [
3464        ("session-op", "session op"),
3465        (
3466            "asp schema transform-batch",
3467            "asp schema write batch transform",
3468        ),
3469        ("asp schema style-batch", "asp schema write batch style"),
3470        (
3471            "asp schema apply-formula-pattern",
3472            "asp schema write batch formula-pattern",
3473        ),
3474        (
3475            "asp schema structure-batch",
3476            "asp schema write batch structure",
3477        ),
3478        (
3479            "asp schema column-size-batch",
3480            "asp schema write batch column-size",
3481        ),
3482        (
3483            "asp schema sheet-layout-batch",
3484            "asp schema write batch sheet-layout",
3485        ),
3486        ("asp schema rules-batch", "asp schema write batch rules"),
3487        (
3488            "asp example transform-batch",
3489            "asp example write batch transform",
3490        ),
3491        ("asp example style-batch", "asp example write batch style"),
3492        (
3493            "asp example apply-formula-pattern",
3494            "asp example write batch formula-pattern",
3495        ),
3496        (
3497            "asp example structure-batch",
3498            "asp example write batch structure",
3499        ),
3500        (
3501            "asp example column-size-batch",
3502            "asp example write batch column-size",
3503        ),
3504        (
3505            "asp example sheet-layout-batch",
3506            "asp example write batch sheet-layout",
3507        ),
3508        ("asp example rules-batch", "asp example write batch rules"),
3509    ];
3510    for (from, to) in replacements {
3511        rewritten = rewritten.replace(from, to);
3512        rewritten = rewritten.replace(&format!("`{from}`"), &format!("`{to}`"));
3513    }
3514
3515    let flat_commands = [
3516        "list-sheets",
3517        "sheet-overview",
3518        "range-values",
3519        "range-export",
3520        "inspect-cells",
3521        "sheet-page",
3522        "read-table",
3523        "named-ranges",
3524        "describe",
3525        "layout-page",
3526        "find-value",
3527        "find-formula",
3528        "formula-map",
3529        "formula-trace",
3530        "scan-volatiles",
3531        "sheet-statistics",
3532        "table-profile",
3533        "check-ref-impact",
3534        "edit",
3535        "range-import",
3536        "append-region",
3537        "clone-template-row",
3538        "clone-row-band",
3539        "replace-in-formulas",
3540        "transform-batch",
3541        "style-batch",
3542        "apply-formula-pattern",
3543        "structure-batch",
3544        "column-size-batch",
3545        "sheet-layout-batch",
3546        "rules-batch",
3547        "define-name",
3548        "update-name",
3549        "delete-name",
3550        "create-workbook",
3551        "copy",
3552        "recalculate",
3553        "verify",
3554        "diff",
3555        "run-manifest",
3556    ];
3557    for flat in flat_commands {
3558        if let Some(canonical) = flat_to_canonical_command(flat) {
3559            rewritten = rewritten.replace(&format!("asp {flat}"), &format!("asp {canonical}"));
3560            rewritten = rewritten.replace(&format!("`{flat}`"), &format!("`{canonical}`"));
3561        }
3562    }
3563
3564    rewritten
3565}
3566
3567fn emit_rewritten_clap_error_and_exit(error: clap::Error) -> ! {
3568    let kind = error.kind();
3569    let code = error.exit_code();
3570    let rendered = rewrite_flat_surface_text(&error.to_string());
3571    match kind {
3572        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
3573            print!("{rendered}");
3574            std::process::exit(0);
3575        }
3576        _ => {
3577            eprint!("{rendered}");
3578            std::process::exit(code);
3579        }
3580    }
3581}
3582
3583fn normalize_legacy_command_argv(argv: Vec<OsString>) -> (Vec<OsString>, Vec<String>) {
3584    if argv.len() <= 1 {
3585        return (argv, Vec::new());
3586    }
3587
3588    let Some(index) = first_subcommand_index(&argv) else {
3589        return (argv, Vec::new());
3590    };
3591
3592    let token = argv[index].to_string_lossy().into_owned();
3593    let mut warnings = Vec::new();
3594
3595    if let Some(path) = flat_to_nested_tokens(&token) {
3596        let next_token = argv
3597            .get(index + 1)
3598            .map(|value| value.to_string_lossy().into_owned());
3599        let conflicts_with_canonical_group =
3600            token == "verify" && matches!(next_token.as_deref(), Some("proof") | Some("diff"));
3601
3602        if !conflicts_with_canonical_group {
3603            let mut normalized = Vec::with_capacity(argv.len() + path.len());
3604            normalized.extend_from_slice(&argv[..index]);
3605            for part in path {
3606                normalized.push(OsString::from(part));
3607            }
3608            normalized.extend_from_slice(&argv[index + 1..]);
3609            warnings.push(format!(
3610                "warning: '{}' is deprecated; use '{}'",
3611                token,
3612                flat_to_canonical_command(&token).unwrap_or_default()
3613            ));
3614            return (normalized, warnings);
3615        }
3616    }
3617
3618    if (token == "schema" || token == "example") && index + 1 < argv.len() {
3619        let target = argv[index + 1].to_string_lossy().into_owned();
3620        if let Some(path) = legacy_discoverability_tokens(&target) {
3621            let mut normalized = Vec::with_capacity(argv.len() + path.len());
3622            normalized.extend_from_slice(&argv[..=index]);
3623            for part in path {
3624                normalized.push(OsString::from(part));
3625            }
3626            normalized.extend_from_slice(&argv[index + 2..]);
3627            warnings.push(format!(
3628                "warning: '{} {}' is deprecated; use '{} {}'",
3629                token,
3630                target,
3631                token,
3632                path.join(" ")
3633            ));
3634            return (normalized, warnings);
3635        }
3636
3637        if target == "session-op" {
3638            let mut normalized = Vec::with_capacity(argv.len() + 1);
3639            normalized.extend_from_slice(&argv[..=index]);
3640            normalized.push(OsString::from("session"));
3641            normalized.push(OsString::from("op"));
3642            normalized.extend_from_slice(&argv[index + 2..]);
3643            warnings.push(format!(
3644                "warning: '{} session-op' is deprecated; use '{} session op'",
3645                token, token
3646            ));
3647            return (normalized, warnings);
3648        }
3649    }
3650
3651    (argv, warnings)
3652}
3653
3654fn maybe_emit_forwarded_leaf_help(argv: &[OsString]) {
3655    let Some(last) = argv.last() else {
3656        return;
3657    };
3658    let last = last.to_string_lossy();
3659    if last.as_ref() != "--help" && last.as_ref() != "-h" {
3660        return;
3661    }
3662
3663    let argv_without_help = &argv[..argv.len() - 1];
3664    let Some(index) = first_subcommand_index(argv_without_help) else {
3665        return;
3666    };
3667
3668    let mut tokens = Vec::new();
3669    for arg in &argv_without_help[index..] {
3670        let token = arg.to_string_lossy();
3671        if token.starts_with('-') {
3672            break;
3673        }
3674        tokens.push(token.into_owned());
3675    }
3676
3677    if let Some(flat) = canonical_leaf_path_to_flat(&tokens) {
3678        let translated = vec![
3679            OsString::from("asp"),
3680            OsString::from(flat),
3681            OsString::from("--help"),
3682        ];
3683        match Cli::try_parse_from(translated) {
3684            Ok(_) => unreachable!("help parse should not succeed"),
3685            Err(error) => emit_rewritten_clap_error_and_exit(error),
3686        }
3687    }
3688}
3689
3690fn parse_flat_command_from_surface(
3691    flat_command: &'static str,
3692    args: Vec<OsString>,
3693) -> Result<Commands, clap::Error> {
3694    let mut argv = vec![OsString::from("asp"), OsString::from(flat_command)];
3695    argv.extend(args);
3696    Cli::try_parse_from(argv).map(|cli| cli.command)
3697}
3698
3699fn resolve_surface_discoverability(
3700    command: SurfaceDiscoverabilityCommands,
3701) -> DiscoverabilityCommands {
3702    match command {
3703        SurfaceDiscoverabilityCommands::Write(command) => match command {
3704            SurfaceDiscoverabilityWriteCommands::Batch(command) => match command {
3705                SurfaceDiscoverabilityBatchCommands::Transform => {
3706                    DiscoverabilityCommands::TransformBatch
3707                }
3708                SurfaceDiscoverabilityBatchCommands::Style => DiscoverabilityCommands::StyleBatch,
3709                SurfaceDiscoverabilityBatchCommands::FormulaPattern => {
3710                    DiscoverabilityCommands::ApplyFormulaPattern
3711                }
3712                SurfaceDiscoverabilityBatchCommands::Structure => {
3713                    DiscoverabilityCommands::StructureBatch
3714                }
3715                SurfaceDiscoverabilityBatchCommands::ColumnSize => {
3716                    DiscoverabilityCommands::ColumnSizeBatch
3717                }
3718                SurfaceDiscoverabilityBatchCommands::SheetLayout => {
3719                    DiscoverabilityCommands::SheetLayoutBatch
3720                }
3721                SurfaceDiscoverabilityBatchCommands::Rules => DiscoverabilityCommands::RulesBatch,
3722            },
3723        },
3724        SurfaceDiscoverabilityCommands::Session(command) => match command {
3725            SurfaceDiscoverabilitySessionCommands::Op { kind } => {
3726                DiscoverabilityCommands::SessionOp { kind }
3727            }
3728        },
3729    }
3730}
3731
3732fn resolve_surface_command(
3733    command: SurfaceCommands,
3734) -> Result<ResolvedSurfaceCommand, clap::Error> {
3735    match command {
3736        SurfaceCommands::Read(command) => match command {
3737            SurfaceReadCommands::Sheets(args) => {
3738                parse_flat_command_from_surface("list-sheets", args.args)
3739                    .map(ResolvedSurfaceCommand::Command)
3740            }
3741            SurfaceReadCommands::Overview(args) => {
3742                parse_flat_command_from_surface("sheet-overview", args.args)
3743                    .map(ResolvedSurfaceCommand::Command)
3744            }
3745            SurfaceReadCommands::Values(args) => {
3746                parse_flat_command_from_surface("range-values", args.args)
3747                    .map(ResolvedSurfaceCommand::Command)
3748            }
3749            SurfaceReadCommands::Export(args) => {
3750                parse_flat_command_from_surface("range-export", args.args)
3751                    .map(ResolvedSurfaceCommand::Command)
3752            }
3753            SurfaceReadCommands::Cells(args) => {
3754                parse_flat_command_from_surface("inspect-cells", args.args)
3755                    .map(ResolvedSurfaceCommand::Command)
3756            }
3757            SurfaceReadCommands::Page(args) => {
3758                parse_flat_command_from_surface("sheet-page", args.args)
3759                    .map(ResolvedSurfaceCommand::Command)
3760            }
3761            SurfaceReadCommands::Table(args) => {
3762                parse_flat_command_from_surface("read-table", args.args)
3763                    .map(ResolvedSurfaceCommand::Command)
3764            }
3765            SurfaceReadCommands::Names(args) => {
3766                parse_flat_command_from_surface("named-ranges", args.args)
3767                    .map(ResolvedSurfaceCommand::Command)
3768            }
3769            SurfaceReadCommands::Workbook(args) => {
3770                parse_flat_command_from_surface("describe", args.args)
3771                    .map(ResolvedSurfaceCommand::Command)
3772            }
3773            SurfaceReadCommands::Layout(args) => {
3774                parse_flat_command_from_surface("layout-page", args.args)
3775                    .map(ResolvedSurfaceCommand::Command)
3776            }
3777        },
3778        SurfaceCommands::Analyze(command) => match command {
3779            SurfaceAnalyzeCommands::FindValue(args) => {
3780                parse_flat_command_from_surface("find-value", args.args)
3781                    .map(ResolvedSurfaceCommand::Command)
3782            }
3783            SurfaceAnalyzeCommands::FindFormula(args) => {
3784                parse_flat_command_from_surface("find-formula", args.args)
3785                    .map(ResolvedSurfaceCommand::Command)
3786            }
3787            SurfaceAnalyzeCommands::FormulaMap(args) => {
3788                parse_flat_command_from_surface("formula-map", args.args)
3789                    .map(ResolvedSurfaceCommand::Command)
3790            }
3791            SurfaceAnalyzeCommands::FormulaTrace(args) => {
3792                parse_flat_command_from_surface("formula-trace", args.args)
3793                    .map(ResolvedSurfaceCommand::Command)
3794            }
3795            SurfaceAnalyzeCommands::ScanVolatiles(args) => {
3796                parse_flat_command_from_surface("scan-volatiles", args.args)
3797                    .map(ResolvedSurfaceCommand::Command)
3798            }
3799            SurfaceAnalyzeCommands::SheetStatistics(args) => {
3800                parse_flat_command_from_surface("sheet-statistics", args.args)
3801                    .map(ResolvedSurfaceCommand::Command)
3802            }
3803            SurfaceAnalyzeCommands::TableProfile(args) => {
3804                parse_flat_command_from_surface("table-profile", args.args)
3805                    .map(ResolvedSurfaceCommand::Command)
3806            }
3807            SurfaceAnalyzeCommands::RefImpact(args) => {
3808                parse_flat_command_from_surface("check-ref-impact", args.args)
3809                    .map(ResolvedSurfaceCommand::Command)
3810            }
3811        },
3812        SurfaceCommands::Write(command) => match command {
3813            SurfaceWriteCommands::Cells(args) => parse_flat_command_from_surface("edit", args.args)
3814                .map(ResolvedSurfaceCommand::Command),
3815            SurfaceWriteCommands::Import(args) => {
3816                parse_flat_command_from_surface("range-import", args.args)
3817                    .map(ResolvedSurfaceCommand::Command)
3818            }
3819            SurfaceWriteCommands::Append(args) => {
3820                parse_flat_command_from_surface("append-region", args.args)
3821                    .map(ResolvedSurfaceCommand::Command)
3822            }
3823            SurfaceWriteCommands::CloneTemplateRow(args) => {
3824                parse_flat_command_from_surface("clone-template-row", args.args)
3825                    .map(ResolvedSurfaceCommand::Command)
3826            }
3827            SurfaceWriteCommands::CloneRowBand(args) => {
3828                parse_flat_command_from_surface("clone-row-band", args.args)
3829                    .map(ResolvedSurfaceCommand::Command)
3830            }
3831            SurfaceWriteCommands::Formulas(command) => match command {
3832                SurfaceWriteFormulaCommands::Replace(args) => {
3833                    parse_flat_command_from_surface("replace-in-formulas", args.args)
3834                        .map(ResolvedSurfaceCommand::Command)
3835                }
3836            },
3837            SurfaceWriteCommands::Name(command) => match command {
3838                SurfaceWriteNameCommands::Define(args) => {
3839                    parse_flat_command_from_surface("define-name", args.args)
3840                        .map(ResolvedSurfaceCommand::Command)
3841                }
3842                SurfaceWriteNameCommands::Update(args) => {
3843                    parse_flat_command_from_surface("update-name", args.args)
3844                        .map(ResolvedSurfaceCommand::Command)
3845                }
3846                SurfaceWriteNameCommands::Delete(args) => {
3847                    parse_flat_command_from_surface("delete-name", args.args)
3848                        .map(ResolvedSurfaceCommand::Command)
3849                }
3850            },
3851            SurfaceWriteCommands::Batch(command) => match command {
3852                SurfaceWriteBatchCommands::Transform(args) => {
3853                    parse_flat_command_from_surface("transform-batch", args.args)
3854                        .map(ResolvedSurfaceCommand::Command)
3855                }
3856                SurfaceWriteBatchCommands::Style(args) => {
3857                    parse_flat_command_from_surface("style-batch", args.args)
3858                        .map(ResolvedSurfaceCommand::Command)
3859                }
3860                SurfaceWriteBatchCommands::FormulaPattern(args) => {
3861                    parse_flat_command_from_surface("apply-formula-pattern", args.args)
3862                        .map(ResolvedSurfaceCommand::Command)
3863                }
3864                SurfaceWriteBatchCommands::Structure(args) => {
3865                    parse_flat_command_from_surface("structure-batch", args.args)
3866                        .map(ResolvedSurfaceCommand::Command)
3867                }
3868                SurfaceWriteBatchCommands::ColumnSize(args) => {
3869                    parse_flat_command_from_surface("column-size-batch", args.args)
3870                        .map(ResolvedSurfaceCommand::Command)
3871                }
3872                SurfaceWriteBatchCommands::SheetLayout(args) => {
3873                    parse_flat_command_from_surface("sheet-layout-batch", args.args)
3874                        .map(ResolvedSurfaceCommand::Command)
3875                }
3876                SurfaceWriteBatchCommands::Rules(args) => {
3877                    parse_flat_command_from_surface("rules-batch", args.args)
3878                        .map(ResolvedSurfaceCommand::Command)
3879                }
3880            },
3881        },
3882        SurfaceCommands::Workbook(command) => match command {
3883            SurfaceWorkbookCommands::Create(args) => {
3884                parse_flat_command_from_surface("create-workbook", args.args)
3885                    .map(ResolvedSurfaceCommand::Command)
3886            }
3887            SurfaceWorkbookCommands::Copy(args) => {
3888                parse_flat_command_from_surface("copy", args.args)
3889                    .map(ResolvedSurfaceCommand::Command)
3890            }
3891            SurfaceWorkbookCommands::Recalculate(args) => {
3892                parse_flat_command_from_surface("recalculate", args.args)
3893                    .map(ResolvedSurfaceCommand::Command)
3894            }
3895        },
3896        SurfaceCommands::Verify(command) => match command {
3897            SurfaceVerifyCommands::Proof(args) => {
3898                parse_flat_command_from_surface("verify", args.args)
3899                    .map(ResolvedSurfaceCommand::Command)
3900            }
3901            SurfaceVerifyCommands::Diff(args) => parse_flat_command_from_surface("diff", args.args)
3902                .map(ResolvedSurfaceCommand::Command),
3903        },
3904        SurfaceCommands::Schema { command } => Ok(ResolvedSurfaceCommand::Schema(
3905            resolve_surface_discoverability(command),
3906        )),
3907        SurfaceCommands::Example { command } => Ok(ResolvedSurfaceCommand::Example(
3908            resolve_surface_discoverability(command),
3909        )),
3910        SurfaceCommands::Session(command) => {
3911            Ok(ResolvedSurfaceCommand::Command(Commands::Session(command)))
3912        }
3913        SurfaceCommands::Sheetport { command } => {
3914            Ok(ResolvedSurfaceCommand::Command(Commands::Sheetport {
3915                command,
3916            }))
3917        }
3918    }
3919}
3920
3921pub async fn run() -> Result<()> {
3922    let argv = normalize_legacy_global_format_argv(std::env::args_os().collect());
3923    let (argv, warnings) = normalize_legacy_command_argv(argv);
3924    maybe_emit_forwarded_leaf_help(&argv);
3925
3926    let surface = match SurfaceCli::try_parse_from(argv) {
3927        Ok(cli) => cli,
3928        Err(error) => error.exit(),
3929    };
3930
3931    let result = match resolve_surface_command(surface.command) {
3932        Ok(ResolvedSurfaceCommand::Command(command)) => {
3933            run_with_options(
3934                command,
3935                surface.output_format,
3936                surface.shape,
3937                surface.compact,
3938                surface.quiet,
3939            )
3940            .await
3941        }
3942        Ok(ResolvedSurfaceCommand::Schema(command)) => match run_schema_command(command) {
3943            Ok(payload) => {
3944                if let Err(error) = output::emit_value(
3945                    &payload,
3946                    surface.output_format,
3947                    surface.shape,
3948                    output::CompactProjectionTarget::None,
3949                    surface.compact,
3950                    surface.quiet,
3951                ) {
3952                    emit_error_and_exit(error);
3953                }
3954                Ok(())
3955            }
3956            Err(error) => emit_error_and_exit(error),
3957        },
3958        Ok(ResolvedSurfaceCommand::Example(command)) => match run_example_command(command) {
3959            Ok(payload) => {
3960                if let Err(error) = output::emit_value(
3961                    &payload,
3962                    surface.output_format,
3963                    surface.shape,
3964                    output::CompactProjectionTarget::None,
3965                    surface.compact,
3966                    surface.quiet,
3967                ) {
3968                    emit_error_and_exit(error);
3969                }
3970                Ok(())
3971            }
3972            Err(error) => emit_error_and_exit(error),
3973        },
3974        Err(error) => emit_rewritten_clap_error_and_exit(error),
3975    };
3976
3977    if result.is_ok() && !surface.quiet {
3978        for warning in &warnings {
3979            eprintln!("{warning}");
3980        }
3981    }
3982
3983    result
3984}
3985
3986pub async fn run_with_options(
3987    command: Commands,
3988    format: OutputFormat,
3989    shape: OutputShape,
3990    compact: bool,
3991    quiet: bool,
3992) -> Result<()> {
3993    if let Err(error) = errors::ensure_output_supported(format) {
3994        emit_error_and_exit(error);
3995    }
3996
3997    let projection_target = compact_projection_target_for_command(&command);
3998    let emit_layout_ascii_direct = matches!(
3999        &command,
4000        Commands::LayoutPage {
4001            render: Some(LayoutRenderArg::Ascii),
4002            ..
4003        }
4004    );
4005
4006    match run_command(command).await {
4007        Ok(payload) => {
4008            if emit_layout_ascii_direct {
4009                if let Some(ascii) = payload.get("ascii_render").and_then(|v| v.as_str()) {
4010                    print!("{ascii}");
4011                    if !ascii.ends_with('\n') {
4012                        println!();
4013                    }
4014                    return Ok(());
4015                }
4016                emit_error_and_exit(anyhow::anyhow!(
4017                    "layout-page --render ascii expected ascii_render in response"
4018                ));
4019            }
4020
4021            if let Err(error) =
4022                output::emit_value(&payload, format, shape, projection_target, compact, quiet)
4023            {
4024                emit_error_and_exit(error);
4025            }
4026            Ok(())
4027        }
4028        Err(error) => emit_error_and_exit(error),
4029    }
4030}
4031
4032fn compact_projection_target_for_command(command: &Commands) -> output::CompactProjectionTarget {
4033    match command {
4034        Commands::RangeValues { .. } => output::CompactProjectionTarget::RangeValues,
4035        Commands::ReadTable { .. } => output::CompactProjectionTarget::ReadTable,
4036        Commands::SheetPage { .. } => output::CompactProjectionTarget::SheetPage,
4037        Commands::FormulaTrace { .. } => output::CompactProjectionTarget::FormulaTrace,
4038        _ => output::CompactProjectionTarget::None,
4039    }
4040}
4041
4042fn emit_error_and_exit(error: anyhow::Error) -> ! {
4043    let envelope = errors::envelope_for(&error);
4044    let stderr = std::io::stderr();
4045    let mut handle = stderr.lock();
4046    if serde_json::to_writer(&mut handle, &envelope).is_err() {
4047        eprintln!("{{\"code\":\"COMMAND_FAILED\",\"message\":\"{}\"}}", error);
4048    } else {
4049        use std::io::Write;
4050        let _ = handle.write_all(b"\n");
4051    }
4052    std::process::exit(1)
4053}
4054
4055#[cfg(test)]
4056mod tests {
4057    use super::*;
4058
4059    #[test]
4060    fn parses_global_flags_and_read_table() {
4061        let cli = Cli::try_parse_from([
4062            "agent-spreadsheet",
4063            "--output-format",
4064            "json",
4065            "--shape",
4066            "compact",
4067            "--compact",
4068            "--quiet",
4069            "read-table",
4070            "workbook.xlsx",
4071            "--sheet",
4072            "Sheet1",
4073            "--range",
4074            "A1:B10",
4075            "--table-name",
4076            "SalesTable",
4077            "--region-id",
4078            "7",
4079            "--limit",
4080            "10",
4081            "--offset",
4082            "2",
4083            "--sample-mode",
4084            "first",
4085            "--filters-json",
4086            r#"[{"column":"Name","op":"eq","value":"Alice"}]"#,
4087            "--table-format",
4088            "values",
4089        ])
4090        .expect("parse command");
4091
4092        assert!(matches!(cli.shape, OutputShape::Compact));
4093        assert!(cli.compact);
4094        assert!(cli.quiet);
4095        match cli.command {
4096            Commands::ReadTable {
4097                file,
4098                sheet,
4099                range,
4100                table_name,
4101                region_id,
4102                limit,
4103                offset,
4104                sample_mode,
4105                filters_json,
4106                filters_file,
4107                table_format,
4108                ..
4109            } => {
4110                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4111                assert_eq!(sheet.as_deref(), Some("Sheet1"));
4112                assert_eq!(range.as_deref(), Some("A1:B10"));
4113                assert_eq!(table_name.as_deref(), Some("SalesTable"));
4114                assert_eq!(region_id, Some(7));
4115                assert_eq!(limit, Some(10));
4116                assert_eq!(offset, Some(2));
4117                assert!(matches!(sample_mode, Some(TableSampleModeArg::First)));
4118                assert_eq!(
4119                    filters_json.as_deref(),
4120                    Some(r#"[{"column":"Name","op":"eq","value":"Alice"}]"#)
4121                );
4122                assert!(filters_file.is_none());
4123                assert!(matches!(table_format, Some(TableReadFormat::Values)));
4124            }
4125            other => panic!("unexpected command: {other:?}"),
4126        }
4127    }
4128
4129    #[test]
4130    fn parses_formula_trace_direction() {
4131        let cli = Cli::try_parse_from([
4132            "agent-spreadsheet",
4133            "formula-trace",
4134            "workbook.xlsx",
4135            "Sheet1",
4136            "C3",
4137            "dependents",
4138            "--depth",
4139            "2",
4140            "--page-size",
4141            "15",
4142            "--cursor-depth",
4143            "2",
4144            "--cursor-offset",
4145            "5",
4146        ])
4147        .expect("parse command");
4148
4149        assert!(matches!(cli.shape, OutputShape::Canonical));
4150
4151        match cli.command {
4152            Commands::FormulaTrace {
4153                direction,
4154                cell,
4155                sheet,
4156                depth,
4157                page_size,
4158                cursor_depth,
4159                cursor_offset,
4160                ..
4161            } => {
4162                assert_eq!(cell, "C3");
4163                assert_eq!(sheet, "Sheet1");
4164                assert_eq!(depth, Some(2));
4165                assert_eq!(page_size, Some(15));
4166                assert_eq!(cursor_depth, Some(2));
4167                assert_eq!(cursor_offset, Some(5));
4168                assert!(matches!(direction, TraceDirectionArg::Dependents));
4169            }
4170            other => panic!("unexpected command: {other:?}"),
4171        }
4172    }
4173
4174    #[test]
4175    fn parses_range_values_include_formulas_flag() {
4176        let cli = Cli::try_parse_from([
4177            "agent-spreadsheet",
4178            "range-values",
4179            "workbook.xlsx",
4180            "Sheet1",
4181            "A1:C10",
4182            "--include-formulas",
4183        ])
4184        .expect("parse command");
4185
4186        match cli.command {
4187            Commands::RangeValues {
4188                file,
4189                sheet,
4190                ranges,
4191                format,
4192                include_formulas,
4193                ..
4194            } => {
4195                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4196                assert_eq!(sheet, "Sheet1");
4197                assert_eq!(ranges, vec!["A1:C10".to_string()]);
4198                assert!(format.is_none());
4199                assert_eq!(include_formulas, Some(true));
4200            }
4201            other => panic!("unexpected command: {other:?}"),
4202        }
4203    }
4204
4205    #[test]
4206    fn parses_range_values_format_argument() {
4207        let cli = Cli::try_parse_from([
4208            "agent-spreadsheet",
4209            "range-values",
4210            "workbook.xlsx",
4211            "Sheet1",
4212            "A1:C10",
4213            "--format",
4214            "json",
4215        ])
4216        .expect("parse command");
4217
4218        match cli.command {
4219            Commands::RangeValues { format, .. } => {
4220                assert!(matches!(format, Some(RangeValuesFormatArg::Json)));
4221            }
4222            other => panic!("unexpected command: {other:?}"),
4223        }
4224    }
4225
4226    #[test]
4227    fn parses_diff_arguments_with_paging_and_filters() {
4228        let cli = Cli::try_parse_from([
4229            "agent-spreadsheet",
4230            "diff",
4231            "baseline.xlsx",
4232            "candidate.xlsx",
4233            "--sheet",
4234            "Sheet1",
4235            "--range",
4236            "A1:C20",
4237            "--details",
4238            "--limit",
4239            "150",
4240            "--offset",
4241            "300",
4242        ])
4243        .expect("parse diff command");
4244
4245        match cli.command {
4246            Commands::Diff {
4247                original,
4248                modified,
4249                sheet,
4250                sheets,
4251                range,
4252                details,
4253                limit,
4254                offset,
4255                exclude_recalc_result,
4256            } => {
4257                assert_eq!(original, PathBuf::from("baseline.xlsx"));
4258                assert_eq!(modified, PathBuf::from("candidate.xlsx"));
4259                assert_eq!(sheet.as_deref(), Some("Sheet1"));
4260                assert!(sheets.is_none());
4261                assert_eq!(range.as_deref(), Some("A1:C20"));
4262                assert!(details);
4263                assert_eq!(limit, 150);
4264                assert_eq!(offset, 300);
4265                assert!(!exclude_recalc_result);
4266            }
4267            other => panic!("unexpected command: {other:?}"),
4268        }
4269    }
4270
4271    #[test]
4272    fn parses_diff_defaults_to_summary_only() {
4273        let cli = Cli::try_parse_from([
4274            "agent-spreadsheet",
4275            "diff",
4276            "baseline.xlsx",
4277            "candidate.xlsx",
4278        ])
4279        .expect("parse diff command defaults");
4280
4281        match cli.command {
4282            Commands::Diff {
4283                details,
4284                limit,
4285                offset,
4286                exclude_recalc_result,
4287                ..
4288            } => {
4289                assert!(!details);
4290                assert_eq!(limit, 200);
4291                assert_eq!(offset, 0);
4292                assert!(!exclude_recalc_result);
4293            }
4294            other => panic!("unexpected command: {other:?}"),
4295        }
4296    }
4297
4298    #[test]
4299    fn parses_diff_exclude_recalc_result_flag() {
4300        let cli = Cli::try_parse_from([
4301            "agent-spreadsheet",
4302            "diff",
4303            "baseline.xlsx",
4304            "candidate.xlsx",
4305            "--exclude-recalc-result",
4306        ])
4307        .expect("parse diff command with exclude recalc flag");
4308
4309        match cli.command {
4310            Commands::Diff {
4311                exclude_recalc_result,
4312                ..
4313            } => {
4314                assert!(exclude_recalc_result);
4315            }
4316            other => panic!("unexpected command: {other:?}"),
4317        }
4318    }
4319
4320    #[test]
4321    fn parses_range_import_arguments() {
4322        let cli = Cli::try_parse_from([
4323            "agent-spreadsheet",
4324            "range-import",
4325            "workbook.xlsx",
4326            "Sheet1",
4327            "--anchor",
4328            "B7",
4329            "--from-grid",
4330            "region.json",
4331            "--in-place",
4332        ])
4333        .expect("parse range-import");
4334
4335        match cli.command {
4336            Commands::RangeImport {
4337                file,
4338                sheet,
4339                anchor,
4340                from_grid,
4341                from_csv,
4342                header,
4343                clear_target,
4344                dry_run,
4345                in_place,
4346                output,
4347                force,
4348            } => {
4349                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4350                assert_eq!(sheet, "Sheet1");
4351                assert_eq!(anchor, "B7");
4352                assert_eq!(from_grid.as_deref(), Some("region.json"));
4353                assert!(from_csv.is_none());
4354                assert!(!header);
4355                assert!(!clear_target);
4356                assert!(!dry_run);
4357                assert!(in_place);
4358                assert!(output.is_none());
4359                assert!(!force);
4360            }
4361            other => panic!("unexpected command: {other:?}"),
4362        }
4363    }
4364
4365    #[test]
4366    fn parses_range_import_from_csv_arguments() {
4367        let cli = Cli::try_parse_from([
4368            "agent-spreadsheet",
4369            "range-import",
4370            "workbook.xlsx",
4371            "Sheet1",
4372            "--anchor",
4373            "B7",
4374            "--from-csv",
4375            "data.csv",
4376            "--header",
4377            "--in-place",
4378        ])
4379        .expect("parse range-import csv");
4380
4381        match cli.command {
4382            Commands::RangeImport {
4383                from_grid,
4384                from_csv,
4385                header,
4386                ..
4387            } => {
4388                assert!(from_grid.is_none());
4389                assert_eq!(from_csv.as_deref(), Some("data.csv"));
4390                assert!(header);
4391            }
4392            other => panic!("unexpected command: {other:?}"),
4393        }
4394    }
4395
4396    #[test]
4397    fn parses_append_region_from_csv_arguments() {
4398        let cli = Cli::try_parse_from([
4399            "agent-spreadsheet",
4400            "append-region",
4401            "workbook.xlsx",
4402            "--sheet",
4403            "Sheet1",
4404            "--region-id",
4405            "7",
4406            "--from-csv",
4407            "rows.csv",
4408            "--header",
4409            "--dry-run",
4410        ])
4411        .expect("parse append-region csv");
4412
4413        match cli.command {
4414            Commands::AppendRegion {
4415                file,
4416                sheet_name,
4417                region_id,
4418                table_name,
4419                rows,
4420                from_csv,
4421                header,
4422                footer_policy,
4423                dry_run,
4424                ..
4425            } => {
4426                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4427                assert_eq!(sheet_name, "Sheet1");
4428                assert_eq!(region_id, Some(7));
4429                assert!(table_name.is_none());
4430                assert!(rows.is_none());
4431                assert_eq!(from_csv.as_deref(), Some("rows.csv"));
4432                assert!(header);
4433                assert!(matches!(footer_policy, AppendRegionFooterPolicyArg::Auto));
4434                assert!(dry_run);
4435            }
4436            other => panic!("unexpected command: {other:?}"),
4437        }
4438    }
4439
4440    #[test]
4441    fn parses_append_region_table_target_with_footer_policy() {
4442        let cli = Cli::try_parse_from([
4443            "agent-spreadsheet",
4444            "append-region",
4445            "workbook.xlsx",
4446            "--sheet",
4447            "Sheet1",
4448            "--table-name",
4449            "SalesTable",
4450            "--rows",
4451            "@rows.json",
4452            "--footer-policy",
4453            "append-at-end",
4454            "--output",
4455            "updated.xlsx",
4456        ])
4457        .expect("parse append-region table target");
4458
4459        match cli.command {
4460            Commands::AppendRegion {
4461                region_id,
4462                table_name,
4463                rows,
4464                footer_policy,
4465                output,
4466                ..
4467            } => {
4468                assert!(region_id.is_none());
4469                assert_eq!(table_name.as_deref(), Some("SalesTable"));
4470                assert_eq!(rows.as_deref(), Some("@rows.json"));
4471                assert!(matches!(
4472                    footer_policy,
4473                    AppendRegionFooterPolicyArg::AppendAtEnd
4474                ));
4475                assert_eq!(output, Some(PathBuf::from("updated.xlsx")));
4476            }
4477            other => panic!("unexpected command: {other:?}"),
4478        }
4479    }
4480
4481    #[test]
4482    fn parses_clone_template_row_arguments() {
4483        let cli = Cli::try_parse_from([
4484            "agent-spreadsheet",
4485            "clone-template-row",
4486            "workbook.xlsx",
4487            "--sheet",
4488            "Sheet1",
4489            "--source-row",
4490            "12",
4491            "--after",
4492            "12",
4493            "--count",
4494            "2",
4495            "--expand-adjacent-sums",
4496            "--patch-targets",
4497            "all-non-formula",
4498            "--merge-policy",
4499            "strict",
4500            "--dry-run",
4501        ])
4502        .expect("parse clone-template-row");
4503
4504        match cli.command {
4505            Commands::CloneTemplateRow {
4506                file,
4507                sheet_name,
4508                source_row,
4509                before,
4510                after,
4511                insert_at,
4512                count,
4513                expand_adjacent_sums,
4514                patch_targets,
4515                merge_policy,
4516                dry_run,
4517                ..
4518            } => {
4519                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4520                assert_eq!(sheet_name, "Sheet1");
4521                assert_eq!(source_row, 12);
4522                assert_eq!(before, None);
4523                assert_eq!(after, Some(12));
4524                assert_eq!(insert_at, None);
4525                assert_eq!(count, 2);
4526                assert!(expand_adjacent_sums);
4527                assert!(matches!(patch_targets, ClonePatchTargetsArg::AllNonFormula));
4528                assert!(matches!(merge_policy, CloneMergePolicyArg::Strict));
4529                assert!(dry_run);
4530            }
4531            other => panic!("unexpected command: {other:?}"),
4532        }
4533    }
4534
4535    #[test]
4536    fn parses_clone_row_band_arguments() {
4537        let cli = Cli::try_parse_from([
4538            "agent-spreadsheet",
4539            "clone-row-band",
4540            "workbook.xlsx",
4541            "--sheet",
4542            "Sheet1",
4543            "--source-rows",
4544            "12:14",
4545            "--before",
4546            "20",
4547            "--repeat",
4548            "2",
4549            "--patch-targets",
4550            "none",
4551            "--merge-policy",
4552            "safe",
4553            "--output",
4554            "updated.xlsx",
4555        ])
4556        .expect("parse clone-row-band");
4557
4558        match cli.command {
4559            Commands::CloneRowBand {
4560                file,
4561                sheet_name,
4562                source_rows,
4563                before,
4564                after,
4565                insert_at,
4566                repeat,
4567                patch_targets,
4568                merge_policy,
4569                output,
4570                ..
4571            } => {
4572                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4573                assert_eq!(sheet_name, "Sheet1");
4574                assert_eq!(source_rows, "12:14");
4575                assert_eq!(before, Some(20));
4576                assert_eq!(after, None);
4577                assert_eq!(insert_at, None);
4578                assert_eq!(repeat, 2);
4579                assert!(matches!(patch_targets, ClonePatchTargetsArg::None));
4580                assert!(matches!(merge_policy, CloneMergePolicyArg::Safe));
4581                assert_eq!(output, Some(PathBuf::from("updated.xlsx")));
4582            }
4583            other => panic!("unexpected command: {other:?}"),
4584        }
4585    }
4586
4587    #[test]
4588    fn parses_inspect_cells_arguments() {
4589        let cli = Cli::try_parse_from([
4590            "agent-spreadsheet",
4591            "inspect-cells",
4592            "workbook.xlsx",
4593            "Sheet1",
4594            "A1:C10",
4595            "D4",
4596            "--include-empty",
4597        ])
4598        .expect("parse command");
4599
4600        match cli.command {
4601            Commands::InspectCells {
4602                file,
4603                sheet,
4604                targets,
4605                include_empty,
4606                budget,
4607                ..
4608            } => {
4609                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4610                assert_eq!(sheet, "Sheet1");
4611                assert_eq!(targets, vec!["A1:C10", "D4"]);
4612                assert!(include_empty);
4613                assert_eq!(budget, None);
4614            }
4615            other => panic!("unexpected command: {other:?}"),
4616        }
4617    }
4618
4619    #[test]
4620    fn parses_sheet_page_arguments() {
4621        let cli = Cli::try_parse_from([
4622            "agent-spreadsheet",
4623            "sheet-page",
4624            "workbook.xlsx",
4625            "Sheet1",
4626            "--start-row",
4627            "2",
4628            "--page-size",
4629            "5",
4630            "--columns",
4631            "A,C:E",
4632            "--columns-by-header",
4633            "Name,Total",
4634            "--include-formulas",
4635            "--include-styles",
4636            "--include-header",
4637            "--format",
4638            "compact",
4639        ])
4640        .expect("parse command");
4641
4642        match cli.command {
4643            Commands::SheetPage {
4644                file,
4645                sheet,
4646                start_row,
4647                page_size,
4648                columns,
4649                columns_by_header,
4650                include_formulas,
4651                include_styles,
4652                include_header,
4653                format,
4654                ..
4655            } => {
4656                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4657                assert_eq!(sheet, "Sheet1");
4658                assert_eq!(start_row, Some(2));
4659                assert_eq!(page_size, Some(5));
4660                assert_eq!(columns, Some(vec!["A".to_string(), "C:E".to_string()]));
4661                assert_eq!(
4662                    columns_by_header,
4663                    Some(vec!["Name".to_string(), "Total".to_string()])
4664                );
4665                assert_eq!(include_formulas, Some(true));
4666                assert_eq!(include_styles, Some(true));
4667                assert_eq!(include_header, Some(true));
4668                assert!(matches!(format, SheetPageFormatArg::Compact));
4669            }
4670            other => panic!("unexpected command: {other:?}"),
4671        }
4672    }
4673
4674    #[test]
4675    fn parses_create_workbook_arguments() {
4676        let cli = Cli::try_parse_from([
4677            "agent-spreadsheet",
4678            "create-workbook",
4679            "workbook.xlsx",
4680            "--sheets",
4681            "Inputs,Calc,Output",
4682            "--overwrite",
4683        ])
4684        .expect("parse create-workbook");
4685
4686        match cli.command {
4687            Commands::CreateWorkbook {
4688                path,
4689                sheets,
4690                overwrite,
4691            } => {
4692                assert_eq!(path, PathBuf::from("workbook.xlsx"));
4693                assert_eq!(
4694                    sheets,
4695                    Some(vec![
4696                        "Inputs".to_string(),
4697                        "Calc".to_string(),
4698                        "Output".to_string(),
4699                    ])
4700                );
4701                assert!(overwrite);
4702            }
4703            other => panic!("unexpected command: {other:?}"),
4704        }
4705    }
4706
4707    #[test]
4708    fn parses_transform_batch_arguments() {
4709        let cli = Cli::try_parse_from([
4710            "agent-spreadsheet",
4711            "transform-batch",
4712            "workbook.xlsx",
4713            "--ops",
4714            "@ops.json",
4715            "--output",
4716            "out.xlsx",
4717            "--force",
4718        ])
4719        .expect("parse transform-batch");
4720
4721        match cli.command {
4722            Commands::TransformBatch {
4723                file,
4724                ops,
4725                dry_run,
4726                in_place,
4727                output,
4728                force,
4729                print_schema,
4730                formula_parse_policy,
4731            } => {
4732                assert_eq!(file, Some(PathBuf::from("workbook.xlsx")));
4733                assert_eq!(ops, Some("@ops.json".to_string()));
4734                assert!(!dry_run);
4735                assert!(!in_place);
4736                assert_eq!(output, Some(PathBuf::from("out.xlsx")));
4737                assert!(force);
4738                assert!(!print_schema);
4739                assert_eq!(formula_parse_policy, None);
4740            }
4741            other => panic!("unexpected command: {other:?}"),
4742        }
4743    }
4744
4745    #[test]
4746    fn parses_style_batch_arguments() {
4747        let cli = Cli::try_parse_from([
4748            "agent-spreadsheet",
4749            "style-batch",
4750            "workbook.xlsx",
4751            "--ops",
4752            "@style.json",
4753            "--dry-run",
4754        ])
4755        .expect("parse style-batch");
4756
4757        match cli.command {
4758            Commands::StyleBatch {
4759                file,
4760                ops,
4761                dry_run,
4762                in_place,
4763                output,
4764                force,
4765                print_schema,
4766            } => {
4767                assert_eq!(file, Some(PathBuf::from("workbook.xlsx")));
4768                assert_eq!(ops, Some("@style.json".to_string()));
4769                assert!(dry_run);
4770                assert!(!in_place);
4771                assert!(output.is_none());
4772                assert!(!force);
4773                assert!(!print_schema);
4774            }
4775            other => panic!("unexpected command: {other:?}"),
4776        }
4777    }
4778
4779    #[test]
4780    fn parses_apply_formula_pattern_arguments() {
4781        let cli = Cli::try_parse_from([
4782            "agent-spreadsheet",
4783            "apply-formula-pattern",
4784            "workbook.xlsx",
4785            "--ops",
4786            "@formula.json",
4787            "--in-place",
4788        ])
4789        .expect("parse apply-formula-pattern");
4790
4791        match cli.command {
4792            Commands::ApplyFormulaPattern {
4793                file,
4794                ops,
4795                dry_run,
4796                in_place,
4797                output,
4798                force,
4799                print_schema,
4800            } => {
4801                assert_eq!(file, Some(PathBuf::from("workbook.xlsx")));
4802                assert_eq!(ops, Some("@formula.json".to_string()));
4803                assert!(!dry_run);
4804                assert!(in_place);
4805                assert!(output.is_none());
4806                assert!(!force);
4807                assert!(!print_schema);
4808            }
4809            other => panic!("unexpected command: {other:?}"),
4810        }
4811    }
4812
4813    #[test]
4814    fn parses_phase_b_batch_write_arguments() {
4815        let structure = Cli::try_parse_from([
4816            "agent-spreadsheet",
4817            "structure-batch",
4818            "workbook.xlsx",
4819            "--ops",
4820            "@structure.json",
4821            "--output",
4822            "out.xlsx",
4823        ])
4824        .expect("parse structure-batch");
4825        match structure.command {
4826            Commands::StructureBatch {
4827                file,
4828                ops,
4829                output,
4830                print_schema,
4831                ..
4832            } => {
4833                assert_eq!(file, Some(PathBuf::from("workbook.xlsx")));
4834                assert_eq!(ops, Some("@structure.json".to_string()));
4835                assert_eq!(output, Some(PathBuf::from("out.xlsx")));
4836                assert!(!print_schema);
4837            }
4838            other => panic!("unexpected command: {other:?}"),
4839        }
4840
4841        let column = Cli::try_parse_from([
4842            "agent-spreadsheet",
4843            "column-size-batch",
4844            "workbook.xlsx",
4845            "--ops",
4846            "@columns.json",
4847            "--in-place",
4848        ])
4849        .expect("parse column-size-batch");
4850        match column.command {
4851            Commands::ColumnSizeBatch {
4852                ops,
4853                in_place,
4854                print_schema,
4855                ..
4856            } => {
4857                assert_eq!(ops, Some("@columns.json".to_string()));
4858                assert!(in_place);
4859                assert!(!print_schema);
4860            }
4861            other => panic!("unexpected command: {other:?}"),
4862        }
4863
4864        let layout = Cli::try_parse_from([
4865            "agent-spreadsheet",
4866            "sheet-layout-batch",
4867            "workbook.xlsx",
4868            "--ops",
4869            "@layout.json",
4870            "--dry-run",
4871        ])
4872        .expect("parse sheet-layout-batch");
4873        match layout.command {
4874            Commands::SheetLayoutBatch {
4875                ops,
4876                dry_run,
4877                print_schema,
4878                ..
4879            } => {
4880                assert_eq!(ops, Some("@layout.json".to_string()));
4881                assert!(dry_run);
4882                assert!(!print_schema);
4883            }
4884            other => panic!("unexpected command: {other:?}"),
4885        }
4886    }
4887
4888    #[test]
4889    fn parses_rules_batch_arguments() {
4890        let cli = Cli::try_parse_from([
4891            "agent-spreadsheet",
4892            "rules-batch",
4893            "workbook.xlsx",
4894            "--ops",
4895            "@rules.json",
4896            "--output",
4897            "rules.xlsx",
4898            "--force",
4899        ])
4900        .expect("parse rules-batch");
4901
4902        match cli.command {
4903            Commands::RulesBatch {
4904                file,
4905                ops,
4906                dry_run,
4907                in_place,
4908                output,
4909                force,
4910                print_schema,
4911                formula_parse_policy,
4912            } => {
4913                assert_eq!(file, Some(PathBuf::from("workbook.xlsx")));
4914                assert_eq!(ops, Some("@rules.json".to_string()));
4915                assert!(!dry_run);
4916                assert!(!in_place);
4917                assert_eq!(output, Some(PathBuf::from("rules.xlsx")));
4918                assert!(force);
4919                assert!(!print_schema);
4920                assert!(formula_parse_policy.is_none());
4921            }
4922            other => panic!("unexpected command: {other:?}"),
4923        }
4924    }
4925
4926    #[test]
4927    fn parses_global_schema_and_example_commands() {
4928        let transform = Cli::try_parse_from(["asp", "schema", "transform-batch"])
4929            .expect("parse schema transform-batch");
4930        match transform.command {
4931            Commands::Schema {
4932                command: DiscoverabilityCommands::TransformBatch,
4933            } => {}
4934            other => panic!("unexpected command: {other:?}"),
4935        }
4936
4937        let style = Cli::try_parse_from(["asp", "example", "style-batch"])
4938            .expect("parse example style-batch");
4939        match style.command {
4940            Commands::Example {
4941                command: DiscoverabilityCommands::StyleBatch,
4942            } => {}
4943            other => panic!("unexpected command: {other:?}"),
4944        }
4945
4946        let session_schema =
4947            Cli::try_parse_from(["asp", "schema", "session-op", "transform.write_matrix"])
4948                .expect("parse schema session-op");
4949        match session_schema.command {
4950            Commands::Schema {
4951                command: DiscoverabilityCommands::SessionOp { kind },
4952            } => {
4953                assert_eq!(kind, "transform.write_matrix");
4954            }
4955            other => panic!("unexpected command: {other:?}"),
4956        }
4957
4958        let session_example =
4959            Cli::try_parse_from(["asp", "example", "session-op", "structure.insert_rows"])
4960                .expect("parse example session-op");
4961        match session_example.command {
4962            Commands::Example {
4963                command: DiscoverabilityCommands::SessionOp { kind },
4964            } => {
4965                assert_eq!(kind, "structure.insert_rows");
4966            }
4967            other => panic!("unexpected command: {other:?}"),
4968        }
4969    }
4970
4971    #[test]
4972    fn parses_named_ranges_and_scan_volatiles_arguments() {
4973        let named = Cli::try_parse_from([
4974            "agent-spreadsheet",
4975            "named-ranges",
4976            "workbook.xlsx",
4977            "--sheet",
4978            "Sheet1",
4979            "--name-prefix",
4980            "Sales",
4981        ])
4982        .expect("parse named-ranges");
4983
4984        match named.command {
4985            Commands::NamedRanges {
4986                file,
4987                sheet,
4988                name_prefix,
4989                ..
4990            } => {
4991                assert_eq!(file, PathBuf::from("workbook.xlsx"));
4992                assert_eq!(sheet.as_deref(), Some("Sheet1"));
4993                assert_eq!(name_prefix.as_deref(), Some("Sales"));
4994            }
4995            other => panic!("unexpected command: {other:?}"),
4996        }
4997
4998        let volatiles = Cli::try_parse_from([
4999            "agent-spreadsheet",
5000            "scan-volatiles",
5001            "workbook.xlsx",
5002            "--sheet",
5003            "Sheet1",
5004            "--limit",
5005            "10",
5006            "--offset",
5007            "5",
5008        ])
5009        .expect("parse scan-volatiles");
5010
5011        match volatiles.command {
5012            Commands::ScanVolatiles {
5013                file,
5014                sheet,
5015                limit,
5016                offset,
5017                formula_parse_policy,
5018            } => {
5019                assert_eq!(file, PathBuf::from("workbook.xlsx"));
5020                assert_eq!(sheet.as_deref(), Some("Sheet1"));
5021                assert_eq!(limit, Some(10));
5022                assert_eq!(offset, Some(5));
5023                assert!(formula_parse_policy.is_none());
5024            }
5025            other => panic!("unexpected command: {other:?}"),
5026        }
5027    }
5028
5029    #[test]
5030    fn parses_find_value_label_direction_arguments() {
5031        let find = Cli::try_parse_from([
5032            "agent-spreadsheet",
5033            "find-value",
5034            "workbook.xlsx",
5035            "Amount",
5036            "--sheet",
5037            "Sheet1",
5038            "--mode",
5039            "label",
5040            "--label-direction",
5041            "below",
5042        ])
5043        .expect("parse find-value");
5044
5045        match find.command {
5046            Commands::FindValue {
5047                file,
5048                query,
5049                sheet,
5050                mode,
5051                label_direction,
5052                ..
5053            } => {
5054                assert_eq!(file, PathBuf::from("workbook.xlsx"));
5055                assert_eq!(query, "Amount");
5056                assert_eq!(sheet.as_deref(), Some("Sheet1"));
5057                assert!(matches!(mode, Some(FindValueMode::Label)));
5058                assert!(matches!(label_direction, Some(LabelDirectionArg::Below)));
5059            }
5060            other => panic!("unexpected command: {other:?}"),
5061        }
5062    }
5063
5064    #[test]
5065    fn parses_find_formula_and_sheet_statistics_arguments() {
5066        let find = Cli::try_parse_from([
5067            "agent-spreadsheet",
5068            "find-formula",
5069            "workbook.xlsx",
5070            "SUM(",
5071            "--sheet",
5072            "Sheet1",
5073            "--limit",
5074            "25",
5075            "--offset",
5076            "50",
5077        ])
5078        .expect("parse find-formula");
5079
5080        match find.command {
5081            Commands::FindFormula {
5082                file,
5083                query,
5084                sheet,
5085                limit,
5086                offset,
5087            } => {
5088                assert_eq!(file, PathBuf::from("workbook.xlsx"));
5089                assert_eq!(query, "SUM(");
5090                assert_eq!(sheet.as_deref(), Some("Sheet1"));
5091                assert_eq!(limit, Some(25));
5092                assert_eq!(offset, Some(50));
5093            }
5094            other => panic!("unexpected command: {other:?}"),
5095        }
5096
5097        let stats = Cli::try_parse_from([
5098            "agent-spreadsheet",
5099            "sheet-statistics",
5100            "workbook.xlsx",
5101            "Summary",
5102        ])
5103        .expect("parse sheet-statistics");
5104
5105        match stats.command {
5106            Commands::SheetStatistics { file, sheet } => {
5107                assert_eq!(file, PathBuf::from("workbook.xlsx"));
5108                assert_eq!(sheet, "Summary");
5109            }
5110            other => panic!("unexpected command: {other:?}"),
5111        }
5112
5113        assert!(
5114            Cli::try_parse_from(["agent-spreadsheet", "find-formula", "workbook.xlsx"]).is_err(),
5115            "missing QUERY should fail clap parsing"
5116        );
5117    }
5118
5119    #[test]
5120    fn parses_sheet_page_all_required_formats() {
5121        for (raw, expected) in [
5122            ("full", SheetPageFormatArg::Full),
5123            ("compact", SheetPageFormatArg::Compact),
5124            ("values_only", SheetPageFormatArg::ValuesOnly),
5125        ] {
5126            let cli = Cli::try_parse_from([
5127                "agent-spreadsheet",
5128                "sheet-page",
5129                "workbook.xlsx",
5130                "Sheet1",
5131                "--format",
5132                raw,
5133            ])
5134            .expect("parse format value");
5135
5136            match cli.command {
5137                Commands::SheetPage { format, .. } => {
5138                    assert!(
5139                        matches!(
5140                            (format, expected),
5141                            (SheetPageFormatArg::Full, SheetPageFormatArg::Full)
5142                                | (SheetPageFormatArg::Compact, SheetPageFormatArg::Compact)
5143                                | (
5144                                    SheetPageFormatArg::ValuesOnly,
5145                                    SheetPageFormatArg::ValuesOnly
5146                                )
5147                        ),
5148                        "format mismatch for {raw}"
5149                    );
5150                }
5151                other => panic!("unexpected command: {other:?}"),
5152            }
5153        }
5154    }
5155
5156    #[test]
5157    fn normalizes_legacy_global_format_for_non_sheet_page_commands() {
5158        let normalized = normalize_legacy_global_format_argv(
5159            [
5160                "agent-spreadsheet",
5161                "list-sheets",
5162                "workbook.xlsx",
5163                "--format",
5164                "json",
5165            ]
5166            .into_iter()
5167            .map(OsString::from)
5168            .collect(),
5169        );
5170
5171        let tokens = normalized
5172            .iter()
5173            .map(|arg| arg.to_string_lossy().into_owned())
5174            .collect::<Vec<_>>();
5175
5176        assert_eq!(
5177            tokens,
5178            vec![
5179                "agent-spreadsheet",
5180                "list-sheets",
5181                "workbook.xlsx",
5182                "--output-format",
5183                "json"
5184            ]
5185        );
5186    }
5187
5188    #[test]
5189    fn preserves_sheet_page_local_format_flag() {
5190        let normalized = normalize_legacy_global_format_argv(
5191            [
5192                "agent-spreadsheet",
5193                "sheet-page",
5194                "workbook.xlsx",
5195                "Sheet1",
5196                "--format",
5197                "compact",
5198            ]
5199            .into_iter()
5200            .map(OsString::from)
5201            .collect(),
5202        );
5203
5204        let tokens = normalized
5205            .iter()
5206            .map(|arg| arg.to_string_lossy().into_owned())
5207            .collect::<Vec<_>>();
5208
5209        assert_eq!(
5210            tokens,
5211            vec![
5212                "agent-spreadsheet",
5213                "sheet-page",
5214                "workbook.xlsx",
5215                "Sheet1",
5216                "--format",
5217                "compact"
5218            ]
5219        );
5220    }
5221
5222    #[test]
5223    fn preserves_range_values_local_format_flag() {
5224        let normalized = normalize_legacy_global_format_argv(
5225            [
5226                "agent-spreadsheet",
5227                "range-values",
5228                "workbook.xlsx",
5229                "Sheet1",
5230                "A1:B2",
5231                "--format",
5232                "json",
5233            ]
5234            .into_iter()
5235            .map(OsString::from)
5236            .collect(),
5237        );
5238
5239        let tokens = normalized
5240            .iter()
5241            .map(|arg| arg.to_string_lossy().into_owned())
5242            .collect::<Vec<_>>();
5243
5244        assert_eq!(
5245            tokens,
5246            vec![
5247                "agent-spreadsheet",
5248                "range-values",
5249                "workbook.xlsx",
5250                "Sheet1",
5251                "A1:B2",
5252                "--format",
5253                "json"
5254            ]
5255        );
5256    }
5257
5258    #[test]
5259    fn parses_sheetport_manifest_validate_arguments() {
5260        let cli = Cli::try_parse_from([
5261            "agent-spreadsheet",
5262            "sheetport",
5263            "manifest",
5264            "validate",
5265            "manifest.yaml",
5266        ])
5267        .expect("parse sheetport manifest validate");
5268
5269        match cli.command {
5270            Commands::Sheetport { command } => match command {
5271                SheetportCommands::Manifest(SheetportManifestCommands::Validate { manifest }) => {
5272                    assert_eq!(manifest, PathBuf::from("manifest.yaml"));
5273                }
5274                other => panic!("unexpected sheetport command: {other:?}"),
5275            },
5276            other => panic!("unexpected command: {other:?}"),
5277        }
5278    }
5279
5280    #[test]
5281    fn parses_sheetport_run_arguments() {
5282        let cli = Cli::try_parse_from([
5283            "agent-spreadsheet",
5284            "sheetport",
5285            "run",
5286            "workbook.xlsx",
5287            "manifest.yaml",
5288            "--inputs",
5289            "@inputs.json",
5290            "--rng-seed",
5291            "42",
5292            "--freeze-volatile",
5293        ])
5294        .expect("parse sheetport run");
5295
5296        match cli.command {
5297            Commands::Sheetport { command } => match command {
5298                SheetportCommands::Run {
5299                    file,
5300                    manifest,
5301                    inputs,
5302                    rng_seed,
5303                    freeze_volatile,
5304                } => {
5305                    assert_eq!(file, PathBuf::from("workbook.xlsx"));
5306                    assert_eq!(manifest, PathBuf::from("manifest.yaml"));
5307                    assert_eq!(inputs.as_deref(), Some("@inputs.json"));
5308                    assert_eq!(rng_seed, Some(42));
5309                    assert!(freeze_volatile);
5310                }
5311                other => panic!("unexpected sheetport command: {other:?}"),
5312            },
5313            other => panic!("unexpected command: {other:?}"),
5314        }
5315    }
5316
5317    #[test]
5318    fn surface_cli_parses_nested_read_table_and_resolves_to_internal_command() {
5319        let cli = SurfaceCli::try_parse_from([
5320            "asp",
5321            "read",
5322            "table",
5323            "workbook.xlsx",
5324            "--sheet",
5325            "Sheet1",
5326            "--table-format",
5327            "csv",
5328        ])
5329        .expect("parse surface read table");
5330
5331        let resolved = resolve_surface_command(cli.command).expect("resolve surface command");
5332        match resolved {
5333            ResolvedSurfaceCommand::Command(Commands::ReadTable {
5334                file,
5335                sheet,
5336                table_format,
5337                ..
5338            }) => {
5339                assert_eq!(file, PathBuf::from("workbook.xlsx"));
5340                assert_eq!(sheet.as_deref(), Some("Sheet1"));
5341                assert!(matches!(table_format, Some(TableReadFormat::Csv)));
5342            }
5343            other => panic!("unexpected resolved command: {other:?}"),
5344        }
5345    }
5346
5347    #[test]
5348    fn surface_cli_parses_nested_schema_targets() {
5349        let cli = SurfaceCli::try_parse_from(["asp", "schema", "write", "batch", "transform"])
5350            .expect("parse surface schema target");
5351
5352        let resolved = resolve_surface_command(cli.command).expect("resolve schema command");
5353        match resolved {
5354            ResolvedSurfaceCommand::Schema(DiscoverabilityCommands::TransformBatch) => {}
5355            other => panic!("unexpected resolved command: {other:?}"),
5356        }
5357    }
5358
5359    #[test]
5360    fn normalizes_legacy_flat_command_to_nested_surface() {
5361        let (normalized, warnings) = normalize_legacy_command_argv(
5362            ["agent-spreadsheet", "append-region", "workbook.xlsx"]
5363                .into_iter()
5364                .map(OsString::from)
5365                .collect(),
5366        );
5367
5368        let tokens = normalized
5369            .iter()
5370            .map(|arg| arg.to_string_lossy().into_owned())
5371            .collect::<Vec<_>>();
5372
5373        assert_eq!(
5374            tokens,
5375            vec!["agent-spreadsheet", "write", "append", "workbook.xlsx"]
5376        );
5377        assert_eq!(
5378            warnings,
5379            vec!["warning: 'append-region' is deprecated; use 'write append'"]
5380        );
5381    }
5382
5383    #[test]
5384    fn legacy_normalization_preserves_canonical_verify_group() {
5385        let (normalized, warnings) = normalize_legacy_command_argv(
5386            ["agent-spreadsheet", "verify", "diff", "a.xlsx", "b.xlsx"]
5387                .into_iter()
5388                .map(OsString::from)
5389                .collect(),
5390        );
5391
5392        let tokens = normalized
5393            .iter()
5394            .map(|arg| arg.to_string_lossy().into_owned())
5395            .collect::<Vec<_>>();
5396
5397        assert_eq!(
5398            tokens,
5399            vec!["agent-spreadsheet", "verify", "diff", "a.xlsx", "b.xlsx"]
5400        );
5401        assert!(warnings.is_empty());
5402    }
5403}