Skip to main content

omni_dev/drive/sheets/
structure.rs

1//! Structural and destructive spreadsheet edits via `spreadsheets.batchUpdate`.
2//!
3//! The `drive sheets add-sheet`/`rename-sheet`/`insert-rows`/`insert-columns`
4//! (additive, issue #1613, [ADR-0075](../../../docs/adrs/adr-0075.md)),
5//! `insert-range` (additive, issue #1838),
6//! `duplicate-sheet`/`reorder-sheet`/`hide-sheet`/`show-sheet` (also
7//! additive, issue #1643, [ADR-0078](../../../docs/adrs/adr-0078.md)),
8//! `move-rows`/`move-columns` (also additive — nothing is discarded, issue
9//! #1834, [ADR-0083](../../../docs/adrs/adr-0083.md)),
10//! `delete-sheet`/`delete-rows`/`delete-columns`/`delete-range` (destructive,
11//! issue #1623,
12//! [ADR-0077](../../../docs/adrs/adr-0077-sheets-deletion-via-batchupdate.md)),
13//! and `update-workbook-properties` (also additive, issue #1836,
14//! [ADR-0086](../../../docs/adrs/adr-0086-workbook-properties.md)) engines,
15//! gated by the ADR-0071 folder write-permission rules.
16//!
17//! `update-workbook-properties` is the one verb here with no sheet target:
18//! every other verb acts on a tab within the workbook, this one acts on the
19//! workbook itself (locale, time zone, auto-recalc, iterative calculation —
20//! deliberately not `spreadsheetTheme`, a large nested type left for a
21//! future issue). [`StructureVerb::sheet_title`] returns `Option<&str>`
22//! rather than `&str` for exactly this reason, and [`resolve_sheet`] returns
23//! `Ok(None)` for it without consulting the sheet list at all. Turning
24//! iterative calculation on is a value effect reached indirectly — it
25//! changes what a circular-reference formula elsewhere *evaluates to*
26//! without changing that formula itself, the same shape of concern
27//! ADR-0081 raised for named-range deletion — so it stays gated as
28//! `sheets-structure` rather than treated as a data mutation; see
29//! [`crate::drive::sheets::types::IterativeCalculationSettings`]'s doc
30//! comment.
31//!
32//! [ADR-0073](../../../docs/adrs/adr-0073.md) §12 deferred this surface
33//! because `batchUpdate` is where "one call destroys far more than its
34//! arguments suggest". Two properties answer that, and each is structural
35//! rather than a matter of care:
36//!
37//! - **Typed verbs, no raw request passthrough.** Every verb builds its own
38//!   [`BatchUpdateRequestItem`], so the gate and `--dry-run` can describe the
39//!   exact effect. There is no `--requests file.json` and deliberately no
40//!   escape hatch, following [ADR-0061](../../../docs/adrs/adr-0061.md)'s
41//!   handling of force-push: the dangerous form must be unreachable, not
42//!   merely discouraged. This still holds for the destructive verbs added by
43//!   ADR-0077 — they gained typed variants, not a passthrough.
44//! - **A distinct gate operation per risk class.** Additive verbs check
45//!   [`DriveOperation::SheetsStructure`]; the four destructive verbs check
46//!   [`DriveOperation::SheetsDelete`] instead — never folded together, and
47//!   never reusing `SheetsWrite` either. See each variant's doc comment for
48//!   why reuse would be silent privilege widening. [`StructureVerb::gate_operation`]
49//!   is the single place this split is decided.
50//!
51//! ADR-0075 §3/§4 originally kept `deleteSheet`/`deleteDimension`/
52//! `deleteRange` unreachable at the type level, pinned by a
53//! `no_destructive_request_is_reachable` grep-guard test. ADR-0077
54//! supersedes that: those requests are now reachable, but only through this
55//! module's gated, validated path — never through a raw passthrough, and
56//! never under the `sheets-structure` operation an existing `allow` rule may
57//! already grant.
58//!
59//! Shape follows `write.rs` exactly: a public wrapper that logs, an `_inner`
60//! that classifies then mutates, and `--dry-run` as an early return *after*
61//! the gate so a preview and a real run share one classification by
62//! construction. One difference is deliberate and tested: a dry run here does
63//! issue a single `spreadsheets.get`, because describing a structural effect
64//! honestly requires the sheet's real current dimensions. It still issues no
65//! `batchUpdate`, and a gate-blocked attempt still issues no Sheets call at
66//! all. `--dry-run` for a destructive verb stays structural-only — no
67//! `values.get` read and no cell content in its output or the request log —
68//! and instead states a fixed caveat that formulas elsewhere in the workbook
69//! may reference what would be deleted, which cannot be checked from the
70//! sheet's own dimensions (ADR-0077).
71
72use std::path::PathBuf;
73use std::time::{Duration, Instant};
74
75use serde::Serialize;
76
77use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
78use crate::drive::client::DriveClient;
79use crate::drive::files_api::FilesApi;
80use crate::drive::lease::check::{
81    conclude_native_leased_write, gate_optional_leased_write, recovery_note, FromLeaseRefusal,
82    LeaseGateRefusal, LeasedWrite,
83};
84use crate::drive::lease::ledger::LeaseBackup;
85use crate::drive::sheets::api::SheetsApi;
86use crate::drive::sheets::client::SheetsClient;
87use crate::drive::sheets::format::{normalize_hex_for_display, parse_hex_color};
88use crate::drive::sheets::grid_range;
89use crate::drive::sheets::target_gate;
90use crate::drive::sheets::types::{
91    AddSheetRequest, BatchUpdateRequestItem, BatchUpdateResponse, ColorStyle,
92    DeleteDimensionRequest, DeleteRangeRequest, DeleteSheetRequest, Dimension, DimensionRange,
93    DuplicateSheetRequest, GridProperties, GridPropertiesUpdate, GridRange, InsertDimensionRequest,
94    InsertRangeRequest, IterativeCalculationSettings, MoveDimensionRequest, NewSheetProperties,
95    RecalculationInterval, SheetProperties, SheetPropertiesUpdate, ShiftDimension, Spreadsheet,
96    SpreadsheetPropertiesUpdate, UpdateSheetPropertiesRequest, UpdateSpreadsheetPropertiesRequest,
97};
98use crate::drive::types::SheetTargetRefusal;
99use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
100use crate::request_log::{self, DriveMutationOutcome};
101
102/// Which structural mutation to perform.
103///
104/// Unlike `write.rs`'s [`WriteVerb`](crate::drive::sheets::write::WriteVerb),
105/// which is fieldless with its arguments in the options struct, these verbs
106/// take disjoint arguments, so the enum carries them. That also means an
107/// impossible combination — a `--title` on an insert, say — is not
108/// representable.
109// `PartialEq`-only, not `Eq` (issue #1836):
110// `UpdateWorkbookProperties::iterative_calculation_convergence_threshold`
111// carries an `f64`, which has no meaningful `Eq` — see `Spreadsheet`'s own
112// doc comment (`types.rs`) for the same reasoning applied to `Color`.
113#[derive(Debug, Clone, PartialEq)]
114pub enum StructureVerb {
115    /// Add a new sheet to the workbook.
116    AddSheet {
117        /// Title for the new sheet.
118        title: String,
119        /// Zero-based position; `None` appends.
120        index: Option<i64>,
121        /// Initial row count; `None` takes Sheets' default.
122        rows: Option<i64>,
123        /// Initial column count; `None` takes Sheets' default.
124        columns: Option<i64>,
125    },
126    /// Rename an existing sheet.
127    RenameSheet {
128        /// Current title of the sheet to rename.
129        sheet: String,
130        /// The new title.
131        new_title: String,
132    },
133    /// Insert empty rows, shifting existing ones down.
134    InsertRows {
135        /// Title of the sheet to modify.
136        sheet: String,
137        /// 1-based row to insert before.
138        at: i64,
139        /// How many rows to insert.
140        count: i64,
141    },
142    /// Insert empty columns, shifting existing ones right.
143    InsertColumns {
144        /// Title of the sheet to modify.
145        sheet: String,
146        /// 1-based column to insert before.
147        at: i64,
148        /// How many columns to insert.
149        count: i64,
150    },
151    /// Insert empty cells into a rectangular range, shifting existing cells
152    /// down or right within the same grid (issue #1838).
153    ///
154    /// All four bounds are required together: this crate only ever sends a
155    /// fully-bounded [`GridRange`]. Whole rows and columns belong to
156    /// [`Self::InsertRows`] and [`Self::InsertColumns`].
157    InsertRange {
158        /// Title of the sheet to modify.
159        sheet: String,
160        /// 1-based first row, inclusive.
161        start_row: i64,
162        /// 1-based last row, inclusive.
163        end_row: i64,
164        /// 1-based first column, inclusive.
165        start_column: i64,
166        /// 1-based last column, inclusive.
167        end_column: i64,
168        /// Which way existing cells shift to make room.
169        shift: ShiftDimension,
170    },
171    /// Move a contiguous block of rows to a new position within the same
172    /// sheet, shifting the rows in between to close the gap (issue #1834).
173    MoveRows {
174        /// Title of the sheet to modify.
175        sheet: String,
176        /// 1-based first row of the block to move, inclusive.
177        at: i64,
178        /// How many rows the block spans.
179        count: i64,
180        /// 1-based row the block moves in front of, numbered as the sheet
181        /// stands *before* the move (the API's own `destinationIndex`
182        /// convention, translated by `move_destination_index`).
183        before: i64,
184    },
185    /// Move a contiguous block of columns to a new position within the same
186    /// sheet, shifting the columns in between to close the gap (issue
187    /// #1834).
188    MoveColumns {
189        /// Title of the sheet to modify.
190        sheet: String,
191        /// 1-based first column of the block to move, inclusive.
192        at: i64,
193        /// How many columns the block spans.
194        count: i64,
195        /// 1-based column the block moves in front of, numbered as the
196        /// sheet stands *before* the move.
197        before: i64,
198    },
199    /// Delete an entire sheet from the workbook.
200    DeleteSheet {
201        /// Title of the sheet to delete.
202        sheet: String,
203    },
204    /// Delete whole rows, shifting the remainder up to close the gap.
205    DeleteRows {
206        /// Title of the sheet to modify.
207        sheet: String,
208        /// 1-based first row to delete, inclusive.
209        at: i64,
210        /// How many rows to delete.
211        count: i64,
212    },
213    /// Delete whole columns, shifting the remainder left to close the gap.
214    DeleteColumns {
215        /// Title of the sheet to modify.
216        sheet: String,
217        /// 1-based first column to delete, inclusive.
218        at: i64,
219        /// How many columns to delete.
220        count: i64,
221    },
222    /// Delete a rectangular cell range, shifting the remainder along one
223    /// axis to close the gap.
224    ///
225    /// All four bounds are required together: this crate only ever sends a
226    /// fully-bounded [`GridRange`]. An open-ended range on one axis would
227    /// degenerate into [`Self::DeleteRows`]/[`Self::DeleteColumns`]
228    /// semantics and is out of scope here.
229    DeleteRange {
230        /// Title of the sheet to modify.
231        sheet: String,
232        /// 1-based first row, inclusive.
233        start_row: i64,
234        /// 1-based last row, inclusive.
235        end_row: i64,
236        /// 1-based first column, inclusive.
237        start_column: i64,
238        /// 1-based last column, inclusive.
239        end_column: i64,
240        /// Which way to shift the remaining cells afterward.
241        shift: ShiftDimension,
242    },
243    /// Copy an existing sheet within the same workbook.
244    DuplicateSheet {
245        /// Title of the sheet to copy.
246        sheet: String,
247        /// Title for the copy; `None` takes Sheets' own "Copy of X" default.
248        title: Option<String>,
249        /// Zero-based position for the copy; `None` appends.
250        index: Option<i64>,
251    },
252    /// Move an existing sheet to a new position among its siblings.
253    ReorderSheet {
254        /// Title of the sheet to move.
255        sheet: String,
256        /// The new zero-based position.
257        index: i64,
258    },
259    /// Hide or show an existing sheet.
260    SetSheetVisibility {
261        /// Title of the sheet to modify.
262        sheet: String,
263        /// `true` hides it, `false` shows it.
264        hidden: bool,
265    },
266    /// Change a sheet's view properties — frozen rows/columns, tab color,
267    /// right-to-left layout, and whether gridlines are hidden (issue #1835).
268    /// Every field is independently optional; the caller sets whichever it
269    /// passed, and `build_request`'s `fields` mask names exactly those.
270    UpdateSheetProperties {
271        /// Title of the sheet to modify.
272        sheet: String,
273        /// Rows to freeze at the top, when changing it.
274        freeze_rows: Option<i64>,
275        /// Columns to freeze at the left, when changing it.
276        freeze_columns: Option<i64>,
277        /// The new tab color, `#RRGGBB`, when setting it.
278        tab_color: Option<String>,
279        /// Clear the tab color back to none. Mutually exclusive with
280        /// `tab_color` (the CLI's `clap::ArgGroup` conflict enforces this;
281        /// `validate_verb_args` refuses the combination defensively too).
282        clear_tab_color: bool,
283        /// The new right-to-left flag, when changing it.
284        right_to_left: Option<bool>,
285        /// The new hide-gridlines flag, when changing it.
286        hide_gridlines: Option<bool>,
287    },
288    /// Change workbook-level properties: locale, time zone, auto-recalc
289    /// and/or iterative calculation (issue #1836). The one verb in this
290    /// enum with no sheet target — see the module doc comment.
291    UpdateWorkbookProperties {
292        /// The new locale, e.g. `"en_US"`. `None` leaves it unchanged.
293        locale: Option<String>,
294        /// The new IANA time zone, e.g. `"America/New_York"`. `None` leaves
295        /// it unchanged.
296        time_zone: Option<String>,
297        /// The new auto-recalculation interval. `None` leaves it unchanged.
298        auto_recalc: Option<RecalculationInterval>,
299        /// Turns iterative calculation on or off. `None` leaves it
300        /// unchanged; the two sub-fields below are only meaningful
301        /// alongside `Some(IterativeCalculationToggle::On)` —
302        /// `validate_verb_args` refuses every other combination.
303        iterative_calculation: Option<IterativeCalculationToggle>,
304        /// Maximum calculation rounds, for `iterative_calculation ==
305        /// Some(On)`. `None` takes Sheets' own default.
306        iterative_calculation_max_iterations: Option<i64>,
307        /// Convergence threshold, for `iterative_calculation ==
308        /// Some(On)`. `None` takes Sheets' own default.
309        iterative_calculation_convergence_threshold: Option<f64>,
310    },
311}
312
313/// Which way `update-workbook-properties --iterative-calculation` sets
314/// iterative calculation.
315///
316/// Not [`RecalculationInterval`]-style wire vocabulary: the API has no
317/// boolean or enum field for this (see
318/// [`IterativeCalculationSettings`]'s doc comment) — this type exists purely
319/// to carry the caller's on/off *intent* through to `build_request`, which
320/// turns it into the right combination of
321/// [`SpreadsheetPropertiesUpdate::iterative_calculation_settings`] and
322/// `fields`.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum IterativeCalculationToggle {
325    /// Turn iterative calculation on (or update its settings; it is
326    /// already on).
327    On,
328    /// Turn iterative calculation off.
329    Off,
330}
331
332impl StructureVerb {
333    /// The `operation` this verb records in the request log.
334    ///
335    /// `build_drive_mutation_record` shapes `command` as `["drive",
336    /// <operation>]`, so these read as `drive sheets-add-sheet` in the log
337    /// even though the CLI spells them `drive sheets add-sheet`.
338    const fn log_operation(&self) -> &'static str {
339        match self {
340            Self::AddSheet { .. } => "sheets-add-sheet",
341            Self::RenameSheet { .. } => "sheets-rename-sheet",
342            Self::InsertRows { .. } => "sheets-insert-rows",
343            Self::InsertColumns { .. } => "sheets-insert-columns",
344            Self::InsertRange { .. } => "sheets-insert-range",
345            Self::MoveRows { .. } => "sheets-move-rows",
346            Self::MoveColumns { .. } => "sheets-move-columns",
347            Self::DeleteSheet { .. } => "sheets-delete-sheet",
348            Self::DeleteRows { .. } => "sheets-delete-rows",
349            Self::DeleteColumns { .. } => "sheets-delete-columns",
350            Self::DeleteRange { .. } => "sheets-delete-range",
351            Self::DuplicateSheet { .. } => "sheets-duplicate-sheet",
352            Self::ReorderSheet { .. } => "sheets-reorder-sheet",
353            Self::SetSheetVisibility { hidden: true, .. } => "sheets-hide-sheet",
354            Self::SetSheetVisibility { hidden: false, .. } => "sheets-show-sheet",
355            Self::UpdateSheetProperties { .. } => "sheets-update-sheet-properties",
356            Self::UpdateWorkbookProperties { .. } => "sheets-update-workbook-properties",
357        }
358    }
359
360    /// Which [`DriveOperation`] gates this verb.
361    ///
362    /// The single place the additive/destructive split is decided — see the
363    /// module doc comment and [`DriveOperation::SheetsDelete`]'s doc comment
364    /// for why the two must never share an operation.
365    const fn gate_operation(&self) -> DriveOperation {
366        match self {
367            Self::AddSheet { .. }
368            | Self::RenameSheet { .. }
369            | Self::InsertRows { .. }
370            | Self::InsertColumns { .. }
371            | Self::InsertRange { .. }
372            | Self::MoveRows { .. }
373            | Self::MoveColumns { .. }
374            | Self::DuplicateSheet { .. }
375            | Self::ReorderSheet { .. }
376            | Self::SetSheetVisibility { .. }
377            | Self::UpdateSheetProperties { .. }
378            | Self::UpdateWorkbookProperties { .. } => DriveOperation::SheetsStructure,
379            Self::DeleteSheet { .. }
380            | Self::DeleteRows { .. }
381            | Self::DeleteColumns { .. }
382            | Self::DeleteRange { .. } => DriveOperation::SheetsDelete,
383        }
384    }
385
386    /// The CLI subcommand that spells this verb, for error messages that
387    /// name the command the user actually typed — `write.rs::describe`'s
388    /// convention.
389    const fn label(&self) -> &'static str {
390        match self {
391            Self::AddSheet { .. } => "add-sheet",
392            Self::RenameSheet { .. } => "rename-sheet",
393            Self::InsertRows { .. } => "insert-rows",
394            Self::InsertColumns { .. } => "insert-columns",
395            Self::InsertRange { .. } => "insert-range",
396            Self::MoveRows { .. } => "move-rows",
397            Self::MoveColumns { .. } => "move-columns",
398            Self::DeleteSheet { .. } => "delete-sheet",
399            Self::DeleteRows { .. } => "delete-rows",
400            Self::DeleteColumns { .. } => "delete-columns",
401            Self::DeleteRange { .. } => "delete-range",
402            Self::DuplicateSheet { .. } => "duplicate-sheet",
403            Self::ReorderSheet { .. } => "reorder-sheet",
404            Self::SetSheetVisibility { hidden: true, .. } => "hide-sheet",
405            Self::SetSheetVisibility { hidden: false, .. } => "show-sheet",
406            Self::UpdateSheetProperties { .. } => "update-sheet-properties",
407            Self::UpdateWorkbookProperties { .. } => "update-workbook-properties",
408        }
409    }
410
411    /// The title of the sheet this verb acts on: the one being created for
412    /// `AddSheet`, the existing target otherwise; `None` for
413    /// `UpdateWorkbookProperties`, which acts on the workbook itself and
414    /// resolves no sheet at all (see the module doc comment).
415    fn sheet_title(&self) -> Option<&str> {
416        match self {
417            Self::AddSheet { title, .. } => Some(title),
418            Self::RenameSheet { sheet, .. }
419            | Self::InsertRows { sheet, .. }
420            | Self::InsertColumns { sheet, .. }
421            | Self::InsertRange { sheet, .. }
422            | Self::MoveRows { sheet, .. }
423            | Self::MoveColumns { sheet, .. }
424            | Self::DeleteSheet { sheet }
425            | Self::DeleteRows { sheet, .. }
426            | Self::DeleteColumns { sheet, .. }
427            | Self::DeleteRange { sheet, .. }
428            | Self::DuplicateSheet { sheet, .. }
429            | Self::ReorderSheet { sheet, .. }
430            | Self::SetSheetVisibility { sheet, .. }
431            | Self::UpdateSheetProperties { sheet, .. } => Some(sheet),
432            Self::UpdateWorkbookProperties { .. } => None,
433        }
434    }
435
436    /// The title this verb moves the sheet *to*, or `None` for every verb
437    /// that renames nothing.
438    ///
439    /// The companion to [`Self::sheet_title`], which necessarily reports the
440    /// title a rename started from — without this the request log could say
441    /// which tab was renamed but not to what, the one structural effect a
442    /// record could not otherwise reconstruct. `DuplicateSheet`'s explicit
443    /// `--title`, when given, is the same kind of fact for the same reason:
444    /// the copy's name is not recoverable from `sheet_title` alone, which
445    /// necessarily names the *source*.
446    fn new_sheet_title(&self) -> Option<&String> {
447        match self {
448            Self::RenameSheet { new_title, .. } => Some(new_title),
449            Self::DuplicateSheet { title, .. } => title.as_ref(),
450            Self::AddSheet { .. }
451            | Self::InsertRows { .. }
452            | Self::InsertColumns { .. }
453            | Self::InsertRange { .. }
454            | Self::MoveRows { .. }
455            | Self::MoveColumns { .. }
456            | Self::DeleteSheet { .. }
457            | Self::DeleteRows { .. }
458            | Self::DeleteColumns { .. }
459            | Self::DeleteRange { .. }
460            | Self::ReorderSheet { .. }
461            | Self::SetSheetVisibility { .. }
462            | Self::UpdateSheetProperties { .. }
463            | Self::UpdateWorkbookProperties { .. } => None,
464        }
465    }
466
467    /// The axis an insert, a row/column delete or a row/column move runs
468    /// along, or `None` for the verbs with no single axis (`add-sheet`,
469    /// `rename-sheet`, `delete-sheet`, `delete-range`).
470    const fn dimension(&self) -> Option<Dimension> {
471        match self {
472            Self::InsertRows { .. } | Self::DeleteRows { .. } | Self::MoveRows { .. } => {
473                Some(Dimension::Rows)
474            }
475            Self::InsertColumns { .. } | Self::DeleteColumns { .. } | Self::MoveColumns { .. } => {
476                Some(Dimension::Columns)
477            }
478            Self::AddSheet { .. }
479            | Self::RenameSheet { .. }
480            | Self::DeleteSheet { .. }
481            | Self::InsertRange { .. }
482            | Self::DeleteRange { .. }
483            | Self::DuplicateSheet { .. }
484            | Self::ReorderSheet { .. }
485            | Self::SetSheetVisibility { .. }
486            | Self::UpdateSheetProperties { .. }
487            | Self::UpdateWorkbookProperties { .. } => None,
488        }
489    }
490}
491
492/// Per-call options.
493#[derive(Debug, Clone)]
494pub struct StructureOptions {
495    /// Spreadsheet id.
496    pub spreadsheet_id: String,
497    /// Which mutation to perform.
498    pub verb: StructureVerb,
499    /// Classify and describe only; never call `batchUpdate`.
500    pub dry_run: bool,
501    /// The lease token presented via `--lease`. Checked only when the
502    /// deciding rule requires one
503    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
504    /// `None` is only ever valid when it does not.
505    pub lease_token: Option<String>,
506    /// Path to the lease ledger the token is checked against. Production
507    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
508    /// result; tests pass a path under a `tempdir`.
509    pub ledger_path: PathBuf,
510}
511
512/// The sheet a verb resolved to, plus its dimensions at that moment.
513///
514/// Captured before the mutation so [`describe`] can state the *change*
515/// ("1000 rows -> 1003") rather than only the request, which is the whole
516/// point of a structural dry run.
517#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
518pub struct SheetSnapshot {
519    /// The sheet's stable numeric id.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub sheet_id: Option<i64>,
522    /// Its title at the time of the attempt.
523    pub title: String,
524    /// Allocated rows, when the API reported them.
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub row_count: Option<i64>,
527    /// Allocated columns, when the API reported them.
528    #[serde(skip_serializing_if = "Option::is_none")]
529    pub column_count: Option<i64>,
530    /// Rows frozen at the top, when the API reported them (issue #1835).
531    #[serde(skip_serializing_if = "Option::is_none")]
532    pub frozen_row_count: Option<i64>,
533    /// Columns frozen at the left, when the API reported them (issue #1835).
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub frozen_column_count: Option<i64>,
536    /// Whether the sheet is right-to-left, when the API reported it (issue
537    /// #1835).
538    #[serde(skip_serializing_if = "Option::is_none")]
539    pub right_to_left: Option<bool>,
540    /// Whether gridlines are hidden, when the API reported it (issue #1835).
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub hide_gridlines: Option<bool>,
543}
544
545impl SheetSnapshot {
546    fn from_properties(props: &SheetProperties) -> Self {
547        let grid = props.grid_properties.as_ref();
548        Self {
549            sheet_id: props.sheet_id,
550            title: props.title.clone(),
551            row_count: grid.and_then(|g| g.row_count),
552            column_count: grid.and_then(|g| g.column_count),
553            frozen_row_count: grid.and_then(|g| g.frozen_row_count),
554            frozen_column_count: grid.and_then(|g| g.frozen_column_count),
555            right_to_left: props.right_to_left,
556            hide_gridlines: grid.and_then(|g| g.hide_gridlines),
557        }
558    }
559}
560
561/// What happened (or, under `--dry-run`, would happen).
562#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
563#[serde(tag = "status", rename_all = "kebab-case")]
564pub enum StructureResult {
565    /// `--dry-run`, and the gate would allow it.
566    WouldChange {
567        /// The target sheet as it stands now. `None` for an `add-sheet`,
568        /// which has no existing sheet to snapshot.
569        ///
570        /// Boxed (issue #1835): `SheetSnapshot` grew enough with
571        /// `update-sheet-properties`'s frozen-row/column and layout fields
572        /// that this enum otherwise trips `clippy::result_large_err`, the
573        /// same reasoning `Changed`'s own `backup` field is already boxed
574        /// for.
575        #[serde(skip_serializing_if = "Option::is_none")]
576        sheet: Option<Box<SheetSnapshot>>,
577        /// How many sheets the workbook currently has.
578        sheet_count: usize,
579    },
580    /// The target is not a Google Sheet. Checked client-side before the
581    /// gate: restructuring a PDF isn't disallowed, it's meaningless.
582    RefusedNotASpreadsheet {
583        /// The target's actual MIME type.
584        mime_type: String,
585    },
586    /// The target is a shortcut, which we do not follow.
587    RefusedShortcut,
588    /// The target has no parents this account can see, so the folder gate
589    /// has no ancestor chain to evaluate. Distinct from `Blocked`; see
590    /// `write.rs`'s variant of the same name.
591    RefusedNoVisibleParents,
592    /// The named sheet does not exist in this workbook.
593    ///
594    /// Not the client-side validation ADR-0073 §7 rejects: §7 is about A1
595    /// *grammar*, where a naive validator rejects legal forms. Here the
596    /// server's own authoritative sheet list is already in hand, and without
597    /// this check a `--dry-run` would promise a change the real run fails.
598    RefusedSheetNotFound {
599        /// The title that was not found.
600        title: String,
601        /// The titles that do exist, so the message can be actionable.
602        available: Vec<String>,
603    },
604    /// `add-sheet` was given a title the workbook already uses, or
605    /// `rename-sheet`'s new title collides with a *different* existing
606    /// sheet. Sheets rejects a duplicate title, and catching it here keeps
607    /// the dry run truthful.
608    RefusedSheetExists {
609        /// The colliding title.
610        title: String,
611    },
612    /// A numeric argument (`--at`, `--count`, `--rows`, `--columns`,
613    /// `--index`) is out of range for what the workbook actually contains —
614    /// checked against the same `spreadsheets.get` response already fetched
615    /// for the dry run, so this costs nothing extra and is exactly as
616    /// correct as the server's own eventual rejection would be (ADR-0075
617    /// §6's reasoning for the sheet-existence/duplicate-title checks,
618    /// applied to a numeric bound instead of a title). Also reused for
619    /// `update-workbook-properties`' argument-combination checks (issue
620    /// #1836, e.g. "needs at least one property to set"), which need no
621    /// workbook state at all — `format.rs::FormatResult::RefusedInvalidRange`
622    /// is the same broadening for `format-cells`' "at least one property"
623    /// check.
624    RefusedInvalidRange {
625        /// What was wrong and why.
626        detail: String,
627    },
628    /// The folder write-permission gate refused it.
629    Blocked {
630        /// The rule that decided the refusal, if any (`None` means the bare
631        /// default policy — every write defaults deny).
632        decided_by: Option<DecidingRule>,
633    },
634    /// No `--lease` was presented, and the deciding rule requires one
635    /// (ADR-0080 §9).
636    RefusedNoLease,
637    /// The presented lease has expired, or was never a token this ledger
638    /// knows about.
639    RefusedLeaseExpired,
640    /// The presented lease is bound to a different file id.
641    RefusedLeaseWrongFile,
642    /// The file has moved since the lease's recorded `version` — the
643    /// staleness check (ADR-0080 §6).
644    RefusedLeaseStale,
645    /// The workbook is already in the requested state (`hide-sheet` on a
646    /// hidden sheet, `show-sheet` on a visible one), so no request is
647    /// sent. Reported identically by a dry run and a real run, and
648    /// distinct from `Changed` so a script can tell a no-op from a change.
649    Unchanged {
650        /// Why nothing needed to change.
651        detail: String,
652    },
653    /// The mutation succeeded.
654    Changed {
655        /// The sheet acted on, as it stood *before* the change.
656        ///
657        /// Boxed for the same `clippy::result_large_err` reason as
658        /// `WouldChange`'s own `sheet` field (issue #1835).
659        #[serde(skip_serializing_if = "Option::is_none")]
660        sheet: Option<Box<SheetSnapshot>>,
661        /// The sheet id, which for `add-sheet` the server assigns and is
662        /// only knowable from the reply.
663        #[serde(skip_serializing_if = "Option::is_none")]
664        sheet_id: Option<i64>,
665        /// Where the lease this write presented backed the file up at
666        /// `drive lease acquire` time — the copy to restore from after a
667        /// destructive verb. `None` when the deciding write-permission
668        /// rule set `require_lease: false`, in which case no backup was
669        /// taken (ADR-0080 §13) and the outcome must not claim one.
670        ///
671        /// Read straight off the ledger record the write was checked
672        /// against (`drive::lease::check::LeaseGrant`), never
673        /// assumed, so the recovery message a real-run delete prints is
674        /// only ever true. Boxed only to keep the enum under clippy's
675        /// `result_large_err` threshold — `LeaseBackup::Bytes` carries a
676        /// path plus a hash, and this enum is a `Result` error type inside
677        /// the engine.
678        #[serde(skip_serializing_if = "Option::is_none")]
679        backup: Option<Box<LeaseBackup>>,
680    },
681    /// An API or validation error.
682    Failed {
683        /// A human-readable summary of what failed.
684        detail: String,
685    },
686}
687
688impl FromLeaseRefusal for StructureResult {
689    fn from_no_lease() -> Self {
690        Self::RefusedNoLease
691    }
692    fn from_lease_expired() -> Self {
693        Self::RefusedLeaseExpired
694    }
695    fn from_lease_wrong_file() -> Self {
696        Self::RefusedLeaseWrongFile
697    }
698    fn from_lease_stale() -> Self {
699        Self::RefusedLeaseStale
700    }
701    fn from_lease_failed(detail: String) -> Self {
702        Self::Failed { detail }
703    }
704}
705
706impl StructureResult {
707    /// The `status` string the request log records.
708    ///
709    /// Hand-written rather than derived from the `#[serde(tag)]` shape,
710    /// matching `WriteResult`/`MoveResult`'s precedent of keeping the log's
711    /// vocabulary decoupled from the wire format.
712    fn log_status(&self) -> &'static str {
713        match self {
714            Self::WouldChange { .. } => "would-change",
715            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
716            Self::RefusedShortcut => "refused-shortcut",
717            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
718            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
719            Self::RefusedSheetExists { .. } => "refused-sheet-exists",
720            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
721            Self::Blocked { .. } => "blocked",
722            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
723            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
724            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
725            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
726            Self::Unchanged { .. } => "unchanged",
727            Self::Changed { .. } => "changed",
728            Self::Failed { .. } => "failed",
729        }
730    }
731}
732
733/// The full outcome of one attempt.
734///
735/// `PartialEq`-only, not `Eq` (issue #1836) — it embeds [`StructureVerb`],
736/// which is `PartialEq`-only for the same reason.
737#[derive(Debug, Clone, Serialize, PartialEq)]
738pub struct StructureOutcome {
739    /// The spreadsheet acted on.
740    pub spreadsheet_id: String,
741    /// Its Drive file name, when the metadata fetch got that far.
742    #[serde(skip_serializing_if = "Option::is_none")]
743    pub file_name: Option<String>,
744    /// The folder the gate evaluated against, when exactly one parent
745    /// resolved it.
746    #[serde(skip_serializing_if = "Option::is_none")]
747    pub resolved_folder_id: Option<String>,
748    /// Which mutation was attempted.
749    ///
750    /// Not serialised: the caller already knows which verb it invoked, and
751    /// the JSON shape is a stable output contract. It is carried so
752    /// [`describe`] can render an outcome from the outcome alone, rather
753    /// than taking a verb a caller could mismatch against it.
754    #[serde(skip)]
755    pub verb: StructureVerb,
756    /// What happened.
757    pub result: StructureResult,
758}
759
760impl JsonlSerialize for StructureOutcome {
761    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
762        write_scalar_jsonl(self, out)
763    }
764}
765
766/// Converts a 1-based inclusive `--at` into the API's zero-based half-open
767/// [`DimensionRange`].
768///
769/// The single conversion site, on purpose. `--at` is 1-based because that is
770/// what A1 and the spreadsheet UI show the user; `DimensionRange` is
771/// 0-based half-open because that is the API. Inlining this at a call site
772/// is how the obvious off-by-one in this feature would get in.
773fn dimension_range(sheet_id: i64, dimension: Dimension, at: i64, count: i64) -> DimensionRange {
774    let start = at - 1;
775    DimensionRange {
776        sheet_id,
777        dimension,
778        start_index: start,
779        end_index: start + count,
780    }
781}
782
783/// Converts a 1-based `--before` into the API's zero-based, pre-move
784/// `destinationIndex`.
785///
786/// The single conversion site, on purpose — see [`dimension_range`]'s doc
787/// comment for why inlining this at a call site is how an off-by-one gets
788/// in. `moveDimension` numbers its destination in the sheet's coordinates
789/// *before* the source is lifted out, which is exactly what `--before`
790/// means, so the conversion is the 1-based/0-based shift and nothing else.
791const fn move_destination_index(before: i64) -> i64 {
792    before - 1
793}
794
795/// The block's landing span after a move, given `--before`'s pre-move
796/// numbering, the block's `count`, and its inclusive pre-move last index.
797///
798/// The single computation site, on purpose — [`describe_would_move`] (the
799/// dry-run preview) and [`describe_moved`] (the real-run message) both need
800/// it, and a formula duplicated between them could drift so the preview and
801/// the confirmation disagree about where the block ended up.
802/// `validate_move_bounds` refuses every `before` in `at..=at + count`, so
803/// callers may assume exactly one of the two branches applies.
804const fn move_landing_span(before: i64, count: i64, last: i64) -> (i64, i64) {
805    if before > last {
806        // Downward: the block lands immediately before where `before` used
807        // to sit — `count` short of the number typed, since the rows/
808        // columns between the block and the destination slide back to
809        // close the gap.
810        (before - count, before - 1)
811    } else {
812        // Upward (`before < at`): the block lands exactly on the number
813        // typed.
814        (before, before + count - 1)
815    }
816}
817
818/// Runs one structural mutation, logging every attempt that isn't a dry run.
819///
820/// Never returns `Err`: every failure is a [`StructureResult`] variant, so
821/// the caller renders one shape and the log records one shape. Exit code
822/// stays 0 regardless, matching ADR-0073 §13.
823pub async fn structure(
824    drive: &DriveClient,
825    sheets: &SheetsClient,
826    opts: &StructureOptions,
827    rules: &[FolderPermissionRule],
828) -> StructureOutcome {
829    let started = Instant::now();
830    let outcome = structure_inner(drive, sheets, opts, rules).await;
831    // The single logging site, and the only reason a dry run leaves no
832    // record — `structure_inner` never logs.
833    if !opts.dry_run {
834        record_attempt(&outcome, opts, started.elapsed());
835    }
836    outcome
837}
838
839async fn structure_inner(
840    drive: &DriveClient,
841    sheets: &SheetsClient,
842    opts: &StructureOptions,
843    rules: &[FolderPermissionRule],
844) -> StructureOutcome {
845    let bare = |result| StructureOutcome {
846        spreadsheet_id: opts.spreadsheet_id.clone(),
847        file_name: None,
848        resolved_folder_id: None,
849        verb: opts.verb.clone(),
850        result,
851    };
852
853    // ── Target resolution, pre-gate refusals, and the gate itself ──────
854    // Shared with `write.rs` via `target_gate::resolve`, so this whole
855    // shape — the metadata fetch, the shortcut/non-spreadsheet checks, and
856    // the file-id-then-ancestor-chain gate lookup — can't quietly drift
857    // between the two engines.
858    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
859        drive,
860        &opts.spreadsheet_id,
861        opts.verb.gate_operation(),
862        rules,
863    )
864    .await
865    {
866        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
867            return bare(StructureResult::Failed { detail })
868        }
869        target_gate::TargetGateOutcome::Refused { target, refusal } => {
870            let result = match refusal {
871                SheetTargetRefusal::Shortcut => StructureResult::RefusedShortcut,
872                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
873                    StructureResult::RefusedNotASpreadsheet { mime_type }
874                }
875                SheetTargetRefusal::NoVisibleParents => StructureResult::RefusedNoVisibleParents,
876            };
877            return StructureOutcome {
878                spreadsheet_id: opts.spreadsheet_id.clone(),
879                file_name: Some(target.name),
880                resolved_folder_id: None,
881                verb: opts.verb.clone(),
882                result,
883            };
884        }
885        // A chain that could not be resolved is a refusal, never a silent
886        // allow — ADR-0071 §3's highest-priority invariant.
887        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
888            return StructureOutcome {
889                spreadsheet_id: opts.spreadsheet_id.clone(),
890                file_name: Some(target.name),
891                resolved_folder_id: None,
892                verb: opts.verb.clone(),
893                result: StructureResult::Failed { detail },
894            };
895        }
896        target_gate::TargetGateOutcome::Gated {
897            target,
898            decision,
899            resolved_folder_id,
900            requires_lease,
901        } => (target, decision, resolved_folder_id, requires_lease),
902    };
903
904    let gated = |result| StructureOutcome {
905        spreadsheet_id: opts.spreadsheet_id.clone(),
906        file_name: Some(target.name.clone()),
907        resolved_folder_id: resolved_folder_id.clone(),
908        verb: opts.verb.clone(),
909        result,
910    };
911
912    if decision.verdict == write_gate::Verdict::Deny {
913        return gated(StructureResult::Blocked {
914            decided_by: decision.decided_by,
915        });
916    }
917
918    // ── Resolve the workbook ───────────────────────────────────────────
919    // After the gate on purpose: a blocked attempt makes zero Sheets calls,
920    // preserving ADR-0073's "a refusal is exactly as auditable as a success"
921    // consequence. This *is* a read, so a dry run reaches it — that is what
922    // lets `describe` state real dimensions rather than guess.
923    let api = SheetsApi::new(sheets);
924    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
925        Ok(workbook) => workbook,
926        Err(err) => {
927            return gated(StructureResult::Failed {
928                detail: format!("{err:#}"),
929            })
930        }
931    };
932
933    let sheet = match resolve_sheet(&workbook, &opts.verb) {
934        Ok(sheet) => sheet,
935        Err(result) => return gated(result),
936    };
937    let sheet_count = workbook.sheets.len();
938
939    if let Err(result) = validate_verb_args(&workbook, &opts.verb, sheet.as_ref()) {
940        return gated(result);
941    }
942
943    // Before the dry-run branch, so a dry run reports the no-op the real
944    // run would, and before the lease check, since nothing is written.
945    if let Some(detail) = already_applied(&workbook, &opts.verb, sheet.as_ref()) {
946        return gated(StructureResult::Unchanged { detail });
947    }
948
949    if opts.dry_run {
950        // A move, not a clone: this branch always returns, so `sheet` is
951        // never read again on it — the later uses below are only reachable
952        // on the disjoint non-dry-run path.
953        return gated(StructureResult::WouldChange {
954            sheet: sheet.map(Box::new),
955            sheet_count,
956        });
957    }
958
959    // ── The request ────────────────────────────────────────────────────
960    // Built before the gate, not after: a sheet that resolved by title but
961    // reported no `sheetId` (a server contract violation `build_request`
962    // refuses on) is the one fallible step between here and the mutating
963    // call, and the gate must be the *last* fallible step before it (see
964    // `gate_leased_write`'s doc comment) — otherwise a request that was
965    // always going to fail to build would still fsync a `pending` audit
966    // record for a write that never happens (#1688).
967    let request = match build_request(&opts.verb, sheet.as_ref()) {
968        Ok(request) => request,
969        Err(detail) => return gated(StructureResult::Failed { detail }),
970    };
971
972    // The lease check (ADR-0080 §9) sits here: after the permission gate
973    // and the `--dry-run` branch, before the mutating call — see
974    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
975    // shared verbatim by every leased engine. Sheets has no revision field
976    // of any kind (§6), so the staleness check is a fresh `files.get`
977    // immediately before `batchUpdate`, not a reuse of the metadata
978    // `target_gate::resolve` fetched before the (potentially slow)
979    // ancestor-chain walk and workbook fetch above.
980    let files_api = FilesApi::new(drive);
981    let leased = LeasedWrite {
982        log_prefix: "drive sheets structure",
983        operation: opts.verb.log_operation(),
984        ledger_path: &opts.ledger_path,
985        file_id: &opts.spreadsheet_id,
986    };
987    let lease_grant = match gate_optional_leased_write(
988        leased,
989        &files_api,
990        requires_lease,
991        opts.lease_token.as_deref(),
992    )
993    .await
994    {
995        Ok(grant) => grant,
996        Err(err) => return gated(err.into_result()),
997    };
998
999    let result = match conclude_native_leased_write(
1000        leased,
1001        &lease_grant,
1002        &files_api,
1003        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
1004        |err| format!("{err:#}"),
1005    )
1006    .await
1007    {
1008        Ok(response) => StructureResult::Changed {
1009            sheet_id: added_sheet_id(&response).or_else(|| sheet.as_ref().and_then(|s| s.sheet_id)),
1010            sheet: sheet.map(Box::new),
1011            backup: lease_grant
1012                .as_ref()
1013                .map(|grant| Box::new(grant.backup.clone())),
1014        },
1015        Err(err) => StructureResult::Failed {
1016            detail: format!("{err:#}"),
1017        },
1018    };
1019    drop(lease_grant);
1020    gated(result)
1021}
1022
1023/// Finds the sheet a verb targets, or classifies why it cannot.
1024///
1025/// `add-sheet` inverts the check: it needs the title *not* to exist, and
1026/// returns `Ok(None)` because there is no existing sheet to snapshot.
1027/// `rename-sheet` checks both directions: the *current* title must exist,
1028/// and the *new* title must not already belong to some other sheet — the
1029/// same duplicate-title refusal `add-sheet` gets, since Sheets rejects a
1030/// duplicate title regardless of which verb produced it. Renaming a sheet
1031/// to the title it already has is not a collision (it names itself, not a
1032/// different sheet) and is allowed through as a no-op mutation.
1033///
1034/// `update-workbook-properties` returns `Ok(None)` immediately, before even
1035/// looking at the sheet list — [`StructureVerb::sheet_title`] returns `None`
1036/// for it, since it names no sheet at all.
1037fn resolve_sheet(
1038    workbook: &Spreadsheet,
1039    verb: &StructureVerb,
1040) -> Result<Option<SheetSnapshot>, StructureResult> {
1041    let Some(wanted) = verb.sheet_title() else {
1042        return Ok(None);
1043    };
1044    let found = workbook
1045        .sheets
1046        .iter()
1047        .filter_map(|sheet| sheet.properties.as_ref())
1048        .find(|props| props.title == wanted);
1049
1050    match verb {
1051        StructureVerb::AddSheet { .. } => match found {
1052            Some(_) => Err(StructureResult::RefusedSheetExists {
1053                title: wanted.to_string(),
1054            }),
1055            None => Ok(None),
1056        },
1057        StructureVerb::RenameSheet { new_title, .. } => match found {
1058            Some(props) => {
1059                let collides = new_title != wanted && workbook.has_sheet_titled(new_title);
1060                if collides {
1061                    return Err(StructureResult::RefusedSheetExists {
1062                        title: new_title.clone(),
1063                    });
1064                }
1065                Ok(Some(SheetSnapshot::from_properties(props)))
1066            }
1067            None => Err(StructureResult::RefusedSheetNotFound {
1068                title: wanted.to_string(),
1069                available: workbook.sheet_titles(),
1070            }),
1071        },
1072        // Like `add-sheet`, a given `--title` must not already be in use —
1073        // and unlike `rename-sheet`, colliding with the *source's own*
1074        // title is still a collision: the source keeps its name, so a copy
1075        // asking for that same name would land on an existing sheet either
1076        // way.
1077        StructureVerb::DuplicateSheet { title, .. } => match found {
1078            Some(props) => {
1079                if let Some(new_title) = title {
1080                    let collides = workbook.has_sheet_titled(new_title);
1081                    if collides {
1082                        return Err(StructureResult::RefusedSheetExists {
1083                            title: new_title.clone(),
1084                        });
1085                    }
1086                }
1087                Ok(Some(SheetSnapshot::from_properties(props)))
1088            }
1089            None => Err(StructureResult::RefusedSheetNotFound {
1090                title: wanted.to_string(),
1091                available: workbook.sheet_titles(),
1092            }),
1093        },
1094        _ => match found {
1095            Some(props) => Ok(Some(SheetSnapshot::from_properties(props))),
1096            None => Err(StructureResult::RefusedSheetNotFound {
1097                title: wanted.to_string(),
1098                available: workbook.sheet_titles(),
1099            }),
1100        },
1101    }
1102}
1103
1104/// Why `verb` would change nothing, when the workbook is already in the
1105/// state it asks for. Only `hide-sheet`/`show-sheet` can tell: their whole
1106/// effect is one flag the fetched workbook already reports.
1107fn already_applied(
1108    workbook: &Spreadsheet,
1109    verb: &StructureVerb,
1110    sheet: Option<&SheetSnapshot>,
1111) -> Option<String> {
1112    let StructureVerb::SetSheetVisibility {
1113        sheet: title,
1114        hidden,
1115    } = verb
1116    else {
1117        return None;
1118    };
1119    let target_id = sheet.and_then(|s| s.sheet_id)?;
1120    let currently_hidden = workbook
1121        .sheets
1122        .iter()
1123        .filter_map(|s| s.properties.as_ref())
1124        .find(|props| props.sheet_id == Some(target_id))?
1125        .hidden
1126        .unwrap_or(false);
1127    (currently_hidden == *hidden).then(|| {
1128        let state = if *hidden { "hidden" } else { "shown" };
1129        format!("sheet '{title}' is already {state}")
1130    })
1131}
1132
1133/// Validates the numeric arguments a verb carries against the workbook's
1134/// actual state, so a dry run can never promise a change the real run then
1135/// rejects. Runs after [`resolve_sheet`] (which is why it can read the
1136/// target sheet's real dimensions) and before the `--dry-run` early return,
1137/// so both share this classification exactly like the gate above it.
1138fn validate_verb_args(
1139    workbook: &Spreadsheet,
1140    verb: &StructureVerb,
1141    sheet: Option<&SheetSnapshot>,
1142) -> Result<(), StructureResult> {
1143    let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1144
1145    match verb {
1146        StructureVerb::AddSheet {
1147            index,
1148            rows,
1149            columns,
1150            ..
1151        } => {
1152            if let Some(rows) = rows {
1153                if *rows < 1 {
1154                    return invalid(format!("--rows must be at least 1, got {rows}"));
1155                }
1156            }
1157            if let Some(columns) = columns {
1158                if *columns < 1 {
1159                    return invalid(format!("--columns must be at least 1, got {columns}"));
1160                }
1161            }
1162            if let Some(index) = index {
1163                let max = workbook.sheets.len() as i64;
1164                if *index < 0 || *index > max {
1165                    return invalid(format!(
1166                        "--index must be between 0 and {max} inclusive (the workbook has \
1167                         {max} sheet(s)), got {index}"
1168                    ));
1169                }
1170            }
1171            Ok(())
1172        }
1173        StructureVerb::RenameSheet { .. }
1174        | StructureVerb::DeleteSheet { .. }
1175        | StructureVerb::DuplicateSheet { index: None, .. }
1176        | StructureVerb::SetSheetVisibility { hidden: false, .. } => Ok(()),
1177        StructureVerb::InsertRows { at, count, .. } => {
1178            validate_insert_bounds(Dimension::Rows, *at, *count, sheet)
1179        }
1180        StructureVerb::InsertColumns { at, count, .. } => {
1181            validate_insert_bounds(Dimension::Columns, *at, *count, sheet)
1182        }
1183        StructureVerb::MoveRows {
1184            at, count, before, ..
1185        } => validate_move_bounds(Dimension::Rows, *at, *count, *before, sheet),
1186        StructureVerb::MoveColumns {
1187            at, count, before, ..
1188        } => validate_move_bounds(Dimension::Columns, *at, *count, *before, sheet),
1189        StructureVerb::DeleteRows { at, count, .. } => {
1190            validate_delete_dimension_bounds(Dimension::Rows, *at, *count, sheet)
1191        }
1192        StructureVerb::DeleteColumns { at, count, .. } => {
1193            validate_delete_dimension_bounds(Dimension::Columns, *at, *count, sheet)
1194        }
1195        StructureVerb::InsertRange {
1196            start_row,
1197            end_row,
1198            start_column,
1199            end_column,
1200            ..
1201        }
1202        | StructureVerb::DeleteRange {
1203            start_row,
1204            end_row,
1205            start_column,
1206            end_column,
1207            ..
1208        } => validate_range_bounds(*start_row, *end_row, *start_column, *end_column, sheet),
1209        StructureVerb::DuplicateSheet {
1210            index: Some(index), ..
1211        } => {
1212            // Same bound as `add-sheet`'s `--index`: a duplicate also adds
1213            // one sheet, so the valid positions are identical.
1214            let max = workbook.sheets.len() as i64;
1215            if *index < 0 || *index > max {
1216                return invalid(format!(
1217                    "--index must be between 0 and {max} inclusive (the workbook has \
1218                     {max} sheet(s)), got {index}"
1219                ));
1220            }
1221            Ok(())
1222        }
1223        StructureVerb::ReorderSheet { index, .. } => {
1224            // Unlike `add-sheet`/`duplicate-sheet`, this repositions an
1225            // *existing* sheet rather than adding one, so the top of the
1226            // valid range is one less.
1227            let len = workbook.sheets.len() as i64;
1228            let max = len - 1;
1229            if *index < 0 || *index > max {
1230                return invalid(format!(
1231                    "--index must be between 0 and {max} inclusive (the workbook has \
1232                     {len} sheet(s)), got {index}"
1233                ));
1234            }
1235            Ok(())
1236        }
1237        StructureVerb::SetSheetVisibility {
1238            sheet: title,
1239            hidden: true,
1240        } => {
1241            let target_id = sheet.and_then(|s| s.sheet_id);
1242            let another_stays_visible = workbook
1243                .sheets
1244                .iter()
1245                .filter_map(|s| s.properties.as_ref())
1246                .filter(|props| props.sheet_id != target_id)
1247                .any(|props| !props.hidden.unwrap_or(false));
1248            if !another_stays_visible {
1249                return invalid(format!(
1250                    "hiding '{title}' would leave the workbook with no visible sheets; \
1251                     Sheets requires at least one"
1252                ));
1253            }
1254            Ok(())
1255        }
1256        StructureVerb::UpdateSheetProperties {
1257            sheet: _title,
1258            freeze_rows,
1259            freeze_columns,
1260            tab_color,
1261            clear_tab_color,
1262            right_to_left,
1263            hide_gridlines,
1264        } => {
1265            if freeze_rows.is_none()
1266                && freeze_columns.is_none()
1267                && tab_color.is_none()
1268                && !clear_tab_color
1269                && right_to_left.is_none()
1270                && hide_gridlines.is_none()
1271            {
1272                return invalid(
1273                    "update-sheet-properties requires at least one property to change".to_string(),
1274                );
1275            }
1276            if tab_color.is_some() && *clear_tab_color {
1277                return invalid(
1278                    "--tab-color and --clear-tab-color are mutually exclusive".to_string(),
1279                );
1280            }
1281            if let Some(hex) = tab_color {
1282                if let Err(detail) = parse_hex_color(hex) {
1283                    return invalid(detail);
1284                }
1285            }
1286            if let Some(rows) = freeze_rows {
1287                check_freeze_bound(
1288                    "--freeze-rows",
1289                    "row",
1290                    *rows,
1291                    sheet.and_then(|s| s.row_count),
1292                )?;
1293            }
1294            if let Some(columns) = freeze_columns {
1295                check_freeze_bound(
1296                    "--freeze-columns",
1297                    "column",
1298                    *columns,
1299                    sheet.and_then(|s| s.column_count),
1300                )?;
1301            }
1302            Ok(())
1303        }
1304        StructureVerb::UpdateWorkbookProperties {
1305            locale,
1306            time_zone,
1307            auto_recalc,
1308            iterative_calculation,
1309            iterative_calculation_max_iterations,
1310            iterative_calculation_convergence_threshold,
1311        } => {
1312            let touches_iterative_settings = iterative_calculation_max_iterations.is_some()
1313                || iterative_calculation_convergence_threshold.is_some();
1314            if touches_iterative_settings
1315                && *iterative_calculation != Some(IterativeCalculationToggle::On)
1316            {
1317                return invalid(
1318                    "--iterative-calculation-max-iterations/\
1319                     --iterative-calculation-convergence-threshold require \
1320                     --iterative-calculation on"
1321                        .to_string(),
1322                );
1323            }
1324            if let Some(max_iterations) = iterative_calculation_max_iterations {
1325                if *max_iterations < 1 {
1326                    return invalid(format!(
1327                        "--iterative-calculation-max-iterations must be at least 1, got \
1328                         {max_iterations}"
1329                    ));
1330                }
1331            }
1332            if let Some(threshold) = iterative_calculation_convergence_threshold {
1333                if !threshold.is_finite() || *threshold <= 0.0 {
1334                    return invalid(format!(
1335                        "--iterative-calculation-convergence-threshold must be a positive, \
1336                         finite number, got {threshold}"
1337                    ));
1338                }
1339            }
1340            // Checked locally because the API will not: an unrecognised zone
1341            // is accepted with a 200 and the workbook is silently reset to
1342            // `Etc/GMT`, discarding its previous zone (issue #1938). An
1343            // unknown `--locale`, by contrast, is a 400 from the API.
1344            if let Some(tz) = time_zone {
1345                if tz.parse::<chrono_tz::Tz>().is_err() {
1346                    return invalid(format!(
1347                        "--time-zone '{tz}' is not a recognised IANA time zone name \
1348                         (for example 'America/New_York' or 'Europe/London'); the \
1349                         Sheets API would silently reset the workbook to 'Etc/GMT' \
1350                         instead of rejecting it"
1351                    ));
1352                }
1353            }
1354            if locale.is_none()
1355                && time_zone.is_none()
1356                && auto_recalc.is_none()
1357                && iterative_calculation.is_none()
1358            {
1359                return invalid(
1360                    "update-workbook-properties needs at least one property to set \
1361                     (--locale, --time-zone, --auto-recalc, --iterative-calculation)"
1362                        .to_string(),
1363                );
1364            }
1365            Ok(())
1366        }
1367    }
1368}
1369
1370/// The `--count >= 1` / `--at >= 1` / overflow checks shared by
1371/// [`validate_insert_bounds`], [`validate_move_bounds`] and
1372/// [`validate_delete_dimension_bounds`] — every span-based verb needs them
1373/// verbatim, so they live in one place rather than three, and returns the
1374/// inclusive last index (`at - 1 + count`) so callers that need it (move,
1375/// delete) don't recompute it.
1376///
1377/// Keeps `dimension_range`'s `at - 1 + count` total, so the one pure
1378/// conversion stays infallible and every value that reaches it is already
1379/// known to fit. Deliberately an *arithmetic* bound and not a ceiling on how
1380/// many rows may be added: the workbook's own state implies no upper bound
1381/// on `--count`, so inventing one would be the client-side validation
1382/// ADR-0073 §7 rejects, with Sheets the authority on how large a sheet may
1383/// actually get.
1384fn validate_span_start(dimension: Dimension, at: i64, count: i64) -> Result<i64, StructureResult> {
1385    let invalid = |detail: String| StructureResult::RefusedInvalidRange { detail };
1386
1387    if count < 1 {
1388        return Err(invalid(format!("--count must be at least 1, got {count}")));
1389    }
1390    if at < 1 {
1391        return Err(invalid(format!("--at must be at least 1, got {at}")));
1392    }
1393    at.checked_sub(1)
1394        .and_then(|start| start.checked_add(count))
1395        .ok_or_else(|| {
1396            invalid(format!(
1397                "--at {at} with --count {count} overflows the {noun} index space",
1398                noun = dimension.noun(),
1399            ))
1400        })
1401}
1402
1403/// Validates a `--freeze-rows`/`--freeze-columns` value: non-negative, and,
1404/// when the sheet's current count is known, strictly less than it — Sheets
1405/// requires at least one row/column to stay unfrozen and rejects a request
1406/// that would freeze the whole grid. Shared by both flags in
1407/// [`validate_verb_args`]'s `UpdateSheetProperties` arm (issue #1835) so the
1408/// bound and its wording can't drift between them.
1409fn check_freeze_bound(
1410    flag: &str,
1411    noun: &str,
1412    value: i64,
1413    current: Option<i64>,
1414) -> Result<(), StructureResult> {
1415    if value < 0 {
1416        return Err(StructureResult::RefusedInvalidRange {
1417            detail: format!("{flag} must be at least 0, got {value}"),
1418        });
1419    }
1420    if let Some(current) = current {
1421        if value >= current {
1422            return Err(StructureResult::RefusedInvalidRange {
1423                detail: format!(
1424                    "{flag} {value} would freeze every {noun}; the sheet has {current} \
1425                     {noun}(s), so the most that can be frozen is {}",
1426                    current - 1
1427                ),
1428            });
1429        }
1430    }
1431    Ok(())
1432}
1433
1434/// The `InsertRows`/`InsertColumns` half of [`validate_verb_args`], split
1435/// out so each caller supplies its own [`Dimension`] directly rather than
1436/// recovering it from the verb.
1437fn validate_insert_bounds(
1438    dimension: Dimension,
1439    at: i64,
1440    count: i64,
1441    sheet: Option<&SheetSnapshot>,
1442) -> Result<(), StructureResult> {
1443    let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1444
1445    validate_span_start(dimension, at, count)?;
1446    let current = match dimension {
1447        Dimension::Rows => sheet.and_then(|s| s.row_count),
1448        Dimension::Columns => sheet.and_then(|s| s.column_count),
1449    };
1450    if let Some(current) = current {
1451        // `at == current + 1` is a legal append (insert after the last
1452        // row/column); anything past that names a position that does not
1453        // exist and is not immediately after one that does.
1454        let max_at = current + 1;
1455        if at > max_at {
1456            return invalid(format!(
1457                "--at {at} is past the end of the sheet, which has {current} {noun}(s); \
1458                 the furthest valid position is {max_at}",
1459                noun = dimension.noun(),
1460            ));
1461        }
1462    }
1463    Ok(())
1464}
1465
1466/// The `MoveRows`/`MoveColumns` half of [`validate_verb_args`].
1467///
1468/// Three bounds, and the third is the one a move adds. The source block must
1469/// already exist in full (delete's rule), and `--before` may name the
1470/// append boundary one past the last row/column (insert's rule) — but a
1471/// destination *inside* the block, or immediately after it, describes a
1472/// position the block already occupies. `moveDimension` accepts those
1473/// happily and changes nothing, so refusing them is the same "never promise
1474/// a change the real run then rejects" rule read the other way round: never
1475/// report having moved something that did not move. That check needs no
1476/// sheet size, only the relationship between `--at`, `--count` and
1477/// `--before`, so it runs whether or not the sheet's dimensions are known.
1478fn validate_move_bounds(
1479    dimension: Dimension,
1480    at: i64,
1481    count: i64,
1482    before: i64,
1483    sheet: Option<&SheetSnapshot>,
1484) -> Result<(), StructureResult> {
1485    let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1486
1487    let last = validate_span_start(dimension, at, count)?;
1488    if before < 1 {
1489        return invalid(format!("--before must be at least 1, got {before}"));
1490    }
1491    // `before == at + count` is the no-op boundary: the block already sits
1492    // immediately before that row/column, so lifting it out and putting it
1493    // back lands it exactly where it was. `before == at - 1` is one further
1494    // out and is a real move.
1495    // `before - 1 <= last` rather than `before <= last + 1`: `last` is only
1496    // known not to overflow `at - 1 + count`, so adding to it could, while
1497    // `before` is already known to be at least 1.
1498    if before >= at && before - 1 <= last {
1499        return invalid(format!(
1500            "--before {before} is inside or immediately after the block being moved \
1501             ({noun}s {at}-{last}), so nothing would move",
1502            noun = dimension.noun(),
1503        ));
1504    }
1505    let current = match dimension {
1506        Dimension::Rows => sheet.and_then(|s| s.row_count),
1507        Dimension::Columns => sheet.and_then(|s| s.column_count),
1508    };
1509    if let Some(current) = current {
1510        if last > current {
1511            return invalid(format!(
1512                "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1513                 sheet, which has {current} {noun}(s)",
1514                noun = dimension.noun(),
1515            ));
1516        }
1517        // The same append boundary insert allows: `before == current + 1`
1518        // moves the block to the very end.
1519        let max_before = current + 1;
1520        if before > max_before {
1521            return invalid(format!(
1522                "--before {before} is past the end of the sheet, which has {current} \
1523                 {noun}(s); the furthest valid position is {max_before}",
1524                noun = dimension.noun(),
1525            ));
1526        }
1527    }
1528    Ok(())
1529}
1530
1531/// The `DeleteRows`/`DeleteColumns` half of [`validate_verb_args`].
1532///
1533/// Mirrors [`validate_insert_bounds`], but deletion has no append-boundary
1534/// case: every row/column named must already exist, so the exclusive end
1535/// index may never exceed the sheet's current size.
1536fn validate_delete_dimension_bounds(
1537    dimension: Dimension,
1538    at: i64,
1539    count: i64,
1540    sheet: Option<&SheetSnapshot>,
1541) -> Result<(), StructureResult> {
1542    let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1543
1544    let last = validate_span_start(dimension, at, count)?;
1545    let current = match dimension {
1546        Dimension::Rows => sheet.and_then(|s| s.row_count),
1547        Dimension::Columns => sheet.and_then(|s| s.column_count),
1548    };
1549    if let Some(current) = current {
1550        if last > current {
1551            return invalid(format!(
1552                "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1553                 sheet, which has {current} {noun}(s)",
1554                noun = dimension.noun(),
1555            ));
1556        }
1557    }
1558    Ok(())
1559}
1560
1561/// The `InsertRange`/`DeleteRange` half of [`validate_verb_args`].
1562///
1563/// Checks both axes are well-ordered and within the sheet's current bounds —
1564/// the same "never promise a change the real run then rejects" reasoning as
1565/// [`validate_insert_bounds`], applied to a rectangle instead of a span.
1566fn validate_range_bounds(
1567    start_row: i64,
1568    end_row: i64,
1569    start_column: i64,
1570    end_column: i64,
1571    sheet: Option<&SheetSnapshot>,
1572) -> Result<(), StructureResult> {
1573    let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1574
1575    if start_row < 1 {
1576        return invalid(format!("--start-row must be at least 1, got {start_row}"));
1577    }
1578    if start_column < 1 {
1579        return invalid(format!(
1580            "--start-column must be at least 1, got {start_column}"
1581        ));
1582    }
1583    if end_row < start_row {
1584        return invalid(format!(
1585            "--end-row ({end_row}) must be at or after --start-row ({start_row})"
1586        ));
1587    }
1588    if end_column < start_column {
1589        return invalid(format!(
1590            "--end-column ({end_column}) must be at or after --start-column ({start_column})"
1591        ));
1592    }
1593    if let Some(current) = sheet.and_then(|s| s.row_count) {
1594        if end_row > current {
1595            return invalid(format!(
1596                "--end-row {end_row} is past the end of the sheet, which has {current} row(s)"
1597            ));
1598        }
1599    }
1600    if let Some(current) = sheet.and_then(|s| s.column_count) {
1601        if end_column > current {
1602            return invalid(format!(
1603                "--end-column {end_column} is past the end of the sheet, which has {current} \
1604                 column(s)"
1605            ));
1606        }
1607    }
1608    Ok(())
1609}
1610
1611/// Builds the single `batchUpdate` request a verb sends.
1612///
1613/// Every verb produces exactly one request, which is why partial application
1614/// is not observable here and why one log record per verb is also one record
1615/// per request.
1616fn build_request(
1617    verb: &StructureVerb,
1618    sheet: Option<&SheetSnapshot>,
1619) -> Result<BatchUpdateRequestItem, String> {
1620    // Every verb but `add-sheet` addresses a sheet by its numeric id, and
1621    // the `fields` mask on `spreadsheets.get` always requests it. A sheet
1622    // that resolved by title but reported no id is a server contract
1623    // violation, not a user error — fail rather than guess at 0, which is a
1624    // real sheet id.
1625    let sheet_id = |verb_name: &str| -> Result<i64, String> {
1626        sheet.and_then(|s| s.sheet_id).ok_or_else(|| {
1627            format!("Sheets did not report a sheetId for the target sheet, so {verb_name} cannot address it")
1628        })
1629    };
1630
1631    match verb {
1632        StructureVerb::AddSheet {
1633            title,
1634            index,
1635            rows,
1636            columns,
1637        } => {
1638            let grid_properties = (rows.is_some() || columns.is_some()).then_some(GridProperties {
1639                row_count: *rows,
1640                column_count: *columns,
1641                ..Default::default()
1642            });
1643            Ok(BatchUpdateRequestItem::AddSheet(AddSheetRequest {
1644                properties: NewSheetProperties {
1645                    title: title.clone(),
1646                    index: *index,
1647                    grid_properties,
1648                },
1649            }))
1650        }
1651        StructureVerb::RenameSheet { new_title, .. } => Ok(
1652            BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1653                properties: SheetPropertiesUpdate {
1654                    sheet_id: sheet_id("rename-sheet")?,
1655                    title: Some(new_title.clone()),
1656                    ..Default::default()
1657                },
1658                // Exactly the one field we set. A wider mask would blank
1659                // every property it named but we left unpopulated.
1660                fields: "title".to_string(),
1661            }),
1662        ),
1663        StructureVerb::InsertRows { at, count, .. } => Ok(BatchUpdateRequestItem::InsertDimension(
1664            InsertDimensionRequest {
1665                range: dimension_range(sheet_id("insert-rows")?, Dimension::Rows, *at, *count),
1666                inherit_from_before: false,
1667            },
1668        )),
1669        StructureVerb::InsertColumns { at, count, .. } => Ok(
1670            BatchUpdateRequestItem::InsertDimension(InsertDimensionRequest {
1671                range: dimension_range(
1672                    sheet_id("insert-columns")?,
1673                    Dimension::Columns,
1674                    *at,
1675                    *count,
1676                ),
1677                inherit_from_before: false,
1678            }),
1679        ),
1680        StructureVerb::InsertRange {
1681            start_row,
1682            end_row,
1683            start_column,
1684            end_column,
1685            shift,
1686            ..
1687        } => Ok(BatchUpdateRequestItem::InsertRange(InsertRangeRequest {
1688            range: grid_range(
1689                sheet_id("insert-range")?,
1690                *start_row,
1691                *end_row,
1692                *start_column,
1693                *end_column,
1694            ),
1695            shift_dimension: *shift,
1696        })),
1697        StructureVerb::MoveRows {
1698            at, count, before, ..
1699        } => Ok(BatchUpdateRequestItem::MoveDimension(
1700            MoveDimensionRequest {
1701                source: dimension_range(sheet_id("move-rows")?, Dimension::Rows, *at, *count),
1702                destination_index: move_destination_index(*before),
1703            },
1704        )),
1705        StructureVerb::MoveColumns {
1706            at, count, before, ..
1707        } => Ok(BatchUpdateRequestItem::MoveDimension(
1708            MoveDimensionRequest {
1709                source: dimension_range(sheet_id("move-columns")?, Dimension::Columns, *at, *count),
1710                destination_index: move_destination_index(*before),
1711            },
1712        )),
1713        StructureVerb::DeleteSheet { .. } => {
1714            Ok(BatchUpdateRequestItem::DeleteSheet(DeleteSheetRequest {
1715                sheet_id: sheet_id("delete-sheet")?,
1716            }))
1717        }
1718        StructureVerb::DeleteRows { at, count, .. } => Ok(BatchUpdateRequestItem::DeleteDimension(
1719            DeleteDimensionRequest {
1720                range: dimension_range(sheet_id("delete-rows")?, Dimension::Rows, *at, *count),
1721            },
1722        )),
1723        StructureVerb::DeleteColumns { at, count, .. } => Ok(
1724            BatchUpdateRequestItem::DeleteDimension(DeleteDimensionRequest {
1725                range: dimension_range(
1726                    sheet_id("delete-columns")?,
1727                    Dimension::Columns,
1728                    *at,
1729                    *count,
1730                ),
1731            }),
1732        ),
1733        StructureVerb::DeleteRange {
1734            start_row,
1735            end_row,
1736            start_column,
1737            end_column,
1738            shift,
1739            ..
1740        } => Ok(BatchUpdateRequestItem::DeleteRange(DeleteRangeRequest {
1741            range: grid_range(
1742                sheet_id("delete-range")?,
1743                *start_row,
1744                *end_row,
1745                *start_column,
1746                *end_column,
1747            ),
1748            shift_dimension: *shift,
1749        })),
1750        StructureVerb::DuplicateSheet { title, index, .. } => Ok(
1751            BatchUpdateRequestItem::DuplicateSheet(DuplicateSheetRequest {
1752                source_sheet_id: sheet_id("duplicate-sheet")?,
1753                insert_sheet_index: *index,
1754                new_sheet_name: title.clone(),
1755            }),
1756        ),
1757        StructureVerb::ReorderSheet { index, .. } => Ok(
1758            BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1759                properties: SheetPropertiesUpdate {
1760                    sheet_id: sheet_id("reorder-sheet")?,
1761                    index: Some(*index),
1762                    ..Default::default()
1763                },
1764                fields: "index".to_string(),
1765            }),
1766        ),
1767        StructureVerb::SetSheetVisibility { hidden, .. } => Ok(
1768            BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1769                properties: SheetPropertiesUpdate {
1770                    sheet_id: sheet_id(if *hidden { "hide-sheet" } else { "show-sheet" })?,
1771                    hidden: Some(*hidden),
1772                    ..Default::default()
1773                },
1774                fields: "hidden".to_string(),
1775            }),
1776        ),
1777        StructureVerb::UpdateSheetProperties {
1778            freeze_rows,
1779            freeze_columns,
1780            tab_color,
1781            clear_tab_color,
1782            right_to_left,
1783            hide_gridlines,
1784            ..
1785        } => {
1786            let mut fields = Vec::new();
1787            let mut grid = GridPropertiesUpdate::default();
1788            if let Some(rows) = freeze_rows {
1789                grid.frozen_row_count = Some(*rows);
1790                fields.push("gridProperties.frozenRowCount");
1791            }
1792            if let Some(columns) = freeze_columns {
1793                grid.frozen_column_count = Some(*columns);
1794                fields.push("gridProperties.frozenColumnCount");
1795            }
1796            if let Some(hide) = hide_gridlines {
1797                grid.hide_gridlines = Some(*hide);
1798                fields.push("gridProperties.hideGridlines");
1799            }
1800            let mut tab_color_style = None;
1801            if let Some(hex) = tab_color {
1802                // Already validated in `validate_verb_args`; re-parsing here
1803                // rather than threading the parsed `Color` through keeps this
1804                // function the sole place a request is assembled from a
1805                // verb, matching every sibling arm's shape.
1806                let color = parse_hex_color(hex)
1807                    .map_err(|detail| format!("update-sheet-properties: {detail}"))?;
1808                tab_color_style = Some(ColorStyle::rgb(color));
1809                fields.push("tabColorStyle");
1810            } else if *clear_tab_color {
1811                fields.push("tabColorStyle");
1812            }
1813            if right_to_left.is_some() {
1814                fields.push("rightToLeft");
1815            }
1816            if fields.is_empty() {
1817                // `validate_verb_args` already refuses this before
1818                // `build_request` is ever reached; this is an unreachable
1819                // defensive fallback, not a real path.
1820                // omni-dev: coverage ignore reason="validate_verb_args refuses an UpdateSheetProperties verb that set none of its six properties before structure_inner ever reaches build_request, and every one of those six pushes a fields entry, so fields is never empty here"
1821                return Err("update-sheet-properties: no property was set".to_string());
1822                // omni-dev: coverage end
1823            }
1824            Ok(BatchUpdateRequestItem::UpdateSheetProperties(
1825                UpdateSheetPropertiesRequest {
1826                    properties: SheetPropertiesUpdate {
1827                        sheet_id: sheet_id("update-sheet-properties")?,
1828                        grid_properties: (grid != GridPropertiesUpdate::default()).then_some(grid),
1829                        tab_color_style,
1830                        right_to_left: *right_to_left,
1831                        ..Default::default()
1832                    },
1833                    fields: fields.join(","),
1834                },
1835            ))
1836        }
1837        StructureVerb::UpdateWorkbookProperties {
1838            locale,
1839            time_zone,
1840            auto_recalc,
1841            iterative_calculation,
1842            iterative_calculation_max_iterations,
1843            iterative_calculation_convergence_threshold,
1844        } => {
1845            // `validate_verb_args` already refused an empty field set and
1846            // every invalid iterative-calculation combination, so every
1847            // branch below is infallible and `fields` is guaranteed
1848            // non-empty.
1849            let mut properties = SpreadsheetPropertiesUpdate::default();
1850            let mut fields = Vec::new();
1851            if let Some(locale) = locale {
1852                properties.locale = Some(locale.clone());
1853                fields.push("locale");
1854            }
1855            if let Some(time_zone) = time_zone {
1856                properties.time_zone = Some(time_zone.clone());
1857                fields.push("timeZone");
1858            }
1859            if let Some(auto_recalc) = auto_recalc {
1860                properties.auto_recalc = Some(*auto_recalc);
1861                fields.push("autoRecalc");
1862            }
1863            match iterative_calculation {
1864                Some(IterativeCalculationToggle::On) => {
1865                    properties.iterative_calculation_settings =
1866                        Some(IterativeCalculationSettings {
1867                            max_iterations: *iterative_calculation_max_iterations,
1868                            convergence_threshold: *iterative_calculation_convergence_threshold,
1869                        });
1870                    fields.push("iterativeCalculationSettings");
1871                }
1872                // `properties.iterative_calculation_settings` stays `None`
1873                // here, and `fields` still names the property — an absent
1874                // key on a masked field clears it, which is how "off" is
1875                // spelled (see `IterativeCalculationSettings`'s doc
1876                // comment).
1877                Some(IterativeCalculationToggle::Off) => {
1878                    fields.push("iterativeCalculationSettings");
1879                }
1880                None => {}
1881            }
1882            Ok(BatchUpdateRequestItem::UpdateSpreadsheetProperties(
1883                UpdateSpreadsheetPropertiesRequest {
1884                    properties,
1885                    fields: fields.join(","),
1886                },
1887            ))
1888        }
1889    }
1890}
1891
1892/// A human-readable summary of the fields an `update-workbook-properties`
1893/// verb sets, e.g. `"locale -> 'en_US', auto-recalc -> ON_CHANGE"`.
1894///
1895/// Shared by the dry-run preview ([`describe_would_change`]), the real-run
1896/// confirmation ([`describe_changed`]) and the request log's
1897/// `fields_changed` (`record_attempt`), so the three can never describe a
1898/// different set of fields than the request actually carries — the same
1899/// "describe from one source of truth" discipline `dimension_range_label`/
1900/// `grid_range_label` use for the other structural verbs.
1901///
1902/// Panics if `verb` is not [`StructureVerb::UpdateWorkbookProperties`];
1903/// every call site matches on the verb first, so this is never reached
1904/// otherwise.
1905fn workbook_properties_summary(verb: &StructureVerb) -> String {
1906    let StructureVerb::UpdateWorkbookProperties {
1907        locale,
1908        time_zone,
1909        auto_recalc,
1910        iterative_calculation,
1911        iterative_calculation_max_iterations,
1912        iterative_calculation_convergence_threshold,
1913    } = verb
1914    // omni-dev: coverage ignore reason="every call site matches on the verb as UpdateWorkbookProperties before calling workbook_properties_summary, so this else-arm exists only to destructure the already-known variant"
1915    else {
1916        unreachable!("workbook_properties_summary called on a non-UpdateWorkbookProperties verb")
1917    };
1918    // omni-dev: coverage end
1919
1920    let mut parts = Vec::new();
1921    if let Some(locale) = locale {
1922        parts.push(format!("locale -> '{locale}'"));
1923    }
1924    if let Some(time_zone) = time_zone {
1925        parts.push(format!("time zone -> '{time_zone}'"));
1926    }
1927    if let Some(auto_recalc) = auto_recalc {
1928        parts.push(format!("auto-recalc -> {}", auto_recalc.as_str()));
1929    }
1930    match iterative_calculation {
1931        Some(IterativeCalculationToggle::On) => {
1932            let bounds = IterativeCalculationSettings {
1933                max_iterations: *iterative_calculation_max_iterations,
1934                convergence_threshold: *iterative_calculation_convergence_threshold,
1935            }
1936            .describe_bounds();
1937            parts.push(format!("iterative calculation -> on{bounds}"));
1938        }
1939        Some(IterativeCalculationToggle::Off) => {
1940            parts.push("iterative calculation -> off".to_string());
1941        }
1942        None => {}
1943    }
1944    parts.join(", ")
1945}
1946
1947/// Converts 1-based inclusive row/column bounds into the API's zero-based
1948/// half-open [`GridRange`]. The [`dimension_range`] of `DeleteRange`: the
1949/// single conversion site for the same reason.
1950fn grid_range(
1951    sheet_id: i64,
1952    start_row: i64,
1953    end_row: i64,
1954    start_column: i64,
1955    end_column: i64,
1956) -> GridRange {
1957    GridRange {
1958        sheet_id,
1959        start_row_index: Some(start_row - 1),
1960        end_row_index: Some(end_row),
1961        start_column_index: Some(start_column - 1),
1962        end_column_index: Some(end_column),
1963    }
1964}
1965
1966/// The `sheetId` the server assigned to a newly added sheet, if this reply
1967/// carries one — via `addSheet` or `duplicateSheet`, whichever this batch
1968/// happened to contain (`structure.rs` never sends both in one request, so
1969/// there is no ambiguity to resolve between them).
1970fn added_sheet_id(response: &BatchUpdateResponse) -> Option<i64> {
1971    response
1972        .replies
1973        .iter()
1974        .find_map(|reply| reply.add_sheet.as_ref().or(reply.duplicate_sheet.as_ref()))
1975        .and_then(|added| added.properties.as_ref())
1976        .and_then(|props| props.sheet_id)
1977}
1978
1979/// The `dimension_range` context value the request log records, e.g.
1980/// `"ROWS 5:7"` — the structural analogue of a cell verb's A1 `range`, for
1981/// effects A1 cannot express. 1-based inclusive, matching the CLI's `--at`.
1982///
1983/// Total over every `--at`/`--count` the CLI can parse, which is load-bearing
1984/// rather than defensive: [`record_attempt`] logs *attempts*, so this runs on
1985/// the refusal path too — with exactly the values
1986/// [`validate_insert_bounds`] just rejected. Arithmetic that only holds for
1987/// validated input would panic while recording the refusal of the input that
1988/// broke it. A span that cannot be computed is omitted (the log key is
1989/// omit-if-absent) rather than recorded inverted; the `refused-invalid-range`
1990/// status and its `detail` already carry the numbers the user typed.
1991fn dimension_range_label(verb: &StructureVerb) -> Option<String> {
1992    let dimension = verb.dimension()?;
1993    let (at, count) = match verb {
1994        StructureVerb::InsertRows { at, count, .. }
1995        | StructureVerb::InsertColumns { at, count, .. }
1996        | StructureVerb::DeleteRows { at, count, .. }
1997        | StructureVerb::DeleteColumns { at, count, .. }
1998        | StructureVerb::MoveRows { at, count, .. }
1999        | StructureVerb::MoveColumns { at, count, .. } => (*at, *count),
2000        _ => return None,
2001    };
2002    // A span is only meaningful for a positive count. `count < 1` is a
2003    // refusal `validate_insert_bounds` has already classified, and
2004    // `at + count - 1` computes cleanly for it while meaning nothing — an
2005    // *inverted* span, which is the misleading answer rather than the
2006    // missing one.
2007    if count < 1 {
2008        return None;
2009    }
2010    let end = at.checked_add(count).and_then(|end| end.checked_sub(1))?;
2011    Some(dimension.span_label(at, end))
2012}
2013
2014/// The `grid_range` context value the request log records for a
2015/// `insert-range` or `delete-range` verb, e.g. `"rows 2-10, columns 2-4"` — 1-based inclusive,
2016/// matching the CLI's `--start-row`/`--end-row`/`--start-column`/
2017/// `--end-column`. `None` for every other verb.
2018fn grid_range_label(verb: &StructureVerb) -> Option<String> {
2019    match verb {
2020        StructureVerb::InsertRange {
2021            start_row,
2022            end_row,
2023            start_column,
2024            end_column,
2025            ..
2026        }
2027        | StructureVerb::DeleteRange {
2028            start_row,
2029            end_row,
2030            start_column,
2031            end_column,
2032            ..
2033        } => Some(format!(
2034            "rows {start_row}-{end_row}, columns {start_column}-{end_column}"
2035        )),
2036        _ => None,
2037    }
2038}
2039
2040/// Emits the `kind: "drivemutation"` record.
2041///
2042/// Inside the engine, never the CLI layer, so a future MCP caller cannot
2043/// bypass it — and so a `Blocked` outcome, which makes zero Sheets calls,
2044/// still leaves a trace. Same reasoning as `write.rs::record_attempt`.
2045fn record_attempt(outcome: &StructureOutcome, opts: &StructureOptions, duration: Duration) {
2046    let error = match &outcome.result {
2047        StructureResult::Failed { detail } => Some(detail.clone()),
2048        _ => None,
2049    };
2050    let decided_by = match &outcome.result {
2051        StructureResult::Blocked { decided_by } => decided_by.as_ref(),
2052        _ => None,
2053    };
2054    let decided_by = write_gate::decided_by_log_fields(decided_by);
2055    let sheet_id = match &outcome.result {
2056        StructureResult::Changed { sheet_id, .. } => *sheet_id,
2057        StructureResult::WouldChange { sheet, .. } => sheet.as_ref().and_then(|s| s.sheet_id),
2058        _ => None,
2059    };
2060    // The destination `dimension_range` cannot carry: it names the span that
2061    // moved, not where it went.
2062    let move_to = match &opts.verb {
2063        StructureVerb::MoveRows { before, .. } | StructureVerb::MoveColumns { before, .. } => {
2064            Some(*before)
2065        }
2066        _ => None,
2067    };
2068    // `format.rs`'s field, reused here rather than duplicated: the only
2069    // structural verbs (issues #1835/#1836) that can set more than one
2070    // field in a single request, so `sheet_title`/`dimension_range`/
2071    // `grid_range` alone can't name the effect.
2072    let fields_changed = match &opts.verb {
2073        StructureVerb::UpdateWorkbookProperties { .. } => {
2074            Some(workbook_properties_summary(&opts.verb))
2075        }
2076        _ => fields_changed_label(&opts.verb),
2077    };
2078
2079    request_log::record_drive_mutation(DriveMutationOutcome {
2080        operation: opts.verb.log_operation(),
2081        file_id: outcome.spreadsheet_id.clone(),
2082        file_name: outcome.file_name.clone().unwrap_or_default(),
2083        status: outcome.result.log_status().to_string(),
2084        resolved_folder_id: outcome.resolved_folder_id.clone(),
2085        decided_by_folder_id: decided_by.folder_id,
2086        decided_by_depth: decided_by.depth,
2087        decided_by_file_id: decided_by.file_id,
2088        sheet_id,
2089        sheet_title: opts.verb.sheet_title().map(ToString::to_string),
2090        sheet_new_title: opts.verb.new_sheet_title().map(ToString::to_string),
2091        dimension_range: dimension_range_label(&opts.verb),
2092        move_to,
2093        grid_range: grid_range_label(&opts.verb),
2094        fields_changed,
2095        error,
2096        duration,
2097        ..Default::default()
2098    });
2099}
2100
2101/// The `fields_changed` context value the request log records for an
2102/// `update-sheet-properties` verb, e.g. `"frozenRowCount=2,
2103/// tabColorStyle=#FF8800"` — a comma-joined list of the properties this verb
2104/// set, reusing the `fields_changed` context key `format.rs` already writes
2105/// for `format-cells`/`update-borders`/etc. rather than adding a new one
2106/// (docs/log.md). `None` for every other verb.
2107///
2108/// Runs on the refusal path too, like [`dimension_range_label`]/
2109/// [`grid_range_label`]: `record_attempt` logs attempts, not only successes,
2110/// so this reports what the verb *asked* to change even when the request was
2111/// never built.
2112fn fields_changed_label(verb: &StructureVerb) -> Option<String> {
2113    let StructureVerb::UpdateSheetProperties {
2114        freeze_rows,
2115        freeze_columns,
2116        tab_color,
2117        clear_tab_color,
2118        right_to_left,
2119        hide_gridlines,
2120        ..
2121    } = verb
2122    else {
2123        return None;
2124    };
2125    let mut parts = Vec::new();
2126    if let Some(rows) = freeze_rows {
2127        parts.push(format!("frozenRowCount={rows}"));
2128    }
2129    if let Some(columns) = freeze_columns {
2130        parts.push(format!("frozenColumnCount={columns}"));
2131    }
2132    if let Some(hide) = hide_gridlines {
2133        parts.push(format!("hideGridlines={hide}"));
2134    }
2135    if let Some(hex) = tab_color {
2136        parts.push(format!("tabColorStyle={hex}"));
2137    } else if *clear_tab_color {
2138        parts.push("tabColorStyle=cleared".to_string());
2139    }
2140    if let Some(rtl) = right_to_left {
2141        parts.push(format!("rightToLeft={rtl}"));
2142    }
2143    if parts.is_empty() {
2144        return None;
2145    }
2146    Some(parts.join(", "))
2147}
2148
2149/// Renders an outcome as human-readable text.
2150///
2151/// Joins [`describe_lines`]; see it for why the line structure, and not just
2152/// the finished string, is what this module produces.
2153#[must_use]
2154pub fn describe(outcome: &StructureOutcome) -> String {
2155    describe_lines(outcome).join("\n")
2156}
2157
2158/// Renders an outcome as its individual lines, **none of which contains a
2159/// newline**.
2160///
2161/// Lives here rather than in the CLI layer so the CLI and a future MCP
2162/// caller describe an outcome identically.
2163///
2164/// The `WouldChange` arms are the substance of this module's answer to "what
2165/// does `--dry-run` show for an effect that isn't a range": the sheet's real
2166/// current dimensions, the resulting ones, and — for an insert — the shift
2167/// that no bounded range could express. That shift is a second line, which
2168/// is why this is the line list and `describe` the convenience over it.
2169///
2170/// Returning the lines rather than one pre-joined string is what lets the
2171/// CLI sanitize each one and then supply the separators itself. Every line
2172/// here interpolates untrusted text — a Drive-supplied file name, sheet
2173/// titles read out of the workbook, raw API error details — so a newline
2174/// arriving inside one of those values must not be able to pass for a line
2175/// break this module chose. Filtering a joined string cannot tell the two
2176/// apart; filtering the parts before joining them cannot confuse them.
2177/// `no_describe_line_contains_a_newline` pins the guarantee.
2178#[must_use]
2179pub fn describe_lines(outcome: &StructureOutcome) -> Vec<String> {
2180    let verb = &outcome.verb;
2181    let book = outcome.file_name.as_deref().map_or_else(
2182        || format!("'{}'", outcome.spreadsheet_id),
2183        |n| format!("'{n}'"),
2184    );
2185
2186    match &outcome.result {
2187        StructureResult::WouldChange { sheet, sheet_count } => {
2188            describe_would_change(verb, sheet.as_deref(), *sheet_count, &book)
2189        }
2190        StructureResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
2191            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
2192             `drive sheets {}` only works on spreadsheets",
2193            verb.label()
2194        )],
2195        StructureResult::RefusedShortcut => vec![format!(
2196            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
2197             resolve the target spreadsheet's id and use that instead",
2198            verb.label()
2199        )],
2200        StructureResult::RefusedNoVisibleParents => {
2201            let op = verb.gate_operation();
2202            vec![format!(
2203                "Refused: {book} has no parent folder visible to this account, so no folder \
2204                 rule can apply to it. This is normal for a Sheet shared by link or email. \
2205                 Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
2206                 [\"{op}\"]}} to write_permissions.rules. (Adding it to a folder in your own \
2207                 Drive and granting that folder `{op}` also works.)"
2208            )]
2209        }
2210        StructureResult::RefusedSheetNotFound { title, available } => {
2211            vec![grid_range::no_sheet_refusal(&book, title, available)]
2212        }
2213        StructureResult::RefusedSheetExists { title } => {
2214            vec![format!(
2215                "Refused: {book} already has a sheet titled '{title}'"
2216            )]
2217        }
2218        StructureResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
2219        StructureResult::Blocked { decided_by } => {
2220            let op = verb.gate_operation();
2221            let action = if matches!(op, DriveOperation::SheetsDelete) {
2222                "destructive edits"
2223            } else {
2224                "structural edits"
2225            };
2226            vec![match decided_by {
2227                Some(rule) => format!(
2228                    "Blocked: {action} to {book} refused by rule on {} {}{}",
2229                    rule.kind_label(),
2230                    rule.id(),
2231                    rule.depth_suffix()
2232                ),
2233                None => format!(
2234                    "Blocked: {action} to {book} refused by default policy (no matching rule \
2235                     for {op})"
2236                ),
2237            }]
2238        }
2239        StructureResult::RefusedNoLease => LeaseGateRefusal::NoLease
2240            .describe_line(&outcome.spreadsheet_id, &book)
2241            .into_iter()
2242            .collect(),
2243        StructureResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
2244            .describe_line(&outcome.spreadsheet_id, &book)
2245            .into_iter()
2246            .collect(),
2247        StructureResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
2248            .describe_line(&outcome.spreadsheet_id, &book)
2249            .into_iter()
2250            .collect(),
2251        StructureResult::RefusedLeaseStale => LeaseGateRefusal::Stale
2252            .describe_line(&outcome.spreadsheet_id, &book)
2253            .into_iter()
2254            .collect(),
2255        StructureResult::Changed {
2256            sheet,
2257            sheet_id,
2258            backup,
2259        } => {
2260            vec![describe_changed(
2261                verb,
2262                sheet.as_deref(),
2263                *sheet_id,
2264                backup.as_deref(),
2265                &book,
2266            )]
2267        }
2268        StructureResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
2269        StructureResult::Failed { detail } => vec![format!("Failed: {detail}")],
2270    }
2271}
2272
2273fn describe_would_change(
2274    verb: &StructureVerb,
2275    sheet: Option<&SheetSnapshot>,
2276    sheet_count: usize,
2277    book: &str,
2278) -> Vec<String> {
2279    let id = sheet
2280        .and_then(|s| s.sheet_id)
2281        .map_or_else(String::new, |id| format!(" (sheetId {id})"));
2282
2283    match verb {
2284        StructureVerb::AddSheet {
2285            title,
2286            index,
2287            rows,
2288            columns,
2289        } => {
2290            let size = match (rows, columns) {
2291                (Some(r), Some(c)) => format!(" ({r} x {c})"),
2292                (Some(r), None) => format!(" ({r} rows)"),
2293                (None, Some(c)) => format!(" ({c} columns)"),
2294                (None, None) => String::new(),
2295            };
2296            let position = index.map_or_else(
2297                || " at the end".to_string(),
2298                |index| format!(" at index {index}"),
2299            );
2300            vec![format!(
2301                "Would add sheet '{title}'{size}{position} of {book} \
2302                 ({sheet_count} sheet(s) -> {})",
2303                sheet_count + 1
2304            )]
2305        }
2306        StructureVerb::RenameSheet {
2307            sheet: from,
2308            new_title,
2309        } => {
2310            vec![format!(
2311                "Would rename sheet '{from}'{id} to '{new_title}' in {book}"
2312            )]
2313        }
2314        StructureVerb::InsertRows {
2315            sheet: from,
2316            at,
2317            count,
2318        } => describe_would_insert(Dimension::Rows, from, &id, *at, *count, sheet, book),
2319        StructureVerb::InsertColumns {
2320            sheet: from,
2321            at,
2322            count,
2323        } => describe_would_insert(Dimension::Columns, from, &id, *at, *count, sheet, book),
2324        StructureVerb::InsertRange {
2325            sheet: from,
2326            start_row,
2327            end_row,
2328            start_column,
2329            end_column,
2330            shift,
2331        } => vec![describe_would_insert_range(
2332            from,
2333            &id,
2334            *start_row,
2335            *end_row,
2336            *start_column,
2337            *end_column,
2338            *shift,
2339            book,
2340        )],
2341        StructureVerb::MoveRows {
2342            sheet: from,
2343            at,
2344            count,
2345            before,
2346        } => describe_would_move(
2347            Dimension::Rows,
2348            from,
2349            &id,
2350            *at,
2351            *count,
2352            *before,
2353            sheet,
2354            book,
2355        ),
2356        StructureVerb::MoveColumns {
2357            sheet: from,
2358            at,
2359            count,
2360            before,
2361        } => describe_would_move(
2362            Dimension::Columns,
2363            from,
2364            &id,
2365            *at,
2366            *count,
2367            *before,
2368            sheet,
2369            book,
2370        ),
2371        StructureVerb::DeleteSheet { sheet: from } => {
2372            vec![format!(
2373                "Would delete sheet '{from}'{id} from {book} ({sheet_count} sheet(s) -> {}); \
2374                 {FORMULA_CAVEAT}",
2375                sheet_count.saturating_sub(1)
2376            )]
2377        }
2378        StructureVerb::DeleteRows {
2379            sheet: from,
2380            at,
2381            count,
2382        } => describe_would_delete_dimension(Dimension::Rows, from, &id, *at, *count, sheet, book),
2383        StructureVerb::DeleteColumns {
2384            sheet: from,
2385            at,
2386            count,
2387        } => {
2388            describe_would_delete_dimension(Dimension::Columns, from, &id, *at, *count, sheet, book)
2389        }
2390        StructureVerb::DeleteRange {
2391            sheet: from,
2392            start_row,
2393            end_row,
2394            start_column,
2395            end_column,
2396            shift,
2397        } => vec![describe_would_delete_range(
2398            from,
2399            &id,
2400            *start_row,
2401            *end_row,
2402            *start_column,
2403            *end_column,
2404            *shift,
2405            book,
2406        )],
2407        StructureVerb::DuplicateSheet {
2408            sheet: from,
2409            title,
2410            index,
2411        } => {
2412            let to = title.as_deref().map_or_else(
2413                || " as a copy Sheets names automatically".to_string(),
2414                |title| format!(" as '{title}'"),
2415            );
2416            let position = index.map_or_else(
2417                // Confirmed against the live API: unlike `add-sheet`,
2418                // `duplicateSheetRequest` does not default to appending —
2419                // an omitted index lands the copy at the *front* (index 0).
2420                || " at the front (Sheets' default; not the end)".to_string(),
2421                |index| format!(" at index {index}"),
2422            );
2423            vec![format!(
2424                "Would duplicate sheet '{from}'{id}{to}{position} of {book} \
2425                 ({sheet_count} sheet(s) -> {})",
2426                sheet_count + 1
2427            )]
2428        }
2429        StructureVerb::ReorderSheet { sheet: from, index } => {
2430            vec![format!(
2431                "Would move sheet '{from}'{id} to index {index} in {book}"
2432            )]
2433        }
2434        StructureVerb::SetSheetVisibility {
2435            sheet: from,
2436            hidden,
2437        } => {
2438            let verb_word = if *hidden { "hide" } else { "show" };
2439            vec![format!("Would {verb_word} sheet '{from}'{id} in {book}")]
2440        }
2441        StructureVerb::UpdateSheetProperties {
2442            sheet: from,
2443            freeze_rows,
2444            freeze_columns,
2445            tab_color,
2446            clear_tab_color,
2447            right_to_left,
2448            hide_gridlines,
2449        } => {
2450            let fragments = update_sheet_properties_fragments(
2451                freeze_rows,
2452                freeze_columns,
2453                tab_color,
2454                *clear_tab_color,
2455                *right_to_left,
2456                *hide_gridlines,
2457                sheet,
2458            );
2459            vec![format!(
2460                "Would update sheet '{from}'{id} in {book}: {}",
2461                fragments.join(", ")
2462            )]
2463        }
2464        StructureVerb::UpdateWorkbookProperties { .. } => vec![format!(
2465            "Would update workbook properties of {book}: {}",
2466            workbook_properties_summary(verb)
2467        )],
2468    }
2469}
2470
2471/// One `"<label> <before> -> <after>"` fragment, or `"<label> -> <after>"`
2472/// when `before` is `None` — the shape every `update-sheet-properties`
2473/// property reduces to (issue #1835). Shared so the four properties that
2474/// carry a "before" value from the snapshot can't phrase it four different
2475/// ways.
2476fn before_after_fragment(
2477    label: &str,
2478    before: Option<impl std::fmt::Display>,
2479    after: impl std::fmt::Display,
2480) -> String {
2481    match before {
2482        Some(before) => format!("{label} {before} -> {after}"),
2483        None => format!("{label} -> {after}"),
2484    }
2485}
2486
2487/// The `hide_gridlines` before/after label: `true`/`false` read as
2488/// "hidden"/"shown" rather than as bare booleans.
2489fn gridlines_label(hidden: bool) -> &'static str {
2490    if hidden {
2491        "hidden"
2492    } else {
2493        "shown"
2494    }
2495}
2496
2497/// The fragments shared by [`describe_would_change`]'s and
2498/// [`describe_changed`]'s `UpdateSheetProperties` arms (issue #1835) — one
2499/// per property the verb actually set, each stated as a before->after where
2500/// the snapshot knows the current value and as just the target otherwise.
2501/// Kept as one function so the dry-run preview and the real-run report can
2502/// never phrase the same effect two different ways.
2503fn update_sheet_properties_fragments(
2504    freeze_rows: &Option<i64>,
2505    freeze_columns: &Option<i64>,
2506    tab_color: &Option<String>,
2507    clear_tab_color: bool,
2508    right_to_left: Option<bool>,
2509    hide_gridlines: Option<bool>,
2510    sheet: Option<&SheetSnapshot>,
2511) -> Vec<String> {
2512    let mut fragments = Vec::new();
2513    if let Some(rows) = freeze_rows {
2514        fragments.push(before_after_fragment(
2515            "frozen rows",
2516            sheet.and_then(|s| s.frozen_row_count),
2517            rows,
2518        ));
2519    }
2520    if let Some(columns) = freeze_columns {
2521        fragments.push(before_after_fragment(
2522            "frozen columns",
2523            sheet.and_then(|s| s.frozen_column_count),
2524            columns,
2525        ));
2526    }
2527    if let Some(hex) = tab_color {
2528        fragments.push(format!("tab color -> {}", normalize_hex_for_display(hex)));
2529    } else if clear_tab_color {
2530        fragments.push("tab color cleared".to_string());
2531    }
2532    if let Some(rtl) = right_to_left {
2533        fragments.push(before_after_fragment(
2534            "right-to-left",
2535            sheet.and_then(|s| s.right_to_left),
2536            rtl,
2537        ));
2538    }
2539    if let Some(hide) = hide_gridlines {
2540        fragments.push(before_after_fragment(
2541            "gridlines",
2542            sheet.and_then(|s| s.hide_gridlines).map(gridlines_label),
2543            gridlines_label(hide),
2544        ));
2545    }
2546    fragments
2547}
2548
2549/// The insert arms of [`describe_would_change`].
2550///
2551/// This is where a structural dry run earns its keep: it names the resulting
2552/// dimension *and* the shift, which is the part of the effect no bounded
2553/// range could express and the reason ADR-0073 §12 called this out.
2554// One parameter per fact the message needs (dimension, the two labels
2555// already formatted by the caller, the insert's own `at`/`count`, the
2556// target's dimensions, and the workbook name) — bundling them into a struct
2557// would just move the same fields one level out for a private helper with a
2558// single call site.
2559#[allow(clippy::too_many_arguments)]
2560fn describe_would_insert(
2561    dimension: Dimension,
2562    from: &str,
2563    id: &str,
2564    at: i64,
2565    count: i64,
2566    sheet: Option<&SheetSnapshot>,
2567    book: &str,
2568) -> Vec<String> {
2569    let summary = format!(
2570        "Would insert {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}",
2571        noun = dimension.noun(),
2572    );
2573    let before = match dimension {
2574        Dimension::Rows => sheet.and_then(|s| s.row_count),
2575        Dimension::Columns => sheet.and_then(|s| s.column_count),
2576    };
2577    // The shift line is the trustworthy half of a structural preview, so it
2578    // is omitted outright when it cannot be stated — an unknown current size,
2579    // or a resulting size that does not fit. A wrong number here would be
2580    // worse than a missing one.
2581    let Some(before) = before else {
2582        return vec![summary];
2583    };
2584    let Some(after) = before.checked_add(count) else {
2585        return vec![summary];
2586    };
2587    // `at > before` only at the append boundary (`at == before + 1`,
2588    // enforced by `validate_verb_args`): there is nothing after the last
2589    // row/column to shift, so "existing N-M shift" would print an inverted,
2590    // self-contradictory range instead of the truth.
2591    let existing = if at <= before {
2592        format!(
2593            "; existing {plural} {at}-{before} shift {direction}",
2594            plural = plural(dimension),
2595            // An insert always pushes the existing rows/columns forward.
2596            direction = shift_word(dimension, true),
2597        )
2598    } else {
2599        format!(
2600            "; appended at the end, no existing {plural} shift",
2601            plural = plural(dimension)
2602        )
2603    };
2604    vec![
2605        summary,
2606        format!(
2607            "  ({before} {plural} -> {after}{existing})",
2608            plural = plural(dimension),
2609        ),
2610    ]
2611}
2612
2613/// The move arms of [`describe_would_change`].
2614///
2615/// Same shape and the same reason as [`describe_would_insert`]: the summary
2616/// names the block and its destination, and the second line names the
2617/// shift — which for a move is the whole point, since `--before` is numbered
2618/// in the sheet's *pre-move* coordinates and the block therefore does not
2619/// land on the number the user typed. Spelling out where the block ends up,
2620/// and which rows/columns slid past it, is what stops that convention being
2621/// a trap.
2622// One parameter per fact the message needs, with a single call site — see
2623// `describe_would_insert`'s note.
2624#[allow(clippy::too_many_arguments)]
2625fn describe_would_move(
2626    dimension: Dimension,
2627    from: &str,
2628    id: &str,
2629    at: i64,
2630    count: i64,
2631    before: i64,
2632    sheet: Option<&SheetSnapshot>,
2633    book: &str,
2634) -> Vec<String> {
2635    let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2636        return vec![format!(
2637            "Would move {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2638            noun = dimension.noun(),
2639        )];
2640    };
2641    let summary = format!(
2642        "Would move {count} {noun}(s) {at}-{last} of '{from}'{id} in {book} to before \
2643         {noun} {before}",
2644        noun = dimension.noun(),
2645    );
2646    let current = match dimension {
2647        Dimension::Rows => sheet.and_then(|s| s.row_count),
2648        Dimension::Columns => sheet.and_then(|s| s.column_count),
2649    };
2650    // The shift line is the trustworthy half of a structural preview, so it
2651    // is omitted outright when the current size is unknown rather than
2652    // guessed at — `describe_would_insert`'s rule.
2653    let Some(current) = current else {
2654        return vec![summary];
2655    };
2656    // `validate_move_bounds` refuses every `before` in `at..=at + count`, so
2657    // exactly one of these two branches applies. Each yields the same five
2658    // facts — the shifted range's old and new bounds, and which way it
2659    // moved — which the single `format!` below turns into the line.
2660    let (land_first, land_last) = move_landing_span(before, count, last);
2661    let (shift_first, shift_last, shifted_first, shifted_last, forward) = if before > last {
2662        // Downward: the rows/columns strictly between the block and the
2663        // destination slide back to close the gap — `count` short of the
2664        // number typed, which is the convention this line exists to spell
2665        // out.
2666        (last + 1, before - 1, at, before - 1 - count, false)
2667    } else {
2668        // Upward (`before < at`): the block lands exactly on the number
2669        // typed, and everything from there to the block's old start slides
2670        // forward by `count`.
2671        (before, at - 1, before + count, at - 1 + count, true)
2672    };
2673    let detail = format!(
2674        "  ({current} {plural} unchanged; {plural} {shift_first}-{shift_last} shift \
2675         {direction} to {shifted_first}-{shifted_last}; moved {plural} land at {land_first}-\
2676         {land_last})",
2677        plural = plural(dimension),
2678        direction = shift_word(dimension, forward),
2679    );
2680    vec![summary, detail]
2681}
2682
2683/// A destructive `--dry-run` cannot check whether some formula elsewhere in
2684/// the workbook references what would be deleted — that would need reading
2685/// every other sheet's formulas, not just this verb's own target, and ADR-0077
2686/// keeps a destructive dry run structural-only (no extra `values.get` read,
2687/// no cell content in its output or the request log). This fixed caveat is
2688/// the honest substitute.
2689const FORMULA_CAVEAT: &str =
2690    "formulas elsewhere in the workbook that reference this may break, which cannot be \
2691     checked automatically";
2692
2693/// Shared by [`describe_would_insert_range`] and [`describe_inserted_range`]
2694/// so the wording can't drift between the preview and the post-execution
2695/// message; tense differs (the dry run hasn't happened yet) so this is two
2696/// constants rather than one, unlike [`FORMULA_CAVEAT`].
2697const INSERT_RANGE_EDGE_CAVEAT_DRY_RUN: &str =
2698    "cells pushed past the sheet's grid extent may be dropped by Sheets";
2699const INSERT_RANGE_EDGE_CAVEAT: &str =
2700    "cells pushed past the sheet's grid extent may have been dropped by Sheets";
2701
2702/// The `DeleteRows`/`DeleteColumns` arm of [`describe_would_change`].
2703///
2704/// Mirrors [`describe_would_insert`]'s shape (a summary line plus a shift
2705/// line), shrinking instead of growing and shifting the opposite direction.
2706/// Unlike insert, deletion has no append-boundary case — every row/column
2707/// named already exists — so the "nothing remains after" branch replaces
2708/// insert's "appended at the end" one.
2709#[allow(clippy::too_many_arguments)]
2710fn describe_would_delete_dimension(
2711    dimension: Dimension,
2712    from: &str,
2713    id: &str,
2714    at: i64,
2715    count: i64,
2716    sheet: Option<&SheetSnapshot>,
2717    book: &str,
2718) -> Vec<String> {
2719    let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2720        return vec![format!(
2721            "Would delete {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2722            noun = dimension.noun(),
2723        )];
2724    };
2725    let summary = format!(
2726        "Would delete {count} {noun}(s) {at}-{last} of '{from}'{id} in {book}",
2727        noun = dimension.noun(),
2728    );
2729    let before = match dimension {
2730        Dimension::Rows => sheet.and_then(|s| s.row_count),
2731        Dimension::Columns => sheet.and_then(|s| s.column_count),
2732    };
2733    let Some(before) = before else {
2734        return vec![summary];
2735    };
2736    let Some(after) = before.checked_sub(count) else {
2737        return vec![summary];
2738    };
2739    let existing = if last < before {
2740        format!(
2741            "; existing {plural} {next}-{before} shift {direction}",
2742            plural = plural(dimension),
2743            next = last + 1,
2744            direction = shift_word(dimension, false),
2745        )
2746    } else {
2747        format!(
2748            "; nothing remains after the deleted {plural}",
2749            plural = plural(dimension)
2750        )
2751    };
2752    vec![
2753        summary,
2754        format!(
2755            "  ({before} {plural} -> {after}{existing}; {FORMULA_CAVEAT})",
2756            plural = plural(dimension),
2757        ),
2758    ]
2759}
2760
2761/// The `DeleteRange` arm of [`describe_would_change`].
2762///
2763/// `deleteRange` never changes the sheet's `rowCount`/`columnCount` — it
2764/// shifts cells within the same bounded grid — so unlike the dimension
2765/// deletes there is no "before -> after" size line to add; the rectangle and
2766/// shift direction already say the whole effect in one line.
2767#[allow(clippy::too_many_arguments)]
2768fn describe_would_delete_range(
2769    from: &str,
2770    id: &str,
2771    start_row: i64,
2772    end_row: i64,
2773    start_column: i64,
2774    end_column: i64,
2775    shift: ShiftDimension,
2776    book: &str,
2777) -> String {
2778    let direction = shift_word(shift.into(), false);
2779    format!(
2780        "Would delete rows {start_row}-{end_row}, columns {start_column}-{end_column} of \
2781         '{from}'{id} in {book}, shifting remaining cells {direction} to close the gap; \
2782         {FORMULA_CAVEAT}"
2783    )
2784}
2785
2786/// The `InsertRange` arm of [`describe_would_change`].
2787#[allow(clippy::too_many_arguments)]
2788fn describe_would_insert_range(
2789    from: &str,
2790    id: &str,
2791    start_row: i64,
2792    end_row: i64,
2793    start_column: i64,
2794    end_column: i64,
2795    shift: ShiftDimension,
2796    book: &str,
2797) -> String {
2798    let direction = shift_word(shift.into(), true);
2799    format!(
2800        "Would insert empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
2801         of '{from}'{id} in {book}, shifting existing cells {direction}; \
2802         {INSERT_RANGE_EDGE_CAVEAT_DRY_RUN}"
2803    )
2804}
2805
2806fn describe_changed(
2807    verb: &StructureVerb,
2808    sheet: Option<&SheetSnapshot>,
2809    sheet_id: Option<i64>,
2810    backup: Option<&LeaseBackup>,
2811    book: &str,
2812) -> String {
2813    let id = sheet_id.map_or_else(String::new, |id| format!(" (sheetId {id})"));
2814    let recovery = recovery_note(backup, matches!(verb, StructureVerb::DeleteSheet { .. }));
2815    match verb {
2816        StructureVerb::AddSheet { title, .. } => {
2817            format!("Added sheet '{title}'{id} to {book}")
2818        }
2819        StructureVerb::RenameSheet {
2820            sheet: from,
2821            new_title,
2822        } => {
2823            format!("Renamed sheet '{from}' to '{new_title}'{id} in {book}")
2824        }
2825        StructureVerb::InsertRows {
2826            sheet: from,
2827            at,
2828            count,
2829        } => describe_inserted(Dimension::Rows, from, &id, *at, *count, sheet, book),
2830        StructureVerb::InsertColumns {
2831            sheet: from,
2832            at,
2833            count,
2834        } => describe_inserted(Dimension::Columns, from, &id, *at, *count, sheet, book),
2835        StructureVerb::InsertRange {
2836            sheet: from,
2837            start_row,
2838            end_row,
2839            start_column,
2840            end_column,
2841            shift,
2842        } => describe_inserted_range(
2843            from,
2844            &id,
2845            *start_row,
2846            *end_row,
2847            *start_column,
2848            *end_column,
2849            *shift,
2850            book,
2851        ),
2852        StructureVerb::MoveRows {
2853            sheet: from,
2854            at,
2855            count,
2856            before,
2857        } => describe_moved(Dimension::Rows, from, &id, *at, *count, *before, book),
2858        StructureVerb::MoveColumns {
2859            sheet: from,
2860            at,
2861            count,
2862            before,
2863        } => describe_moved(Dimension::Columns, from, &id, *at, *count, *before, book),
2864        StructureVerb::DeleteSheet { sheet: from } => {
2865            format!("Deleted sheet '{from}'{id} from {book}; {recovery}")
2866        }
2867        StructureVerb::DeleteRows {
2868            sheet: from,
2869            at,
2870            count,
2871        } => describe_deleted_dimension(
2872            Dimension::Rows,
2873            from,
2874            &id,
2875            *at,
2876            *count,
2877            sheet,
2878            book,
2879            &recovery,
2880        ),
2881        StructureVerb::DeleteColumns {
2882            sheet: from,
2883            at,
2884            count,
2885        } => describe_deleted_dimension(
2886            Dimension::Columns,
2887            from,
2888            &id,
2889            *at,
2890            *count,
2891            sheet,
2892            book,
2893            &recovery,
2894        ),
2895        StructureVerb::DeleteRange {
2896            sheet: from,
2897            start_row,
2898            end_row,
2899            start_column,
2900            end_column,
2901            shift,
2902        } => describe_deleted_range(
2903            from,
2904            &id,
2905            *start_row,
2906            *end_row,
2907            *start_column,
2908            *end_column,
2909            *shift,
2910            book,
2911            &recovery,
2912        ),
2913        StructureVerb::DuplicateSheet {
2914            sheet: from, title, ..
2915        } => {
2916            let to = title
2917                .as_deref()
2918                .map_or_else(String::new, |title| format!(" as '{title}'"));
2919            format!("Duplicated sheet '{from}'{to}{id} in {book}")
2920        }
2921        StructureVerb::ReorderSheet { sheet: from, index } => {
2922            format!("Moved sheet '{from}'{id} to index {index} in {book}")
2923        }
2924        StructureVerb::SetSheetVisibility {
2925            sheet: from,
2926            hidden,
2927        } => {
2928            let verb_word = if *hidden { "Hid" } else { "Showed" };
2929            format!("{verb_word} sheet '{from}'{id} in {book}")
2930        }
2931        StructureVerb::UpdateSheetProperties {
2932            sheet: from,
2933            freeze_rows,
2934            freeze_columns,
2935            tab_color,
2936            clear_tab_color,
2937            right_to_left,
2938            hide_gridlines,
2939        } => {
2940            let fragments = update_sheet_properties_fragments(
2941                freeze_rows,
2942                freeze_columns,
2943                tab_color,
2944                *clear_tab_color,
2945                *right_to_left,
2946                *hide_gridlines,
2947                sheet,
2948            );
2949            format!(
2950                "Updated sheet '{from}'{id} in {book}: {}",
2951                fragments.join(", ")
2952            )
2953        }
2954        StructureVerb::UpdateWorkbookProperties { .. } => format!(
2955            "Updated workbook properties of {book}: {}",
2956            workbook_properties_summary(verb)
2957        ),
2958    }
2959}
2960
2961/// The insert arms of [`describe_changed`].
2962// Same shape as `describe_would_insert`, for the same reason: one parameter
2963// per fact the message needs, with a single call site.
2964#[allow(clippy::too_many_arguments)]
2965fn describe_inserted(
2966    dimension: Dimension,
2967    from: &str,
2968    id: &str,
2969    at: i64,
2970    count: i64,
2971    sheet: Option<&SheetSnapshot>,
2972    book: &str,
2973) -> String {
2974    let now = match dimension {
2975        Dimension::Rows => sheet.and_then(|s| s.row_count),
2976        Dimension::Columns => sheet.and_then(|s| s.column_count),
2977    }
2978    .and_then(|before| before.checked_add(count))
2979    .map_or_else(String::new, |after| {
2980        format!(" ({after} {} now)", plural(dimension))
2981    });
2982    format!(
2983        "Inserted {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}{now}",
2984        noun = dimension.noun(),
2985    )
2986}
2987
2988/// The move arms of [`describe_changed`].
2989///
2990/// One line, like [`describe_inserted`], but it still names the landing
2991/// span: `--before` is pre-move numbering, so "moved rows 2-3 to before row
2992/// 6" alone would leave the reader to redo the arithmetic that
2993/// [`describe_would_move`] already does for the preview. It takes no
2994/// [`SheetSnapshot`], unlike every other `describe_*` helper here: a move
2995/// changes neither the row nor the column count, so there is no "now N
2996/// rows" tail the sheet's dimensions could supply — the landing span is
2997/// determined by the block's own numbers alone.
2998fn describe_moved(
2999    dimension: Dimension,
3000    from: &str,
3001    id: &str,
3002    at: i64,
3003    count: i64,
3004    before: i64,
3005    book: &str,
3006) -> String {
3007    let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3008    let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3009    let now = last.map_or_else(String::new, |last| {
3010        let (land_first, land_last) = move_landing_span(before, count, last);
3011        format!(" (now {} {land_first}-{land_last})", plural(dimension))
3012    });
3013    format!(
3014        "Moved {count} {noun}(s) {range} of '{from}'{id} in {book} to before {noun} \
3015         {before}{now}",
3016        noun = dimension.noun(),
3017    )
3018}
3019
3020/// The `DeleteRows`/`DeleteColumns` arm of [`describe_changed`].
3021#[allow(clippy::too_many_arguments)]
3022fn describe_deleted_dimension(
3023    dimension: Dimension,
3024    from: &str,
3025    id: &str,
3026    at: i64,
3027    count: i64,
3028    sheet: Option<&SheetSnapshot>,
3029    book: &str,
3030    recovery: &str,
3031) -> String {
3032    let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3033    let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3034    let now = match dimension {
3035        Dimension::Rows => sheet.and_then(|s| s.row_count),
3036        Dimension::Columns => sheet.and_then(|s| s.column_count),
3037    }
3038    .and_then(|before| before.checked_sub(count))
3039    .map_or_else(String::new, |after| {
3040        format!(" ({after} {} now)", plural(dimension))
3041    });
3042    format!(
3043        "Deleted {count} {noun}(s) {range} of '{from}'{id} in {book}{now}; {recovery}",
3044        noun = dimension.noun(),
3045    )
3046}
3047
3048/// The `DeleteRange` arm of [`describe_changed`].
3049#[allow(clippy::too_many_arguments)]
3050fn describe_deleted_range(
3051    from: &str,
3052    id: &str,
3053    start_row: i64,
3054    end_row: i64,
3055    start_column: i64,
3056    end_column: i64,
3057    shift: ShiftDimension,
3058    book: &str,
3059    recovery: &str,
3060) -> String {
3061    let direction = shift_word(shift.into(), false);
3062    format!(
3063        "Deleted rows {start_row}-{end_row}, columns {start_column}-{end_column} of '{from}'{id} \
3064         in {book}, shifted remaining cells {direction}; {recovery}"
3065    )
3066}
3067
3068/// The `InsertRange` arm of [`describe_changed`].
3069#[allow(clippy::too_many_arguments)]
3070fn describe_inserted_range(
3071    from: &str,
3072    id: &str,
3073    start_row: i64,
3074    end_row: i64,
3075    start_column: i64,
3076    end_column: i64,
3077    shift: ShiftDimension,
3078    book: &str,
3079) -> String {
3080    let direction = shift_word(shift.into(), true);
3081    format!(
3082        "Inserted empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
3083         of '{from}'{id} in {book}, shifting existing cells {direction}; \
3084         {INSERT_RANGE_EDGE_CAVEAT}"
3085    )
3086}
3087
3088const fn plural(dimension: Dimension) -> &'static str {
3089    match dimension {
3090        Dimension::Rows => "rows",
3091        Dimension::Columns => "columns",
3092    }
3093}
3094
3095/// The direction word for a shift along `dimension`: `forward` is the
3096/// direction rows/columns move when something is inserted or a block moves
3097/// upward past them (`down`/`right`); the other way (a downward move's
3098/// displaced rows/columns closing the gap, or a deletion closing the gap
3099/// left behind) is `!forward` (`up`/`left`). Shared by every `describe_*`
3100/// preview/post-execution message in this module (insert, move, delete
3101/// dimension, delete/insert range) so the direction vocabulary can't drift
3102/// apart between them.
3103const fn shift_word(dimension: Dimension, forward: bool) -> &'static str {
3104    match (dimension, forward) {
3105        (Dimension::Rows, true) => "down",
3106        (Dimension::Rows, false) => "up",
3107        (Dimension::Columns, true) => "right",
3108        (Dimension::Columns, false) => "left",
3109    }
3110}
3111
3112#[cfg(test)]
3113#[allow(clippy::unwrap_used, clippy::expect_used)]
3114mod tests {
3115    use super::*;
3116    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
3117    use crate::drive::sheets::client::SHEETS_API_URL;
3118    use crate::drive::test_support::{seed_lease, seed_lease_with_backup};
3119    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
3120    use crate::drive::write_gate::Verdict;
3121    use crate::test_support::env::MapEnv;
3122    use crate::utils::secret::Secret;
3123    use std::collections::HashSet;
3124
3125    fn test_credentials() -> DriveCredentials {
3126        DriveCredentials {
3127            client_id: "client-1".to_string(),
3128            client_secret: Secret::new("secret-1"),
3129            refresh_token: Secret::new("refresh-1"),
3130            scope: DriveGrantedScopes::READONLY,
3131        }
3132    }
3133
3134    /// Both clients against one wiremock server, sharing an OAuth session.
3135    ///
3136    /// `replace_session` must run before the derive: it swaps the Drive
3137    /// client's whole transport, so deriving first would leave the Sheets
3138    /// client pointed at the real `oauth2.googleapis.com`.
3139    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
3140        wiremock::Mock::given(wiremock::matchers::method("POST"))
3141            .and(wiremock::matchers::path("/token"))
3142            .respond_with(
3143                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3144                    "access_token": "test-token", "expires_in": 3600,
3145                })),
3146            )
3147            .mount(server)
3148            .await;
3149        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
3150        crate::drive::client::test_support::replace_session(
3151            &mut drive,
3152            &test_credentials(),
3153            &format!("{}/token", server.uri()),
3154        );
3155        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
3156        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
3157        (drive, sheets)
3158    }
3159
3160    /// `version: "1"` throughout — matches [`opts`]'s default seeded
3161    /// lease, so any test reaching the mutating call has a live, non-stale
3162    /// lease by construction (ADR-0080 §9).
3163    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
3164        let parents: Vec<&str> = parents.to_vec();
3165        wiremock::Mock::given(wiremock::matchers::method("GET"))
3166            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
3167            .respond_with(
3168                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3169                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
3170                    "version": "1",
3171                })),
3172            )
3173    }
3174
3175    fn mount_folder(id: &str) -> wiremock::Mock {
3176        mount_file(id, "application/vnd.google-apps.folder", &[])
3177    }
3178
3179    fn allow_rule(folder: &str) -> FolderPermissionRule {
3180        FolderPermissionRule {
3181            folder_id: Some(folder.to_string()),
3182            file_id: None,
3183            recursive: true,
3184            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3185            deny: HashSet::default(),
3186            require_lease: true,
3187        }
3188    }
3189
3190    /// The `spreadsheets.get` reply body shared by [`mount_workbook`] and
3191    /// its variants: `Q1` (1000 x 26) fixed, plus whatever `Q2` object the
3192    /// caller supplies — the one thing every variant actually varies.
3193    fn workbook_body(q2: serde_json::Value) -> serde_json::Value {
3194        serde_json::json!({
3195            "spreadsheetId": "sheet-1",
3196            "properties": {"title": "Budget"},
3197            "sheets": [
3198                {"properties": {
3199                    "sheetId": 0, "title": "Q1", "index": 0,
3200                    "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
3201                q2,
3202            ],
3203        })
3204    }
3205
3206    /// The `Q2` sheet object [`mount_workbook`] mounts: 500 x 10, no extra
3207    /// properties.
3208    fn default_q2() -> serde_json::Value {
3209        serde_json::json!({"properties": {
3210            "sheetId": 118_293, "title": "Q2", "index": 1,
3211            "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3212    }
3213
3214    /// [`default_q2`], but hidden — what `show-sheet` needs to have
3215    /// something to show.
3216    fn hidden_q2() -> serde_json::Value {
3217        serde_json::json!({"properties": {
3218            "sheetId": 118_293, "title": "Q2", "index": 1, "hidden": true,
3219            "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3220    }
3221
3222    /// A `spreadsheets.get` reply with two sheets, `Q1` (1000 x 26) and
3223    /// `Q2` (500 x 10).
3224    fn mount_workbook() -> wiremock::Mock {
3225        mount_workbook_with_q2(default_q2())
3226    }
3227
3228    fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
3229        wiremock::Mock::given(wiremock::matchers::method("POST"))
3230            .and(wiremock::matchers::path(
3231                "/v4/spreadsheets/sheet-1:batchUpdate",
3232            ))
3233            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
3234    }
3235
3236    /// Seeds a fresh, isolated ledger with a live lease for `"sheet-1"` at
3237    /// version `"1"` (matching [`mount_file`]'s default) and returns
3238    /// options carrying it. Every existing test built before the lease
3239    /// (ADR-0080 §9) reaches its mutating call this way by construction —
3240    /// see [`opts_for`]'s doc comment for why this doesn't need touching
3241    /// each test individually. A test exercising the lease *refusal* paths
3242    /// builds `StructureOptions` directly instead (see the
3243    /// "the Drive write lease" test section below).
3244    fn opts(verb: StructureVerb, dry_run: bool) -> StructureOptions {
3245        opts_for("sheet-1", verb, dry_run)
3246    }
3247
3248    /// [`opts`], generalised over the spreadsheet id.
3249    ///
3250    /// The backing ledger directory is deliberately leaked (`into_path`,
3251    /// not dropped) rather than threaded through every caller as a
3252    /// `tempfile::TempDir` binding: it must outlive the `.await` on
3253    /// `structure(...)`, and plumbing that lifetime through several dozen
3254    /// pre-existing tests — none of which are *about* the lease — would
3255    /// dwarf the fix in size for no reviewing benefit. The leak costs a
3256    /// few bytes of `/tmp` per test process, cleaned up by the OS.
3257    fn opts_for(spreadsheet_id: &str, verb: StructureVerb, dry_run: bool) -> StructureOptions {
3258        let ledger_path = tempfile::tempdir()
3259            .unwrap()
3260            .keep()
3261            .join("lease-ledger.jsonl");
3262        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
3263        StructureOptions {
3264            spreadsheet_id: spreadsheet_id.to_string(),
3265            verb,
3266            dry_run,
3267            lease_token: Some(token),
3268            ledger_path,
3269        }
3270    }
3271
3272    fn rename() -> StructureVerb {
3273        StructureVerb::RenameSheet {
3274            sheet: "Q2".to_string(),
3275            new_title: "Q3".to_string(),
3276        }
3277    }
3278
3279    fn insert_rows() -> StructureVerb {
3280        StructureVerb::InsertRows {
3281            sheet: "Q2".to_string(),
3282            at: 5,
3283            count: 3,
3284        }
3285    }
3286
3287    /// Rows 2-3 of `Q2` moved to before row 6 — a downward move, the case
3288    /// where `--before`'s pre-move numbering and the block's landing
3289    /// position differ.
3290    fn move_rows() -> StructureVerb {
3291        StructureVerb::MoveRows {
3292            sheet: "Q2".to_string(),
3293            at: 2,
3294            count: 2,
3295            before: 6,
3296        }
3297    }
3298
3299    fn add_sheet() -> StructureVerb {
3300        StructureVerb::AddSheet {
3301            title: "Q3".to_string(),
3302            index: None,
3303            rows: None,
3304            columns: None,
3305        }
3306    }
3307
3308    fn delete_sheet() -> StructureVerb {
3309        StructureVerb::DeleteSheet {
3310            sheet: "Q2".to_string(),
3311        }
3312    }
3313
3314    fn delete_rows() -> StructureVerb {
3315        StructureVerb::DeleteRows {
3316            sheet: "Q2".to_string(),
3317            at: 5,
3318            count: 3,
3319        }
3320    }
3321
3322    fn delete_columns() -> StructureVerb {
3323        StructureVerb::DeleteColumns {
3324            sheet: "Q2".to_string(),
3325            at: 3,
3326            count: 2,
3327        }
3328    }
3329
3330    fn insert_range() -> StructureVerb {
3331        StructureVerb::InsertRange {
3332            sheet: "Q2".to_string(),
3333            start_row: 2,
3334            end_row: 4,
3335            start_column: 2,
3336            end_column: 3,
3337            shift: ShiftDimension::Rows,
3338        }
3339    }
3340
3341    /// [`insert_range`]'s twin with the other shift axis, so the `COLUMNS`
3342    /// wire value and "shift ... right" wording get exercised too — `Rows`
3343    /// is otherwise the only [`ShiftDimension`] any test ever names, mirroring
3344    /// [`delete_range_columns_shift`].
3345    fn insert_range_columns_shift() -> StructureVerb {
3346        StructureVerb::InsertRange {
3347            sheet: "Q2".to_string(),
3348            start_row: 2,
3349            end_row: 4,
3350            start_column: 2,
3351            end_column: 3,
3352            shift: ShiftDimension::Columns,
3353        }
3354    }
3355
3356    fn delete_range() -> StructureVerb {
3357        StructureVerb::DeleteRange {
3358            sheet: "Q2".to_string(),
3359            start_row: 2,
3360            end_row: 4,
3361            start_column: 2,
3362            end_column: 3,
3363            shift: ShiftDimension::Rows,
3364        }
3365    }
3366
3367    /// [`delete_range`]'s twin with the other shift axis, so the `COLUMNS`
3368    /// wire value and "shift ... left" wording get exercised too — `Rows` is
3369    /// otherwise the only [`ShiftDimension`] any test ever names.
3370    fn delete_range_columns_shift() -> StructureVerb {
3371        StructureVerb::DeleteRange {
3372            sheet: "Q2".to_string(),
3373            start_row: 2,
3374            end_row: 4,
3375            start_column: 2,
3376            end_column: 3,
3377            shift: ShiftDimension::Columns,
3378        }
3379    }
3380
3381    fn duplicate_sheet() -> StructureVerb {
3382        StructureVerb::DuplicateSheet {
3383            sheet: "Q2".to_string(),
3384            title: Some("Q2 Copy".to_string()),
3385            index: Some(1),
3386        }
3387    }
3388
3389    fn duplicate_sheet_default() -> StructureVerb {
3390        StructureVerb::DuplicateSheet {
3391            sheet: "Q2".to_string(),
3392            title: None,
3393            index: None,
3394        }
3395    }
3396
3397    fn reorder_sheet() -> StructureVerb {
3398        StructureVerb::ReorderSheet {
3399            sheet: "Q2".to_string(),
3400            index: 0,
3401        }
3402    }
3403
3404    fn hide_sheet() -> StructureVerb {
3405        StructureVerb::SetSheetVisibility {
3406            sheet: "Q2".to_string(),
3407            hidden: true,
3408        }
3409    }
3410
3411    fn show_sheet() -> StructureVerb {
3412        StructureVerb::SetSheetVisibility {
3413            sheet: "Q2".to_string(),
3414            hidden: false,
3415        }
3416    }
3417
3418    fn update_workbook_properties() -> StructureVerb {
3419        StructureVerb::UpdateWorkbookProperties {
3420            locale: Some("en_US".to_string()),
3421            time_zone: Some("America/New_York".to_string()),
3422            auto_recalc: Some(RecalculationInterval::OnChange),
3423            iterative_calculation: None,
3424            iterative_calculation_max_iterations: None,
3425            iterative_calculation_convergence_threshold: None,
3426        }
3427    }
3428
3429    fn delete_allow_rule(folder: &str) -> FolderPermissionRule {
3430        FolderPermissionRule {
3431            folder_id: Some(folder.to_string()),
3432            file_id: None,
3433            recursive: true,
3434            allow: std::iter::once(DriveOperation::SheetsDelete).collect(),
3435            deny: HashSet::default(),
3436            require_lease: true,
3437        }
3438    }
3439
3440    // ── the safety property this module now enforces ───────────────────
3441    //
3442    // ADR-0075 §3/§4 kept every destructive `batchUpdate` request
3443    // unreachable at the type level, pinned by a
3444    // `no_destructive_request_is_reachable` grep-guard test. ADR-0077
3445    // (issue #1623) supersedes that: the requests are now reachable, but
3446    // only through `StructureVerb::gate_operation` routing them to
3447    // `DriveOperation::SheetsDelete` — never `SheetsStructure`, and never
3448    // through a raw passthrough (there still isn't one). The tests below are
3449    // what replace the old grep-guard: they pin the *new* invariant instead
3450    // of the old absence.
3451
3452    #[test]
3453    fn every_delete_verb_gates_on_sheets_delete_not_sheets_structure() {
3454        for verb in [
3455            StructureVerb::DeleteSheet {
3456                sheet: "Q1".to_string(),
3457            },
3458            StructureVerb::DeleteRows {
3459                sheet: "Q1".to_string(),
3460                at: 1,
3461                count: 1,
3462            },
3463            StructureVerb::DeleteColumns {
3464                sheet: "Q1".to_string(),
3465                at: 1,
3466                count: 1,
3467            },
3468            StructureVerb::DeleteRange {
3469                sheet: "Q1".to_string(),
3470                start_row: 1,
3471                end_row: 2,
3472                start_column: 1,
3473                end_column: 2,
3474                shift: ShiftDimension::Rows,
3475            },
3476        ] {
3477            assert_eq!(verb.gate_operation(), DriveOperation::SheetsDelete);
3478        }
3479    }
3480
3481    #[test]
3482    fn every_additive_verb_still_gates_on_sheets_structure() {
3483        for verb in [
3484            add_sheet(),
3485            StructureVerb::RenameSheet {
3486                sheet: "Q1".to_string(),
3487                new_title: "Q2".to_string(),
3488            },
3489            StructureVerb::InsertRows {
3490                sheet: "Q1".to_string(),
3491                at: 1,
3492                count: 1,
3493            },
3494            StructureVerb::InsertColumns {
3495                sheet: "Q1".to_string(),
3496                at: 1,
3497                count: 1,
3498            },
3499            StructureVerb::InsertRange {
3500                sheet: "Q1".to_string(),
3501                start_row: 1,
3502                end_row: 2,
3503                start_column: 1,
3504                end_column: 2,
3505                shift: ShiftDimension::Rows,
3506            },
3507            StructureVerb::MoveRows {
3508                sheet: "Q1".to_string(),
3509                at: 1,
3510                count: 1,
3511                before: 3,
3512            },
3513            StructureVerb::MoveColumns {
3514                sheet: "Q1".to_string(),
3515                at: 1,
3516                count: 1,
3517                before: 3,
3518            },
3519            StructureVerb::DuplicateSheet {
3520                sheet: "Q1".to_string(),
3521                title: None,
3522                index: None,
3523            },
3524            StructureVerb::ReorderSheet {
3525                sheet: "Q1".to_string(),
3526                index: 0,
3527            },
3528            StructureVerb::SetSheetVisibility {
3529                sheet: "Q1".to_string(),
3530                hidden: true,
3531            },
3532            StructureVerb::UpdateSheetProperties {
3533                sheet: "Q1".to_string(),
3534                freeze_rows: None,
3535                freeze_columns: None,
3536                tab_color: None,
3537                clear_tab_color: false,
3538                right_to_left: Some(true),
3539                hide_gridlines: None,
3540            },
3541            StructureVerb::UpdateWorkbookProperties {
3542                locale: Some("en_US".to_string()),
3543                time_zone: None,
3544                auto_recalc: None,
3545                iterative_calculation: None,
3546                iterative_calculation_max_iterations: None,
3547                iterative_calculation_convergence_threshold: None,
3548            },
3549        ] {
3550            assert_eq!(verb.gate_operation(), DriveOperation::SheetsStructure);
3551        }
3552    }
3553
3554    // ── the 1-based/0-based conversion, the obvious off-by-one ─────────
3555
3556    #[test]
3557    fn at_is_one_based_inclusive_and_becomes_zero_based_half_open() {
3558        // "insert 3 rows before row 5" == indices [4, 7).
3559        let range = dimension_range(7, Dimension::Rows, 5, 3);
3560        assert_eq!(range.start_index, 4);
3561        assert_eq!(range.end_index, 7);
3562        assert_eq!(range.sheet_id, 7);
3563        assert_eq!(range.dimension, Dimension::Rows);
3564    }
3565
3566    #[test]
3567    fn inserting_at_row_one_starts_at_index_zero() {
3568        // The boundary case, and the reason `inherit_from_before` is fixed
3569        // at false: the API rejects `true` when start_index is 0.
3570        let range = dimension_range(0, Dimension::Rows, 1, 1);
3571        assert_eq!(range.start_index, 0);
3572        assert_eq!(range.end_index, 1);
3573    }
3574
3575    #[test]
3576    fn a_single_insert_spans_exactly_one_index() {
3577        let range = dimension_range(0, Dimension::Columns, 3, 1);
3578        assert_eq!((range.start_index, range.end_index), (2, 3));
3579    }
3580
3581    #[test]
3582    fn dimension_range_label_is_one_based_inclusive_like_the_flag() {
3583        // Insert 3 rows at row 5 => rows 5, 6, 7 are the new ones.
3584        assert_eq!(
3585            dimension_range_label(&insert_rows()).as_deref(),
3586            Some("ROWS 5:7")
3587        );
3588        assert_eq!(
3589            dimension_range_label(&StructureVerb::InsertColumns {
3590                sheet: "Q2".to_string(),
3591                at: 2,
3592                count: 1,
3593            })
3594            .as_deref(),
3595            Some("COLUMNS 2:2")
3596        );
3597        // Verbs that span no dimension record none.
3598        assert_eq!(dimension_range_label(&rename()), None);
3599        assert_eq!(dimension_range_label(&add_sheet()), None);
3600    }
3601
3602    #[test]
3603    fn dimension_range_label_omits_a_span_it_cannot_compute() {
3604        // `record_attempt` logs attempts, refused ones included, so this is
3605        // reached with arguments no validation let through. An uncomputable
3606        // span is omitted rather than recorded inverted — the omit-if-absent
3607        // log key already expresses "no span", and the refusal's own detail
3608        // carries the numbers.
3609        for (at, count) in [(i64::MAX, 5), (1, i64::MIN), (-1, i64::MIN)] {
3610            assert_eq!(
3611                dimension_range_label(&StructureVerb::InsertRows {
3612                    sheet: "Q2".to_string(),
3613                    at,
3614                    count,
3615                }),
3616                None,
3617                "--at {at} --count {count} must not produce a span"
3618            );
3619        }
3620    }
3621
3622    // ── refusals that must precede the gate and the network ────────────
3623
3624    #[tokio::test]
3625    async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
3626        let server = wiremock::MockServer::start().await;
3627        let (drive, sheets) = clients(&server).await;
3628        mount_file("sheet-1", "application/pdf", &["parent-1"])
3629            .mount(&server)
3630            .await;
3631        // No mock for parent-1 (the gate never runs) and none for any Sheets
3632        // endpoint, even though the rule below would otherwise permit this.
3633        let outcome = structure(
3634            &drive,
3635            &sheets,
3636            &opts(rename(), false),
3637            &[allow_rule("parent-1")],
3638        )
3639        .await;
3640        assert!(matches!(
3641            outcome.result,
3642            StructureResult::RefusedNotASpreadsheet { .. }
3643        ));
3644        let text = describe(&outcome);
3645        assert!(text.contains("is not a Google Sheet"), "{text}");
3646        assert!(text.contains("drive sheets rename-sheet"), "{text}");
3647    }
3648
3649    #[tokio::test]
3650    async fn shortcut_is_refused_with_its_own_message() {
3651        let server = wiremock::MockServer::start().await;
3652        let (drive, sheets) = clients(&server).await;
3653        mount_file(
3654            "sheet-1",
3655            "application/vnd.google-apps.shortcut",
3656            &["parent-1"],
3657        )
3658        .mount(&server)
3659        .await;
3660        let outcome = structure(
3661            &drive,
3662            &sheets,
3663            &opts(rename(), false),
3664            &[allow_rule("parent-1")],
3665        )
3666        .await;
3667        assert!(matches!(outcome.result, StructureResult::RefusedShortcut));
3668        let text = describe(&outcome);
3669        assert!(text.contains("is a shortcut"), "{text}");
3670    }
3671
3672    #[tokio::test]
3673    async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_blocked() {
3674        let server = wiremock::MockServer::start().await;
3675        let (drive, sheets) = clients(&server).await;
3676        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3677            .mount(&server)
3678            .await;
3679        let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3680        assert!(matches!(
3681            outcome.result,
3682            StructureResult::RefusedNoVisibleParents
3683        ));
3684        let text = describe(&outcome);
3685        assert!(text.contains("no parent folder visible"), "{text}");
3686        // Names the operation the operator would have to grant, so the
3687        // message is actionable rather than merely accurate.
3688        assert!(text.contains("sheets-structure"), "{text}");
3689    }
3690
3691    /// The same message for a delete verb must name `sheets-delete`, not
3692    /// `sheets-structure` — suggesting the wrong operation here would send an
3693    /// operator to grant a capability that does not cover what they asked
3694    /// for.
3695    #[tokio::test]
3696    async fn a_delete_verbs_no_visible_parents_message_names_sheets_delete() {
3697        let server = wiremock::MockServer::start().await;
3698        let (drive, sheets) = clients(&server).await;
3699        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3700            .mount(&server)
3701            .await;
3702        let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3703        let text = describe(&outcome);
3704        assert!(text.contains("sheets-delete"), "{text}");
3705        assert!(!text.contains("sheets-structure"), "{text}");
3706    }
3707
3708    // ── the gate ───────────────────────────────────────────────────────
3709
3710    #[tokio::test]
3711    async fn denied_target_makes_zero_sheets_calls() {
3712        let server = wiremock::MockServer::start().await;
3713        let (drive, sheets) = clients(&server).await;
3714        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3715            .mount(&server)
3716            .await;
3717        mount_folder("parent-1").mount(&server).await;
3718        // Deliberately no `spreadsheets.get` mock and no `batchUpdate` mock:
3719        // a blocked attempt must not reach the Sheets API at all, which is
3720        // what keeps a refusal exactly as auditable as a success.
3721        let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3722        assert!(matches!(
3723            outcome.result,
3724            StructureResult::Blocked { decided_by: None }
3725        ));
3726        let text = describe(&outcome);
3727        assert!(text.contains("default policy"), "{text}");
3728    }
3729
3730    #[tokio::test]
3731    async fn a_blocked_delete_names_destructive_edits_and_sheets_delete() {
3732        let server = wiremock::MockServer::start().await;
3733        let (drive, sheets) = clients(&server).await;
3734        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3735            .mount(&server)
3736            .await;
3737        mount_folder("parent-1").mount(&server).await;
3738        let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3739        assert!(matches!(
3740            outcome.result,
3741            StructureResult::Blocked { decided_by: None }
3742        ));
3743        let text = describe(&outcome);
3744        assert!(text.contains("destructive edits"), "{text}");
3745        assert!(text.contains("default policy"), "{text}");
3746        assert!(text.contains("sheets-delete"), "{text}");
3747        assert!(!text.contains("structural edits"), "{text}");
3748    }
3749
3750    #[tokio::test]
3751    async fn a_sheets_write_rule_alone_does_not_permit_a_structural_edit() {
3752        let server = wiremock::MockServer::start().await;
3753        let (drive, sheets) = clients(&server).await;
3754        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3755            .mount(&server)
3756            .await;
3757        mount_folder("parent-1").mount(&server).await;
3758        // The non-widening property, observed end to end rather than only in
3759        // the gate's unit tests: a folder granted cell writes must not gain
3760        // the power to restructure the workbook.
3761        let rules = [FolderPermissionRule {
3762            folder_id: Some("parent-1".to_string()),
3763            file_id: None,
3764            recursive: true,
3765            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
3766            deny: HashSet::default(),
3767            require_lease: true,
3768        }];
3769        let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3770        assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3771    }
3772
3773    /// The property ADR-0077 exists to protect: a folder granted
3774    /// `sheets-structure` before deletion existed must not silently gain the
3775    /// power to destroy data now that it does.
3776    #[tokio::test]
3777    async fn a_sheets_structure_rule_alone_does_not_permit_deletion() {
3778        let server = wiremock::MockServer::start().await;
3779        let (drive, sheets) = clients(&server).await;
3780        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3781            .mount(&server)
3782            .await;
3783        mount_folder("parent-1").mount(&server).await;
3784        let rules = [allow_rule("parent-1")]; // grants SheetsStructure only
3785        for verb in [delete_sheet(), delete_rows(), delete_range()] {
3786            let outcome = structure(&drive, &sheets, &opts(verb, false), &rules).await;
3787            assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3788        }
3789    }
3790
3791    /// The converse: `sheets-delete` must not grant the additive verbs
3792    /// either — the two operations are siblings, not a hierarchy.
3793    #[tokio::test]
3794    async fn a_sheets_delete_rule_alone_does_not_permit_a_structural_edit() {
3795        let server = wiremock::MockServer::start().await;
3796        let (drive, sheets) = clients(&server).await;
3797        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3798            .mount(&server)
3799            .await;
3800        mount_folder("parent-1").mount(&server).await;
3801        let rules = [delete_allow_rule("parent-1")];
3802        let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3803        assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3804    }
3805
3806    #[tokio::test]
3807    async fn an_explicit_deny_is_reported_with_the_deciding_rule() {
3808        let server = wiremock::MockServer::start().await;
3809        let (drive, sheets) = clients(&server).await;
3810        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3811            .mount(&server)
3812            .await;
3813        mount_folder("parent-1").mount(&server).await;
3814        let rules = [FolderPermissionRule {
3815            folder_id: Some("parent-1".to_string()),
3816            file_id: None,
3817            recursive: true,
3818            allow: HashSet::default(),
3819            deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
3820            require_lease: true,
3821        }];
3822        let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3823        let StructureResult::Blocked { decided_by } = &outcome.result else {
3824            panic!("expected Blocked, got {:?}", outcome.result);
3825        };
3826        let rule = decided_by.as_ref().expect("an explicit rule decided this");
3827        assert_eq!(rule.kind_label(), "folder");
3828        assert_eq!(rule.id(), "parent-1");
3829        assert_eq!(rule.depth_suffix(), " (depth 0)");
3830    }
3831
3832    #[tokio::test]
3833    async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
3834        let server = wiremock::MockServer::start().await;
3835        let (drive, sheets) = clients(&server).await;
3836        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3837            .mount(&server)
3838            .await;
3839        wiremock::Mock::given(wiremock::matchers::method("GET"))
3840            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
3841            .respond_with(wiremock::ResponseTemplate::new(500))
3842            .mount(&server)
3843            .await;
3844        let outcome = structure(
3845            &drive,
3846            &sheets,
3847            &opts(rename(), false),
3848            &[allow_rule("parent-1")],
3849        )
3850        .await;
3851        assert!(matches!(outcome.result, StructureResult::Failed { .. }));
3852    }
3853
3854    // ── --dry-run ──────────────────────────────────────────────────────
3855
3856    /// A dry run must never issue `batchUpdate`.
3857    ///
3858    /// It *does* issue one `spreadsheets.get` — deliberately, and unlike
3859    /// `sheets write`'s dry run, which touches Sheets not at all. Describing
3860    /// a structural effect honestly needs the sheet's real dimensions, and a
3861    /// read is not a mutation. The absent `batchUpdate` mock is what proves
3862    /// the distinction: wiremock fails an unmatched request.
3863    #[tokio::test]
3864    async fn dry_run_reads_the_workbook_but_never_calls_batch_update() {
3865        let server = wiremock::MockServer::start().await;
3866        let (drive, sheets) = clients(&server).await;
3867        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3868            .mount(&server)
3869            .await;
3870        mount_folder("parent-1").mount(&server).await;
3871        mount_workbook().expect(1).mount(&server).await;
3872        let outcome = structure(
3873            &drive,
3874            &sheets,
3875            &opts(insert_rows(), true),
3876            &[allow_rule("parent-1")],
3877        )
3878        .await;
3879        assert!(matches!(
3880            outcome.result,
3881            StructureResult::WouldChange { .. }
3882        ));
3883    }
3884
3885    #[tokio::test]
3886    async fn dry_run_names_the_resulting_dimensions_and_the_shift() {
3887        let server = wiremock::MockServer::start().await;
3888        let (drive, sheets) = clients(&server).await;
3889        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3890            .mount(&server)
3891            .await;
3892        mount_folder("parent-1").mount(&server).await;
3893        mount_workbook().mount(&server).await;
3894        let outcome = structure(
3895            &drive,
3896            &sheets,
3897            &opts(insert_rows(), true),
3898            &[allow_rule("parent-1")],
3899        )
3900        .await;
3901        let text = describe(&outcome);
3902        // The answer to "what does --dry-run show when the effect isn't a
3903        // range": the real before/after, plus the shift a range can't express.
3904        assert!(
3905            text.contains("Would insert 3 row(s) before row 5"),
3906            "{text}"
3907        );
3908        assert!(text.contains("500 rows -> 503"), "{text}");
3909        assert!(text.contains("shift down"), "{text}");
3910        assert!(text.contains("sheetId 118293"), "{text}");
3911    }
3912
3913    /// A destructive dry run must never issue `batchUpdate` either, and stays
3914    /// structural-only (ADR-0077): no `values.get`, no cell content, just the
3915    /// same single `spreadsheets.get` an additive dry run already issues.
3916    #[tokio::test]
3917    async fn dry_run_never_calls_batch_update_for_a_delete_verb() {
3918        let server = wiremock::MockServer::start().await;
3919        let (drive, sheets) = clients(&server).await;
3920        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3921            .mount(&server)
3922            .await;
3923        mount_folder("parent-1").mount(&server).await;
3924        mount_workbook().mount(&server).await;
3925        for verb in [delete_sheet(), delete_rows(), delete_range()] {
3926            let outcome = structure(
3927                &drive,
3928                &sheets,
3929                &opts(verb, true),
3930                &[delete_allow_rule("parent-1")],
3931            )
3932            .await;
3933            assert!(matches!(
3934                outcome.result,
3935                StructureResult::WouldChange { .. }
3936            ));
3937        }
3938    }
3939
3940    #[tokio::test]
3941    async fn dry_run_names_the_deleted_dimensions_and_the_shift_plus_the_caveat() {
3942        let server = wiremock::MockServer::start().await;
3943        let (drive, sheets) = clients(&server).await;
3944        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3945            .mount(&server)
3946            .await;
3947        mount_folder("parent-1").mount(&server).await;
3948        mount_workbook().mount(&server).await;
3949        let outcome = structure(
3950            &drive,
3951            &sheets,
3952            &opts(delete_rows(), true),
3953            &[delete_allow_rule("parent-1")],
3954        )
3955        .await;
3956        let text = describe(&outcome);
3957        assert!(text.contains("Would delete 3 row(s) 5-7"), "{text}");
3958        assert!(text.contains("500 rows -> 497"), "{text}");
3959        assert!(text.contains("shift up"), "{text}");
3960        assert!(
3961            text.contains("formulas elsewhere in the workbook"),
3962            "{text}"
3963        );
3964    }
3965
3966    #[tokio::test]
3967    async fn dry_run_names_the_deleted_sheet_and_the_caveat() {
3968        let server = wiremock::MockServer::start().await;
3969        let (drive, sheets) = clients(&server).await;
3970        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3971            .mount(&server)
3972            .await;
3973        mount_folder("parent-1").mount(&server).await;
3974        mount_workbook().mount(&server).await;
3975        let outcome = structure(
3976            &drive,
3977            &sheets,
3978            &opts(delete_sheet(), true),
3979            &[delete_allow_rule("parent-1")],
3980        )
3981        .await;
3982        let text = describe(&outcome);
3983        assert!(text.contains("Would delete sheet 'Q2'"), "{text}");
3984        assert!(text.contains("2 sheet(s) -> 1"), "{text}");
3985        assert!(
3986            text.contains("formulas elsewhere in the workbook"),
3987            "{text}"
3988        );
3989    }
3990
3991    #[tokio::test]
3992    async fn dry_run_names_the_deleted_range_and_the_shift() {
3993        let server = wiremock::MockServer::start().await;
3994        let (drive, sheets) = clients(&server).await;
3995        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3996            .mount(&server)
3997            .await;
3998        mount_folder("parent-1").mount(&server).await;
3999        mount_workbook().mount(&server).await;
4000        let outcome = structure(
4001            &drive,
4002            &sheets,
4003            &opts(delete_range(), true),
4004            &[delete_allow_rule("parent-1")],
4005        )
4006        .await;
4007        let text = describe(&outcome);
4008        assert!(
4009            text.contains("Would delete rows 2-4, columns 2-3"),
4010            "{text}"
4011        );
4012        assert!(text.contains("shifting remaining cells up"), "{text}");
4013    }
4014
4015    #[tokio::test]
4016    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
4017        let server = wiremock::MockServer::start().await;
4018        let (drive, sheets) = clients(&server).await;
4019        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4020            .mount(&server)
4021            .await;
4022        mount_folder("parent-1").mount(&server).await;
4023        // The gate precedes the dry-run early return, so a preview of a
4024        // denied edit reports the denial rather than a rosy preview.
4025        let dry = structure(&drive, &sheets, &opts(rename(), true), &[]).await;
4026        let wet = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
4027        assert_eq!(dry.result, wet.result);
4028        assert!(matches!(dry.result, StructureResult::Blocked { .. }));
4029    }
4030
4031    // ── resolving the target sheet ─────────────────────────────────────
4032
4033    #[tokio::test]
4034    async fn a_missing_sheet_is_refused_and_lists_what_does_exist() {
4035        let server = wiremock::MockServer::start().await;
4036        let (drive, sheets) = clients(&server).await;
4037        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4038            .mount(&server)
4039            .await;
4040        mount_folder("parent-1").mount(&server).await;
4041        mount_workbook().mount(&server).await;
4042        let verb = StructureVerb::RenameSheet {
4043            sheet: "Nope".to_string(),
4044            new_title: "Q3".to_string(),
4045        };
4046        let outcome = structure(
4047            &drive,
4048            &sheets,
4049            &opts(verb.clone(), false),
4050            &[allow_rule("parent-1")],
4051        )
4052        .await;
4053        assert!(matches!(
4054            outcome.result,
4055            StructureResult::RefusedSheetNotFound { .. }
4056        ));
4057        let text = describe(&outcome);
4058        assert!(text.contains("no sheet titled 'Nope'"), "{text}");
4059        assert!(text.contains("'Q1'"), "{text}");
4060        assert!(text.contains("'Q2'"), "{text}");
4061    }
4062
4063    /// A duplicate title is caught before `batchUpdate` rather than left to
4064    /// the server, so a dry run cannot promise a change the real run fails.
4065    #[tokio::test]
4066    async fn add_sheet_refuses_a_title_the_workbook_already_uses() {
4067        let server = wiremock::MockServer::start().await;
4068        let (drive, sheets) = clients(&server).await;
4069        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4070            .mount(&server)
4071            .await;
4072        mount_folder("parent-1").mount(&server).await;
4073        mount_workbook().mount(&server).await;
4074        let verb = StructureVerb::AddSheet {
4075            title: "Q1".to_string(),
4076            index: None,
4077            rows: None,
4078            columns: None,
4079        };
4080        // No batchUpdate mock: the refusal must short-circuit the mutation.
4081        let outcome = structure(
4082            &drive,
4083            &sheets,
4084            &opts(verb.clone(), false),
4085            &[allow_rule("parent-1")],
4086        )
4087        .await;
4088        assert!(matches!(
4089            outcome.result,
4090            StructureResult::RefusedSheetExists { .. }
4091        ));
4092        assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4093    }
4094
4095    /// The rename analogue of the `add-sheet` test above: renaming to a
4096    /// title a *different* sheet already has must be caught before
4097    /// `batchUpdate`, not left to a dry run that then can't be trusted.
4098    #[tokio::test]
4099    async fn rename_sheet_refuses_a_title_another_sheet_already_uses() {
4100        let server = wiremock::MockServer::start().await;
4101        let (drive, sheets) = clients(&server).await;
4102        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4103            .mount(&server)
4104            .await;
4105        mount_folder("parent-1").mount(&server).await;
4106        mount_workbook().mount(&server).await;
4107        let verb = StructureVerb::RenameSheet {
4108            sheet: "Q2".to_string(),
4109            new_title: "Q1".to_string(),
4110        };
4111        // No batchUpdate mock: the refusal must short-circuit the mutation,
4112        // and the same must hold for a --dry-run preview of it.
4113        let outcome = structure(
4114            &drive,
4115            &sheets,
4116            &opts(verb.clone(), false),
4117            &[allow_rule("parent-1")],
4118        )
4119        .await;
4120        assert!(matches!(
4121            outcome.result,
4122            StructureResult::RefusedSheetExists { .. }
4123        ));
4124        assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4125
4126        let dry = structure(
4127            &drive,
4128            &sheets,
4129            &opts(verb.clone(), true),
4130            &[allow_rule("parent-1")],
4131        )
4132        .await;
4133        assert_eq!(dry.result, outcome.result);
4134    }
4135
4136    /// Renaming a sheet to the title it already has names itself, not a
4137    /// different sheet, so it must not be refused as a duplicate.
4138    #[tokio::test]
4139    async fn rename_sheet_to_its_own_current_title_is_not_a_duplicate() {
4140        let server = wiremock::MockServer::start().await;
4141        let (drive, sheets) = clients(&server).await;
4142        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4143            .mount(&server)
4144            .await;
4145        mount_folder("parent-1").mount(&server).await;
4146        mount_workbook().mount(&server).await;
4147        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4148            .mount(&server)
4149            .await;
4150        let verb = StructureVerb::RenameSheet {
4151            sheet: "Q2".to_string(),
4152            new_title: "Q2".to_string(),
4153        };
4154        let outcome = structure(
4155            &drive,
4156            &sheets,
4157            &opts(verb, false),
4158            &[allow_rule("parent-1")],
4159        )
4160        .await;
4161        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4162    }
4163
4164    // ── argument validation ──────────────────────────────────────────────
4165
4166    #[tokio::test]
4167    async fn insert_rows_refuses_a_count_below_one() {
4168        let server = wiremock::MockServer::start().await;
4169        let (drive, sheets) = clients(&server).await;
4170        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4171            .mount(&server)
4172            .await;
4173        mount_folder("parent-1").mount(&server).await;
4174        mount_workbook().mount(&server).await;
4175        let verb = StructureVerb::InsertRows {
4176            sheet: "Q2".to_string(),
4177            at: 5,
4178            count: 0,
4179        };
4180        // No batchUpdate mock: the refusal must short-circuit the mutation.
4181        let outcome = structure(
4182            &drive,
4183            &sheets,
4184            &opts(verb.clone(), false),
4185            &[allow_rule("parent-1")],
4186        )
4187        .await;
4188        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4189            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4190        };
4191        assert!(detail.contains("--count"), "{detail}");
4192        assert!(describe(&outcome).starts_with("Refused: --count"));
4193    }
4194
4195    #[tokio::test]
4196    async fn insert_columns_refuses_an_at_below_one() {
4197        let server = wiremock::MockServer::start().await;
4198        let (drive, sheets) = clients(&server).await;
4199        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4200            .mount(&server)
4201            .await;
4202        mount_folder("parent-1").mount(&server).await;
4203        mount_workbook().mount(&server).await;
4204        let verb = StructureVerb::InsertColumns {
4205            sheet: "Q2".to_string(),
4206            at: 0,
4207            count: 1,
4208        };
4209        let outcome = structure(
4210            &drive,
4211            &sheets,
4212            &opts(verb, false),
4213            &[allow_rule("parent-1")],
4214        )
4215        .await;
4216        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4217            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4218        };
4219        assert!(detail.contains("--at"), "{detail}");
4220    }
4221
4222    /// `Q2` has 500 rows (from `mount_workbook`); `--at 502` names a
4223    /// position that does not exist and is not immediately after one that
4224    /// does, so it must be refused rather than sent to `batchUpdate` or
4225    /// promised by a `--dry-run`.
4226    #[tokio::test]
4227    async fn insert_rows_refuses_an_at_past_the_sheets_end() {
4228        let server = wiremock::MockServer::start().await;
4229        let (drive, sheets) = clients(&server).await;
4230        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4231            .mount(&server)
4232            .await;
4233        mount_folder("parent-1").mount(&server).await;
4234        mount_workbook().mount(&server).await;
4235        let verb = StructureVerb::InsertRows {
4236            sheet: "Q2".to_string(),
4237            at: 502,
4238            count: 1,
4239        };
4240        let outcome = structure(
4241            &drive,
4242            &sheets,
4243            &opts(verb.clone(), false),
4244            &[allow_rule("parent-1")],
4245        )
4246        .await;
4247        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4248            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4249        };
4250        assert!(detail.contains("500 row(s)"), "{detail}");
4251        assert!(detail.contains("501"), "{detail}");
4252
4253        let dry = structure(
4254            &drive,
4255            &sheets,
4256            &opts(verb, true),
4257            &[allow_rule("parent-1")],
4258        )
4259        .await;
4260        assert_eq!(dry.result, outcome.result);
4261    }
4262
4263    #[test]
4264    fn an_at_and_count_that_would_overflow_the_index_space_are_refused() {
4265        let sheet = SheetSnapshot {
4266            sheet_id: Some(1),
4267            title: "Q2".to_string(),
4268            row_count: Some(500),
4269            column_count: Some(26),
4270            ..Default::default()
4271        };
4272        // Both halves of `at - 1 + count`, since either can be the one that
4273        // overflows: a huge `--at` (refused for being past the end too, but
4274        // only *after* this check) and a huge `--count` at a legal `--at`
4275        // (which nothing else bounds — see the comment on the guard).
4276        for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
4277            let Err(StructureResult::RefusedInvalidRange { detail }) =
4278                validate_insert_bounds(Dimension::Rows, at, count, Some(&sheet))
4279            else {
4280                panic!("expected RefusedInvalidRange for --at {at} --count {count}");
4281            };
4282            assert!(detail.contains("overflows the row index space"), "{detail}");
4283        }
4284    }
4285
4286    #[tokio::test]
4287    async fn a_refused_out_of_range_insert_still_records_without_panicking() {
4288        // The regression that matters for `dimension_range_label`: this runs
4289        // with `dry_run: false`, so `record_attempt` builds a log record from
4290        // the very arguments `validate_insert_bounds` just rejected. Computing
4291        // the span with plain arithmetic panics here rather than in the
4292        // mutation it refused to make.
4293        let server = wiremock::MockServer::start().await;
4294        let (drive, sheets) = clients(&server).await;
4295        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4296            .mount(&server)
4297            .await;
4298        mount_folder("parent-1").mount(&server).await;
4299        mount_workbook().mount(&server).await;
4300
4301        let outcome = structure(
4302            &drive,
4303            &sheets,
4304            &opts(
4305                StructureVerb::InsertRows {
4306                    sheet: "Q2".to_string(),
4307                    at: i64::MAX,
4308                    count: 5,
4309                },
4310                false,
4311            ),
4312            &[allow_rule("parent-1")],
4313        )
4314        .await;
4315        assert!(
4316            matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
4317            "{:?}",
4318            outcome.result
4319        );
4320    }
4321
4322    /// `--at` equal to `row_count + 1` is a legal append (insert after the
4323    /// last row), the one boundary value the check above must not reject —
4324    /// and the dry-run message must not print an inverted "existing rows
4325    /// 501-500 shift down".
4326    #[tokio::test]
4327    async fn insert_rows_allows_at_equal_to_row_count_plus_one_as_an_append() {
4328        let server = wiremock::MockServer::start().await;
4329        let (drive, sheets) = clients(&server).await;
4330        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4331            .mount(&server)
4332            .await;
4333        mount_folder("parent-1").mount(&server).await;
4334        mount_workbook().mount(&server).await;
4335        let verb = StructureVerb::InsertRows {
4336            sheet: "Q2".to_string(),
4337            at: 501,
4338            count: 2,
4339        };
4340        let outcome = structure(
4341            &drive,
4342            &sheets,
4343            &opts(verb.clone(), true),
4344            &[allow_rule("parent-1")],
4345        )
4346        .await;
4347        assert!(matches!(
4348            outcome.result,
4349            StructureResult::WouldChange { .. }
4350        ));
4351        let text = describe(&outcome);
4352        assert!(text.contains("500 rows -> 502"), "{text}");
4353        assert!(text.contains("appended at the end"), "{text}");
4354        assert!(!text.contains("501-500"), "{text}");
4355    }
4356
4357    #[tokio::test]
4358    async fn add_sheet_refuses_non_positive_rows_and_columns() {
4359        let server = wiremock::MockServer::start().await;
4360        let (drive, sheets) = clients(&server).await;
4361        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4362            .mount(&server)
4363            .await;
4364        mount_folder("parent-1").mount(&server).await;
4365        mount_workbook().mount(&server).await;
4366        let verb = StructureVerb::AddSheet {
4367            title: "Q3".to_string(),
4368            index: None,
4369            rows: Some(0),
4370            columns: None,
4371        };
4372        let outcome = structure(
4373            &drive,
4374            &sheets,
4375            &opts(verb, false),
4376            &[allow_rule("parent-1")],
4377        )
4378        .await;
4379        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4380            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4381        };
4382        assert!(detail.contains("--rows"), "{detail}");
4383    }
4384
4385    /// `mount_workbook` has 2 sheets, so 0/1/2 are the only valid indices
4386    /// (2 means "append", the same as omitting `--index`).
4387    #[tokio::test]
4388    async fn add_sheet_refuses_an_out_of_range_index() {
4389        let server = wiremock::MockServer::start().await;
4390        let (drive, sheets) = clients(&server).await;
4391        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4392            .mount(&server)
4393            .await;
4394        mount_folder("parent-1").mount(&server).await;
4395        mount_workbook().mount(&server).await;
4396        let verb = StructureVerb::AddSheet {
4397            title: "Q3".to_string(),
4398            index: Some(3),
4399            rows: None,
4400            columns: None,
4401        };
4402        let outcome = structure(
4403            &drive,
4404            &sheets,
4405            &opts(verb, false),
4406            &[allow_rule("parent-1")],
4407        )
4408        .await;
4409        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4410            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4411        };
4412        assert!(detail.contains("--index"), "{detail}");
4413    }
4414
4415    #[tokio::test]
4416    async fn add_sheet_allows_an_index_equal_to_the_sheet_count() {
4417        let server = wiremock::MockServer::start().await;
4418        let (drive, sheets) = clients(&server).await;
4419        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4420            .mount(&server)
4421            .await;
4422        mount_folder("parent-1").mount(&server).await;
4423        mount_workbook().mount(&server).await;
4424        let verb = StructureVerb::AddSheet {
4425            title: "Q3".to_string(),
4426            index: Some(2),
4427            rows: None,
4428            columns: None,
4429        };
4430        let outcome = structure(
4431            &drive,
4432            &sheets,
4433            &opts(verb, true),
4434            &[allow_rule("parent-1")],
4435        )
4436        .await;
4437        assert!(matches!(
4438            outcome.result,
4439            StructureResult::WouldChange { .. }
4440        ));
4441    }
4442
4443    // ── the mutation ───────────────────────────────────────────────────
4444
4445    #[tokio::test]
4446    async fn rename_sends_a_title_masked_update_for_the_resolved_sheet_id() {
4447        let server = wiremock::MockServer::start().await;
4448        let (drive, sheets) = clients(&server).await;
4449        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4450            .mount(&server)
4451            .await;
4452        mount_folder("parent-1").mount(&server).await;
4453        mount_workbook().mount(&server).await;
4454        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4455            .expect(1)
4456            .mount(&server)
4457            .await;
4458        let outcome = structure(
4459            &drive,
4460            &sheets,
4461            &opts(rename(), false),
4462            &[allow_rule("parent-1")],
4463        )
4464        .await;
4465        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4466
4467        let requests = server.received_requests().await.unwrap();
4468        let body: serde_json::Value = requests
4469            .iter()
4470            .find(|r| r.url.path().ends_with(":batchUpdate"))
4471            .map(|r| serde_json::from_slice(&r.body).unwrap())
4472            .expect("a batchUpdate request");
4473        let update = &body["requests"][0]["updateSheetProperties"];
4474        // Resolved by title to the numeric id the API addresses.
4475        assert_eq!(update["properties"]["sheetId"], 118_293);
4476        assert_eq!(update["properties"]["title"], "Q3");
4477        // The mask names exactly the one field we set; a wider mask would
4478        // blank every property it named but we left unpopulated.
4479        assert_eq!(update["fields"], "title");
4480        assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4481    }
4482
4483    #[tokio::test]
4484    async fn insert_rows_sends_a_zero_based_half_open_range() {
4485        let server = wiremock::MockServer::start().await;
4486        let (drive, sheets) = clients(&server).await;
4487        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4488            .mount(&server)
4489            .await;
4490        mount_folder("parent-1").mount(&server).await;
4491        mount_workbook().mount(&server).await;
4492        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4493            .mount(&server)
4494            .await;
4495        let outcome = structure(
4496            &drive,
4497            &sheets,
4498            &opts(insert_rows(), false),
4499            &[allow_rule("parent-1")],
4500        )
4501        .await;
4502        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4503
4504        let requests = server.received_requests().await.unwrap();
4505        let body: serde_json::Value = requests
4506            .iter()
4507            .find(|r| r.url.path().ends_with(":batchUpdate"))
4508            .map(|r| serde_json::from_slice(&r.body).unwrap())
4509            .expect("a batchUpdate request");
4510        let insert = &body["requests"][0]["insertDimension"];
4511        assert_eq!(insert["range"]["dimension"], "ROWS");
4512        assert_eq!(insert["range"]["sheetId"], 118_293);
4513        // `--at 5 --count 3` on the wire, 1-based inclusive to 0-based
4514        // half-open.
4515        assert_eq!(insert["range"]["startIndex"], 4);
4516        assert_eq!(insert["range"]["endIndex"], 7);
4517        assert_eq!(insert["inheritFromBefore"], false);
4518    }
4519
4520    #[tokio::test]
4521    async fn delete_sheet_sends_the_resolved_sheet_id() {
4522        let server = wiremock::MockServer::start().await;
4523        let (drive, sheets) = clients(&server).await;
4524        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4525            .mount(&server)
4526            .await;
4527        mount_folder("parent-1").mount(&server).await;
4528        mount_workbook().mount(&server).await;
4529        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4530            .expect(1)
4531            .mount(&server)
4532            .await;
4533        let outcome = structure(
4534            &drive,
4535            &sheets,
4536            &opts(delete_sheet(), false),
4537            &[delete_allow_rule("parent-1")],
4538        )
4539        .await;
4540        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4541        assert!(describe(&outcome).contains("Deleted sheet 'Q2'"));
4542        // `seed_lease` records a bytes backup, so the message names its
4543        // path; the Drive-copy and no-lease wordings have their own tests
4544        // in the lease section below.
4545        assert!(describe(&outcome).contains("backed the file up when it was acquired"));
4546        assert!(describe(&outcome).contains("/tmp/test-backup"));
4547        assert!(describe(&outcome).contains("Google Drive's own version history"));
4548
4549        let requests = server.received_requests().await.unwrap();
4550        let body: serde_json::Value = requests
4551            .iter()
4552            .find(|r| r.url.path().ends_with(":batchUpdate"))
4553            .map(|r| serde_json::from_slice(&r.body).unwrap())
4554            .expect("a batchUpdate request");
4555        assert_eq!(body["requests"][0]["deleteSheet"]["sheetId"], 118_293);
4556        assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4557    }
4558
4559    #[tokio::test]
4560    async fn delete_rows_sends_a_zero_based_half_open_range() {
4561        let server = wiremock::MockServer::start().await;
4562        let (drive, sheets) = clients(&server).await;
4563        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4564            .mount(&server)
4565            .await;
4566        mount_folder("parent-1").mount(&server).await;
4567        mount_workbook().mount(&server).await;
4568        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4569            .mount(&server)
4570            .await;
4571        let outcome = structure(
4572            &drive,
4573            &sheets,
4574            &opts(delete_rows(), false),
4575            &[delete_allow_rule("parent-1")],
4576        )
4577        .await;
4578        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4579
4580        let requests = server.received_requests().await.unwrap();
4581        let body: serde_json::Value = requests
4582            .iter()
4583            .find(|r| r.url.path().ends_with(":batchUpdate"))
4584            .map(|r| serde_json::from_slice(&r.body).unwrap())
4585            .expect("a batchUpdate request");
4586        let delete = &body["requests"][0]["deleteDimension"];
4587        assert_eq!(delete["range"]["dimension"], "ROWS");
4588        assert_eq!(delete["range"]["sheetId"], 118_293);
4589        // `--at 5 --count 3` on the wire, 1-based inclusive to 0-based
4590        // half-open — same conversion as insert, same site.
4591        assert_eq!(delete["range"]["startIndex"], 4);
4592        assert_eq!(delete["range"]["endIndex"], 7);
4593    }
4594
4595    #[tokio::test]
4596    async fn delete_columns_sends_a_zero_based_half_open_range() {
4597        let server = wiremock::MockServer::start().await;
4598        let (drive, sheets) = clients(&server).await;
4599        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4600            .mount(&server)
4601            .await;
4602        mount_folder("parent-1").mount(&server).await;
4603        mount_workbook().mount(&server).await;
4604        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4605            .mount(&server)
4606            .await;
4607        let outcome = structure(
4608            &drive,
4609            &sheets,
4610            &opts(delete_columns(), false),
4611            &[delete_allow_rule("parent-1")],
4612        )
4613        .await;
4614        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4615        assert!(describe(&outcome).contains("Deleted 2 column(s) 3-4"));
4616
4617        let requests = server.received_requests().await.unwrap();
4618        let body: serde_json::Value = requests
4619            .iter()
4620            .find(|r| r.url.path().ends_with(":batchUpdate"))
4621            .map(|r| serde_json::from_slice(&r.body).unwrap())
4622            .expect("a batchUpdate request");
4623        let delete = &body["requests"][0]["deleteDimension"];
4624        assert_eq!(delete["range"]["dimension"], "COLUMNS");
4625        assert_eq!(delete["range"]["sheetId"], 118_293);
4626        // `--at 3 --count 2` on the wire, 1-based inclusive to 0-based
4627        // half-open.
4628        assert_eq!(delete["range"]["startIndex"], 2);
4629        assert_eq!(delete["range"]["endIndex"], 4);
4630    }
4631
4632    #[tokio::test]
4633    async fn insert_range_sends_a_zero_based_half_open_grid_range() {
4634        let server = wiremock::MockServer::start().await;
4635        let (drive, sheets) = clients(&server).await;
4636        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4637            .mount(&server)
4638            .await;
4639        mount_folder("parent-1").mount(&server).await;
4640        mount_workbook().mount(&server).await;
4641        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4642            .mount(&server)
4643            .await;
4644        let outcome = structure(
4645            &drive,
4646            &sheets,
4647            &opts(insert_range(), false),
4648            &[allow_rule("parent-1")],
4649        )
4650        .await;
4651        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4652        assert!(describe(&outcome).contains("shifting existing cells down"));
4653        assert!(describe(&outcome).contains("may have been dropped by Sheets"));
4654
4655        let requests = server.received_requests().await.unwrap();
4656        let body: serde_json::Value = requests
4657            .iter()
4658            .find(|r| r.url.path().ends_with(":batchUpdate"))
4659            .map(|r| serde_json::from_slice(&r.body).unwrap())
4660            .expect("a batchUpdate request");
4661        assert_eq!(
4662            body["requests"][0]["insertRange"],
4663            serde_json::json!({
4664                "range": {
4665                    "sheetId": 118_293,
4666                    "startRowIndex": 1,
4667                    "endRowIndex": 4,
4668                    "startColumnIndex": 1,
4669                    "endColumnIndex": 3,
4670                },
4671                "shiftDimension": "ROWS",
4672            })
4673        );
4674    }
4675
4676    #[tokio::test]
4677    async fn insert_range_with_columns_shift_sends_the_column_direction() {
4678        let server = wiremock::MockServer::start().await;
4679        let (drive, sheets) = clients(&server).await;
4680        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4681            .mount(&server)
4682            .await;
4683        mount_folder("parent-1").mount(&server).await;
4684        mount_workbook().mount(&server).await;
4685        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4686            .mount(&server)
4687            .await;
4688        let outcome = structure(
4689            &drive,
4690            &sheets,
4691            &opts(insert_range_columns_shift(), false),
4692            &[allow_rule("parent-1")],
4693        )
4694        .await;
4695        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4696        assert!(describe(&outcome).contains("shifting existing cells right"));
4697
4698        let requests = server.received_requests().await.unwrap();
4699        let body: serde_json::Value = requests
4700            .iter()
4701            .find(|r| r.url.path().ends_with(":batchUpdate"))
4702            .map(|r| serde_json::from_slice(&r.body).unwrap())
4703            .expect("a batchUpdate request");
4704        assert_eq!(
4705            body["requests"][0]["insertRange"]["shiftDimension"],
4706            "COLUMNS"
4707        );
4708    }
4709
4710    #[tokio::test]
4711    async fn insert_range_dry_run_at_the_grid_edge_warns_without_mutating() {
4712        let server = wiremock::MockServer::start().await;
4713        let (drive, sheets) = clients(&server).await;
4714        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4715            .mount(&server)
4716            .await;
4717        mount_folder("parent-1").mount(&server).await;
4718        mount_workbook().mount(&server).await;
4719        let outcome = structure(
4720            &drive,
4721            &sheets,
4722            &opts(
4723                StructureVerb::InsertRange {
4724                    sheet: "Q2".to_string(),
4725                    start_row: 500,
4726                    end_row: 500,
4727                    start_column: 10,
4728                    end_column: 10,
4729                    shift: ShiftDimension::Columns,
4730                },
4731                true,
4732            ),
4733            &[allow_rule("parent-1")],
4734        )
4735        .await;
4736        assert!(matches!(
4737            outcome.result,
4738            StructureResult::WouldChange { .. }
4739        ));
4740        let text = describe(&outcome);
4741        assert!(text.contains("shifting existing cells right"), "{text}");
4742        assert!(text.contains("may be dropped by Sheets"), "{text}");
4743        assert!(server
4744            .received_requests()
4745            .await
4746            .unwrap()
4747            .iter()
4748            .all(|request| !request.url.path().ends_with(":batchUpdate")));
4749    }
4750
4751    #[tokio::test]
4752    async fn insert_range_dry_run_with_rows_shift_warns_without_mutating() {
4753        let server = wiremock::MockServer::start().await;
4754        let (drive, sheets) = clients(&server).await;
4755        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4756            .mount(&server)
4757            .await;
4758        mount_folder("parent-1").mount(&server).await;
4759        mount_workbook().mount(&server).await;
4760        let outcome = structure(
4761            &drive,
4762            &sheets,
4763            &opts(
4764                StructureVerb::InsertRange {
4765                    sheet: "Q2".to_string(),
4766                    start_row: 500,
4767                    end_row: 500,
4768                    start_column: 10,
4769                    end_column: 10,
4770                    shift: ShiftDimension::Rows,
4771                },
4772                true,
4773            ),
4774            &[allow_rule("parent-1")],
4775        )
4776        .await;
4777        assert!(matches!(
4778            outcome.result,
4779            StructureResult::WouldChange { .. }
4780        ));
4781        let text = describe(&outcome);
4782        assert!(text.contains("shifting existing cells down"), "{text}");
4783        assert!(text.contains("may be dropped by Sheets"), "{text}");
4784        assert!(server
4785            .received_requests()
4786            .await
4787            .unwrap()
4788            .iter()
4789            .all(|request| !request.url.path().ends_with(":batchUpdate")));
4790    }
4791
4792    #[tokio::test]
4793    async fn delete_range_sends_a_zero_based_half_open_grid_range() {
4794        let server = wiremock::MockServer::start().await;
4795        let (drive, sheets) = clients(&server).await;
4796        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4797            .mount(&server)
4798            .await;
4799        mount_folder("parent-1").mount(&server).await;
4800        mount_workbook().mount(&server).await;
4801        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4802            .mount(&server)
4803            .await;
4804        let outcome = structure(
4805            &drive,
4806            &sheets,
4807            &opts(delete_range(), false),
4808            &[delete_allow_rule("parent-1")],
4809        )
4810        .await;
4811        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4812
4813        let requests = server.received_requests().await.unwrap();
4814        let body: serde_json::Value = requests
4815            .iter()
4816            .find(|r| r.url.path().ends_with(":batchUpdate"))
4817            .map(|r| serde_json::from_slice(&r.body).unwrap())
4818            .expect("a batchUpdate request");
4819        let delete = &body["requests"][0]["deleteRange"];
4820        assert_eq!(delete["range"]["sheetId"], 118_293);
4821        // `--start-row 2 --end-row 4 --start-column 2 --end-column 3` on the
4822        // wire, 1-based inclusive to 0-based half-open.
4823        assert_eq!(delete["range"]["startRowIndex"], 1);
4824        assert_eq!(delete["range"]["endRowIndex"], 4);
4825        assert_eq!(delete["range"]["startColumnIndex"], 1);
4826        assert_eq!(delete["range"]["endColumnIndex"], 3);
4827        assert_eq!(delete["shiftDimension"], "ROWS");
4828    }
4829
4830    #[tokio::test]
4831    async fn delete_range_with_columns_shift_sends_the_column_direction() {
4832        let server = wiremock::MockServer::start().await;
4833        let (drive, sheets) = clients(&server).await;
4834        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4835            .mount(&server)
4836            .await;
4837        mount_folder("parent-1").mount(&server).await;
4838        mount_workbook().mount(&server).await;
4839        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4840            .mount(&server)
4841            .await;
4842        let outcome = structure(
4843            &drive,
4844            &sheets,
4845            &opts(delete_range_columns_shift(), false),
4846            &[delete_allow_rule("parent-1")],
4847        )
4848        .await;
4849        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4850        assert!(describe(&outcome).contains("shifted remaining cells left"));
4851
4852        let requests = server.received_requests().await.unwrap();
4853        let body: serde_json::Value = requests
4854            .iter()
4855            .find(|r| r.url.path().ends_with(":batchUpdate"))
4856            .map(|r| serde_json::from_slice(&r.body).unwrap())
4857            .expect("a batchUpdate request");
4858        assert_eq!(
4859            body["requests"][0]["deleteRange"]["shiftDimension"],
4860            "COLUMNS"
4861        );
4862    }
4863
4864    #[tokio::test]
4865    async fn delete_dimension_past_the_end_of_the_sheet_is_refused() {
4866        let server = wiremock::MockServer::start().await;
4867        let (drive, sheets) = clients(&server).await;
4868        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4869            .mount(&server)
4870            .await;
4871        mount_folder("parent-1").mount(&server).await;
4872        mount_workbook().mount(&server).await;
4873        // Q2 has 500 rows; deleting 5 rows starting at 499 reaches row 503.
4874        let outcome = structure(
4875            &drive,
4876            &sheets,
4877            &opts(
4878                StructureVerb::DeleteRows {
4879                    sheet: "Q2".to_string(),
4880                    at: 499,
4881                    count: 5,
4882                },
4883                true,
4884            ),
4885            &[delete_allow_rule("parent-1")],
4886        )
4887        .await;
4888        assert!(matches!(
4889            outcome.result,
4890            StructureResult::RefusedInvalidRange { .. }
4891        ));
4892    }
4893
4894    #[tokio::test]
4895    async fn delete_range_with_end_before_start_is_refused() {
4896        let server = wiremock::MockServer::start().await;
4897        let (drive, sheets) = clients(&server).await;
4898        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4899            .mount(&server)
4900            .await;
4901        mount_folder("parent-1").mount(&server).await;
4902        mount_workbook().mount(&server).await;
4903        let outcome = structure(
4904            &drive,
4905            &sheets,
4906            &opts(
4907                StructureVerb::DeleteRange {
4908                    sheet: "Q2".to_string(),
4909                    start_row: 5,
4910                    end_row: 2,
4911                    start_column: 1,
4912                    end_column: 2,
4913                    shift: ShiftDimension::Rows,
4914                },
4915                true,
4916            ),
4917            &[delete_allow_rule("parent-1")],
4918        )
4919        .await;
4920        assert!(matches!(
4921            outcome.result,
4922            StructureResult::RefusedInvalidRange { .. }
4923        ));
4924    }
4925
4926    #[tokio::test]
4927    async fn delete_dimension_refuses_a_non_positive_count() {
4928        let server = wiremock::MockServer::start().await;
4929        let (drive, sheets) = clients(&server).await;
4930        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4931            .mount(&server)
4932            .await;
4933        mount_folder("parent-1").mount(&server).await;
4934        mount_workbook().mount(&server).await;
4935        let outcome = structure(
4936            &drive,
4937            &sheets,
4938            &opts(
4939                StructureVerb::DeleteRows {
4940                    sheet: "Q2".to_string(),
4941                    at: 1,
4942                    count: 0,
4943                },
4944                true,
4945            ),
4946            &[delete_allow_rule("parent-1")],
4947        )
4948        .await;
4949        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4950            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4951        };
4952        assert!(detail.contains("--count must be at least 1"), "{detail}");
4953    }
4954
4955    #[tokio::test]
4956    async fn delete_dimension_refuses_an_at_below_one() {
4957        let server = wiremock::MockServer::start().await;
4958        let (drive, sheets) = clients(&server).await;
4959        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4960            .mount(&server)
4961            .await;
4962        mount_folder("parent-1").mount(&server).await;
4963        mount_workbook().mount(&server).await;
4964        let outcome = structure(
4965            &drive,
4966            &sheets,
4967            &opts(
4968                StructureVerb::DeleteColumns {
4969                    sheet: "Q2".to_string(),
4970                    at: 0,
4971                    count: 1,
4972                },
4973                true,
4974            ),
4975            &[delete_allow_rule("parent-1")],
4976        )
4977        .await;
4978        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4979            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4980        };
4981        assert!(detail.contains("--at must be at least 1"), "{detail}");
4982    }
4983
4984    #[tokio::test]
4985    async fn delete_dimension_refuses_an_at_and_count_that_overflow_the_index_space() {
4986        let server = wiremock::MockServer::start().await;
4987        let (drive, sheets) = clients(&server).await;
4988        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4989            .mount(&server)
4990            .await;
4991        mount_folder("parent-1").mount(&server).await;
4992        mount_workbook().mount(&server).await;
4993        let outcome = structure(
4994            &drive,
4995            &sheets,
4996            &opts(
4997                StructureVerb::DeleteRows {
4998                    sheet: "Q2".to_string(),
4999                    at: i64::MAX,
5000                    count: 2,
5001                },
5002                true,
5003            ),
5004            &[delete_allow_rule("parent-1")],
5005        )
5006        .await;
5007        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5008            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5009        };
5010        assert!(detail.contains("overflows the row index space"), "{detail}");
5011    }
5012
5013    #[tokio::test]
5014    async fn delete_range_refuses_a_start_row_below_one() {
5015        let server = wiremock::MockServer::start().await;
5016        let (drive, sheets) = clients(&server).await;
5017        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5018            .mount(&server)
5019            .await;
5020        mount_folder("parent-1").mount(&server).await;
5021        mount_workbook().mount(&server).await;
5022        let outcome = structure(
5023            &drive,
5024            &sheets,
5025            &opts(
5026                StructureVerb::DeleteRange {
5027                    sheet: "Q2".to_string(),
5028                    start_row: 0,
5029                    end_row: 2,
5030                    start_column: 1,
5031                    end_column: 2,
5032                    shift: ShiftDimension::Rows,
5033                },
5034                true,
5035            ),
5036            &[delete_allow_rule("parent-1")],
5037        )
5038        .await;
5039        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5040            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5041        };
5042        assert!(
5043            detail.contains("--start-row must be at least 1"),
5044            "{detail}"
5045        );
5046    }
5047
5048    #[tokio::test]
5049    async fn delete_range_refuses_a_start_column_below_one() {
5050        let server = wiremock::MockServer::start().await;
5051        let (drive, sheets) = clients(&server).await;
5052        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5053            .mount(&server)
5054            .await;
5055        mount_folder("parent-1").mount(&server).await;
5056        mount_workbook().mount(&server).await;
5057        let outcome = structure(
5058            &drive,
5059            &sheets,
5060            &opts(
5061                StructureVerb::DeleteRange {
5062                    sheet: "Q2".to_string(),
5063                    start_row: 1,
5064                    end_row: 2,
5065                    start_column: 0,
5066                    end_column: 2,
5067                    shift: ShiftDimension::Rows,
5068                },
5069                true,
5070            ),
5071            &[delete_allow_rule("parent-1")],
5072        )
5073        .await;
5074        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5075            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5076        };
5077        assert!(
5078            detail.contains("--start-column must be at least 1"),
5079            "{detail}"
5080        );
5081    }
5082
5083    #[tokio::test]
5084    async fn delete_range_refuses_an_end_column_before_start_column() {
5085        let server = wiremock::MockServer::start().await;
5086        let (drive, sheets) = clients(&server).await;
5087        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5088            .mount(&server)
5089            .await;
5090        mount_folder("parent-1").mount(&server).await;
5091        mount_workbook().mount(&server).await;
5092        // Rows are well-ordered on their own; only the columns are inverted,
5093        // so this is refused for a different reason than
5094        // `delete_range_with_end_before_start_is_refused`.
5095        let outcome = structure(
5096            &drive,
5097            &sheets,
5098            &opts(
5099                StructureVerb::DeleteRange {
5100                    sheet: "Q2".to_string(),
5101                    start_row: 1,
5102                    end_row: 2,
5103                    start_column: 5,
5104                    end_column: 2,
5105                    shift: ShiftDimension::Rows,
5106                },
5107                true,
5108            ),
5109            &[delete_allow_rule("parent-1")],
5110        )
5111        .await;
5112        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5113            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5114        };
5115        assert!(
5116            detail.contains("--end-column (2) must be at or after --start-column (5)"),
5117            "{detail}"
5118        );
5119    }
5120
5121    #[tokio::test]
5122    async fn delete_range_refuses_an_end_row_past_the_end_of_the_sheet() {
5123        let server = wiremock::MockServer::start().await;
5124        let (drive, sheets) = clients(&server).await;
5125        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5126            .mount(&server)
5127            .await;
5128        mount_folder("parent-1").mount(&server).await;
5129        mount_workbook().mount(&server).await;
5130        // Q2 has 500 rows.
5131        let outcome = structure(
5132            &drive,
5133            &sheets,
5134            &opts(
5135                StructureVerb::DeleteRange {
5136                    sheet: "Q2".to_string(),
5137                    start_row: 1,
5138                    end_row: 501,
5139                    start_column: 1,
5140                    end_column: 2,
5141                    shift: ShiftDimension::Rows,
5142                },
5143                true,
5144            ),
5145            &[delete_allow_rule("parent-1")],
5146        )
5147        .await;
5148        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5149            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5150        };
5151        assert!(
5152            detail.contains("--end-row 501 is past the end of the sheet"),
5153            "{detail}"
5154        );
5155    }
5156
5157    #[tokio::test]
5158    async fn delete_range_refuses_an_end_column_past_the_end_of_the_sheet() {
5159        let server = wiremock::MockServer::start().await;
5160        let (drive, sheets) = clients(&server).await;
5161        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5162            .mount(&server)
5163            .await;
5164        mount_folder("parent-1").mount(&server).await;
5165        mount_workbook().mount(&server).await;
5166        // Q2 has 10 columns.
5167        let outcome = structure(
5168            &drive,
5169            &sheets,
5170            &opts(
5171                StructureVerb::DeleteRange {
5172                    sheet: "Q2".to_string(),
5173                    start_row: 1,
5174                    end_row: 2,
5175                    start_column: 1,
5176                    end_column: 11,
5177                    shift: ShiftDimension::Rows,
5178                },
5179                true,
5180            ),
5181            &[delete_allow_rule("parent-1")],
5182        )
5183        .await;
5184        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5185            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5186        };
5187        assert!(
5188            detail.contains("--end-column 11 is past the end of the sheet"),
5189            "{detail}"
5190        );
5191    }
5192
5193    #[tokio::test]
5194    async fn add_sheet_reports_the_server_assigned_sheet_id() {
5195        let server = wiremock::MockServer::start().await;
5196        let (drive, sheets) = clients(&server).await;
5197        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5198            .mount(&server)
5199            .await;
5200        mount_folder("parent-1").mount(&server).await;
5201        mount_workbook().mount(&server).await;
5202        mount_batch_update(serde_json::json!({
5203            "spreadsheetId": "sheet-1",
5204            "replies": [{"addSheet": {"properties": {"sheetId": 999, "title": "Q3"}}}],
5205        }))
5206        .mount(&server)
5207        .await;
5208        let outcome = structure(
5209            &drive,
5210            &sheets,
5211            &opts(add_sheet(), false),
5212            &[allow_rule("parent-1")],
5213        )
5214        .await;
5215        // The one fact the reply carries that the request did not know.
5216        assert!(matches!(
5217            outcome.result,
5218            StructureResult::Changed {
5219                sheet_id: Some(999),
5220                ..
5221            }
5222        ));
5223        assert!(describe(&outcome).contains("sheetId 999"));
5224    }
5225
5226    #[tokio::test]
5227    async fn add_sheet_omits_grid_properties_when_no_size_was_asked_for() {
5228        let server = wiremock::MockServer::start().await;
5229        let (drive, sheets) = clients(&server).await;
5230        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5231            .mount(&server)
5232            .await;
5233        mount_folder("parent-1").mount(&server).await;
5234        mount_workbook().mount(&server).await;
5235        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5236            .mount(&server)
5237            .await;
5238        let _ = structure(
5239            &drive,
5240            &sheets,
5241            &opts(add_sheet(), false),
5242            &[allow_rule("parent-1")],
5243        )
5244        .await;
5245        let requests = server.received_requests().await.unwrap();
5246        let body: serde_json::Value = requests
5247            .iter()
5248            .find(|r| r.url.path().ends_with(":batchUpdate"))
5249            .map(|r| serde_json::from_slice(&r.body).unwrap())
5250            .expect("a batchUpdate request");
5251        let props = &body["requests"][0]["addSheet"]["properties"];
5252        // Omitted, not zeroed: Sheets' own default (1000 x 26) should apply
5253        // rather than a size we invented.
5254        assert!(props.get("gridProperties").is_none(), "{props}");
5255        assert!(props.get("index").is_none(), "{props}");
5256        assert_eq!(props["title"], "Q3");
5257    }
5258
5259    #[tokio::test]
5260    async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
5261        let server = wiremock::MockServer::start().await;
5262        let (drive, sheets) = clients(&server).await;
5263        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5264            .mount(&server)
5265            .await;
5266        mount_folder("parent-1").mount(&server).await;
5267        mount_workbook().mount(&server).await;
5268        wiremock::Mock::given(wiremock::matchers::method("POST"))
5269            .and(wiremock::matchers::path(
5270                "/v4/spreadsheets/sheet-1:batchUpdate",
5271            ))
5272            .respond_with(
5273                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
5274                    "error": {
5275                        "code": 403,
5276                        "message": "The caller does not have permission",
5277                        "status": "PERMISSION_DENIED",
5278                    }
5279                })),
5280            )
5281            .mount(&server)
5282            .await;
5283        let outcome = structure(
5284            &drive,
5285            &sheets,
5286            &opts(rename(), false),
5287            &[allow_rule("parent-1")],
5288        )
5289        .await;
5290        let StructureResult::Failed { detail } = &outcome.result else {
5291            panic!("expected Failed, got {:?}", outcome.result);
5292        };
5293        // The `google.rpc` envelope has no `errors[]`, so the scope hint has
5294        // to match on `status` — ADR-0073 §2.
5295        assert!(detail.contains("drive auth login"), "{detail}");
5296    }
5297
5298    // ── duplicate-sheet / reorder-sheet / hide-show-sheet (issue #1643) ─
5299
5300    #[tokio::test]
5301    async fn duplicate_sheet_apply_sends_source_title_and_index() {
5302        let server = wiremock::MockServer::start().await;
5303        let (drive, sheets) = clients(&server).await;
5304        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5305            .mount(&server)
5306            .await;
5307        mount_folder("parent-1").mount(&server).await;
5308        mount_workbook().mount(&server).await;
5309        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5310            .mount(&server)
5311            .await;
5312        let outcome = structure(
5313            &drive,
5314            &sheets,
5315            &opts(duplicate_sheet(), false),
5316            &[allow_rule("parent-1")],
5317        )
5318        .await;
5319        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5320
5321        let requests = server.received_requests().await.unwrap();
5322        let body: serde_json::Value = requests
5323            .iter()
5324            .find(|r| r.url.path().ends_with(":batchUpdate"))
5325            .map(|r| serde_json::from_slice(&r.body).unwrap())
5326            .expect("a batchUpdate request");
5327        let dup = &body["requests"][0]["duplicateSheet"];
5328        assert_eq!(dup["sourceSheetId"], 118_293);
5329        assert_eq!(dup["insertSheetIndex"], 1);
5330        assert_eq!(dup["newSheetName"], "Q2 Copy");
5331    }
5332
5333    #[tokio::test]
5334    async fn duplicate_sheet_apply_with_default_title_and_index() {
5335        let server = wiremock::MockServer::start().await;
5336        let (drive, sheets) = clients(&server).await;
5337        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5338            .mount(&server)
5339            .await;
5340        mount_folder("parent-1").mount(&server).await;
5341        mount_workbook().mount(&server).await;
5342        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5343            .mount(&server)
5344            .await;
5345        let outcome = structure(
5346            &drive,
5347            &sheets,
5348            &opts(duplicate_sheet_default(), false),
5349            &[allow_rule("parent-1")],
5350        )
5351        .await;
5352        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5353
5354        let requests = server.received_requests().await.unwrap();
5355        let body: serde_json::Value = requests
5356            .iter()
5357            .find(|r| r.url.path().ends_with(":batchUpdate"))
5358            .map(|r| serde_json::from_slice(&r.body).unwrap())
5359            .expect("a batchUpdate request");
5360        let dup = &body["requests"][0]["duplicateSheet"];
5361        assert!(dup.get("insertSheetIndex").is_none());
5362        assert!(dup.get("newSheetName").is_none());
5363    }
5364
5365    #[tokio::test]
5366    async fn duplicate_sheet_dry_run_names_title_and_index() {
5367        let server = wiremock::MockServer::start().await;
5368        let (drive, sheets) = clients(&server).await;
5369        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5370            .mount(&server)
5371            .await;
5372        mount_folder("parent-1").mount(&server).await;
5373        mount_workbook().mount(&server).await;
5374        let outcome = structure(
5375            &drive,
5376            &sheets,
5377            &opts(duplicate_sheet(), true),
5378            &[allow_rule("parent-1")],
5379        )
5380        .await;
5381        let text = describe(&outcome);
5382        assert!(text.contains("Would duplicate sheet 'Q2'"), "{text}");
5383        assert!(text.contains("as 'Q2 Copy'"), "{text}");
5384        assert!(text.contains("at index 1"), "{text}");
5385        let requests = server.received_requests().await.unwrap();
5386        assert!(!requests
5387            .iter()
5388            .any(|r| r.url.path().ends_with(":batchUpdate")));
5389    }
5390
5391    #[tokio::test]
5392    async fn duplicate_sheet_dry_run_uses_defaults_when_omitted() {
5393        let server = wiremock::MockServer::start().await;
5394        let (drive, sheets) = clients(&server).await;
5395        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5396            .mount(&server)
5397            .await;
5398        mount_folder("parent-1").mount(&server).await;
5399        mount_workbook().mount(&server).await;
5400        let outcome = structure(
5401            &drive,
5402            &sheets,
5403            &opts(duplicate_sheet_default(), true),
5404            &[allow_rule("parent-1")],
5405        )
5406        .await;
5407        let text = describe(&outcome);
5408        assert!(
5409            text.contains("as a copy Sheets names automatically"),
5410            "{text}"
5411        );
5412        assert!(text.contains("at the front"), "{text}");
5413    }
5414
5415    #[tokio::test]
5416    async fn duplicate_sheet_refuses_a_missing_source_sheet() {
5417        let server = wiremock::MockServer::start().await;
5418        let (drive, sheets) = clients(&server).await;
5419        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5420            .mount(&server)
5421            .await;
5422        mount_folder("parent-1").mount(&server).await;
5423        mount_workbook().mount(&server).await;
5424        let outcome = structure(
5425            &drive,
5426            &sheets,
5427            &opts(
5428                StructureVerb::DuplicateSheet {
5429                    sheet: "Nope".to_string(),
5430                    title: None,
5431                    index: None,
5432                },
5433                false,
5434            ),
5435            &[allow_rule("parent-1")],
5436        )
5437        .await;
5438        assert!(matches!(
5439            outcome.result,
5440            StructureResult::RefusedSheetNotFound { .. }
5441        ));
5442    }
5443
5444    #[tokio::test]
5445    async fn duplicate_sheet_refuses_a_colliding_new_title() {
5446        let server = wiremock::MockServer::start().await;
5447        let (drive, sheets) = clients(&server).await;
5448        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5449            .mount(&server)
5450            .await;
5451        mount_folder("parent-1").mount(&server).await;
5452        mount_workbook().mount(&server).await;
5453        let outcome = structure(
5454            &drive,
5455            &sheets,
5456            &opts(
5457                StructureVerb::DuplicateSheet {
5458                    sheet: "Q2".to_string(),
5459                    title: Some("Q1".to_string()),
5460                    index: None,
5461                },
5462                false,
5463            ),
5464            &[allow_rule("parent-1")],
5465        )
5466        .await;
5467        assert!(matches!(
5468            outcome.result,
5469            StructureResult::RefusedSheetExists { ref title } if title == "Q1"
5470        ));
5471    }
5472
5473    #[tokio::test]
5474    async fn duplicate_sheet_refuses_an_out_of_range_index() {
5475        let server = wiremock::MockServer::start().await;
5476        let (drive, sheets) = clients(&server).await;
5477        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5478            .mount(&server)
5479            .await;
5480        mount_folder("parent-1").mount(&server).await;
5481        mount_workbook().mount(&server).await;
5482        let outcome = structure(
5483            &drive,
5484            &sheets,
5485            &opts(
5486                StructureVerb::DuplicateSheet {
5487                    sheet: "Q2".to_string(),
5488                    title: None,
5489                    index: Some(99),
5490                },
5491                false,
5492            ),
5493            &[allow_rule("parent-1")],
5494        )
5495        .await;
5496        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5497            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5498        };
5499        assert!(
5500            detail.contains("--index must be between 0 and 2"),
5501            "{detail}"
5502        );
5503    }
5504
5505    #[tokio::test]
5506    async fn reorder_sheet_apply_sends_index_masked_update() {
5507        let server = wiremock::MockServer::start().await;
5508        let (drive, sheets) = clients(&server).await;
5509        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5510            .mount(&server)
5511            .await;
5512        mount_folder("parent-1").mount(&server).await;
5513        mount_workbook().mount(&server).await;
5514        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5515            .mount(&server)
5516            .await;
5517        let outcome = structure(
5518            &drive,
5519            &sheets,
5520            &opts(reorder_sheet(), false),
5521            &[allow_rule("parent-1")],
5522        )
5523        .await;
5524        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5525
5526        let requests = server.received_requests().await.unwrap();
5527        let body: serde_json::Value = requests
5528            .iter()
5529            .find(|r| r.url.path().ends_with(":batchUpdate"))
5530            .map(|r| serde_json::from_slice(&r.body).unwrap())
5531            .expect("a batchUpdate request");
5532        let update = &body["requests"][0]["updateSheetProperties"];
5533        assert_eq!(update["properties"]["sheetId"], 118_293);
5534        assert_eq!(update["properties"]["index"], 0);
5535        assert_eq!(update["fields"], "index");
5536    }
5537
5538    #[tokio::test]
5539    async fn reorder_sheet_dry_run_names_the_target_index() {
5540        let server = wiremock::MockServer::start().await;
5541        let (drive, sheets) = clients(&server).await;
5542        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5543            .mount(&server)
5544            .await;
5545        mount_folder("parent-1").mount(&server).await;
5546        mount_workbook().mount(&server).await;
5547        let outcome = structure(
5548            &drive,
5549            &sheets,
5550            &opts(reorder_sheet(), true),
5551            &[allow_rule("parent-1")],
5552        )
5553        .await;
5554        let text = describe(&outcome);
5555        assert!(text.contains("Would move sheet 'Q2'"), "{text}");
5556        assert!(text.contains("to index 0"), "{text}");
5557    }
5558
5559    #[tokio::test]
5560    async fn reorder_sheet_refuses_an_out_of_range_index() {
5561        let server = wiremock::MockServer::start().await;
5562        let (drive, sheets) = clients(&server).await;
5563        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5564            .mount(&server)
5565            .await;
5566        mount_folder("parent-1").mount(&server).await;
5567        mount_workbook().mount(&server).await;
5568        let outcome = structure(
5569            &drive,
5570            &sheets,
5571            &opts(
5572                StructureVerb::ReorderSheet {
5573                    sheet: "Q2".to_string(),
5574                    index: 5,
5575                },
5576                false,
5577            ),
5578            &[allow_rule("parent-1")],
5579        )
5580        .await;
5581        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5582            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5583        };
5584        assert!(
5585            detail.contains("--index must be between 0 and 1"),
5586            "{detail}"
5587        );
5588    }
5589
5590    #[tokio::test]
5591    async fn hide_sheet_apply_sends_hidden_true() {
5592        let server = wiremock::MockServer::start().await;
5593        let (drive, sheets) = clients(&server).await;
5594        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5595            .mount(&server)
5596            .await;
5597        mount_folder("parent-1").mount(&server).await;
5598        mount_workbook().mount(&server).await;
5599        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5600            .mount(&server)
5601            .await;
5602        let outcome = structure(
5603            &drive,
5604            &sheets,
5605            &opts(hide_sheet(), false),
5606            &[allow_rule("parent-1")],
5607        )
5608        .await;
5609        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5610
5611        let requests = server.received_requests().await.unwrap();
5612        let body: serde_json::Value = requests
5613            .iter()
5614            .find(|r| r.url.path().ends_with(":batchUpdate"))
5615            .map(|r| serde_json::from_slice(&r.body).unwrap())
5616            .expect("a batchUpdate request");
5617        let update = &body["requests"][0]["updateSheetProperties"];
5618        assert_eq!(update["properties"]["sheetId"], 118_293);
5619        assert_eq!(update["properties"]["hidden"], true);
5620        assert_eq!(update["fields"], "hidden");
5621    }
5622
5623    #[tokio::test]
5624    async fn show_sheet_apply_sends_hidden_false() {
5625        let server = wiremock::MockServer::start().await;
5626        let (drive, sheets) = clients(&server).await;
5627        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5628            .mount(&server)
5629            .await;
5630        mount_folder("parent-1").mount(&server).await;
5631        mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5632        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5633            .mount(&server)
5634            .await;
5635        let outcome = structure(
5636            &drive,
5637            &sheets,
5638            &opts(show_sheet(), false),
5639            &[allow_rule("parent-1")],
5640        )
5641        .await;
5642        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5643
5644        let requests = server.received_requests().await.unwrap();
5645        let body: serde_json::Value = requests
5646            .iter()
5647            .find(|r| r.url.path().ends_with(":batchUpdate"))
5648            .map(|r| serde_json::from_slice(&r.body).unwrap())
5649            .expect("a batchUpdate request");
5650        let update = &body["requests"][0]["updateSheetProperties"];
5651        assert_eq!(update["properties"]["hidden"], false);
5652    }
5653
5654    #[tokio::test]
5655    async fn hide_sheet_dry_run_names_the_hide() {
5656        let server = wiremock::MockServer::start().await;
5657        let (drive, sheets) = clients(&server).await;
5658        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5659            .mount(&server)
5660            .await;
5661        mount_folder("parent-1").mount(&server).await;
5662        mount_workbook().mount(&server).await;
5663        let outcome = structure(
5664            &drive,
5665            &sheets,
5666            &opts(hide_sheet(), true),
5667            &[allow_rule("parent-1")],
5668        )
5669        .await;
5670        let text = describe(&outcome);
5671        assert!(text.contains("Would hide sheet 'Q2'"), "{text}");
5672    }
5673
5674    #[tokio::test]
5675    async fn show_sheet_dry_run_names_the_show() {
5676        let server = wiremock::MockServer::start().await;
5677        let (drive, sheets) = clients(&server).await;
5678        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5679            .mount(&server)
5680            .await;
5681        mount_folder("parent-1").mount(&server).await;
5682        mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5683        let outcome = structure(
5684            &drive,
5685            &sheets,
5686            &opts(show_sheet(), true),
5687            &[allow_rule("parent-1")],
5688        )
5689        .await;
5690        let text = describe(&outcome);
5691        assert!(text.contains("Would show sheet 'Q2'"), "{text}");
5692    }
5693
5694    /// Hiding a hidden sheet or showing a visible one sends nothing and
5695    /// says so, in a dry run exactly as in a real run, rather than
5696    /// reporting a change that did not happen.
5697    #[tokio::test]
5698    async fn a_visibility_no_op_is_unchanged_in_dry_run_and_real_run() {
5699        for (verb, q2, state) in [
5700            (hide_sheet(), hidden_q2(), "hidden"),
5701            (show_sheet(), default_q2(), "shown"),
5702        ] {
5703            for dry_run in [true, false] {
5704                let server = wiremock::MockServer::start().await;
5705                let (drive, sheets) = clients(&server).await;
5706                mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5707                    .mount(&server)
5708                    .await;
5709                mount_folder("parent-1").mount(&server).await;
5710                mount_workbook_with_q2(q2.clone()).mount(&server).await;
5711                let outcome = structure(
5712                    &drive,
5713                    &sheets,
5714                    &opts(verb.clone(), dry_run),
5715                    &[allow_rule("parent-1")],
5716                )
5717                .await;
5718                assert_eq!(
5719                    outcome.result,
5720                    StructureResult::Unchanged {
5721                        detail: format!("sheet 'Q2' is already {state}")
5722                    },
5723                    "dry_run={dry_run}"
5724                );
5725                let text = describe(&outcome);
5726                assert!(
5727                    text.starts_with(&format!("Unchanged: sheet 'Q2' is already {state} in ")),
5728                    "{text}"
5729                );
5730                let requests = server.received_requests().await.unwrap();
5731                assert!(
5732                    !requests
5733                        .iter()
5734                        .any(|r| r.url.path().ends_with(":batchUpdate")),
5735                    "a no-op must not call batchUpdate"
5736                );
5737            }
5738        }
5739    }
5740
5741    #[tokio::test]
5742    async fn hide_sheet_refuses_leaving_no_visible_sheets() {
5743        let server = wiremock::MockServer::start().await;
5744        let (drive, sheets) = clients(&server).await;
5745        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5746            .mount(&server)
5747            .await;
5748        mount_folder("parent-1").mount(&server).await;
5749        wiremock::Mock::given(wiremock::matchers::method("GET"))
5750            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
5751            .respond_with(
5752                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5753                    "spreadsheetId": "sheet-1",
5754                    "properties": {"title": "Budget"},
5755                    "sheets": [
5756                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0, "hidden": true}},
5757                        {"properties": {"sheetId": 118_293, "title": "Q2", "index": 1}},
5758                    ],
5759                })),
5760            )
5761            .mount(&server)
5762            .await;
5763        let outcome = structure(
5764            &drive,
5765            &sheets,
5766            &opts(hide_sheet(), false),
5767            &[allow_rule("parent-1")],
5768        )
5769        .await;
5770        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5771            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5772        };
5773        assert!(
5774            detail.contains("would leave the workbook with no visible sheets"),
5775            "{detail}"
5776        );
5777    }
5778
5779    // ── update-sheet-properties (issue #1835) ───────────────────────────
5780
5781    fn update_sheet_properties(
5782        freeze_rows: Option<i64>,
5783        freeze_columns: Option<i64>,
5784        tab_color: Option<&str>,
5785        clear_tab_color: bool,
5786        right_to_left: Option<bool>,
5787        hide_gridlines: Option<bool>,
5788    ) -> StructureVerb {
5789        StructureVerb::UpdateSheetProperties {
5790            sheet: "Q2".to_string(),
5791            freeze_rows,
5792            freeze_columns,
5793            tab_color: tab_color.map(ToString::to_string),
5794            clear_tab_color,
5795            right_to_left,
5796            hide_gridlines,
5797        }
5798    }
5799
5800    /// A `spreadsheets.get` reply like [`mount_workbook`], but `Q2` reports
5801    /// `frozenRowCount: 1` — the fixture
5802    /// [`update_sheet_properties_dry_run_reports_before_and_after`] needs to
5803    /// exercise the before->after line.
5804    fn mount_workbook_with_frozen_rows(
5805        sheet_id: i64,
5806        row_count: i64,
5807        frozen_row_count: i64,
5808    ) -> wiremock::Mock {
5809        mount_workbook_with_q2(serde_json::json!({
5810        "properties": {
5811            "sheetId": sheet_id, "title": "Q2", "index": 1,
5812            "gridProperties": {
5813                "rowCount": row_count, "columnCount": 10,
5814                "frozenRowCount": frozen_row_count,
5815            }}}))
5816    }
5817
5818    /// A `spreadsheets.get` reply like [`mount_workbook`], but `Q2` already
5819    /// carries the three view properties whose "before" value is read off
5820    /// the snapshot rather than off `gridProperties.rowCount` —
5821    /// `frozenColumnCount`, `rightToLeft` and `hideGridlines`. Without a
5822    /// fixture that reports them, every fragment
5823    /// [`update_sheet_properties_fragments`] builds for them falls to the
5824    /// `before: None` shape, so the `"<label> <before> -> <after>"` half of
5825    /// the description would never be exercised for anything but frozen
5826    /// rows (issue #1835).
5827    fn mount_workbook_with_view_state() -> wiremock::Mock {
5828        mount_workbook_with_q2(serde_json::json!({
5829        "properties": {
5830            "sheetId": 118_293, "title": "Q2", "index": 1,
5831            "rightToLeft": true,
5832            "gridProperties": {
5833                "rowCount": 500, "columnCount": 10,
5834                "frozenColumnCount": 2,
5835                "hideGridlines": true,
5836            }}}))
5837    }
5838
5839    #[tokio::test]
5840    async fn update_sheet_properties_apply_sends_only_the_set_fields_in_the_mask() {
5841        let server = wiremock::MockServer::start().await;
5842        let (drive, sheets) = clients(&server).await;
5843        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5844            .mount(&server)
5845            .await;
5846        mount_folder("parent-1").mount(&server).await;
5847        mount_workbook().mount(&server).await;
5848        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5849            .mount(&server)
5850            .await;
5851        let verb = update_sheet_properties(Some(2), None, Some("#FF8800"), false, None, None);
5852        let outcome = structure(
5853            &drive,
5854            &sheets,
5855            &opts(verb, false),
5856            &[allow_rule("parent-1")],
5857        )
5858        .await;
5859        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5860
5861        let requests = server.received_requests().await.unwrap();
5862        let body: serde_json::Value = requests
5863            .iter()
5864            .find(|r| r.url.path().ends_with(":batchUpdate"))
5865            .map(|r| serde_json::from_slice(&r.body).unwrap())
5866            .expect("a batchUpdate request");
5867        let update = &body["requests"][0]["updateSheetProperties"];
5868        assert_eq!(
5869            update["fields"],
5870            "gridProperties.frozenRowCount,tabColorStyle"
5871        );
5872        assert_eq!(update["properties"]["sheetId"], 118_293);
5873        assert_eq!(update["properties"]["gridProperties"]["frozenRowCount"], 2);
5874        let rgb = &update["properties"]["tabColorStyle"]["rgbColor"];
5875        assert!((rgb["red"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{rgb}");
5876        assert!(
5877            (rgb["green"].as_f64().unwrap() - (136.0 / 255.0)).abs() < 1e-6,
5878            "{rgb}"
5879        );
5880        assert!((rgb["blue"].as_f64().unwrap() - 0.0).abs() < 1e-6, "{rgb}");
5881        assert!(
5882            update["properties"]["gridProperties"]
5883                .get("frozenColumnCount")
5884                .is_none(),
5885            "{update}"
5886        );
5887        assert!(
5888            update["properties"].get("rightToLeft").is_none(),
5889            "{update}"
5890        );
5891        assert!(update["properties"].get("hidden").is_none(), "{update}");
5892        assert!(update["properties"].get("title").is_none(), "{update}");
5893        assert!(update["properties"].get("index").is_none(), "{update}");
5894    }
5895
5896    #[tokio::test]
5897    async fn update_sheet_properties_apply_clear_tab_color_names_tab_color_style_with_no_value() {
5898        let server = wiremock::MockServer::start().await;
5899        let (drive, sheets) = clients(&server).await;
5900        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5901            .mount(&server)
5902            .await;
5903        mount_folder("parent-1").mount(&server).await;
5904        mount_workbook().mount(&server).await;
5905        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5906            .mount(&server)
5907            .await;
5908        let verb = update_sheet_properties(None, None, None, true, None, None);
5909        let outcome = structure(
5910            &drive,
5911            &sheets,
5912            &opts(verb, false),
5913            &[allow_rule("parent-1")],
5914        )
5915        .await;
5916        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5917
5918        let requests = server.received_requests().await.unwrap();
5919        let body: serde_json::Value = requests
5920            .iter()
5921            .find(|r| r.url.path().ends_with(":batchUpdate"))
5922            .map(|r| serde_json::from_slice(&r.body).unwrap())
5923            .expect("a batchUpdate request");
5924        let update = &body["requests"][0]["updateSheetProperties"];
5925        assert_eq!(update["fields"], "tabColorStyle");
5926        assert!(
5927            update["properties"].get("tabColorStyle").is_none(),
5928            "{update}"
5929        );
5930    }
5931
5932    #[tokio::test]
5933    async fn update_sheet_properties_dry_run_reports_before_and_after() {
5934        let server = wiremock::MockServer::start().await;
5935        let (drive, sheets) = clients(&server).await;
5936        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5937            .mount(&server)
5938            .await;
5939        mount_folder("parent-1").mount(&server).await;
5940        mount_workbook_with_frozen_rows(118_293, 500, 1)
5941            .mount(&server)
5942            .await;
5943        let verb = update_sheet_properties(Some(3), None, None, false, None, None);
5944        let outcome = structure(
5945            &drive,
5946            &sheets,
5947            &opts(verb, true),
5948            &[allow_rule("parent-1")],
5949        )
5950        .await;
5951        let text = describe(&outcome);
5952        assert!(text.contains("frozen rows 1 -> 3"), "{text}");
5953    }
5954
5955    #[tokio::test]
5956    async fn update_sheet_properties_apply_sends_every_property_and_reports_each_one() {
5957        // The companion of
5958        // `update_sheet_properties_apply_sends_only_the_set_fields_in_the_mask`,
5959        // which sets two properties: this one sets all five, so every
5960        // `fields` entry and every description fragment is exercised, and
5961        // the mask's order is pinned against the order `build_request`
5962        // pushes them in. `mount_workbook`'s Q2 reports none of the three
5963        // optional view properties, so this is also the run where each
5964        // fragment takes its `before: None` shape ("<label> -> <after>").
5965        let server = wiremock::MockServer::start().await;
5966        let (drive, sheets) = clients(&server).await;
5967        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5968            .mount(&server)
5969            .await;
5970        mount_folder("parent-1").mount(&server).await;
5971        mount_workbook().mount(&server).await;
5972        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5973            .mount(&server)
5974            .await;
5975        // A bare hex, so the reported text also pins that the display
5976        // normalization supplies the '#' the user didn't type.
5977        let verb = update_sheet_properties(
5978            Some(2),
5979            Some(3),
5980            Some("0000FF"),
5981            false,
5982            Some(true),
5983            Some(true),
5984        );
5985        let outcome = structure(
5986            &drive,
5987            &sheets,
5988            &opts(verb, false),
5989            &[allow_rule("parent-1")],
5990        )
5991        .await;
5992        assert!(
5993            matches!(outcome.result, StructureResult::Changed { .. }),
5994            "{:?}",
5995            outcome.result
5996        );
5997
5998        let requests = server.received_requests().await.unwrap();
5999        let body: serde_json::Value = requests
6000            .iter()
6001            .find(|r| r.url.path().ends_with(":batchUpdate"))
6002            .map(|r| serde_json::from_slice(&r.body).unwrap())
6003            .expect("a batchUpdate request");
6004        let update = &body["requests"][0]["updateSheetProperties"];
6005        assert_eq!(
6006            update["fields"],
6007            "gridProperties.frozenRowCount,gridProperties.frozenColumnCount,\
6008             gridProperties.hideGridlines,tabColorStyle,rightToLeft"
6009        );
6010        let grid = &update["properties"]["gridProperties"];
6011        assert_eq!(grid["frozenRowCount"], 2);
6012        assert_eq!(grid["frozenColumnCount"], 3);
6013        assert_eq!(grid["hideGridlines"], true);
6014        assert_eq!(update["properties"]["rightToLeft"], true);
6015
6016        let text = describe(&outcome);
6017        assert!(text.starts_with("Updated sheet 'Q2'"), "{text}");
6018        for fragment in [
6019            "frozen rows -> 2",
6020            "frozen columns -> 3",
6021            "tab color -> #0000FF",
6022            "right-to-left -> true",
6023            "gridlines -> hidden",
6024        ] {
6025            assert!(text.contains(fragment), "{fragment} missing from {text}");
6026        }
6027    }
6028
6029    #[tokio::test]
6030    async fn update_sheet_properties_dry_run_reports_clears_and_the_values_being_replaced() {
6031        // The other half of
6032        // `update_sheet_properties_apply_sends_every_property_and_reports_each_one`:
6033        // the sheet already carries `frozenColumnCount`/`rightToLeft`/
6034        // `hideGridlines`, so each fragment takes its
6035        // "<label> <before> -> <after>" shape, gridlines read as
6036        // hidden/shown rather than as booleans, and `--clear-tab-color`
6037        // states the clear instead of a target color. Frozen rows are left
6038        // unset, which is the only run where that property contributes no
6039        // fragment at all.
6040        let server = wiremock::MockServer::start().await;
6041        let (drive, sheets) = clients(&server).await;
6042        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6043            .mount(&server)
6044            .await;
6045        mount_folder("parent-1").mount(&server).await;
6046        mount_workbook_with_view_state().mount(&server).await;
6047        let verb = update_sheet_properties(None, Some(4), None, true, Some(false), Some(false));
6048        let outcome = structure(
6049            &drive,
6050            &sheets,
6051            &opts(verb, true),
6052            &[allow_rule("parent-1")],
6053        )
6054        .await;
6055        let text = describe(&outcome);
6056        assert!(text.starts_with("Would update sheet 'Q2'"), "{text}");
6057        assert!(!text.contains("frozen rows"), "{text}");
6058        for fragment in [
6059            "frozen columns 2 -> 4",
6060            "tab color cleared",
6061            "right-to-left true -> false",
6062            "gridlines hidden -> shown",
6063        ] {
6064            assert!(text.contains(fragment), "{fragment} missing from {text}");
6065        }
6066    }
6067
6068    #[test]
6069    fn fields_changed_label_names_exactly_the_properties_the_verb_set() {
6070        // `record_attempt` logs what the verb *asked* to change, so this is
6071        // what lands in the request log's `fields_changed` context key for
6072        // an `update-sheet-properties` attempt — including a refused one.
6073        let every = update_sheet_properties(
6074            Some(2),
6075            Some(3),
6076            Some("#FF8800"),
6077            false,
6078            Some(true),
6079            Some(false),
6080        );
6081        assert_eq!(
6082            fields_changed_label(&every).as_deref(),
6083            Some(
6084                "frozenRowCount=2, frozenColumnCount=3, hideGridlines=false, \
6085                 tabColorStyle=#FF8800, rightToLeft=true"
6086            )
6087        );
6088        // A clear is a property change with no value, so it says so rather
6089        // than reporting a hex nobody asked for.
6090        let cleared = update_sheet_properties(None, None, None, true, None, None);
6091        assert_eq!(
6092            fields_changed_label(&cleared).as_deref(),
6093            Some("tabColorStyle=cleared")
6094        );
6095        // The shape `update_sheet_properties_refuses_when_nothing_is_set`
6096        // logs: refused before any property was set, so there is nothing to
6097        // name and the key is omitted rather than written empty.
6098        let empty = update_sheet_properties(None, None, None, false, None, None);
6099        assert_eq!(fields_changed_label(&empty), None);
6100        // Every other verb has no `fields_changed` at all.
6101        assert_eq!(fields_changed_label(&rename()), None);
6102    }
6103
6104    #[tokio::test]
6105    async fn update_sheet_properties_refuses_a_tab_color_and_a_clear_together() {
6106        // The CLI's `clap::ArgGroup` already makes these two mutually
6107        // exclusive, but `StructureVerb` is also reachable from a
6108        // non-CLI caller, so the engine refuses the combination itself
6109        // rather than silently letting the color win.
6110        let server = wiremock::MockServer::start().await;
6111        let (drive, sheets) = clients(&server).await;
6112        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6113            .mount(&server)
6114            .await;
6115        mount_folder("parent-1").mount(&server).await;
6116        mount_workbook().mount(&server).await;
6117        let verb = update_sheet_properties(None, None, Some("#FF8800"), true, None, None);
6118        let outcome = structure(
6119            &drive,
6120            &sheets,
6121            &opts(verb, true),
6122            &[allow_rule("parent-1")],
6123        )
6124        .await;
6125        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6126            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; validate_verb_args always refuses --tab-color with --clear-tab-color as RefusedInvalidRange"
6127        };
6128        assert!(detail.contains("mutually exclusive"), "{detail}");
6129    }
6130
6131    #[tokio::test]
6132    async fn update_sheet_properties_names_its_own_subcommand_in_a_refusal() {
6133        // `label()` exists so a refusal names the command the user typed;
6134        // the `not a Google Sheet` refusal is the shortest path to it, and
6135        // nothing else in this module renders the verb's own name.
6136        let server = wiremock::MockServer::start().await;
6137        let (drive, sheets) = clients(&server).await;
6138        mount_file("sheet-1", "application/pdf", &["parent-1"])
6139            .mount(&server)
6140            .await;
6141        let verb = update_sheet_properties(Some(2), None, None, false, None, None);
6142        let outcome = structure(
6143            &drive,
6144            &sheets,
6145            &opts(verb, false),
6146            &[allow_rule("parent-1")],
6147        )
6148        .await;
6149        assert!(matches!(
6150            outcome.result,
6151            StructureResult::RefusedNotASpreadsheet { .. }
6152        ));
6153        let text = describe(&outcome);
6154        assert!(
6155            text.contains("drive sheets update-sheet-properties"),
6156            "{text}"
6157        );
6158    }
6159
6160    #[tokio::test]
6161    async fn update_sheet_properties_refuses_a_negative_freeze_count() {
6162        let server = wiremock::MockServer::start().await;
6163        let (drive, sheets) = clients(&server).await;
6164        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6165            .mount(&server)
6166            .await;
6167        mount_folder("parent-1").mount(&server).await;
6168        mount_workbook().mount(&server).await;
6169        let verb = update_sheet_properties(Some(-1), None, None, false, None, None);
6170        let outcome = structure(
6171            &drive,
6172            &sheets,
6173            &opts(verb, true),
6174            &[allow_rule("parent-1")],
6175        )
6176        .await;
6177        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6178            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; check_freeze_bound always refuses a negative --freeze-rows as RefusedInvalidRange"
6179        };
6180        assert!(
6181            detail.contains("--freeze-rows must be at least 0"),
6182            "{detail}"
6183        );
6184    }
6185
6186    #[tokio::test]
6187    async fn update_sheet_properties_refuses_freezing_every_row() {
6188        let server = wiremock::MockServer::start().await;
6189        let (drive, sheets) = clients(&server).await;
6190        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6191            .mount(&server)
6192            .await;
6193        mount_folder("parent-1").mount(&server).await;
6194        mount_workbook_with_frozen_rows(118_293, 5, 0)
6195            .mount(&server)
6196            .await;
6197        let verb = update_sheet_properties(Some(5), None, None, false, None, None);
6198        let outcome = structure(
6199            &drive,
6200            &sheets,
6201            &opts(verb, true),
6202            &[allow_rule("parent-1")],
6203        )
6204        .await;
6205        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6206            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; check_freeze_bound always refuses a --freeze-rows at or above the sheet's known row count as RefusedInvalidRange"
6207        };
6208        assert!(detail.contains("would freeze every row"), "{detail}");
6209    }
6210
6211    #[tokio::test]
6212    async fn update_sheet_properties_allows_freezing_up_to_one_less_than_the_row_count() {
6213        // The boundary `check_freeze_bound` is supposed to allow: one
6214        // fewer than the sheet's current row count leaves exactly one row
6215        // unfrozen, which Sheets permits (the `>=` refusal in
6216        // `update_sheet_properties_refuses_freezing_every_row` above only
6217        // pins the refused side of this same bound).
6218        let server = wiremock::MockServer::start().await;
6219        let (drive, sheets) = clients(&server).await;
6220        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6221            .mount(&server)
6222            .await;
6223        mount_folder("parent-1").mount(&server).await;
6224        mount_workbook_with_frozen_rows(118_293, 5, 0)
6225            .mount(&server)
6226            .await;
6227        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6228            .mount(&server)
6229            .await;
6230        let verb = update_sheet_properties(Some(4), None, None, false, None, None);
6231        let outcome = structure(
6232            &drive,
6233            &sheets,
6234            &opts(verb, false),
6235            &[allow_rule("parent-1")],
6236        )
6237        .await;
6238        assert!(
6239            matches!(outcome.result, StructureResult::Changed { .. }),
6240            "{:?}",
6241            outcome.result
6242        );
6243    }
6244
6245    #[tokio::test]
6246    async fn update_sheet_properties_invents_no_freeze_ceiling_when_the_count_is_unknown() {
6247        // The other side of `check_freeze_bound`'s `Option`: a sheet with
6248        // no `gridProperties` at all — which is what Sheets reports for a
6249        // non-grid (`OBJECT`, full-page chart) sheet — leaves
6250        // `row_count`/`column_count` unknown. With no count to compare
6251        // against there is no ceiling to enforce, so the request goes
6252        // through and Sheets stays the authority on whether it is legal,
6253        // rather than omni-dev guessing a bound (ADR-0073 §7).
6254        let server = wiremock::MockServer::start().await;
6255        let (drive, sheets) = clients(&server).await;
6256        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6257            .mount(&server)
6258            .await;
6259        mount_folder("parent-1").mount(&server).await;
6260        wiremock::Mock::given(wiremock::matchers::method("GET"))
6261            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6262            .respond_with(
6263                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6264                    "spreadsheetId": "sheet-1",
6265                    "properties": {"title": "Budget"},
6266                    "sheets": [
6267                        {"properties": {"sheetId": 118_293, "title": "Q2", "index": 0}}
6268                    ],
6269                })),
6270            )
6271            .mount(&server)
6272            .await;
6273        let verb = update_sheet_properties(Some(9_000), Some(9_000), None, false, None, None);
6274        let outcome = structure(
6275            &drive,
6276            &sheets,
6277            &opts(verb, true),
6278            &[allow_rule("parent-1")],
6279        )
6280        .await;
6281        assert!(
6282            matches!(outcome.result, StructureResult::WouldChange { .. }),
6283            "{:?}",
6284            outcome.result
6285        );
6286    }
6287
6288    #[tokio::test]
6289    async fn update_sheet_properties_refuses_a_negative_freeze_column_count() {
6290        let server = wiremock::MockServer::start().await;
6291        let (drive, sheets) = clients(&server).await;
6292        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6293            .mount(&server)
6294            .await;
6295        mount_folder("parent-1").mount(&server).await;
6296        mount_workbook().mount(&server).await;
6297        let verb = update_sheet_properties(None, Some(-1), None, false, None, None);
6298        let outcome = structure(
6299            &drive,
6300            &sheets,
6301            &opts(verb, true),
6302            &[allow_rule("parent-1")],
6303        )
6304        .await;
6305        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6306            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; check_freeze_bound always refuses a negative --freeze-columns as RefusedInvalidRange"
6307        };
6308        assert!(
6309            detail.contains("--freeze-columns must be at least 0"),
6310            "{detail}"
6311        );
6312    }
6313
6314    #[tokio::test]
6315    async fn update_sheet_properties_refuses_freezing_every_column() {
6316        let server = wiremock::MockServer::start().await;
6317        let (drive, sheets) = clients(&server).await;
6318        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6319            .mount(&server)
6320            .await;
6321        mount_folder("parent-1").mount(&server).await;
6322        // mount_workbook()'s Q2 has columnCount 10.
6323        mount_workbook().mount(&server).await;
6324        let verb = update_sheet_properties(None, Some(10), None, false, None, None);
6325        let outcome = structure(
6326            &drive,
6327            &sheets,
6328            &opts(verb, true),
6329            &[allow_rule("parent-1")],
6330        )
6331        .await;
6332        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6333            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; check_freeze_bound always refuses a --freeze-columns at or above the sheet's known column count as RefusedInvalidRange"
6334        };
6335        assert!(detail.contains("would freeze every column"), "{detail}");
6336    }
6337
6338    #[tokio::test]
6339    async fn update_sheet_properties_refuses_an_invalid_hex_color() {
6340        let server = wiremock::MockServer::start().await;
6341        let (drive, sheets) = clients(&server).await;
6342        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6343            .mount(&server)
6344            .await;
6345        mount_folder("parent-1").mount(&server).await;
6346        mount_workbook().mount(&server).await;
6347        let verb = update_sheet_properties(None, None, Some("nope"), false, None, None);
6348        let outcome = structure(
6349            &drive,
6350            &sheets,
6351            &opts(verb, true),
6352            &[allow_rule("parent-1")],
6353        )
6354        .await;
6355        assert!(matches!(
6356            outcome.result,
6357            StructureResult::RefusedInvalidRange { .. }
6358        ));
6359    }
6360
6361    #[tokio::test]
6362    async fn update_sheet_properties_refuses_when_nothing_is_set() {
6363        let server = wiremock::MockServer::start().await;
6364        let (drive, sheets) = clients(&server).await;
6365        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6366            .mount(&server)
6367            .await;
6368        mount_folder("parent-1").mount(&server).await;
6369        mount_workbook().mount(&server).await;
6370        let verb = update_sheet_properties(None, None, None, false, None, None);
6371        let outcome = structure(
6372            &drive,
6373            &sheets,
6374            &opts(verb, true),
6375            &[allow_rule("parent-1")],
6376        )
6377        .await;
6378        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6379            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; validate_verb_args always refuses an update-sheet-properties verb that set no property as RefusedInvalidRange"
6380        };
6381        assert!(detail.contains("at least one"), "{detail}");
6382    }
6383
6384    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
6385
6386    fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
6387        FolderPermissionRule {
6388            require_lease: false,
6389            ..allow_rule(folder)
6390        }
6391    }
6392
6393    #[tokio::test]
6394    async fn refuses_without_a_lease_when_the_rule_requires_one() {
6395        let server = wiremock::MockServer::start().await;
6396        let (drive, sheets) = clients(&server).await;
6397        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6398            .mount(&server)
6399            .await;
6400        mount_folder("parent-1").mount(&server).await;
6401        mount_workbook().mount(&server).await;
6402        // No batchUpdate mock mounted — a refusal must make zero mutating
6403        // calls.
6404
6405        let mut o = opts(rename(), false);
6406        o.lease_token = None;
6407        let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6408        assert_eq!(outcome.result, StructureResult::RefusedNoLease);
6409    }
6410
6411    #[tokio::test]
6412    async fn refuses_an_unknown_lease_token() {
6413        let server = wiremock::MockServer::start().await;
6414        let (drive, sheets) = clients(&server).await;
6415        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6416            .mount(&server)
6417            .await;
6418        mount_folder("parent-1").mount(&server).await;
6419        mount_workbook().mount(&server).await;
6420
6421        let mut o = opts(rename(), false);
6422        o.lease_token = Some("bogus-token".to_string());
6423        // Never seeded — no ledger exists at this fresh path.
6424        o.ledger_path = tempfile::tempdir()
6425            .unwrap()
6426            .keep()
6427            .join("lease-ledger.jsonl");
6428        let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6429        assert_eq!(outcome.result, StructureResult::RefusedLeaseExpired);
6430    }
6431
6432    #[tokio::test]
6433    async fn refuses_a_lease_bound_to_a_different_file() {
6434        let server = wiremock::MockServer::start().await;
6435        let (drive, sheets) = clients(&server).await;
6436        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6437            .mount(&server)
6438            .await;
6439        mount_folder("parent-1").mount(&server).await;
6440        mount_workbook().mount(&server).await;
6441
6442        let ledger_path = tempfile::tempdir()
6443            .unwrap()
6444            .keep()
6445            .join("lease-ledger.jsonl");
6446        let token = seed_lease(&ledger_path, "other-sheet", "1");
6447        let mut o = opts(rename(), false);
6448        o.lease_token = Some(token);
6449        o.ledger_path = ledger_path;
6450        let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6451        assert_eq!(outcome.result, StructureResult::RefusedLeaseWrongFile);
6452    }
6453
6454    #[tokio::test]
6455    async fn refuses_a_stale_lease() {
6456        let server = wiremock::MockServer::start().await;
6457        let (drive, sheets) = clients(&server).await;
6458        // Live version "1" (`mount_file`'s default) but the lease was
6459        // acquired at "0" — the file has moved since.
6460        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6461            .mount(&server)
6462            .await;
6463        mount_folder("parent-1").mount(&server).await;
6464        mount_workbook().mount(&server).await;
6465
6466        let ledger_path = tempfile::tempdir()
6467            .unwrap()
6468            .keep()
6469            .join("lease-ledger.jsonl");
6470        let token = seed_lease(&ledger_path, "sheet-1", "0");
6471        let mut o = opts(rename(), false);
6472        o.lease_token = Some(token);
6473        o.ledger_path = ledger_path;
6474        let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6475        assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6476    }
6477
6478    #[tokio::test]
6479    async fn reports_a_lock_acquisition_failure_as_failed() {
6480        // A pre-existing lock file simulates another `drive lease`
6481        // operation genuinely in progress — reported as an operational
6482        // failure, not folded into `RefusedLeaseExpired`.
6483        let server = wiremock::MockServer::start().await;
6484        let (drive, sheets) = clients(&server).await;
6485        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6486            .mount(&server)
6487            .await;
6488        mount_folder("parent-1").mount(&server).await;
6489        mount_workbook().mount(&server).await;
6490
6491        let o = opts(rename(), false);
6492        // Under `flock` (issue #1687), a busy lock now waits rather than
6493        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
6494        // held `LedgerLock` no longer reproduces an immediate failure here.
6495        // A directory at the lock path does: opening it for write fails
6496        // outright with an I/O error, which is never retried.
6497        let mut lock_path = o.ledger_path.clone().into_os_string();
6498        lock_path.push(".lock");
6499        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
6500
6501        let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6502        assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6503    }
6504
6505    #[tokio::test]
6506    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
6507        // The gate's own resolve step succeeds off the first `files.get`,
6508        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
6509        // fails — the change must report `Failed` and never reach
6510        // `batchUpdate`.
6511        let server = wiremock::MockServer::start().await;
6512        let (drive, sheets) = clients(&server).await;
6513        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6514            .up_to_n_times(1)
6515            .with_priority(1)
6516            .mount(&server)
6517            .await;
6518        wiremock::Mock::given(wiremock::matchers::method("GET"))
6519            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
6520            .respond_with(wiremock::ResponseTemplate::new(500))
6521            .with_priority(2)
6522            .mount(&server)
6523            .await;
6524        mount_folder("parent-1").mount(&server).await;
6525        mount_workbook().mount(&server).await;
6526        wiremock::Mock::given(wiremock::matchers::method("POST"))
6527            .and(wiremock::matchers::path(
6528                "/v4/spreadsheets/sheet-1:batchUpdate",
6529            ))
6530            .respond_with(wiremock::ResponseTemplate::new(200))
6531            .expect(0)
6532            .mount(&server)
6533            .await;
6534
6535        let outcome = structure(
6536            &drive,
6537            &sheets,
6538            &opts(rename(), false),
6539            &[allow_rule("parent-1")],
6540        )
6541        .await;
6542        assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6543    }
6544
6545    #[tokio::test]
6546    async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
6547        // No lease token and no ledger seeded, with a rule that sets
6548        // `require_lease: false` — the change must still succeed, taking
6549        // the `else { None }` branch and never consulting the ledger.
6550        let server = wiremock::MockServer::start().await;
6551        let (drive, sheets) = clients(&server).await;
6552        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6553            .mount(&server)
6554            .await;
6555        mount_folder("parent-1").mount(&server).await;
6556        mount_workbook().mount(&server).await;
6557        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6558            .expect(1)
6559            .mount(&server)
6560            .await;
6561
6562        let mut o = opts(rename(), false);
6563        o.lease_token = None;
6564        let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6565        assert!(matches!(
6566            outcome.result,
6567            StructureResult::Changed { backup: None, .. }
6568        ));
6569    }
6570
6571    #[tokio::test]
6572    async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
6573        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
6574        // not the *meaning* — a token volunteered anyway is checked exactly
6575        // like a required one, including staleness.
6576        let server = wiremock::MockServer::start().await;
6577        let (drive, sheets) = clients(&server).await;
6578        // Live version "1" (`mount_file`'s default) but the lease was
6579        // acquired at "0" — the file has moved since.
6580        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6581            .mount(&server)
6582            .await;
6583        mount_folder("parent-1").mount(&server).await;
6584        mount_workbook().mount(&server).await;
6585        // No batchUpdate mock mounted — a refusal must make zero mutating
6586        // calls.
6587
6588        let ledger_path = tempfile::tempdir()
6589            .unwrap()
6590            .keep()
6591            .join("lease-ledger.jsonl");
6592        let token = seed_lease(&ledger_path, "sheet-1", "0");
6593        let mut o = opts(rename(), false);
6594        o.lease_token = Some(token);
6595        o.ledger_path = ledger_path;
6596        let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6597        assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6598    }
6599
6600    /// A native document's lease backs it up as a Drive copy (ADR-0080
6601    /// §3); a real-run delete under it names that copy's file id — the one
6602    /// thing a user can search the Drive UI for — and says the copy dates
6603    /// from acquisition, since a multi-use lease (§5) may have written
6604    /// since.
6605    #[tokio::test]
6606    async fn a_leased_delete_names_the_drive_copy_to_restore_from() {
6607        let server = wiremock::MockServer::start().await;
6608        let (drive, sheets) = clients(&server).await;
6609        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6610            .mount(&server)
6611            .await;
6612        mount_folder("parent-1").mount(&server).await;
6613        mount_workbook().mount(&server).await;
6614        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6615            .expect(1)
6616            .mount(&server)
6617            .await;
6618
6619        let ledger_path = tempfile::tempdir()
6620            .unwrap()
6621            .keep()
6622            .join("lease-ledger.jsonl");
6623        let token = seed_lease_with_backup(
6624            &ledger_path,
6625            "sheet-1",
6626            "1",
6627            LeaseBackup::DriveCopy {
6628                file_id: "backup-copy-1".to_string(),
6629            },
6630        );
6631        let mut o = opts(delete_sheet(), false);
6632        o.lease_token = Some(token);
6633        o.ledger_path = ledger_path;
6634        let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
6635
6636        assert!(matches!(
6637            &outcome.result,
6638            StructureResult::Changed {
6639                backup: Some(backup),
6640                ..
6641            } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6642        ));
6643        let text = describe(&outcome);
6644        assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6645        assert!(
6646            text.contains(
6647                "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6648            ),
6649            "{text}"
6650        );
6651        assert!(
6652            text.contains("restores a single deleted sheet automatically"),
6653            "{text}"
6654        );
6655        assert!(
6656            text.contains("Google Drive's own version history"),
6657            "{text}"
6658        );
6659        assert!(!text.contains("only recovery path"), "{text}");
6660
6661        // The copy rides the JSON outcome too, so a machine caller can find
6662        // it without parsing the message.
6663        let json = serde_json::to_value(&outcome).unwrap();
6664        assert_eq!(json["result"]["backup"]["kind"], "drive_copy");
6665        assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
6666    }
6667
6668    /// ADR-0080 §13: a `require_lease: false` rule takes no backup of its
6669    /// own, so `recovery_note` falls back to ADR-0077 §5's "no recovery
6670    /// path but Drive's own version history" wording — but a lease
6671    /// volunteered anyway is still checked and its backup still named,
6672    /// making that fallback wording false unless the presented lease is
6673    /// honoured.
6674    #[tokio::test]
6675    async fn a_presented_lease_under_a_non_requiring_rule_still_names_the_backup() {
6676        let server = wiremock::MockServer::start().await;
6677        let (drive, sheets) = clients(&server).await;
6678        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6679            .mount(&server)
6680            .await;
6681        mount_folder("parent-1").mount(&server).await;
6682        mount_workbook().mount(&server).await;
6683        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6684            .expect(1)
6685            .mount(&server)
6686            .await;
6687
6688        let ledger_path = tempfile::tempdir()
6689            .unwrap()
6690            .keep()
6691            .join("lease-ledger.jsonl");
6692        let token = seed_lease_with_backup(
6693            &ledger_path,
6694            "sheet-1",
6695            "1",
6696            LeaseBackup::DriveCopy {
6697                file_id: "backup-copy-1".to_string(),
6698            },
6699        );
6700        let mut o = opts(delete_sheet(), false);
6701        o.lease_token = Some(token);
6702        o.ledger_path = ledger_path;
6703        let rule = FolderPermissionRule {
6704            require_lease: false,
6705            ..delete_allow_rule("parent-1")
6706        };
6707        let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6708
6709        assert!(matches!(
6710            &outcome.result,
6711            StructureResult::Changed {
6712                backup: Some(backup),
6713                ..
6714            } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6715        ));
6716        let text = describe(&outcome);
6717        assert!(
6718            text.contains(
6719                "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6720            ),
6721            "{text}"
6722        );
6723        assert!(!text.contains("only recovery path"), "{text}");
6724    }
6725
6726    /// The other half of the honesty rule: a `require_lease: false` rule
6727    /// reaches the delete with no lease and no backup, and the message
6728    /// must not claim one — it falls back to ADR-0077's original wording
6729    /// and says why.
6730    #[tokio::test]
6731    async fn an_unleased_delete_does_not_claim_a_backup_exists() {
6732        let server = wiremock::MockServer::start().await;
6733        let (drive, sheets) = clients(&server).await;
6734        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6735            .mount(&server)
6736            .await;
6737        mount_folder("parent-1").mount(&server).await;
6738        mount_workbook().mount(&server).await;
6739        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6740            .expect(1)
6741            .mount(&server)
6742            .await;
6743
6744        let mut o = opts(delete_sheet(), false);
6745        o.lease_token = None;
6746        let rule = FolderPermissionRule {
6747            require_lease: false,
6748            ..delete_allow_rule("parent-1")
6749        };
6750        let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6751
6752        assert!(matches!(
6753            outcome.result,
6754            StructureResult::Changed { backup: None, .. }
6755        ));
6756        let text = describe(&outcome);
6757        assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6758        assert!(text.contains("no lease backup was taken"), "{text}");
6759        assert!(text.contains("`require_lease: false`"), "{text}");
6760        assert!(
6761            text.contains("version history is the only recovery path"),
6762            "{text}"
6763        );
6764        assert!(!text.contains("restore from that copy"), "{text}");
6765        assert!(serde_json::to_value(&outcome).unwrap()["result"]
6766            .get("backup")
6767            .is_none());
6768    }
6769
6770    // ── move-rows / move-columns (issue #1834) ─────────────────────────
6771
6772    /// The `batchUpdate` body the server actually received.
6773    fn sent_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
6774        requests
6775            .iter()
6776            .find(|r| r.url.path().ends_with(":batchUpdate"))
6777            .map(|r| serde_json::from_slice(&r.body).unwrap())
6778            .expect("a batchUpdate request")
6779    }
6780
6781    /// [`mount_workbook`] with the `Q2` sheet object supplied wholesale —
6782    /// the hook the "does not consult" tests below use to add grid state
6783    /// this engine must keep ignoring.
6784    fn mount_workbook_with_q2(q2: serde_json::Value) -> wiremock::Mock {
6785        wiremock::Mock::given(wiremock::matchers::method("GET"))
6786            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6787            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(workbook_body(q2)))
6788    }
6789
6790    /// Everything a move's real run needs mounted, minus the workbook.
6791    async fn mount_move_prereqs(server: &wiremock::MockServer) {
6792        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6793            .mount(server)
6794            .await;
6795        mount_folder("parent-1").mount(server).await;
6796        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6797            .mount(server)
6798            .await;
6799    }
6800
6801    /// The conversion this verb exists to get right: `--at 2 --count 2`
6802    /// becomes the half-open `[1, 3)`, and `--before 6` becomes
6803    /// `destinationIndex: 5` — the API's *pre-move* numbering, so the block
6804    /// actually lands on rows 4-5, not row 6.
6805    #[tokio::test]
6806    async fn move_rows_apply_sends_move_dimension_with_pre_move_destination() {
6807        let server = wiremock::MockServer::start().await;
6808        let (drive, sheets) = clients(&server).await;
6809        mount_move_prereqs(&server).await;
6810        mount_workbook().mount(&server).await;
6811
6812        let outcome = structure(
6813            &drive,
6814            &sheets,
6815            &opts(move_rows(), false),
6816            &[allow_rule("parent-1")],
6817        )
6818        .await;
6819        assert!(
6820            matches!(outcome.result, StructureResult::Changed { .. }),
6821            "{:?}",
6822            outcome.result
6823        );
6824
6825        let requests = server.received_requests().await.unwrap();
6826        let body = sent_batch_update(&requests);
6827        assert_eq!(
6828            body["requests"][0]["moveDimension"],
6829            serde_json::json!({
6830                "source": {
6831                    "sheetId": 118_293, "dimension": "ROWS",
6832                    "startIndex": 1, "endIndex": 3,
6833                },
6834                "destinationIndex": 5,
6835            })
6836        );
6837        assert_eq!(body["requests"].as_array().unwrap().len(), 1);
6838
6839        // The real-run message resolves the pre-move numbering for the
6840        // reader rather than echoing `--before` alone.
6841        let rendered = describe(&outcome);
6842        assert!(rendered.contains("Moved 2 row(s) 2-3"), "{rendered}");
6843        assert!(rendered.contains("to before row 6"), "{rendered}");
6844        assert!(rendered.contains("(now rows 4-5)"), "{rendered}");
6845    }
6846
6847    #[tokio::test]
6848    async fn move_columns_apply_sends_move_dimension() {
6849        let server = wiremock::MockServer::start().await;
6850        let (drive, sheets) = clients(&server).await;
6851        mount_move_prereqs(&server).await;
6852        mount_workbook().mount(&server).await;
6853
6854        let outcome = structure(
6855            &drive,
6856            &sheets,
6857            &opts(
6858                StructureVerb::MoveColumns {
6859                    sheet: "Q2".to_string(),
6860                    at: 2,
6861                    count: 2,
6862                    before: 6,
6863                },
6864                false,
6865            ),
6866            &[allow_rule("parent-1")],
6867        )
6868        .await;
6869        assert!(
6870            matches!(outcome.result, StructureResult::Changed { .. }),
6871            "{:?}",
6872            outcome.result
6873        );
6874
6875        let requests = server.received_requests().await.unwrap();
6876        let body = sent_batch_update(&requests);
6877        assert_eq!(
6878            body["requests"][0]["moveDimension"],
6879            serde_json::json!({
6880                "source": {
6881                    "sheetId": 118_293, "dimension": "COLUMNS",
6882                    "startIndex": 1, "endIndex": 3,
6883                },
6884                "destinationIndex": 5,
6885            })
6886        );
6887    }
6888
6889    /// The append boundary: `Q2` has 500 rows, so `--before 501` is the
6890    /// furthest legal destination — "move it to the very end" — and must
6891    /// not be refused the way `--before 502` is.
6892    #[tokio::test]
6893    async fn move_rows_to_the_end_uses_row_count_plus_one() {
6894        let server = wiremock::MockServer::start().await;
6895        let (drive, sheets) = clients(&server).await;
6896        mount_move_prereqs(&server).await;
6897        mount_workbook().mount(&server).await;
6898
6899        let outcome = structure(
6900            &drive,
6901            &sheets,
6902            &opts(
6903                StructureVerb::MoveRows {
6904                    sheet: "Q2".to_string(),
6905                    at: 1,
6906                    count: 1,
6907                    before: 501,
6908                },
6909                false,
6910            ),
6911            &[allow_rule("parent-1")],
6912        )
6913        .await;
6914        assert!(
6915            matches!(outcome.result, StructureResult::Changed { .. }),
6916            "{:?}",
6917            outcome.result
6918        );
6919
6920        let requests = server.received_requests().await.unwrap();
6921        let body = sent_batch_update(&requests);
6922        assert_eq!(
6923            body["requests"][0]["moveDimension"]["destinationIndex"],
6924            500
6925        );
6926    }
6927
6928    /// The source block must exist in full — `delete`'s rule, and the
6929    /// reason a move validates two spans rather than one.
6930    #[tokio::test]
6931    async fn move_rows_refuses_a_source_past_the_sheets_end() {
6932        let server = wiremock::MockServer::start().await;
6933        let (drive, sheets) = clients(&server).await;
6934        mount_move_prereqs(&server).await;
6935        mount_workbook().mount(&server).await;
6936
6937        let outcome = structure(
6938            &drive,
6939            &sheets,
6940            &opts(
6941                StructureVerb::MoveRows {
6942                    sheet: "Q2".to_string(),
6943                    at: 499,
6944                    count: 3,
6945                    before: 1,
6946                },
6947                false,
6948            ),
6949            &[allow_rule("parent-1")],
6950        )
6951        .await;
6952        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6953            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
6954        };
6955        assert!(detail.contains("500 row(s)"), "{detail}");
6956        assert!(detail.contains("501"), "{detail}");
6957    }
6958
6959    #[tokio::test]
6960    async fn move_rows_refuses_a_destination_past_the_end() {
6961        let server = wiremock::MockServer::start().await;
6962        let (drive, sheets) = clients(&server).await;
6963        mount_move_prereqs(&server).await;
6964        mount_workbook().mount(&server).await;
6965
6966        let outcome = structure(
6967            &drive,
6968            &sheets,
6969            &opts(
6970                StructureVerb::MoveRows {
6971                    sheet: "Q2".to_string(),
6972                    at: 5,
6973                    count: 1,
6974                    before: 502,
6975                },
6976                false,
6977            ),
6978            &[allow_rule("parent-1")],
6979        )
6980        .await;
6981        let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6982            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
6983        };
6984        assert!(detail.contains("500 row(s)"), "{detail}");
6985        assert!(detail.contains("501"), "{detail}");
6986    }
6987
6988    /// The refusal a move adds on top of insert's and delete's: for
6989    /// `--at 2 --count 2` the block occupies rows 2-3, and every
6990    /// `--before` in `2..=4` describes where it already is. Sheets accepts
6991    /// all three and changes nothing, so reporting "Moved" would be a
6992    /// lie — the "never promise a change the real run then rejects" rule
6993    /// read the other way round.
6994    #[tokio::test]
6995    async fn move_rows_refuses_a_destination_inside_or_adjacent_to_the_block() {
6996        let server = wiremock::MockServer::start().await;
6997        let (drive, sheets) = clients(&server).await;
6998        mount_move_prereqs(&server).await;
6999        mount_workbook().mount(&server).await;
7000
7001        for before in 2..=4 {
7002            let outcome = structure(
7003                &drive,
7004                &sheets,
7005                &opts(
7006                    StructureVerb::MoveRows {
7007                        sheet: "Q2".to_string(),
7008                        at: 2,
7009                        count: 2,
7010                        before,
7011                    },
7012                    false,
7013                ),
7014                &[allow_rule("parent-1")],
7015            )
7016            .await;
7017            let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
7018                panic!(
7019                    "expected RefusedInvalidRange for --before {before}, got {:?}",
7020                    outcome.result
7021                );
7022            };
7023            assert!(detail.contains("nothing would move"), "{detail}");
7024            assert!(detail.contains("rows 2-3"), "{detail}");
7025        }
7026    }
7027
7028    /// The two values immediately outside that band, which must stay legal.
7029    /// For rows 2-3: `--before 1` is the smallest upward move (one position
7030    /// earlier) and `--before 5` the smallest downward one — `4` is the
7031    /// no-op boundary, so `5` is the first real move above the block, not
7032    /// `4`.
7033    #[tokio::test]
7034    async fn move_rows_allows_a_destination_immediately_adjacent_but_outside_the_block() {
7035        let server = wiremock::MockServer::start().await;
7036        let (drive, sheets) = clients(&server).await;
7037        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7038            .mount(&server)
7039            .await;
7040        mount_folder("parent-1").mount(&server).await;
7041        mount_workbook().mount(&server).await;
7042        // `dry_run: true` throughout: the point is the classification, and
7043        // a dry run issues no `batchUpdate` to mock.
7044        for before in [1, 5] {
7045            let outcome = structure(
7046                &drive,
7047                &sheets,
7048                &opts(
7049                    StructureVerb::MoveRows {
7050                        sheet: "Q2".to_string(),
7051                        at: 2,
7052                        count: 2,
7053                        before,
7054                    },
7055                    true,
7056                ),
7057                &[allow_rule("parent-1")],
7058            )
7059            .await;
7060            assert!(
7061                matches!(outcome.result, StructureResult::WouldChange { .. }),
7062                "--before {before} must be a real move, got {:?}",
7063                outcome.result
7064            );
7065        }
7066    }
7067
7068    #[test]
7069    fn move_bounds_refuse_every_argument_below_one() {
7070        let sheet = SheetSnapshot {
7071            sheet_id: Some(1),
7072            title: "Q2".to_string(),
7073            row_count: Some(500),
7074            column_count: Some(26),
7075            ..Default::default()
7076        };
7077        for (at, count, before, expected) in [
7078            (2, 0, 9, "--count must be at least 1, got 0"),
7079            (0, 1, 9, "--at must be at least 1, got 0"),
7080            (2, 1, 0, "--before must be at least 1, got 0"),
7081        ] {
7082            let Err(StructureResult::RefusedInvalidRange { detail }) =
7083                validate_move_bounds(Dimension::Rows, at, count, before, Some(&sheet))
7084            else {
7085                panic!(
7086                    "expected RefusedInvalidRange for --at {at} --count {count} --before {before}"
7087                );
7088            };
7089            assert_eq!(detail, expected);
7090        }
7091    }
7092
7093    #[test]
7094    fn a_move_at_and_count_that_would_overflow_the_index_space_are_refused() {
7095        let sheet = SheetSnapshot {
7096            sheet_id: Some(1),
7097            title: "Q2".to_string(),
7098            row_count: Some(500),
7099            column_count: Some(26),
7100            ..Default::default()
7101        };
7102        // `--before 1` throughout, so the overflow guard is what fires and
7103        // not the inside-the-block refusal that follows it.
7104        for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
7105            let Err(StructureResult::RefusedInvalidRange { detail }) =
7106                validate_move_bounds(Dimension::Rows, at, count, 1, Some(&sheet))
7107            else {
7108                panic!("expected RefusedInvalidRange for --at {at} --count {count}");
7109            };
7110            assert!(detail.contains("overflows the row index space"), "{detail}");
7111        }
7112    }
7113
7114    /// `record_attempt` builds a record from the very arguments
7115    /// `validate_move_bounds` just rejected, so `dimension_range_label`'s
7116    /// span arithmetic runs on them. Plain arithmetic panics here rather
7117    /// than in the mutation it refused to make — the
7118    /// `a_refused_out_of_range_insert_still_records_without_panicking`
7119    /// regression, for the move verbs.
7120    #[tokio::test]
7121    async fn a_refused_move_still_records_without_panicking() {
7122        let server = wiremock::MockServer::start().await;
7123        let (drive, sheets) = clients(&server).await;
7124        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7125            .mount(&server)
7126            .await;
7127        mount_folder("parent-1").mount(&server).await;
7128        mount_workbook().mount(&server).await;
7129
7130        let outcome = structure(
7131            &drive,
7132            &sheets,
7133            &opts(
7134                StructureVerb::MoveRows {
7135                    sheet: "Q2".to_string(),
7136                    at: i64::MAX,
7137                    count: 5,
7138                    before: 1,
7139                },
7140                false,
7141            ),
7142            &[allow_rule("parent-1")],
7143        )
7144        .await;
7145        assert!(
7146            matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
7147            "{:?}",
7148            outcome.result
7149        );
7150    }
7151
7152    /// The dry run's second line is where `--before`'s pre-move numbering
7153    /// stops being a trap: rows 2-3 moved to before row 6 land on rows
7154    /// 4-5, because rows 4-5 slid up into the gap first.
7155    #[tokio::test]
7156    async fn move_rows_dry_run_describes_a_downward_move_and_its_landing_rows() {
7157        let server = wiremock::MockServer::start().await;
7158        let (drive, sheets) = clients(&server).await;
7159        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7160            .mount(&server)
7161            .await;
7162        mount_folder("parent-1").mount(&server).await;
7163        mount_workbook().mount(&server).await;
7164
7165        let outcome = structure(
7166            &drive,
7167            &sheets,
7168            &opts(move_rows(), true),
7169            &[allow_rule("parent-1")],
7170        )
7171        .await;
7172        let lines = describe_lines(&outcome);
7173        assert_eq!(lines.len(), 2, "{lines:?}");
7174        assert!(lines[0].contains("Would move 2 row(s) 2-3"), "{lines:?}");
7175        assert!(lines[0].contains("to before row 6"), "{lines:?}");
7176        assert_eq!(
7177            lines[1],
7178            "  (500 rows unchanged; rows 4-5 shift up to 2-3; moved rows land at 4-5)"
7179        );
7180    }
7181
7182    /// The mirror image: an upward move lands the block exactly on the
7183    /// number typed, and pushes everything from there to its old start
7184    /// forward by `--count`.
7185    #[tokio::test]
7186    async fn move_rows_dry_run_describes_an_upward_move_and_its_landing_rows() {
7187        let server = wiremock::MockServer::start().await;
7188        let (drive, sheets) = clients(&server).await;
7189        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7190            .mount(&server)
7191            .await;
7192        mount_folder("parent-1").mount(&server).await;
7193        mount_workbook().mount(&server).await;
7194
7195        let outcome = structure(
7196            &drive,
7197            &sheets,
7198            &opts(
7199                StructureVerb::MoveRows {
7200                    sheet: "Q2".to_string(),
7201                    at: 6,
7202                    count: 2,
7203                    before: 2,
7204                },
7205                true,
7206            ),
7207            &[allow_rule("parent-1")],
7208        )
7209        .await;
7210        let lines = describe_lines(&outcome);
7211        assert_eq!(lines.len(), 2, "{lines:?}");
7212        assert!(lines[0].contains("Would move 2 row(s) 6-7"), "{lines:?}");
7213        assert!(lines[0].contains("to before row 2"), "{lines:?}");
7214        assert_eq!(
7215            lines[1],
7216            "  (500 rows unchanged; rows 2-5 shift down to 4-7; moved rows land at 2-3)"
7217        );
7218    }
7219
7220    /// [`move_rows_dry_run_describes_a_downward_move_and_its_landing_rows`]'s
7221    /// twin on the other dimension, pinning `describe_would_move`'s
7222    /// `Dimension::Columns` wording (noun, plural, and `left`/`right`
7223    /// direction words) before the two branches are collapsed into one
7224    /// template.
7225    #[tokio::test]
7226    async fn move_columns_dry_run_describes_a_downward_move_and_its_landing_columns() {
7227        let server = wiremock::MockServer::start().await;
7228        let (drive, sheets) = clients(&server).await;
7229        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7230            .mount(&server)
7231            .await;
7232        mount_folder("parent-1").mount(&server).await;
7233        mount_workbook().mount(&server).await;
7234
7235        let outcome = structure(
7236            &drive,
7237            &sheets,
7238            &opts(
7239                StructureVerb::MoveColumns {
7240                    sheet: "Q2".to_string(),
7241                    at: 2,
7242                    count: 2,
7243                    before: 6,
7244                },
7245                true,
7246            ),
7247            &[allow_rule("parent-1")],
7248        )
7249        .await;
7250        let lines = describe_lines(&outcome);
7251        assert_eq!(lines.len(), 2, "{lines:?}");
7252        assert!(lines[0].contains("Would move 2 column(s) 2-3"), "{lines:?}");
7253        assert!(lines[0].contains("to before column 6"), "{lines:?}");
7254        assert_eq!(
7255            lines[1],
7256            "  (10 columns unchanged; columns 4-5 shift left to 2-3; moved columns land at 4-5)"
7257        );
7258    }
7259
7260    /// [`move_rows_dry_run_describes_an_upward_move_and_its_landing_rows`]'s
7261    /// twin on the other dimension — see the downward test above for why
7262    /// this pins the refactor.
7263    #[tokio::test]
7264    async fn move_columns_dry_run_describes_an_upward_move_and_its_landing_columns() {
7265        let server = wiremock::MockServer::start().await;
7266        let (drive, sheets) = clients(&server).await;
7267        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7268            .mount(&server)
7269            .await;
7270        mount_folder("parent-1").mount(&server).await;
7271        mount_workbook().mount(&server).await;
7272
7273        let outcome = structure(
7274            &drive,
7275            &sheets,
7276            &opts(
7277                StructureVerb::MoveColumns {
7278                    sheet: "Q2".to_string(),
7279                    at: 6,
7280                    count: 2,
7281                    before: 2,
7282                },
7283                true,
7284            ),
7285            &[allow_rule("parent-1")],
7286        )
7287        .await;
7288        let lines = describe_lines(&outcome);
7289        assert_eq!(lines.len(), 2, "{lines:?}");
7290        assert!(lines[0].contains("Would move 2 column(s) 6-7"), "{lines:?}");
7291        assert!(lines[0].contains("to before column 2"), "{lines:?}");
7292        assert_eq!(
7293            lines[1],
7294            "  (10 columns unchanged; columns 2-5 shift right to 4-7; moved columns land at 2-3)"
7295        );
7296    }
7297
7298    /// A preview and a real run share one classification by construction
7299    /// (the dry run returns after the gate, not before it), so a refusal
7300    /// must read identically either way — `--dry-run` may never promise a
7301    /// move the real run then refuses.
7302    #[tokio::test]
7303    async fn move_rows_dry_run_matches_the_real_run_refusal() {
7304        let server = wiremock::MockServer::start().await;
7305        let (drive, sheets) = clients(&server).await;
7306        mount_move_prereqs(&server).await;
7307        mount_workbook().mount(&server).await;
7308
7309        let verb = StructureVerb::MoveRows {
7310            sheet: "Q2".to_string(),
7311            at: 5,
7312            count: 1,
7313            before: 502,
7314        };
7315        let real = structure(
7316            &drive,
7317            &sheets,
7318            &opts(verb.clone(), false),
7319            &[allow_rule("parent-1")],
7320        )
7321        .await;
7322        let dry = structure(
7323            &drive,
7324            &sheets,
7325            &opts(verb, true),
7326            &[allow_rule("parent-1")],
7327        )
7328        .await;
7329        assert!(
7330            matches!(real.result, StructureResult::RefusedInvalidRange { .. }),
7331            "{:?}",
7332            real.result
7333        );
7334        assert_eq!(dry.result, real.result);
7335    }
7336
7337    /// Frozen panes are Sheets' own business. `moveDimension` interacts with
7338    /// them server-side (a frozen band does not travel with the move), and
7339    /// no client-side check here may grow around that: the request must be
7340    /// byte-identical to the one sent against a sheet with no frozen rows,
7341    /// and the move must not be refused. ADR-0073 §7's rule — Sheets is the
7342    /// authority on its own semantics.
7343    #[tokio::test]
7344    async fn move_rows_does_not_consult_frozen_rows() {
7345        let server = wiremock::MockServer::start().await;
7346        let (drive, sheets) = clients(&server).await;
7347        mount_move_prereqs(&server).await;
7348        mount_workbook_with_q2(serde_json::json!({
7349            "properties": {
7350                "sheetId": 118_293, "title": "Q2", "index": 1,
7351                "gridProperties": {
7352                    "rowCount": 500, "columnCount": 10, "frozenRowCount": 2,
7353                },
7354            },
7355        }))
7356        .mount(&server)
7357        .await;
7358
7359        let outcome = structure(
7360            &drive,
7361            &sheets,
7362            &opts(
7363                StructureVerb::MoveRows {
7364                    sheet: "Q2".to_string(),
7365                    at: 1,
7366                    count: 1,
7367                    before: 3,
7368                },
7369                false,
7370            ),
7371            &[allow_rule("parent-1")],
7372        )
7373        .await;
7374        assert!(
7375            matches!(outcome.result, StructureResult::Changed { .. }),
7376            "{:?}",
7377            outcome.result
7378        );
7379
7380        let requests = server.received_requests().await.unwrap();
7381        let body = sent_batch_update(&requests);
7382        assert_eq!(
7383            body["requests"][0]["moveDimension"],
7384            serde_json::json!({
7385                "source": {
7386                    "sheetId": 118_293, "dimension": "ROWS",
7387                    "startIndex": 0, "endIndex": 1,
7388                },
7389                "destinationIndex": 2,
7390            })
7391        );
7392    }
7393
7394    /// The same claim for row/column groups: an outline group whose range
7395    /// spans the block being moved is state this engine never reads, so it
7396    /// can neither refuse nor reshape the request. Sheets resolves what
7397    /// happens to the group.
7398    #[tokio::test]
7399    async fn move_rows_does_not_consult_dimension_groups() {
7400        let server = wiremock::MockServer::start().await;
7401        let (drive, sheets) = clients(&server).await;
7402        mount_move_prereqs(&server).await;
7403        mount_workbook_with_q2(serde_json::json!({
7404            "properties": {
7405                "sheetId": 118_293, "title": "Q2", "index": 1,
7406                "gridProperties": {"rowCount": 500, "columnCount": 10},
7407            },
7408            "rowGroups": [{
7409                "range": {
7410                    "sheetId": 118_293, "dimension": "ROWS",
7411                    "startIndex": 1, "endIndex": 4,
7412                },
7413            }],
7414        }))
7415        .mount(&server)
7416        .await;
7417
7418        let outcome = structure(
7419            &drive,
7420            &sheets,
7421            &opts(
7422                StructureVerb::MoveRows {
7423                    sheet: "Q2".to_string(),
7424                    at: 2,
7425                    count: 1,
7426                    before: 1,
7427                },
7428                false,
7429            ),
7430            &[allow_rule("parent-1")],
7431        )
7432        .await;
7433        assert!(
7434            matches!(outcome.result, StructureResult::Changed { .. }),
7435            "{:?}",
7436            outcome.result
7437        );
7438
7439        let requests = server.received_requests().await.unwrap();
7440        let body = sent_batch_update(&requests);
7441        assert_eq!(
7442            body["requests"][0]["moveDimension"],
7443            serde_json::json!({
7444                "source": {
7445                    "sheetId": 118_293, "dimension": "ROWS",
7446                    "startIndex": 1, "endIndex": 2,
7447                },
7448                "destinationIndex": 0,
7449            })
7450        );
7451    }
7452
7453    // ── update-workbook-properties (issue #1836) ────────────────────────
7454
7455    #[test]
7456    fn sheet_title_is_none_for_update_workbook_properties() {
7457        assert_eq!(update_workbook_properties().sheet_title(), None);
7458        assert_eq!(update_workbook_properties().new_sheet_title(), None);
7459        assert_eq!(update_workbook_properties().dimension(), None);
7460    }
7461
7462    #[test]
7463    fn resolve_sheet_returns_none_for_update_workbook_properties_without_consulting_sheets() {
7464        // An empty `sheets` list would make every other verb's
7465        // `resolve_sheet` fail with `RefusedSheetNotFound` — this verb must
7466        // return `Ok(None)` regardless, since it never looks.
7467        let workbook = Spreadsheet {
7468            spreadsheet_id: Some("sheet-1".to_string()),
7469            properties: None,
7470            sheets: Vec::new(),
7471            named_ranges: Vec::new(),
7472        };
7473        assert_eq!(
7474            resolve_sheet(&workbook, &update_workbook_properties()),
7475            Ok(None)
7476        );
7477    }
7478
7479    #[test]
7480    fn validate_verb_args_refuses_update_workbook_properties_with_no_fields() {
7481        let verb = StructureVerb::UpdateWorkbookProperties {
7482            locale: None,
7483            time_zone: None,
7484            auto_recalc: None,
7485            iterative_calculation: None,
7486            iterative_calculation_max_iterations: None,
7487            iterative_calculation_convergence_threshold: None,
7488        };
7489        let workbook = Spreadsheet::default();
7490        let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7491        assert!(
7492            matches!(&err, StructureResult::RefusedInvalidRange { detail }
7493                if detail.contains("at least one property")),
7494            "{err:?}"
7495        );
7496    }
7497
7498    fn update_time_zone(time_zone: &str) -> StructureVerb {
7499        StructureVerb::UpdateWorkbookProperties {
7500            locale: None,
7501            time_zone: Some(time_zone.to_string()),
7502            auto_recalc: None,
7503            iterative_calculation: None,
7504            iterative_calculation_max_iterations: None,
7505            iterative_calculation_convergence_threshold: None,
7506        }
7507    }
7508
7509    #[test]
7510    fn validate_verb_args_refuses_an_unrecognised_time_zone() {
7511        // The API accepts this with a 200 and resets the workbook to
7512        // `Etc/GMT` (issue #1938), so the refusal has to be local.
7513        let workbook = Spreadsheet::default();
7514        let err =
7515            validate_verb_args(&workbook, &update_time_zone("Bogus/Zone9"), None).unwrap_err();
7516        assert!(
7517            matches!(&err, StructureResult::RefusedInvalidRange { detail }
7518                if detail.contains("'Bogus/Zone9'")
7519                    && detail.contains("IANA")
7520                    && detail.contains("America/New_York")),
7521            "{err:?}"
7522        );
7523    }
7524
7525    #[test]
7526    fn validate_verb_args_accepts_recognised_iana_time_zones() {
7527        let workbook = Spreadsheet::default();
7528        for tz in ["Europe/London", "America/New_York", "Etc/GMT", "UTC"] {
7529            assert_eq!(
7530                validate_verb_args(&workbook, &update_time_zone(tz), None),
7531                Ok(()),
7532                "{tz}"
7533            );
7534        }
7535    }
7536
7537    #[test]
7538    fn validate_verb_args_refuses_iterative_calculation_settings_without_the_toggle() {
7539        let verb = StructureVerb::UpdateWorkbookProperties {
7540            locale: None,
7541            time_zone: None,
7542            auto_recalc: None,
7543            iterative_calculation: None,
7544            iterative_calculation_max_iterations: Some(50),
7545            iterative_calculation_convergence_threshold: None,
7546        };
7547        let workbook = Spreadsheet::default();
7548        let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7549        assert!(
7550            matches!(&err, StructureResult::RefusedInvalidRange { detail }
7551                if detail.contains("--iterative-calculation on")),
7552            "{err:?}"
7553        );
7554
7555        // Also refused when explicitly turning it *off*.
7556        let verb_off = StructureVerb::UpdateWorkbookProperties {
7557            locale: None,
7558            time_zone: None,
7559            auto_recalc: None,
7560            iterative_calculation: Some(IterativeCalculationToggle::Off),
7561            iterative_calculation_max_iterations: Some(50),
7562            iterative_calculation_convergence_threshold: None,
7563        };
7564        assert!(validate_verb_args(&workbook, &verb_off, None).is_err());
7565    }
7566
7567    #[test]
7568    fn validate_verb_args_refuses_non_positive_iterative_calculation_bounds() {
7569        let workbook = Spreadsheet::default();
7570        let bad_max = StructureVerb::UpdateWorkbookProperties {
7571            locale: None,
7572            time_zone: None,
7573            auto_recalc: None,
7574            iterative_calculation: Some(IterativeCalculationToggle::On),
7575            iterative_calculation_max_iterations: Some(0),
7576            iterative_calculation_convergence_threshold: None,
7577        };
7578        assert!(validate_verb_args(&workbook, &bad_max, None).is_err());
7579
7580        let bad_threshold = StructureVerb::UpdateWorkbookProperties {
7581            locale: None,
7582            time_zone: None,
7583            auto_recalc: None,
7584            iterative_calculation: Some(IterativeCalculationToggle::On),
7585            iterative_calculation_max_iterations: None,
7586            iterative_calculation_convergence_threshold: Some(0.0),
7587        };
7588        assert!(validate_verb_args(&workbook, &bad_threshold, None).is_err());
7589
7590        let nan_threshold = StructureVerb::UpdateWorkbookProperties {
7591            locale: None,
7592            time_zone: None,
7593            auto_recalc: None,
7594            iterative_calculation: Some(IterativeCalculationToggle::On),
7595            iterative_calculation_max_iterations: None,
7596            iterative_calculation_convergence_threshold: Some(f64::NAN),
7597        };
7598        assert!(validate_verb_args(&workbook, &nan_threshold, None).is_err());
7599    }
7600
7601    #[test]
7602    fn validate_verb_args_accepts_positive_iterative_calculation_bounds() {
7603        let workbook = Spreadsheet::default();
7604        let verb = StructureVerb::UpdateWorkbookProperties {
7605            locale: None,
7606            time_zone: None,
7607            auto_recalc: None,
7608            iterative_calculation: Some(IterativeCalculationToggle::On),
7609            iterative_calculation_max_iterations: Some(50),
7610            iterative_calculation_convergence_threshold: Some(0.01),
7611        };
7612        assert!(validate_verb_args(&workbook, &verb, None).is_ok());
7613    }
7614
7615    #[test]
7616    fn validate_verb_args_accepts_a_single_field() {
7617        let workbook = Spreadsheet::default();
7618        let verb = StructureVerb::UpdateWorkbookProperties {
7619            locale: Some("fr_FR".to_string()),
7620            time_zone: None,
7621            auto_recalc: None,
7622            iterative_calculation: None,
7623            iterative_calculation_max_iterations: None,
7624            iterative_calculation_convergence_threshold: None,
7625        };
7626        assert!(validate_verb_args(&workbook, &verb, None).is_ok());
7627    }
7628
7629    #[test]
7630    fn build_request_masks_exactly_the_fields_given() {
7631        let verb = StructureVerb::UpdateWorkbookProperties {
7632            locale: Some("en_US".to_string()),
7633            time_zone: None,
7634            auto_recalc: Some(RecalculationInterval::Hour),
7635            iterative_calculation: None,
7636            iterative_calculation_max_iterations: None,
7637            iterative_calculation_convergence_threshold: None,
7638        };
7639        let request = build_request(&verb, None).unwrap();
7640        let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7641            panic!("expected UpdateSpreadsheetProperties, got {request:?}"); // omni-dev: coverage ignore-line reason="build_request always returns UpdateSpreadsheetProperties for an UpdateWorkbookProperties verb, so this else-arm exists only to unwrap the shared enum"
7642        };
7643        assert_eq!(req.fields, "locale,autoRecalc");
7644        assert_eq!(req.properties.locale.as_deref(), Some("en_US"));
7645        assert_eq!(req.properties.time_zone, None);
7646        assert_eq!(
7647            req.properties.auto_recalc,
7648            Some(RecalculationInterval::Hour)
7649        );
7650        assert_eq!(req.properties.iterative_calculation_settings, None);
7651    }
7652
7653    #[test]
7654    fn build_request_turns_iterative_calculation_on_with_explicit_settings() {
7655        let verb = StructureVerb::UpdateWorkbookProperties {
7656            locale: None,
7657            time_zone: None,
7658            auto_recalc: None,
7659            iterative_calculation: Some(IterativeCalculationToggle::On),
7660            iterative_calculation_max_iterations: Some(50),
7661            iterative_calculation_convergence_threshold: Some(0.01),
7662        };
7663        let request = build_request(&verb, None).unwrap();
7664        let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7665            panic!("expected UpdateSpreadsheetProperties, got {request:?}"); // omni-dev: coverage ignore-line reason="build_request always returns UpdateSpreadsheetProperties for an UpdateWorkbookProperties verb, so this else-arm exists only to unwrap the shared enum"
7666        };
7667        assert_eq!(req.fields, "iterativeCalculationSettings");
7668        assert_eq!(
7669            req.properties.iterative_calculation_settings,
7670            Some(IterativeCalculationSettings {
7671                max_iterations: Some(50),
7672                convergence_threshold: Some(0.01),
7673            })
7674        );
7675    }
7676
7677    #[test]
7678    fn build_request_turns_iterative_calculation_off_by_naming_the_field_without_a_value() {
7679        // The field-mask idiom `IterativeCalculationSettings`'s doc comment
7680        // describes: `fields` names the property, but
7681        // `iterative_calculation_settings` itself stays `None`, so it is
7682        // omitted from the JSON body and the server clears it.
7683        let verb = StructureVerb::UpdateWorkbookProperties {
7684            locale: None,
7685            time_zone: None,
7686            auto_recalc: None,
7687            iterative_calculation: Some(IterativeCalculationToggle::Off),
7688            iterative_calculation_max_iterations: None,
7689            iterative_calculation_convergence_threshold: None,
7690        };
7691        let request = build_request(&verb, None).unwrap();
7692        let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7693            panic!("expected UpdateSpreadsheetProperties, got {request:?}"); // omni-dev: coverage ignore-line reason="build_request always returns UpdateSpreadsheetProperties for an UpdateWorkbookProperties verb, so this else-arm exists only to unwrap the shared enum"
7694        };
7695        assert_eq!(req.fields, "iterativeCalculationSettings");
7696        assert_eq!(req.properties.iterative_calculation_settings, None);
7697        let json = serde_json::to_value(&req.properties).unwrap();
7698        assert!(
7699            json.as_object().unwrap().is_empty(),
7700            "expected an empty properties object, got {json}"
7701        );
7702    }
7703
7704    #[test]
7705    fn workbook_properties_summary_formats_every_field() {
7706        let verb = StructureVerb::UpdateWorkbookProperties {
7707            locale: Some("en_US".to_string()),
7708            time_zone: Some("America/New_York".to_string()),
7709            auto_recalc: Some(RecalculationInterval::OnChange),
7710            iterative_calculation: Some(IterativeCalculationToggle::On),
7711            iterative_calculation_max_iterations: Some(50),
7712            iterative_calculation_convergence_threshold: Some(0.01),
7713        };
7714        let summary = workbook_properties_summary(&verb);
7715        assert_eq!(
7716            summary,
7717            "locale -> 'en_US', time zone -> 'America/New_York', \
7718             auto-recalc -> ON_CHANGE, iterative calculation -> on \
7719             (max 50 iterations, threshold 0.01)"
7720        );
7721    }
7722
7723    #[tokio::test]
7724    async fn update_workbook_properties_dry_run_reports_would_change_with_no_sheet() {
7725        let server = wiremock::MockServer::start().await;
7726        let (drive, sheets) = clients(&server).await;
7727        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7728            .mount(&server)
7729            .await;
7730        mount_folder("parent-1").mount(&server).await;
7731        mount_workbook().mount(&server).await;
7732
7733        let outcome = structure(
7734            &drive,
7735            &sheets,
7736            &opts(update_workbook_properties(), true),
7737            &[allow_rule("parent-1")],
7738        )
7739        .await;
7740        match &outcome.result {
7741            StructureResult::WouldChange { sheet, sheet_count } => {
7742                assert_eq!(*sheet, None);
7743                assert_eq!(*sheet_count, 2);
7744            }
7745            other => panic!("expected WouldChange, got {other:?}"), // omni-dev: coverage ignore-line reason="guards this test's assumption; a dry run against an allowed target always reaches WouldChange here"
7746        }
7747        let lines = describe_lines(&outcome);
7748        assert_eq!(lines.len(), 1);
7749        assert!(
7750            lines[0].starts_with("Would update workbook properties of 'sheet-1'"),
7751            "{lines:?}"
7752        );
7753
7754        // A dry run reads the workbook (to report `sheet_count` honestly)
7755        // but never calls `batchUpdate`.
7756        let requests = server.received_requests().await.unwrap();
7757        assert!(!requests
7758            .iter()
7759            .any(|r| r.url.path().ends_with(":batchUpdate")));
7760    }
7761
7762    #[tokio::test]
7763    async fn update_workbook_properties_refuses_an_unrecognised_time_zone_on_dry_run_and_apply() {
7764        // Issue #1938: a dry run used to report "would change" for a zone the
7765        // API silently replaces with `Etc/GMT`. Both paths now refuse it.
7766        for dry_run in [true, false] {
7767            let server = wiremock::MockServer::start().await;
7768            let (drive, sheets) = clients(&server).await;
7769            mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7770                .mount(&server)
7771                .await;
7772            mount_folder("parent-1").mount(&server).await;
7773            mount_workbook().mount(&server).await;
7774
7775            let outcome = structure(
7776                &drive,
7777                &sheets,
7778                &opts(update_time_zone("Bogus/Zone9"), dry_run),
7779                &[allow_rule("parent-1")],
7780            )
7781            .await;
7782            assert!(
7783                matches!(&outcome.result, StructureResult::RefusedInvalidRange { detail }
7784                    if detail.contains("'Bogus/Zone9'")),
7785                "dry_run={dry_run}: {:?}",
7786                outcome.result
7787            );
7788            let requests = server.received_requests().await.unwrap();
7789            assert!(
7790                !requests
7791                    .iter()
7792                    .any(|r| r.url.path().ends_with(":batchUpdate")),
7793                "dry_run={dry_run}"
7794            );
7795        }
7796    }
7797
7798    #[tokio::test]
7799    async fn update_workbook_properties_apply_sends_the_masked_request_and_reports_changed() {
7800        let server = wiremock::MockServer::start().await;
7801        let (drive, sheets) = clients(&server).await;
7802        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7803            .mount(&server)
7804            .await;
7805        mount_folder("parent-1").mount(&server).await;
7806        mount_workbook().mount(&server).await;
7807        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
7808            .mount(&server)
7809            .await;
7810
7811        let outcome = structure(
7812            &drive,
7813            &sheets,
7814            &opts(update_workbook_properties(), false),
7815            &[allow_rule("parent-1")],
7816        )
7817        .await;
7818        match &outcome.result {
7819            StructureResult::Changed {
7820                sheet, sheet_id, ..
7821            } => {
7822                assert_eq!(*sheet, None);
7823                assert_eq!(*sheet_id, None);
7824            }
7825            other => panic!("expected Changed, got {other:?}"), // omni-dev: coverage ignore-line reason="guards this test's assumption; an apply against an allowed target with a mocked batchUpdate always reaches Changed here"
7826        }
7827
7828        let requests = server.received_requests().await.unwrap();
7829        let body = sent_batch_update(&requests);
7830        let update = &body["requests"][0]["updateSpreadsheetProperties"];
7831        assert_eq!(update["properties"]["locale"], "en_US");
7832        assert_eq!(update["properties"]["timeZone"], "America/New_York");
7833        assert_eq!(update["properties"]["autoRecalc"], "ON_CHANGE");
7834        assert_eq!(update["fields"], "locale,timeZone,autoRecalc");
7835    }
7836
7837    #[test]
7838    fn record_attempt_carries_the_fields_changed_summary_and_no_sheet_title() {
7839        let opts = opts(update_workbook_properties(), false);
7840        assert_eq!(opts.verb.sheet_title(), None);
7841        assert_eq!(
7842            workbook_properties_summary(&opts.verb),
7843            "locale -> 'en_US', time zone -> 'America/New_York', auto-recalc -> ON_CHANGE"
7844        );
7845    }
7846
7847    // ── plumbing ───────────────────────────────────────────────────────
7848
7849    #[test]
7850    fn log_operation_is_distinct_per_verb() {
7851        let verbs = [
7852            add_sheet(),
7853            rename(),
7854            insert_rows(),
7855            StructureVerb::InsertColumns {
7856                sheet: "Q2".to_string(),
7857                at: 1,
7858                count: 1,
7859            },
7860            insert_range(),
7861            move_rows(),
7862            StructureVerb::MoveColumns {
7863                sheet: "Q2".to_string(),
7864                at: 1,
7865                count: 1,
7866                before: 3,
7867            },
7868            delete_sheet(),
7869            delete_rows(),
7870            StructureVerb::DeleteColumns {
7871                sheet: "Q2".to_string(),
7872                at: 1,
7873                count: 1,
7874            },
7875            delete_range(),
7876            duplicate_sheet(),
7877            reorder_sheet(),
7878            hide_sheet(),
7879            show_sheet(),
7880            update_workbook_properties(),
7881        ];
7882        let names: Vec<&str> = verbs.iter().map(StructureVerb::log_operation).collect();
7883        assert_eq!(
7884            names,
7885            vec![
7886                "sheets-add-sheet",
7887                "sheets-rename-sheet",
7888                "sheets-insert-rows",
7889                "sheets-insert-columns",
7890                "sheets-insert-range",
7891                "sheets-move-rows",
7892                "sheets-move-columns",
7893                "sheets-delete-sheet",
7894                "sheets-delete-rows",
7895                "sheets-delete-columns",
7896                "sheets-delete-range",
7897                "sheets-duplicate-sheet",
7898                "sheets-reorder-sheet",
7899                "sheets-hide-sheet",
7900                "sheets-show-sheet",
7901                "sheets-update-workbook-properties",
7902            ]
7903        );
7904        // One record per user-visible verb means the operations must not
7905        // collide with each other or with the cell verbs.
7906        let unique: HashSet<&&str> = names.iter().collect();
7907        assert_eq!(unique.len(), names.len());
7908        assert!(!names.contains(&"sheets-write"));
7909    }
7910
7911    #[test]
7912    fn insert_range_records_its_one_based_inclusive_grid_context() {
7913        assert_eq!(
7914            grid_range_label(&insert_range()).as_deref(),
7915            Some("rows 2-4, columns 2-3")
7916        );
7917    }
7918
7919    /// One of every [`StructureResult`] variant.
7920    ///
7921    /// Exhaustive and wildcard-free on purpose, mirroring `write.rs`'s
7922    /// `every_write_result`: adding a variant breaks this build, which is
7923    /// what forces the new arm through the newline check below.
7924    fn every_structure_result() -> Vec<StructureResult> {
7925        let all = vec![
7926            StructureResult::WouldChange {
7927                sheet: Some(Box::new(SheetSnapshot {
7928                    sheet_id: Some(7),
7929                    title: "Q2".to_string(),
7930                    row_count: Some(500),
7931                    column_count: Some(26),
7932                    ..Default::default()
7933                })),
7934                sheet_count: 2,
7935            },
7936            StructureResult::RefusedNotASpreadsheet {
7937                mime_type: "application/pdf".to_string(),
7938            },
7939            StructureResult::RefusedShortcut,
7940            StructureResult::RefusedNoVisibleParents,
7941            StructureResult::RefusedSheetNotFound {
7942                title: "Nope".to_string(),
7943                available: vec!["Q1".to_string(), "Q2".to_string()],
7944            },
7945            StructureResult::RefusedSheetExists {
7946                title: "Q1".to_string(),
7947            },
7948            StructureResult::RefusedInvalidRange {
7949                detail: "--count must be at least 1, got 0".to_string(),
7950            },
7951            StructureResult::Blocked { decided_by: None },
7952            StructureResult::RefusedNoLease,
7953            StructureResult::RefusedLeaseExpired,
7954            StructureResult::RefusedLeaseWrongFile,
7955            StructureResult::RefusedLeaseStale,
7956            StructureResult::Unchanged {
7957                detail: "sheet 'Q2' is already hidden".to_string(),
7958            },
7959            StructureResult::Changed {
7960                sheet: Some(Box::new(SheetSnapshot {
7961                    sheet_id: Some(7),
7962                    title: "Q2".to_string(),
7963                    row_count: Some(500),
7964                    column_count: Some(26),
7965                    ..Default::default()
7966                })),
7967                sheet_id: Some(7),
7968                backup: Some(Box::new(LeaseBackup::DriveCopy {
7969                    file_id: "backup-copy-1".to_string(),
7970                })),
7971            },
7972            StructureResult::Failed {
7973                detail: "boom".to_string(),
7974            },
7975        ];
7976        // Compile-time exhaustiveness: a new variant fails to match here.
7977        for result in &all {
7978            match result {
7979                StructureResult::WouldChange { .. }
7980                | StructureResult::RefusedNotASpreadsheet { .. }
7981                | StructureResult::RefusedShortcut
7982                | StructureResult::RefusedNoVisibleParents
7983                | StructureResult::RefusedSheetNotFound { .. }
7984                | StructureResult::RefusedSheetExists { .. }
7985                | StructureResult::RefusedInvalidRange { .. }
7986                | StructureResult::Blocked { .. }
7987                | StructureResult::RefusedNoLease
7988                | StructureResult::RefusedLeaseExpired
7989                | StructureResult::RefusedLeaseWrongFile
7990                | StructureResult::RefusedLeaseStale
7991                | StructureResult::Unchanged { .. }
7992                | StructureResult::Changed { .. }
7993                | StructureResult::Failed { .. } => {}
7994            }
7995        }
7996        all
7997    }
7998
7999    /// No `describe_lines` line ever contains a newline, and the insert
8000    /// preview is the only arm that emits a second line.
8001    ///
8002    /// Both halves are load-bearing, and they are different claims. The
8003    /// second is presentation: `write.rs`/`create.rs` pin every arm to one
8004    /// line, and a structural insert earns its extra one because the shift is
8005    /// the substance of the dry run (ADR-0075 §6).
8006    ///
8007    /// The first is what makes `cli::drive::sheets::structure`'s sanitizing
8008    /// sound. Every line here interpolates untrusted text — a Drive-supplied
8009    /// file name, workbook sheet titles, raw API error details — and the CLI
8010    /// strips control characters from each line before supplying the
8011    /// separators itself. That is only equivalent to filtering each
8012    /// interpolation while this module contributes no newline of its own: a
8013    /// line built with an embedded `\n` would smuggle a separator past the
8014    /// filter and let an injected one pass for a real one.
8015    ///
8016    /// The wildcard-free `every_structure_result` is the other half — a new
8017    /// variant will not compile until it is listed there, so it cannot reach
8018    /// the terminal without passing through this check.
8019    #[test]
8020    fn no_describe_line_contains_a_newline() {
8021        for verb in [
8022            add_sheet(),
8023            rename(),
8024            insert_rows(),
8025            StructureVerb::InsertColumns {
8026                sheet: "Q2".to_string(),
8027                at: 2,
8028                count: 1,
8029            },
8030            move_rows(),
8031            StructureVerb::MoveColumns {
8032                sheet: "Q2".to_string(),
8033                at: 2,
8034                count: 1,
8035                before: 5,
8036            },
8037            delete_sheet(),
8038            delete_rows(),
8039            delete_columns(),
8040            insert_range(),
8041            insert_range_columns_shift(),
8042            delete_range(),
8043            delete_range_columns_shift(),
8044            duplicate_sheet(),
8045            duplicate_sheet_default(),
8046            reorder_sheet(),
8047            hide_sheet(),
8048            show_sheet(),
8049            update_workbook_properties(),
8050        ] {
8051            // A verb with a single axis (insert, delete-dimension or
8052            // move-dimension) earns a second `WouldChange` line for the
8053            // shift; every other verb, additive or destructive, stays one
8054            // line.
8055            let has_dimension_shift = verb.dimension().is_some();
8056            for result in every_structure_result() {
8057                let previews_an_insert =
8058                    has_dimension_shift && matches!(result, StructureResult::WouldChange { .. });
8059                let outcome = StructureOutcome {
8060                    spreadsheet_id: "sheet-1".to_string(),
8061                    file_name: Some("Budget".to_string()),
8062                    resolved_folder_id: None,
8063                    verb: verb.clone(),
8064                    result,
8065                };
8066                let lines = describe_lines(&outcome);
8067                assert_eq!(
8068                    lines.len(),
8069                    usize::from(previews_an_insert) + 1,
8070                    "unexpected line count for {:?}/{:?}: {lines:?}",
8071                    outcome.verb,
8072                    outcome.result
8073                );
8074                for rendered in &lines {
8075                    assert!(
8076                        !rendered.chars().any(char::is_control),
8077                        "describe_lines emitted a control character for {:?}/{:?}: \
8078                         {rendered:?}",
8079                        outcome.verb,
8080                        outcome.result
8081                    );
8082                }
8083                // `describe` is the join, so it reconstructs exactly the
8084                // separators the CLI would supply and nothing else.
8085                assert_eq!(describe(&outcome), lines.join("\n"));
8086            }
8087        }
8088    }
8089
8090    /// [`describe_would_delete_dimension`]'s own defensive branches, reached
8091    /// with arguments `validate_verb_args` would already have refused (an
8092    /// overflowing `--at`/`--count`) or a workbook snapshot the real pipeline
8093    /// never hands it (`sheet: None`, or a size too small to subtract
8094    /// `count` from) — the `describe_lines(&outcome)` bypass used above lets
8095    /// a test construct exactly that, the same way
8096    /// `dimension_range_label_omits_a_span_it_cannot_compute` does for
8097    /// `record_attempt`'s logging path.
8098    #[test]
8099    fn describe_would_delete_dimension_handles_unvalidated_edge_cases() {
8100        let would_change = |verb: StructureVerb, sheet: Option<SheetSnapshot>| StructureOutcome {
8101            spreadsheet_id: "sheet-1".to_string(),
8102            file_name: Some("Budget".to_string()),
8103            resolved_folder_id: None,
8104            verb,
8105            result: StructureResult::WouldChange {
8106                sheet: sheet.map(Box::new),
8107                sheet_count: 2,
8108            },
8109        };
8110        // `sheet_id: None` keeps every expected string free of the
8111        // `(sheetId N)` suffix, which is orthogonal to what this test checks.
8112        let sheet_with_columns = |column_count| SheetSnapshot {
8113            sheet_id: None,
8114            title: "Q2".to_string(),
8115            row_count: None,
8116            column_count: Some(column_count),
8117            ..Default::default()
8118        };
8119
8120        // `--at`/`--count` overflowing the `last` computation: no dash-range
8121        // or shift line, just the plain summary.
8122        let overflow = describe(&would_change(
8123            StructureVerb::DeleteColumns {
8124                sheet: "Q2".to_string(),
8125                at: i64::MAX,
8126                count: 2,
8127            },
8128            Some(sheet_with_columns(26)),
8129        ));
8130        assert!(
8131            overflow.contains("Would delete 2 column(s) from column"),
8132            "{overflow}"
8133        );
8134        assert!(!overflow.contains('-'), "{overflow}");
8135
8136        // No sheet snapshot at all: the shift line needs a real "before"
8137        // size, so it is omitted rather than guessed.
8138        let no_sheet = describe(&would_change(delete_columns(), None));
8139        assert_eq!(no_sheet, "Would delete 2 column(s) 3-4 of 'Q2' in 'Budget'");
8140
8141        // A `before` too small for `count` to subtract from without
8142        // underflowing `i64::MIN`: also omitted rather than reported wrong.
8143        let after_underflows = describe(&would_change(
8144            StructureVerb::DeleteColumns {
8145                sheet: "Q2".to_string(),
8146                at: 0,
8147                count: i64::MAX,
8148            },
8149            Some(sheet_with_columns(-2)),
8150        ));
8151        assert_eq!(
8152            after_underflows,
8153            format!(
8154                "Would delete {} column(s) 0-{} of 'Q2' in 'Budget'",
8155                i64::MAX,
8156                i64::MAX - 1
8157            )
8158        );
8159
8160        // Deleting exactly through the sheet's last column: "nothing
8161        // remains", not an inverted "existing N-M shift".
8162        let to_the_end = describe(&would_change(
8163            StructureVerb::DeleteColumns {
8164                sheet: "Q2".to_string(),
8165                at: 25,
8166                count: 2,
8167            },
8168            Some(sheet_with_columns(26)),
8169        ));
8170        assert!(
8171            to_the_end.contains("nothing remains after the deleted columns"),
8172            "{to_the_end}"
8173        );
8174    }
8175
8176    #[test]
8177    fn log_status_covers_every_variant() {
8178        let statuses = [
8179            StructureResult::WouldChange {
8180                sheet: None,
8181                sheet_count: 1,
8182            }
8183            .log_status(),
8184            StructureResult::RefusedNotASpreadsheet {
8185                mime_type: "application/pdf".to_string(),
8186            }
8187            .log_status(),
8188            StructureResult::RefusedShortcut.log_status(),
8189            StructureResult::RefusedNoVisibleParents.log_status(),
8190            StructureResult::RefusedSheetNotFound {
8191                title: "x".to_string(),
8192                available: Vec::new(),
8193            }
8194            .log_status(),
8195            StructureResult::RefusedSheetExists {
8196                title: "x".to_string(),
8197            }
8198            .log_status(),
8199            StructureResult::RefusedInvalidRange {
8200                detail: "x".to_string(),
8201            }
8202            .log_status(),
8203            StructureResult::Blocked { decided_by: None }.log_status(),
8204            StructureResult::RefusedNoLease.log_status(),
8205            StructureResult::RefusedLeaseExpired.log_status(),
8206            StructureResult::RefusedLeaseWrongFile.log_status(),
8207            StructureResult::RefusedLeaseStale.log_status(),
8208            StructureResult::Unchanged {
8209                detail: "x".to_string(),
8210            }
8211            .log_status(),
8212            StructureResult::Changed {
8213                sheet: None,
8214                sheet_id: None,
8215                backup: None,
8216            }
8217            .log_status(),
8218            StructureResult::Failed {
8219                detail: "x".to_string(),
8220            }
8221            .log_status(),
8222        ];
8223        let unique: HashSet<&&str> = statuses.iter().collect();
8224        assert_eq!(unique.len(), statuses.len(), "statuses must be distinct");
8225        assert!(statuses.iter().all(|s| !s.is_empty()));
8226    }
8227
8228    #[test]
8229    fn a_verdict_of_allow_is_what_lets_a_run_proceed() {
8230        // Pins the gate's vocabulary this module depends on, so a rename of
8231        // the enum can't silently invert the check above.
8232        assert_ne!(Verdict::Allow, Verdict::Deny);
8233    }
8234
8235    #[test]
8236    fn build_request_fails_rather_than_guessing_a_missing_sheet_id() {
8237        // Sheet id 0 is a real sheet (the first tab), so defaulting would
8238        // aim a rename at the wrong tab.
8239        let sheet = SheetSnapshot {
8240            sheet_id: None,
8241            title: "Q2".to_string(),
8242            row_count: None,
8243            column_count: None,
8244            ..Default::default()
8245        };
8246        let err = build_request(&rename(), Some(&sheet)).unwrap_err();
8247        assert!(err.contains("sheetId"), "{err}");
8248    }
8249
8250    #[test]
8251    fn added_sheet_id_reads_the_add_sheet_reply_only() {
8252        let empty: BatchUpdateResponse =
8253            serde_json::from_value(serde_json::json!({"spreadsheetId": "s", "replies": [{}]}))
8254                .unwrap();
8255        assert_eq!(added_sheet_id(&empty), None);
8256        let added: BatchUpdateResponse = serde_json::from_value(serde_json::json!({
8257            "replies": [{"addSheet": {"properties": {"sheetId": 42, "title": "New"}}}]
8258        }))
8259        .unwrap();
8260        assert_eq!(added_sheet_id(&added), Some(42));
8261    }
8262
8263    // ── file-id rules (issue #1612) ────────────────────────────────────
8264
8265    #[tokio::test]
8266    async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
8267        // The case issue #1612 exists for: before file rules there was no
8268        // rule an operator could write that would permit this at all.
8269        let server = wiremock::MockServer::start().await;
8270        let (drive, sheets) = clients(&server).await;
8271        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8272            .mount(&server)
8273            .await;
8274        mount_workbook().mount(&server).await;
8275        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8276            .expect(1)
8277            .mount(&server)
8278            .await;
8279
8280        let outcome = structure(
8281            &drive,
8282            &sheets,
8283            &opts(rename(), false),
8284            &[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsStructure])],
8285        )
8286        .await;
8287
8288        assert!(
8289            matches!(outcome.result, StructureResult::Changed { .. }),
8290            "{:?}",
8291            outcome.result
8292        );
8293    }
8294
8295    #[tokio::test]
8296    async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
8297        // Depth −1 beats depth 0 in the restrictive direction too.
8298        let server = wiremock::MockServer::start().await;
8299        let (drive, sheets) = clients(&server).await;
8300        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8301            .mount(&server)
8302            .await;
8303        // No mock for parent-1 and none for any Sheets endpoint: the file
8304        // rule must decide before either is reached.
8305
8306        let outcome = structure(
8307            &drive,
8308            &sheets,
8309            &opts(rename(), false),
8310            &[
8311                allow_rule("parent-1"),
8312                FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsStructure]),
8313            ],
8314        )
8315        .await;
8316
8317        match &outcome.result {
8318            StructureResult::Blocked { decided_by } => {
8319                let rule = decided_by.as_ref().expect("a file rule decided this");
8320                assert_eq!(rule.kind_label(), "file");
8321                assert_eq!(rule.id(), "sheet-1");
8322            }
8323            other => panic!("expected Blocked, got {other:?}"),
8324        }
8325        let text = describe(&outcome);
8326        assert!(text.contains("refused by rule on file sheet-1"), "{text}");
8327        assert!(!text.contains("depth"), "a file rule has no depth: {text}");
8328    }
8329
8330    #[tokio::test]
8331    async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
8332        let server = wiremock::MockServer::start().await;
8333        let (drive, sheets) = clients(&server).await;
8334        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8335            .mount(&server)
8336            .await;
8337        let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
8338        let text = describe(&outcome);
8339        assert!(text.contains("file_id"), "{text}");
8340        assert!(text.contains("sheets-structure"), "{text}");
8341    }
8342
8343    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
8344
8345    #[tokio::test]
8346    async fn a_leased_structure_change_concludes_its_audit_pair_with_allowed() {
8347        let server = wiremock::MockServer::start().await;
8348        let (drive, sheets) = clients(&server).await;
8349        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8350            .mount(&server)
8351            .await;
8352        mount_folder("parent-1").mount(&server).await;
8353        mount_workbook().mount(&server).await;
8354        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8355            .mount(&server)
8356            .await;
8357        let dir = tempfile::tempdir().unwrap();
8358        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8359        let verb = StructureVerb::RenameSheet {
8360            sheet: "Q2".to_string(),
8361            new_title: "Q2 (final)".to_string(),
8362        };
8363
8364        let outcome = structure(
8365            &drive,
8366            &sheets,
8367            &opts(verb, false),
8368            &[allow_rule("parent-1")],
8369        )
8370        .await;
8371        assert!(matches!(outcome.result, StructureResult::Changed { .. }));
8372
8373        let records = audit.records();
8374        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
8375        // The verb, not the engine — the same `["drive", <log_operation>]`
8376        // this write's `drivemutation` record carries, so an auditor can
8377        // see which verb ran without joining back to `log.jsonl`.
8378        assert_eq!(records[0].command, ["drive", "sheets-rename-sheet"]);
8379    }
8380
8381    /// #1688: a sheet that resolved by title but reported no `sheetId` (a
8382    /// server contract violation, see [`build_request_fails_rather_than_guessing_a_missing_sheet_id`])
8383    /// is exactly the case `build_request` refuses on — and it must never
8384    /// open a `pending` audit record for a write that can never be issued.
8385    /// `build_request_fails_rather_than_guessing_a_missing_sheet_id` covers
8386    /// the pure function; this covers the engine path through it, which was
8387    /// untested before this fix (the bug this test guards against left the
8388    /// `pending` record unconcluded).
8389    #[tokio::test]
8390    async fn a_missing_sheet_id_fails_before_opening_the_audit_pair() {
8391        let server = wiremock::MockServer::start().await;
8392        let (drive, sheets) = clients(&server).await;
8393        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8394            .mount(&server)
8395            .await;
8396        mount_folder("parent-1").mount(&server).await;
8397        wiremock::Mock::given(wiremock::matchers::method("GET"))
8398            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
8399            .respond_with(
8400                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8401                    "spreadsheetId": "sheet-1",
8402                    "properties": {"title": "Budget"},
8403                    "sheets": [
8404                        {"properties": {"title": "Q2", "index": 0}}
8405                    ],
8406                })),
8407            )
8408            .mount(&server)
8409            .await;
8410        // No batchUpdate mock mounted: `build_request`'s own missing-`sheetId`
8411        // check is the one that must catch this, before any mutating call.
8412        let dir = tempfile::tempdir().unwrap();
8413        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8414
8415        let outcome = structure(
8416            &drive,
8417            &sheets,
8418            &opts(rename(), false),
8419            &[allow_rule("parent-1")],
8420        )
8421        .await;
8422        match outcome.result {
8423            StructureResult::Failed { detail } => {
8424                assert!(detail.contains("sheetId"), "{detail}");
8425            }
8426            other => panic!("expected Failed, got {other:?}"),
8427        }
8428        assert!(audit.records().is_empty(), "{:?}", audit.records());
8429    }
8430}