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