Skip to main content

omni_dev/drive/sheets/
format.rs

1//! Cell/border formatting, merging and auto-resize via
2//! `spreadsheets.batchUpdate` (issue #1643,
3//! [ADR-0077](../../../docs/adrs/adr-0077.md)).
4//!
5//! Six verbs, all gated by [`DriveOperation::SheetsStructure`] — none
6//! destroy data, with one deliberate exception called out below.
7//!
8//! Shape follows `structure.rs` exactly: `target_gate::resolve` for the
9//! target and gate, then a `spreadsheets.get` (needed to resolve the
10//! target sheet's numeric id and, for `merge-cells`, its current values),
11//! then a `--dry-run` early return, then the mutation, then one
12//! `drivemutation` record. Unlike `structure.rs`'s verbs (each addressing a
13//! whole *sheet*), these address a *range*, composed from `--sheet`/
14//! `--range` exactly as `write.rs`'s cell verbs do (`a1::compose`), then
15//! converted to a numeric [`GridRange`] via `grid_range::parse_grid_range`.
16//!
17//! **`format-cells` cannot write a value.** It builds a `repeatCell`
18//! request whose `cell` is [`RepeatCellData`] — a type with no
19//! `userEnteredValue` field at all — so there is nowhere to put one
20//! regardless of what a caller asks for. This is `format-cells`'
21//! structural answer to "does `updateCells` belong here or in the
22//! cell-write surface": it uses `repeatCell` exclusively and never
23//! `updateCells`, so the `sheets-structure` grant can never be used to
24//! write a value the way `sheets-write` can.
25//!
26//! **`merge-cells` is the one request here that discards data.** Merging a
27//! range keeps only the top-left cell's value; every other non-blank cell
28//! in the range is silently dropped. Both the dry run and the real run
29//! read the range's current values first (one `values.get`, after the gate
30//! like every other read in this module) and the outcome — and its
31//! `--dry-run` preview — name every non-top-left, non-blank cell that
32//! would be lost, exactly the honesty ADR-0075 §6 requires of a preview
33//! whose effect a bounded range can't otherwise express.
34
35use std::path::PathBuf;
36use std::time::{Duration, Instant};
37
38use serde::Serialize;
39
40use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
41use crate::drive::client::DriveClient;
42use crate::drive::files_api::FilesApi;
43use crate::drive::lease::check::{
44    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
45    LeasedWrite,
46};
47use crate::drive::sheets::a1;
48use crate::drive::sheets::api::{
49    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
50};
51use crate::drive::sheets::client::SheetsClient;
52use crate::drive::sheets::grid_range;
53use crate::drive::sheets::target_gate;
54use crate::drive::sheets::types::{
55    AutoResizeDimensionsRequest, BatchUpdateRequestItem, Border, CellFormat, ColorStyle, Dimension,
56    DimensionProperties, DimensionRange, GridRange, MergeCellsRequest, NumberFormat, Padding,
57    RepeatCellData, RepeatCellRequest, Spreadsheet, TextFormat, TextRotation, UnmergeCellsRequest,
58    UpdateBordersRequest, UpdateDimensionPropertiesRequest, ValueRange,
59};
60use crate::drive::types::SheetTargetRefusal;
61use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
62use crate::request_log::{self, DriveMutationOutcome};
63
64/// The `--text-rotation-angle`/`--text-rotation-vertical` choice.
65///
66/// Unlike two independent `Option` fields, this makes "both set" — the one
67/// invalid state for `CellFormat.textRotation` — unrepresentable, rather
68/// than relying on a runtime check to reject it after the fact. The CLI's
69/// `conflicts_with` on the two flags is what keeps user input flowing into
70/// exactly one variant.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum TextRotationFlag {
73    /// Rotation angle in degrees, -90 to 90 ([`build_cell_format`] refuses
74    /// anything outside that range).
75    Angle(i64),
76    /// Stack text vertically instead of rotating it.
77    Vertical,
78}
79
80/// The `CellFormat` flags `format-cells` exposes.
81///
82/// Every field optional; [`build_cell_format`] refuses an all-`None` set
83/// and builds the `fields` mask from exactly the ones populated, following
84/// `SheetPropertiesUpdate`'s discipline.
85#[derive(Debug, Clone, Default, PartialEq, Eq)]
86pub struct CellFormatFlags {
87    /// Bold.
88    pub bold: Option<bool>,
89    /// Italic.
90    pub italic: Option<bool>,
91    /// Strikethrough.
92    pub strikethrough: Option<bool>,
93    /// Underline.
94    pub underline: Option<bool>,
95    /// Point size.
96    pub font_size: Option<i64>,
97    /// `#RRGGBB` text color.
98    pub text_color: Option<String>,
99    /// `#RRGGBB` background color.
100    pub background: Option<String>,
101    /// Already wire-shaped (`"LEFT"`/`"CENTER"`/`"RIGHT"`) — the CLI's
102    /// `clap::ValueEnum` validates and normalises it, so nothing here can
103    /// be a value Sheets would reject as a *grammar* error.
104    pub horizontal_align: Option<String>,
105    /// Already wire-shaped (`"TOP"`/`"MIDDLE"`/`"BOTTOM"`).
106    pub vertical_align: Option<String>,
107    /// Display pattern, e.g. `"#,##0.00"`. Must be given together with
108    /// `number_format_type`.
109    pub number_format_pattern: Option<String>,
110    /// Already wire-shaped (`"NUMBER"`, `"CURRENCY"`, …). Must be given
111    /// together with `number_format_pattern`.
112    pub number_format_type: Option<String>,
113    /// Already wire-shaped (`"OVERFLOW_CELL"`/`"CLIP"`/`"WRAP"`).
114    pub wrap: Option<String>,
115    /// Font family name, e.g. `"Arial"`.
116    pub font_family: Option<String>,
117    /// The rotation angle/vertical-stacking choice, if any.
118    pub text_rotation: Option<TextRotationFlag>,
119    /// Already wire-shaped (`"LINKED"`/`"PLAIN_TEXT"`).
120    pub hyperlink_display_type: Option<String>,
121    /// Top padding, in pixels.
122    pub padding_top: Option<i64>,
123    /// Right padding, in pixels.
124    pub padding_right: Option<i64>,
125    /// Bottom padding, in pixels.
126    pub padding_bottom: Option<i64>,
127    /// Left padding, in pixels.
128    pub padding_left: Option<i64>,
129    /// Already wire-shaped (`"LEFT_TO_RIGHT"`/`"RIGHT_TO_LEFT"`).
130    pub text_direction: Option<String>,
131}
132
133/// Which sides an `update-borders` verb sets. At least one must be `true`.
134///
135/// `inner_horizontal`/`inner_vertical` are the grid lines *between* cells
136/// in a multi-cell range — deliberately excluded from `--all`, which
137/// stays scoped to the four outer edges.
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub struct BorderSides {
140    /// Top edge.
141    pub top: bool,
142    /// Bottom edge.
143    pub bottom: bool,
144    /// Left edge.
145    pub left: bool,
146    /// Right edge.
147    pub right: bool,
148    /// Horizontal grid lines between rows within the range.
149    pub inner_horizontal: bool,
150    /// Vertical grid lines between columns within the range.
151    pub inner_vertical: bool,
152}
153
154impl BorderSides {
155    fn any(self) -> bool {
156        self.top
157            || self.bottom
158            || self.left
159            || self.right
160            || self.inner_horizontal
161            || self.inner_vertical
162    }
163}
164
165/// Which mutation to perform.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum FormatVerb {
168    /// Apply a cell format across a range.
169    FormatCells {
170        /// A sheet title, supplying a prefix for a bare `range`.
171        sheet: Option<String>,
172        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
173        range: Option<String>,
174        /// The properties to set. Boxed: `CellFormatFlags` has grown large
175        /// enough that an unboxed field would make every other, far
176        /// smaller `FormatVerb` variant pay for its size.
177        format: Box<CellFormatFlags>,
178    },
179    /// Set border lines on a range's edges.
180    UpdateBorders {
181        /// A sheet title, supplying a prefix for a bare `range`.
182        sheet: Option<String>,
183        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
184        range: Option<String>,
185        /// Which edges to set.
186        sides: BorderSides,
187        /// Already wire-shaped (`"SOLID"`, `"DASHED"`, …).
188        style: String,
189        /// `#RRGGBB`. `None` takes solid black, matching the spreadsheet
190        /// UI's own default border color.
191        color: Option<String>,
192    },
193    /// Merge a range into one cell.
194    MergeCells {
195        /// A sheet title, supplying a prefix for a bare `range`.
196        sheet: Option<String>,
197        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
198        range: Option<String>,
199        /// Already wire-shaped (`"MERGE_ALL"`, `"MERGE_ROWS"`,
200        /// `"MERGE_COLUMNS"`).
201        merge_type: String,
202    },
203    /// Split a previously merged range back apart.
204    UnmergeCells {
205        /// A sheet title, supplying a prefix for a bare `range`.
206        sheet: Option<String>,
207        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
208        range: Option<String>,
209    },
210    /// Resize rows or columns to fit their content.
211    AutoResizeDimension {
212        /// Title of the sheet to modify.
213        sheet: String,
214        /// Rows or columns.
215        dimension: Dimension,
216        /// 1-based first row/column, inclusive.
217        start: i64,
218        /// 1-based last row/column, inclusive.
219        end: i64,
220    },
221    /// Set an explicit pixel width (columns) or height (rows).
222    UpdateDimensionProperties {
223        /// Title of the sheet to modify.
224        sheet: String,
225        /// Rows or columns.
226        dimension: Dimension,
227        /// 1-based first row/column, inclusive.
228        start: i64,
229        /// 1-based last row/column, inclusive.
230        end: i64,
231        /// The new size, in pixels.
232        pixel_size: i64,
233    },
234}
235
236impl FormatVerb {
237    /// The `operation` this verb records in the request log.
238    const fn log_operation(&self) -> &'static str {
239        match self {
240            Self::FormatCells { .. } => "sheets-format-cells",
241            Self::UpdateBorders { .. } => "sheets-update-borders",
242            Self::MergeCells { .. } => "sheets-merge-cells",
243            Self::UnmergeCells { .. } => "sheets-unmerge-cells",
244            Self::AutoResizeDimension { .. } => "sheets-auto-resize-dimension",
245            Self::UpdateDimensionProperties { .. } => "sheets-update-dimension-properties",
246        }
247    }
248
249    /// The CLI subcommand that spells this verb, for error messages.
250    const fn label(&self) -> &'static str {
251        match self {
252            Self::FormatCells { .. } => "format-cells",
253            Self::UpdateBorders { .. } => "update-borders",
254            Self::MergeCells { .. } => "merge-cells",
255            Self::UnmergeCells { .. } => "unmerge-cells",
256            Self::AutoResizeDimension { .. } => "auto-resize-dimension",
257            Self::UpdateDimensionProperties { .. } => "update-dimension-properties",
258        }
259    }
260
261    /// `Some((sheet, range))` for the four range-targeted verbs; `None` for
262    /// the two dimension-span verbs, which name their sheet directly.
263    fn sheet_and_range(&self) -> Option<(Option<&str>, Option<&str>)> {
264        match self {
265            Self::FormatCells { sheet, range, .. }
266            | Self::UpdateBorders { sheet, range, .. }
267            | Self::MergeCells { sheet, range, .. }
268            | Self::UnmergeCells { sheet, range, .. } => Some((sheet.as_deref(), range.as_deref())),
269            Self::AutoResizeDimension { .. } | Self::UpdateDimensionProperties { .. } => None,
270        }
271    }
272}
273
274/// Per-call options.
275#[derive(Debug, Clone)]
276pub struct FormatOptions {
277    /// Spreadsheet id.
278    pub spreadsheet_id: String,
279    /// Which mutation to perform.
280    pub verb: FormatVerb,
281    /// Classify and describe only; never call `batchUpdate`.
282    pub dry_run: bool,
283    /// The lease token presented via `--lease`. Checked only when the
284    /// deciding rule requires one
285    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
286    /// `None` is only ever valid when it does not.
287    pub lease_token: Option<String>,
288    /// Path to the lease ledger the token is checked against. Production
289    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
290    /// result; tests pass a path under a `tempdir`.
291    pub ledger_path: PathBuf,
292}
293
294/// What happened (or, under `--dry-run`, would happen).
295#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
296#[serde(tag = "status", rename_all = "kebab-case")]
297pub enum FormatResult {
298    /// `--dry-run`, and the gate would allow it.
299    WouldChange {
300        /// A human-readable summary of the effect — also the request log's
301        /// `fields_changed`.
302        summary: String,
303        /// `merge-cells` only: the non-top-left, non-blank cells that would
304        /// be discarded, as `"A1: value"` strings.
305        #[serde(skip_serializing_if = "Vec::is_empty")]
306        discarded_cells: Vec<String>,
307    },
308    /// The target is not a Google Sheet.
309    RefusedNotASpreadsheet {
310        /// The target's actual MIME type.
311        mime_type: String,
312    },
313    /// The target is a shortcut, which we do not follow.
314    RefusedShortcut,
315    /// The target has no parents this account can see.
316    RefusedNoVisibleParents,
317    /// The named sheet does not exist in this workbook.
318    RefusedSheetNotFound {
319        /// The title that was not found.
320        title: String,
321        /// The titles that do exist.
322        available: Vec<String>,
323    },
324    /// The `--sheet`/`--range` pair was invalid (conflicting, empty, or a
325    /// bare range naming no sheet at all).
326    RefusedInvalidRange {
327        /// What was wrong and why.
328        detail: String,
329    },
330    /// The folder write-permission gate refused it.
331    Blocked {
332        /// The rule that decided the refusal, if any.
333        decided_by: Option<DecidingRule>,
334    },
335    /// No `--lease` was presented, and the deciding rule requires one
336    /// (ADR-0080 §9).
337    RefusedNoLease,
338    /// The presented lease has expired, or was never a token this ledger
339    /// knows about.
340    RefusedLeaseExpired,
341    /// The presented lease is bound to a different file id.
342    RefusedLeaseWrongFile,
343    /// The file has moved since the lease's recorded `version` — the
344    /// staleness check (ADR-0080 §6).
345    RefusedLeaseStale,
346    /// The mutation succeeded.
347    Changed {
348        /// Same summary as [`Self::WouldChange`], describing what was
349        /// actually done.
350        summary: String,
351        /// `merge-cells` only: the cells whose values were discarded.
352        #[serde(skip_serializing_if = "Vec::is_empty")]
353        discarded_cells: Vec<String>,
354    },
355    /// The API answered 2xx — the mutation **was applied** — but its reply
356    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: the
357    /// formatting change happened, so the sheet already carries it.
358    AppliedReplyUnreadable {
359        /// Same summary as [`Self::WouldChange`].
360        summary: String,
361        /// Same as [`Self::Changed`] — read before the mutating call, so
362        /// not lost with the reply.
363        #[serde(skip_serializing_if = "Vec::is_empty")]
364        discarded_cells: Vec<String>,
365        /// Why the reply could not be read.
366        detail: String,
367    },
368    /// An API or validation error.
369    Failed {
370        /// A human-readable summary of what failed.
371        detail: String,
372    },
373}
374
375impl FromLeaseRefusal for FormatResult {
376    fn from_no_lease() -> Self {
377        Self::RefusedNoLease
378    }
379    fn from_lease_expired() -> Self {
380        Self::RefusedLeaseExpired
381    }
382    fn from_lease_wrong_file() -> Self {
383        Self::RefusedLeaseWrongFile
384    }
385    fn from_lease_stale() -> Self {
386        Self::RefusedLeaseStale
387    }
388    fn from_lease_failed(detail: String) -> Self {
389        Self::Failed { detail }
390    }
391}
392
393impl FormatResult {
394    fn log_status(&self) -> &'static str {
395        match self {
396            Self::WouldChange { .. } => "would-change",
397            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
398            Self::RefusedShortcut => "refused-shortcut",
399            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
400            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
401            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
402            Self::Blocked { .. } => "blocked",
403            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
404            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
405            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
406            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
407            Self::Changed { .. } => "changed",
408            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
409            Self::Failed { .. } => "failed",
410        }
411    }
412}
413
414/// The full outcome of one attempt.
415#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
416pub struct FormatOutcome {
417    /// The spreadsheet acted on.
418    pub spreadsheet_id: String,
419    /// Its Drive file name, when the metadata fetch got that far.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub file_name: Option<String>,
422    /// The folder the gate evaluated against.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub resolved_folder_id: Option<String>,
425    /// Which mutation was attempted. Not serialised, like
426    /// `StructureOutcome::verb` — carried so `describe` can render from the
427    /// outcome alone.
428    #[serde(skip)]
429    pub verb: FormatVerb,
430    /// What happened.
431    pub result: FormatResult,
432}
433
434impl JsonlSerialize for FormatOutcome {
435    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
436        write_scalar_jsonl(self, out)
437    }
438}
439
440/// Parses `#RRGGBB` (the `#` optional) into a [`crate::drive::sheets::types::Color`].
441///
442/// The one conversion site: the Sheets API's `Color` is three floats
443/// 0.0-1.0, not the 0-255-per-byte a hex string suggests.
444///
445/// `pub(super)` (issue #1793): `conditional_format.rs`'s `GradientRule`/
446/// `BooleanRule` colors reuse this rather than duplicating hex parsing.
447pub(super) fn parse_hex_color(input: &str) -> Result<crate::drive::sheets::types::Color, String> {
448    let hex = input.strip_prefix('#').unwrap_or(input);
449    if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
450        return Err(format!(
451            "'{input}' is not a color; expected 6 hex digits, optionally prefixed with '#' \
452             (e.g. #FF8800)"
453        ));
454    }
455    let byte =
456        |slice: &str| -> f32 { f32::from(u8::from_str_radix(slice, 16).unwrap_or(0)) / 255.0 };
457    Ok(crate::drive::sheets::types::Color {
458        red: byte(&hex[0..2]),
459        green: byte(&hex[2..4]),
460        blue: byte(&hex[4..6]),
461    })
462}
463
464/// Normalizes a `#RRGGBB`/`RRGGBB` string to always carry its leading `#`,
465/// for display only (e.g. `structure.rs`'s `update-sheet-properties`
466/// dry-run/changed text, issue #1835). Shares [`parse_hex_color`]'s own
467/// `strip_prefix` so the two never disagree about what counts as "this
468/// already has a `#`".
469pub(super) fn normalize_hex_for_display(input: &str) -> String {
470    format!("#{}", input.strip_prefix('#').unwrap_or(input))
471}
472
473/// Renders a [`crate::drive::sheets::types::Color`] back to `#RRGGBB`, for
474/// display only — the inverse of [`parse_hex_color`]'s 0.0-1.0-per-channel
475/// scale. Used to report a chart's *existing* border colour in a preview
476/// (issue #1837), which the API returns as a `Color`, never as a hex string.
477pub(super) fn format_hex_color(color: crate::drive::sheets::types::Color) -> String {
478    let channel = |v: f32| -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 };
479    format!(
480        "#{:02X}{:02X}{:02X}",
481        channel(color.red),
482        channel(color.green),
483        channel(color.blue)
484    )
485}
486
487/// Converts a padding value to the wire's `i32`, rejecting anything that
488/// doesn't fit or is negative — a pixel padding can be neither.
489fn to_padding_i32(value: i64, flag: &str) -> Result<i32, String> {
490    match i32::try_from(value) {
491        Ok(n) if n >= 0 => Ok(n),
492        _ => Err(format!(
493            "{flag} must be a non-negative number that fits in 32 bits, got {value}"
494        )),
495    }
496}
497
498/// Builds the `CellFormat` and its `fields` mask (relative to
499/// `cell.userEnteredFormat`, per `repeatCell`'s own convention) from
500/// exactly the flags populated. Errors if none are, or if only one of
501/// `number_format_pattern`/`number_format_type` is given.
502fn build_cell_format(flags: &CellFormatFlags) -> Result<(CellFormat, String), String> {
503    let mut format = CellFormat::default();
504    let mut fields = Vec::new();
505    let mut text_format = TextFormat::default();
506    let mut text_format_used = false;
507
508    if let Some(bold) = flags.bold {
509        text_format.bold = Some(bold);
510        text_format_used = true;
511        fields.push("userEnteredFormat.textFormat.bold");
512    }
513    if let Some(italic) = flags.italic {
514        text_format.italic = Some(italic);
515        text_format_used = true;
516        fields.push("userEnteredFormat.textFormat.italic");
517    }
518    if let Some(strikethrough) = flags.strikethrough {
519        text_format.strikethrough = Some(strikethrough);
520        text_format_used = true;
521        fields.push("userEnteredFormat.textFormat.strikethrough");
522    }
523    if let Some(underline) = flags.underline {
524        text_format.underline = Some(underline);
525        text_format_used = true;
526        fields.push("userEnteredFormat.textFormat.underline");
527    }
528    if let Some(font_size) = flags.font_size {
529        text_format.font_size = Some(font_size);
530        text_format_used = true;
531        fields.push("userEnteredFormat.textFormat.fontSize");
532    }
533    if let Some(hex) = &flags.text_color {
534        let color = parse_hex_color(hex)?;
535        text_format.foreground_color_style = Some(ColorStyle::rgb(color));
536        text_format_used = true;
537        fields.push("userEnteredFormat.textFormat.foregroundColorStyle");
538    }
539    if let Some(font_family) = &flags.font_family {
540        text_format.font_family = Some(font_family.clone());
541        text_format_used = true;
542        fields.push("userEnteredFormat.textFormat.fontFamily");
543    }
544    if text_format_used {
545        format.text_format = Some(text_format);
546    }
547
548    if let Some(hex) = &flags.background {
549        let color = parse_hex_color(hex)?;
550        format.background_color_style = Some(ColorStyle::rgb(color));
551        fields.push("userEnteredFormat.backgroundColorStyle");
552    }
553    if let Some(align) = &flags.horizontal_align {
554        format.horizontal_alignment = Some(align.clone());
555        fields.push("userEnteredFormat.horizontalAlignment");
556    }
557    if let Some(align) = &flags.vertical_align {
558        format.vertical_alignment = Some(align.clone());
559        fields.push("userEnteredFormat.verticalAlignment");
560    }
561    match (&flags.number_format_pattern, &flags.number_format_type) {
562        (Some(pattern), Some(format_type)) => {
563            format.number_format = Some(NumberFormat {
564                format_type: format_type.clone(),
565                pattern: pattern.clone(),
566            });
567            fields.push("userEnteredFormat.numberFormat");
568        }
569        (None, None) => {}
570        _ => {
571            return Err(
572                "--number-format and --number-format-type must be given together".to_string(),
573            )
574        }
575    }
576    if let Some(wrap) = &flags.wrap {
577        format.wrap_strategy = Some(wrap.clone());
578        fields.push("userEnteredFormat.wrapStrategy");
579    }
580    if let Some(rotation) = flags.text_rotation {
581        format.text_rotation = Some(match rotation {
582            TextRotationFlag::Angle(angle) => {
583                if !(-90..=90).contains(&angle) {
584                    return Err(format!(
585                        "--text-rotation-angle must be between -90 and 90, got {angle}"
586                    ));
587                }
588                TextRotation {
589                    angle: Some(angle as i32),
590                    vertical: None,
591                }
592            }
593            TextRotationFlag::Vertical => TextRotation {
594                angle: None,
595                vertical: Some(true),
596            },
597        });
598        fields.push("userEnteredFormat.textRotation");
599    }
600    if let Some(hyperlink_display_type) = &flags.hyperlink_display_type {
601        format.hyperlink_display_type = Some(hyperlink_display_type.clone());
602        fields.push("userEnteredFormat.hyperlinkDisplayType");
603    }
604    if flags.padding_top.is_some()
605        || flags.padding_right.is_some()
606        || flags.padding_bottom.is_some()
607        || flags.padding_left.is_some()
608    {
609        format.padding = Some(Padding {
610            top: flags
611                .padding_top
612                .map(|n| to_padding_i32(n, "--padding-top"))
613                .transpose()?,
614            right: flags
615                .padding_right
616                .map(|n| to_padding_i32(n, "--padding-right"))
617                .transpose()?,
618            bottom: flags
619                .padding_bottom
620                .map(|n| to_padding_i32(n, "--padding-bottom"))
621                .transpose()?,
622            left: flags
623                .padding_left
624                .map(|n| to_padding_i32(n, "--padding-left"))
625                .transpose()?,
626        });
627        fields.push("userEnteredFormat.padding");
628    }
629    if let Some(text_direction) = &flags.text_direction {
630        format.text_direction = Some(text_direction.clone());
631        fields.push("userEnteredFormat.textDirection");
632    }
633
634    if fields.is_empty() {
635        return Err(
636            "format-cells needs at least one property to set (--bold, --background, …)".to_string(),
637        );
638    }
639    Ok((format, fields.join(",")))
640}
641
642/// Runs one formatting mutation, logging every attempt that isn't a dry
643/// run. Never returns `Err`, matching `structure.rs::structure`.
644pub async fn format(
645    drive: &DriveClient,
646    sheets: &SheetsClient,
647    opts: &FormatOptions,
648    rules: &[FolderPermissionRule],
649) -> FormatOutcome {
650    let started = Instant::now();
651    let outcome = format_inner(drive, sheets, opts, rules).await;
652    if !opts.dry_run {
653        record_attempt(&outcome, opts, started.elapsed());
654    }
655    outcome
656}
657
658async fn format_inner(
659    drive: &DriveClient,
660    sheets: &SheetsClient,
661    opts: &FormatOptions,
662    rules: &[FolderPermissionRule],
663) -> FormatOutcome {
664    let bare = |result| FormatOutcome {
665        spreadsheet_id: opts.spreadsheet_id.clone(),
666        file_name: None,
667        resolved_folder_id: None,
668        verb: opts.verb.clone(),
669        result,
670    };
671
672    // Compose the range first, like `write_inner`: pure, and a conflicting
673    // --sheet/--range pair should fail without spending a request.
674    let composed_range = if let Some((sheet, range)) = opts.verb.sheet_and_range() {
675        match a1::compose(sheet, range) {
676            Ok(composed) => Some(composed),
677            Err(err) => {
678                return bare(FormatResult::RefusedInvalidRange {
679                    detail: err.to_string(),
680                })
681            }
682        }
683    } else {
684        None
685    };
686
687    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
688        drive,
689        &opts.spreadsheet_id,
690        DriveOperation::SheetsStructure,
691        rules,
692    )
693    .await
694    {
695        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
696            return bare(FormatResult::Failed { detail })
697        }
698        target_gate::TargetGateOutcome::Refused { target, refusal } => {
699            let result = match refusal {
700                SheetTargetRefusal::Shortcut => FormatResult::RefusedShortcut,
701                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
702                    FormatResult::RefusedNotASpreadsheet { mime_type }
703                }
704                SheetTargetRefusal::NoVisibleParents => FormatResult::RefusedNoVisibleParents,
705            };
706            return FormatOutcome {
707                spreadsheet_id: opts.spreadsheet_id.clone(),
708                file_name: Some(target.name),
709                resolved_folder_id: None,
710                verb: opts.verb.clone(),
711                result,
712            };
713        }
714        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
715            return FormatOutcome {
716                spreadsheet_id: opts.spreadsheet_id.clone(),
717                file_name: Some(target.name),
718                resolved_folder_id: None,
719                verb: opts.verb.clone(),
720                result: FormatResult::Failed { detail },
721            };
722        }
723        target_gate::TargetGateOutcome::Gated {
724            target,
725            decision,
726            resolved_folder_id,
727            requires_lease,
728        } => (target, decision, resolved_folder_id, requires_lease),
729    };
730
731    let gated = |result| FormatOutcome {
732        spreadsheet_id: opts.spreadsheet_id.clone(),
733        file_name: Some(target.name.clone()),
734        resolved_folder_id: resolved_folder_id.clone(),
735        verb: opts.verb.clone(),
736        result,
737    };
738
739    if decision.verdict == write_gate::Verdict::Deny {
740        return gated(FormatResult::Blocked {
741            decided_by: decision.decided_by,
742        });
743    }
744
745    let api = SheetsApi::new(sheets);
746    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
747        Ok(workbook) => workbook,
748        Err(err) => {
749            return gated(FormatResult::Failed {
750                detail: format!("{err:#}"),
751            })
752        }
753    };
754
755    // ── Resolve the target: a range for four verbs, a sheet+span for two ─
756    let resolved = match resolve_target(&workbook, &opts.verb, composed_range.as_deref()) {
757        Ok(resolved) => resolved,
758        Err(result) => return gated(result),
759    };
760
761    // `merge-cells` reads current values before doing anything else, dry
762    // run or real: its preview and its record both need to name what would
763    // be (or was) discarded, and that fact must come from the same read on
764    // both paths.
765    let discarded_cells = if matches!(opts.verb, FormatVerb::MergeCells { .. }) {
766        match read_discarded_cells(&api, &opts.spreadsheet_id, &resolved).await {
767            Ok(cells) => cells,
768            Err(detail) => return gated(FormatResult::Failed { detail }),
769        }
770    } else {
771        Vec::new()
772    };
773
774    let summary = match describe_effect(&opts.verb, &discarded_cells) {
775        Ok(summary) => summary,
776        Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
777    };
778
779    if opts.dry_run {
780        return gated(FormatResult::WouldChange {
781            summary,
782            discarded_cells,
783        });
784    }
785
786    // Built before the gate, not after: `build_request` (in particular its
787    // `parse_hex_color` call) is the one fallible step between here and the
788    // mutating call, and the gate must be the *last* fallible step before
789    // it (see `gate_leased_write`'s doc comment) — otherwise a request that
790    // was always going to fail to build would still fsync a `pending`
791    // audit record for a write that never happens (#1688).
792    let request = match build_request(&opts.verb, &resolved) {
793        Ok(request) => request,
794        Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
795    };
796
797    // The lease check (ADR-0080 §9) sits here: after the permission gate
798    // and the `--dry-run` branch, before the mutating call — see
799    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
800    // shared verbatim by every leased engine. A fresh `files.get` immediately
801    // before `batchUpdate`, not a reuse of the metadata `target_gate::resolve`
802    // fetched before the (potentially slow) ancestor-chain walk and workbook
803    // fetch above.
804    let files_api = FilesApi::new(drive);
805    let leased = LeasedWrite {
806        log_prefix: "drive sheets format",
807        operation: opts.verb.log_operation(),
808        ledger_path: &opts.ledger_path,
809        file_id: &opts.spreadsheet_id,
810    };
811    let lease_grant = match gate_optional_leased_write(
812        leased,
813        &files_api,
814        requires_lease,
815        opts.lease_token.as_deref(),
816    )
817    .await
818    {
819        Ok(grant) => grant,
820        Err(err) => return gated(err.into_result()),
821    };
822
823    let result = match conclude_native_leased_write(
824        leased,
825        &lease_grant,
826        &files_api,
827        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
828        |err| format!("{err:#}"),
829    )
830    .await
831    {
832        Ok(BatchUpdateOutcome::Applied(_response)) => FormatResult::Changed {
833            summary,
834            discarded_cells,
835        },
836        // `RetryHint::Idempotent` (see `describe_lines`): re-applying the same
837        // formatting, merge or resize leaves the sheet as it is (#2021).
838        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
839            FormatResult::AppliedReplyUnreadable {
840                summary,
841                discarded_cells,
842                detail,
843            }
844        }
845        Err(err) => FormatResult::Failed {
846            detail: format!("{err:#}"),
847        },
848    };
849    drop(lease_grant);
850    gated(result)
851}
852
853/// What a verb resolved to: enough to build its request, whichever shape
854/// the verb needs.
855enum ResolvedTarget {
856    /// A numeric range plus the sheet title it's on (for describing).
857    Range {
858        sheet_title: String,
859        grid: GridRange,
860    },
861    /// A dimension span on a named, resolved sheet.
862    Dimension { range: DimensionRange },
863}
864
865fn resolve_target(
866    workbook: &Spreadsheet,
867    verb: &FormatVerb,
868    composed_range: Option<&str>,
869) -> Result<ResolvedTarget, FormatResult> {
870    match verb {
871        FormatVerb::AutoResizeDimension {
872            sheet,
873            dimension,
874            start,
875            end,
876        }
877        | FormatVerb::UpdateDimensionProperties {
878            sheet,
879            dimension,
880            start,
881            end,
882            ..
883        } => {
884            let sheet_id = find_sheet_id(workbook, sheet)?;
885            if *start < 1 || *end < *start {
886                return Err(FormatResult::RefusedInvalidRange {
887                    detail: format!(
888                        "--start must be at least 1 and --end must be >= --start (got \
889                         --start {start} --end {end})"
890                    ),
891                });
892            }
893            let range = DimensionRange {
894                sheet_id,
895                dimension: *dimension,
896                start_index: start - 1,
897                end_index: *end,
898            };
899            Ok(ResolvedTarget::Dimension { range })
900        }
901        FormatVerb::FormatCells { .. }
902        | FormatVerb::UpdateBorders { .. }
903        | FormatVerb::MergeCells { .. }
904        | FormatVerb::UnmergeCells { .. } => {
905            let composed = composed_range.unwrap_or_default();
906            let (title, grid) = grid_range::resolve_grid_range(
907                workbook,
908                composed,
909                |detail| FormatResult::RefusedInvalidRange { detail },
910                |title, available| FormatResult::RefusedSheetNotFound { title, available },
911            )?;
912            // Merging needs a fixed extent: its preview (and the request
913            // log) must name exactly which cells would be discarded, and an
914            // open-ended column/row range (`A:A`) has no fixed extent to
915            // check that against.
916            if matches!(verb, FormatVerb::MergeCells { .. }) {
917                let bare_range =
918                    a1::split_sheet_prefix(composed).map_or(composed, |(_, bare_range)| bare_range);
919                let Some(cells) = grid_range::cell_count(&grid) else {
920                    return Err(FormatResult::RefusedInvalidRange {
921                        detail: format!(
922                            "'{bare_range}' is open-ended; merge-cells needs a fully bounded \
923                             range (e.g. A1:D20) so its preview can name exactly what would be \
924                             discarded"
925                        ),
926                    });
927                };
928                // A bounded range can still be pathologically large, and the
929                // discard read behind the preview is proportional to it —
930                // refuse before that read rather than cap what it reports,
931                // so the structured outputs stay complete (#2025).
932                if cells > MAX_CELLS_PER_MERGE {
933                    return Err(FormatResult::RefusedInvalidRange {
934                        detail: format!(
935                            "'{bare_range}' covers {cells} cells, over the \
936                             {MAX_CELLS_PER_MERGE}-cell budget for merge-cells; narrow --range"
937                        ),
938                    });
939                }
940            }
941            // Inner grid lines only exist *between* rows/columns within the
942            // range — refuse when the range is positively known (bounded on
943            // that axis) to be too narrow to have one, rather than send a
944            // request that can have no visible effect. An axis left
945            // open-ended (e.g. `A1:A`) almost certainly spans more than one
946            // row/column, so it is let through rather than guessed at.
947            if let FormatVerb::UpdateBorders { sides, .. } = verb {
948                if sides.inner_horizontal
949                    && matches!(
950                        (grid.start_row_index, grid.end_row_index),
951                        (Some(start), Some(end)) if end - start <= 1
952                    )
953                {
954                    return Err(FormatResult::RefusedInvalidRange {
955                        detail: "--inner-horizontal needs a range spanning more than one row; \
956                                 a single row has no interior horizontal grid line"
957                            .to_string(),
958                    });
959                }
960                if sides.inner_vertical
961                    && matches!(
962                        (grid.start_column_index, grid.end_column_index),
963                        (Some(start), Some(end)) if end - start <= 1
964                    )
965                {
966                    return Err(FormatResult::RefusedInvalidRange {
967                        detail: "--inner-vertical needs a range spanning more than one column; \
968                                 a single column has no interior vertical grid line"
969                            .to_string(),
970                    });
971                }
972            }
973            Ok(ResolvedTarget::Range {
974                sheet_title: title,
975                grid,
976            })
977        }
978    }
979}
980
981fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, FormatResult> {
982    grid_range::find_sheet_id(workbook, title, |title, available| {
983        FormatResult::RefusedSheetNotFound { title, available }
984    })
985}
986
987/// Reads the current values of a `merge-cells` target range, returning
988/// every non-top-left, non-blank cell as an `"A1: value"` string — what
989/// would be (or was) discarded.
990///
991/// Only ever called for [`FormatVerb::MergeCells`], whose target is always
992/// [`ResolvedTarget::Range`].
993async fn read_discarded_cells(
994    api: &SheetsApi<'_>,
995    spreadsheet_id: &str,
996    resolved: &ResolvedTarget,
997) -> Result<Vec<String>, String> {
998    let ResolvedTarget::Range { sheet_title, grid } = resolved else {
999        return Ok(Vec::new());
1000    };
1001    // A single cell can't discard anything by merging — skip the read
1002    // entirely rather than ask the API to confirm the obvious.
1003    let is_single_cell = matches!(
1004        (
1005            grid.start_row_index,
1006            grid.end_row_index,
1007            grid.start_column_index,
1008            grid.end_column_index,
1009        ),
1010        (Some(r0), Some(r1), Some(c0), Some(c1)) if r1 - r0 <= 1 && c1 - c0 <= 1
1011    );
1012    if is_single_cell {
1013        return Ok(Vec::new());
1014    }
1015    // `None` only for an unbounded or empty target, neither of which
1016    // reaches here: `resolve_target` refuses the first and the single-cell
1017    // short-circuit above covers the second.
1018    let Some(a1_range) = grid_range::bounded_range_to_a1(sheet_title, grid) else {
1019        return Ok(Vec::new());
1020    };
1021    let values = api
1022        .values_get(spreadsheet_id, &a1_range, ValueRenderOption::Formatted)
1023        .await
1024        .map_err(|err| format!("{err:#}"))?;
1025    // `values.get` indexes its result relative to the range it was asked
1026    // for, not the sheet — the discarded-cell addresses must add the
1027    // range's own start row/column back on.
1028    Ok(discarded_from_values(
1029        &values,
1030        grid.start_row_index.unwrap_or(0),
1031        grid.start_column_index.unwrap_or(0),
1032    ))
1033}
1034
1035/// `row_offset`/`col_offset` are the target range's own start row/column —
1036/// `values.get`'s response is indexed relative to the range it read, not
1037/// the sheet, so they must be added back to get the true A1 address.
1038fn discarded_from_values(values: &ValueRange, row_offset: i64, col_offset: i64) -> Vec<String> {
1039    let mut discarded = Vec::new();
1040    for (row_idx, row) in values.values.iter().enumerate() {
1041        for (col_idx, cell) in row.iter().enumerate() {
1042            if row_idx == 0 && col_idx == 0 {
1043                continue;
1044            }
1045            let is_blank = cell.is_null() || cell.as_str().is_some_and(str::is_empty);
1046            if is_blank {
1047                continue;
1048            }
1049            let address = format!(
1050                "{}{}",
1051                grid_range::column_index_to_letters(col_idx as i64 + col_offset),
1052                row_idx as i64 + row_offset + 1
1053            );
1054            let display = cell
1055                .as_str()
1056                .map_or_else(|| cell.to_string(), str::to_string);
1057            discarded.push(format!("{address}: {display}"));
1058        }
1059    }
1060    discarded
1061}
1062
1063/// The number of discarded cells `describe_effect`'s `MergeCells` arm
1064/// renders before eliding the rest. Higher than
1065/// `grid_range::RENDERED_LOCATION_LIMIT` (200 vs 50): this preview
1066/// substitutes for a missing confirmation flag and shows a value per
1067/// cell, not just an address (ADR-0078 §4's amendment, #1999). The exact
1068/// discarded count and the full list (`-o json` / the `drivemutation`
1069/// log's `discarded_cells`) are never elided — only this rendered line.
1070const MERGE_DISCARD_RENDER_LIMIT: usize = 200;
1071
1072/// The largest range, in cells, `merge-cells` accepts. Checked in
1073/// `resolve_target`, before `read_discarded_cells`' `values.get`, so it
1074/// bounds that read, the in-memory discard list, and both structured
1075/// outputs carrying it (`-o json` and the `drivemutation` log's
1076/// `discarded_cells`) — which is what lets those outputs stay complete
1077/// rather than elide (ADR-0078 §4's second amendment, #2025). Matches
1078/// `read-cell-format`'s budget, so the crate has one cell-budget number.
1079/// There is deliberately no flag to exceed it (ADR-0075 §3).
1080const MAX_CELLS_PER_MERGE: i64 = 50_000;
1081
1082fn describe_effect(verb: &FormatVerb, discarded_cells: &[String]) -> Result<String, String> {
1083    match verb {
1084        FormatVerb::FormatCells { format, .. } => {
1085            let (_, fields) = build_cell_format(format)?;
1086            Ok(format!("set {fields}"))
1087        }
1088        FormatVerb::UpdateBorders { sides, style, .. } => {
1089            if !sides.any() {
1090                return Err(
1091                    "update-borders needs at least one side (--top, --bottom, --left, --right, \
1092                     --all, --inner-horizontal, --inner-vertical)"
1093                        .to_string(),
1094                );
1095            }
1096            let mut names = Vec::new();
1097            if sides.top {
1098                names.push("top");
1099            }
1100            if sides.bottom {
1101                names.push("bottom");
1102            }
1103            if sides.left {
1104                names.push("left");
1105            }
1106            if sides.right {
1107                names.push("right");
1108            }
1109            if sides.inner_horizontal {
1110                names.push("inner-horizontal");
1111            }
1112            if sides.inner_vertical {
1113                names.push("inner-vertical");
1114            }
1115            Ok(format!("set {} border(s), style {style}", names.join(", ")))
1116        }
1117        FormatVerb::MergeCells { merge_type, .. } => {
1118            if discarded_cells.is_empty() {
1119                Ok(format!("merge ({merge_type})"))
1120            } else {
1121                // Past `MERGE_DISCARD_RENDER_LIMIT` the rendered line
1122                // elides the rest (ADR-0078 §4's amendment, #1999); the
1123                // exact count above and the full list in `-o json` / the
1124                // `drivemutation` log's `discarded_cells` are not.
1125                let elided = discarded_cells.len() > MERGE_DISCARD_RENDER_LIMIT;
1126                Ok(format!(
1127                    "merge ({merge_type}), discarding {} cell(s): {}{}",
1128                    discarded_cells.len(),
1129                    grid_range::truncate_joined(discarded_cells, MERGE_DISCARD_RENDER_LIMIT, "; "),
1130                    if elided {
1131                        " (full list in -o json / the drivemutation log's discarded_cells)"
1132                    } else {
1133                        ""
1134                    }
1135                ))
1136            }
1137        }
1138        FormatVerb::UnmergeCells { .. } => Ok("unmerge".to_string()),
1139        FormatVerb::AutoResizeDimension {
1140            sheet,
1141            dimension,
1142            start,
1143            end,
1144        } => Ok(format!(
1145            "auto-resize {}",
1146            dimension_span(sheet, *dimension, *start, *end)
1147        )),
1148        FormatVerb::UpdateDimensionProperties {
1149            sheet,
1150            dimension,
1151            start,
1152            end,
1153            pixel_size,
1154        } => Ok(format!(
1155            "set the pixelSize of {} to {pixel_size}",
1156            dimension_span(sheet, *dimension, *start, *end)
1157        )),
1158    }
1159}
1160
1161/// A 1-based inclusive dimension span as prose naming its sheet, e.g.
1162/// `column(s) B:D of 'Q1'` or `row(s) 2:5 of 'Q1'` — columns in A1 letters,
1163/// since that is how the grid labels them.
1164fn dimension_span(sheet: &str, dimension: Dimension, start: i64, end: i64) -> String {
1165    let (first, last) = match dimension {
1166        Dimension::Columns => (
1167            grid_range::column_index_to_letters(start - 1),
1168            grid_range::column_index_to_letters(end - 1),
1169        ),
1170        Dimension::Rows => (start.to_string(), end.to_string()),
1171    };
1172    format!(
1173        "{}(s) {first}:{last} of {}",
1174        dimension.noun(),
1175        a1::quote_sheet_title(sheet)
1176    )
1177}
1178
1179/// The real-run line's past-tense opening for `summary`, which
1180/// [`describe_effect`] always starts with the verb's imperative (`set`,
1181/// `merge`, `unmerge`, `auto-resize`). The summary itself stays imperative:
1182/// it is also the JSON `summary` and the request log's `fields_changed`,
1183/// and the dry run reads it after "Would".
1184fn past_tense_summary(verb: &FormatVerb, summary: &str) -> String {
1185    let (imperative, past) = match verb {
1186        FormatVerb::FormatCells { .. }
1187        | FormatVerb::UpdateBorders { .. }
1188        | FormatVerb::UpdateDimensionProperties { .. } => ("set", "Set"),
1189        FormatVerb::MergeCells { .. } => ("merge", "Merged cells"),
1190        FormatVerb::UnmergeCells { .. } => ("unmerge", "Unmerged cells"),
1191        FormatVerb::AutoResizeDimension { .. } => ("auto-resize", "Auto-resized"),
1192    };
1193    summary.strip_prefix(imperative).map_or_else(
1194        || format!("Applied: {summary}"),
1195        |rest| format!("{past}{rest}"),
1196    )
1197}
1198
1199fn build_request(
1200    verb: &FormatVerb,
1201    resolved: &ResolvedTarget,
1202) -> Result<BatchUpdateRequestItem, String> {
1203    // Exhaustive over `FormatVerb`, not `(FormatVerb, ResolvedTarget)`: a
1204    // new verb forces a new arm here at compile time, the same guarantee
1205    // `resolve_target` gets. Each arm's `resolved` shape is guaranteed by
1206    // `resolve_target`, which picks it from the verb itself — an
1207    // `unreachable!` names that invariant instead of a runtime `Err` that
1208    // would only fire if `resolve_target` and this fell out of sync.
1209    match verb {
1210        FormatVerb::FormatCells { format, .. } => {
1211            let ResolvedTarget::Range { grid, .. } = resolved else {
1212                unreachable!("resolve_target always resolves FormatCells to a Range")
1213            };
1214            let (cell_format, fields) = build_cell_format(format)?;
1215            Ok(BatchUpdateRequestItem::RepeatCell(RepeatCellRequest {
1216                range: *grid,
1217                cell: RepeatCellData {
1218                    user_entered_format: cell_format,
1219                },
1220                fields,
1221            }))
1222        }
1223        FormatVerb::UpdateBorders {
1224            sides,
1225            style,
1226            color,
1227            ..
1228        } => {
1229            let ResolvedTarget::Range { grid, .. } = resolved else {
1230                unreachable!("resolve_target always resolves UpdateBorders to a Range")
1231            };
1232            let color = match color {
1233                Some(hex) => parse_hex_color(hex)?,
1234                None => crate::drive::sheets::types::Color::default(),
1235            };
1236            let border = Border {
1237                style: style.clone(),
1238                color_style: ColorStyle::rgb(color),
1239            };
1240            let mut request = UpdateBordersRequest {
1241                range: *grid,
1242                ..Default::default()
1243            };
1244            if sides.top {
1245                request.top = Some(border.clone());
1246            }
1247            if sides.bottom {
1248                request.bottom = Some(border.clone());
1249            }
1250            if sides.left {
1251                request.left = Some(border.clone());
1252            }
1253            if sides.right {
1254                request.right = Some(border.clone());
1255            }
1256            if sides.inner_horizontal {
1257                request.inner_horizontal = Some(border.clone());
1258            }
1259            if sides.inner_vertical {
1260                request.inner_vertical = Some(border);
1261            }
1262            Ok(BatchUpdateRequestItem::UpdateBorders(request))
1263        }
1264        FormatVerb::MergeCells { merge_type, .. } => {
1265            let ResolvedTarget::Range { grid, .. } = resolved else {
1266                unreachable!("resolve_target always resolves MergeCells to a Range")
1267            };
1268            Ok(BatchUpdateRequestItem::MergeCells(MergeCellsRequest {
1269                range: *grid,
1270                merge_type: merge_type.clone(),
1271            }))
1272        }
1273        FormatVerb::UnmergeCells { .. } => {
1274            let ResolvedTarget::Range { grid, .. } = resolved else {
1275                unreachable!("resolve_target always resolves UnmergeCells to a Range")
1276            };
1277            Ok(BatchUpdateRequestItem::UnmergeCells(UnmergeCellsRequest {
1278                range: *grid,
1279            }))
1280        }
1281        FormatVerb::AutoResizeDimension { .. } => {
1282            let ResolvedTarget::Dimension { range } = resolved else {
1283                unreachable!("resolve_target always resolves AutoResizeDimension to a Dimension")
1284            };
1285            Ok(BatchUpdateRequestItem::AutoResizeDimensions(
1286                AutoResizeDimensionsRequest {
1287                    dimensions: range.clone(),
1288                },
1289            ))
1290        }
1291        FormatVerb::UpdateDimensionProperties { pixel_size, .. } => {
1292            let ResolvedTarget::Dimension { range } = resolved else {
1293                unreachable!(
1294                    "resolve_target always resolves UpdateDimensionProperties to a Dimension"
1295                )
1296            };
1297            Ok(BatchUpdateRequestItem::UpdateDimensionProperties(
1298                UpdateDimensionPropertiesRequest {
1299                    range: range.clone(),
1300                    properties: DimensionProperties {
1301                        pixel_size: *pixel_size,
1302                    },
1303                    fields: "pixelSize".to_string(),
1304                },
1305            ))
1306        }
1307    }
1308}
1309
1310fn record_attempt(outcome: &FormatOutcome, opts: &FormatOptions, duration: Duration) {
1311    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1312}
1313
1314/// The request-log record for `outcome`, split from [`record_attempt`] so a
1315/// test can read the fields it carries.
1316fn mutation_record(
1317    outcome: &FormatOutcome,
1318    opts: &FormatOptions,
1319    duration: Duration,
1320) -> DriveMutationOutcome {
1321    let error = match &outcome.result {
1322        FormatResult::Failed { detail } | FormatResult::AppliedReplyUnreadable { detail, .. } => {
1323            Some(detail.clone())
1324        }
1325        _ => None,
1326    };
1327    let decided_by = match &outcome.result {
1328        FormatResult::Blocked { decided_by } => decided_by.as_ref(),
1329        _ => None,
1330    };
1331    let decided_by = write_gate::decided_by_log_fields(decided_by);
1332    let (fields_changed, discarded_cells) = match &outcome.result {
1333        FormatResult::Changed {
1334            summary,
1335            discarded_cells,
1336        }
1337        | FormatResult::AppliedReplyUnreadable {
1338            summary,
1339            discarded_cells,
1340            ..
1341        }
1342        | FormatResult::WouldChange {
1343            summary,
1344            discarded_cells,
1345        } => (Some(summary.clone()), discarded_cells.clone()),
1346        _ => (None, Vec::new()),
1347    };
1348
1349    DriveMutationOutcome {
1350        operation: opts.verb.log_operation(),
1351        file_id: outcome.spreadsheet_id.clone(),
1352        file_name: outcome.file_name.clone().unwrap_or_default(),
1353        status: outcome.result.log_status().to_string(),
1354        resolved_folder_id: outcome.resolved_folder_id.clone(),
1355        decided_by_folder_id: decided_by.folder_id,
1356        decided_by_depth: decided_by.depth,
1357        decided_by_file_id: decided_by.file_id,
1358        fields_changed,
1359        discarded_cells,
1360        error,
1361        duration,
1362        ..Default::default()
1363    }
1364}
1365
1366/// Renders an outcome as human-readable text.
1367#[must_use]
1368pub fn describe(outcome: &FormatOutcome) -> String {
1369    describe_lines(outcome).join("\n")
1370}
1371
1372/// Renders an outcome as its individual lines, none of which contains a
1373/// newline — see `structure.rs::describe_lines` for why this shape exists.
1374#[must_use]
1375pub fn describe_lines(outcome: &FormatOutcome) -> Vec<String> {
1376    let verb = &outcome.verb;
1377    let book = outcome.file_name.as_deref().map_or_else(
1378        || format!("'{}'", outcome.spreadsheet_id),
1379        |n| format!("'{n}'"),
1380    );
1381    match &outcome.result {
1382        FormatResult::WouldChange { summary, .. } => {
1383            vec![format!("Would {summary} in {book}")]
1384        }
1385        FormatResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1386            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1387             `drive sheets {}` only works on spreadsheets",
1388            verb.label()
1389        )],
1390        FormatResult::RefusedShortcut => vec![format!(
1391            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1392            verb.label()
1393        )],
1394        FormatResult::RefusedNoVisibleParents => vec![format!(
1395            "Refused: {book} has no parent folder visible to this account, so no folder \
1396             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1397             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1398        )],
1399        FormatResult::RefusedSheetNotFound { title, available } => {
1400            vec![grid_range::no_sheet_refusal(&book, title, available)]
1401        }
1402        FormatResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1403        FormatResult::Blocked { decided_by } => vec![match decided_by {
1404            Some(rule) => format!(
1405                "Blocked: {} on {book} refused by rule on {} {}{}",
1406                verb.label(),
1407                rule.kind_label(),
1408                rule.id(),
1409                rule.depth_suffix()
1410            ),
1411            None => format!(
1412                "Blocked: {} on {book} refused by default policy (no matching rule for \
1413                 sheets-structure)",
1414                verb.label()
1415            ),
1416        }],
1417        FormatResult::RefusedNoLease => LeaseGateRefusal::NoLease
1418            .describe_line(&outcome.spreadsheet_id, &book)
1419            .into_iter()
1420            .collect(),
1421        FormatResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1422            .describe_line(&outcome.spreadsheet_id, &book)
1423            .into_iter()
1424            .collect(),
1425        FormatResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1426            .describe_line(&outcome.spreadsheet_id, &book)
1427            .into_iter()
1428            .collect(),
1429        FormatResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1430            .describe_line(&outcome.spreadsheet_id, &book)
1431            .into_iter()
1432            .collect(),
1433        FormatResult::Changed { summary, .. } => {
1434            vec![format!("{} in {book}", past_tense_summary(verb, summary))]
1435        }
1436        // Idempotent: the same format / merge / unmerge / resize again is a no-op.
1437        FormatResult::AppliedReplyUnreadable {
1438            summary, detail, ..
1439        } => {
1440            vec![applied_reply_unreadable_line(
1441                summary,
1442                &book,
1443                RetryHint::Idempotent,
1444                detail,
1445            )]
1446        }
1447        FormatResult::Failed { detail } => vec![format!("Failed: {detail}")],
1448    }
1449}
1450
1451#[cfg(test)]
1452#[allow(clippy::unwrap_used, clippy::expect_used)]
1453mod tests {
1454    use super::*;
1455    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1456    use crate::drive::sheets::client::SHEETS_API_URL;
1457    use crate::drive::test_support::seed_lease;
1458    use crate::test_support::env::MapEnv;
1459    use crate::utils::secret::Secret;
1460    use std::collections::HashSet;
1461
1462    fn test_credentials() -> DriveCredentials {
1463        DriveCredentials {
1464            client_id: "client-1".to_string(),
1465            client_secret: Secret::new("secret-1"),
1466            refresh_token: Secret::new("refresh-1"),
1467            scope: DriveGrantedScopes::READONLY,
1468        }
1469    }
1470
1471    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1472        wiremock::Mock::given(wiremock::matchers::method("POST"))
1473            .and(wiremock::matchers::path("/token"))
1474            .respond_with(
1475                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1476                    "access_token": "test-token", "expires_in": 3600,
1477                })),
1478            )
1479            .mount(server)
1480            .await;
1481        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1482        crate::drive::client::test_support::replace_session(
1483            &mut drive,
1484            &test_credentials(),
1485            &format!("{}/token", server.uri()),
1486        );
1487        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1488        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1489        (drive, sheets)
1490    }
1491
1492    /// `version: "1"` throughout — matches [`format_cells_opts`]'s default
1493    /// seeded lease, so any test reaching the mutating call has a live,
1494    /// non-stale lease by construction (ADR-0080 §9).
1495    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1496        let parents: Vec<&str> = parents.to_vec();
1497        wiremock::Mock::given(wiremock::matchers::method("GET"))
1498            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1499            .respond_with(
1500                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1501                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1502                    "version": "1",
1503                })),
1504            )
1505    }
1506
1507    fn mount_folder(id: &str) -> wiremock::Mock {
1508        mount_file(id, "application/vnd.google-apps.folder", &[])
1509    }
1510
1511    fn allow_rule(folder: &str) -> FolderPermissionRule {
1512        FolderPermissionRule {
1513            folder_id: Some(folder.to_string()),
1514            file_id: None,
1515            recursive: true,
1516            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1517            deny: HashSet::default(),
1518            require_lease: true,
1519        }
1520    }
1521
1522    fn mount_workbook() -> wiremock::Mock {
1523        wiremock::Mock::given(wiremock::matchers::method("GET"))
1524            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1525            .respond_with(
1526                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1527                    "spreadsheetId": "sheet-1",
1528                    "properties": {"title": "Budget"},
1529                    "sheets": [
1530                        {"properties": {
1531                            "sheetId": 0, "title": "Q1", "index": 0,
1532                            "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1533                    ],
1534                })),
1535            )
1536    }
1537
1538    fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
1539        wiremock::Mock::given(wiremock::matchers::method("POST"))
1540            .and(wiremock::matchers::path(
1541                "/v4/spreadsheets/sheet-1:batchUpdate",
1542            ))
1543            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1544    }
1545
1546    /// Seeds a fresh, isolated ledger with a live lease for `"sheet-1"` at
1547    /// version `"1"` (matching [`mount_file`]'s default) and returns options
1548    /// carrying it. Every existing test built before the lease (ADR-0080 §9)
1549    /// reaches its mutating call this way by construction — see
1550    /// `structure.rs::opts_for`'s doc comment for why this doesn't need
1551    /// touching each test individually. A test exercising the lease
1552    /// *refusal* paths builds `FormatOptions` directly instead (see the "the
1553    /// Drive write lease" test section below).
1554    fn format_cells_opts(dry_run: bool) -> FormatOptions {
1555        let ledger_path = tempfile::tempdir()
1556            .unwrap()
1557            .keep()
1558            .join("lease-ledger.jsonl");
1559        let token = seed_lease(&ledger_path, "sheet-1", "1");
1560        FormatOptions {
1561            spreadsheet_id: "sheet-1".to_string(),
1562            verb: FormatVerb::FormatCells {
1563                sheet: Some("Q1".to_string()),
1564                range: Some("A1:B2".to_string()),
1565                format: Box::new(CellFormatFlags {
1566                    bold: Some(true),
1567                    ..Default::default()
1568                }),
1569            },
1570            dry_run,
1571            lease_token: Some(token),
1572            ledger_path,
1573        }
1574    }
1575
1576    #[tokio::test]
1577    async fn dry_run_reports_would_change_and_makes_no_batch_update_call() {
1578        let server = wiremock::MockServer::start().await;
1579        let (drive, sheets) = clients(&server).await;
1580        mount_file(
1581            "sheet-1",
1582            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1583            &["folder-1"],
1584        )
1585        .mount(&server)
1586        .await;
1587        mount_folder("folder-1").mount(&server).await;
1588        mount_workbook().mount(&server).await;
1589        let rules = vec![allow_rule("folder-1")];
1590
1591        let outcome = format(&drive, &sheets, &format_cells_opts(true), &rules).await;
1592        match outcome.result {
1593            FormatResult::WouldChange { summary, .. } => {
1594                assert!(summary.contains("bold"), "{summary}");
1595            }
1596            other => panic!("expected WouldChange, got {other:?}"),
1597        }
1598        // No batchUpdate mock is mounted, so a stray call would 404 and the
1599        // outcome would be `Failed` instead — the absence of that failure
1600        // is itself the assertion that dry-run made no mutating call.
1601    }
1602
1603    #[tokio::test]
1604    async fn a_denied_gate_blocks_before_any_batch_update_call() {
1605        let server = wiremock::MockServer::start().await;
1606        let (drive, sheets) = clients(&server).await;
1607        mount_file(
1608            "sheet-1",
1609            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1610            &["folder-1"],
1611        )
1612        .mount(&server)
1613        .await;
1614        mount_folder("folder-1").mount(&server).await;
1615        // No workbook or batch-update mock: a blocked attempt must reach
1616        // neither.
1617        let rules: Vec<FolderPermissionRule> = Vec::new();
1618
1619        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
1620        match outcome.result {
1621            FormatResult::Blocked { decided_by } => assert!(decided_by.is_none()),
1622            other => panic!("expected Blocked, got {other:?}"),
1623        }
1624    }
1625
1626    #[tokio::test]
1627    async fn format_cells_sends_a_repeat_cell_request_with_no_value_field() {
1628        let server = wiremock::MockServer::start().await;
1629        let (drive, sheets) = clients(&server).await;
1630        mount_file(
1631            "sheet-1",
1632            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1633            &["folder-1"],
1634        )
1635        .mount(&server)
1636        .await;
1637        mount_folder("folder-1").mount(&server).await;
1638        mount_workbook().mount(&server).await;
1639        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
1640            .mount(&server)
1641            .await;
1642        let rules = vec![allow_rule("folder-1")];
1643
1644        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
1645        assert!(matches!(outcome.result, FormatResult::Changed { .. }));
1646
1647        let requests = server.received_requests().await.unwrap();
1648        let batch = requests
1649            .iter()
1650            .find(|r| r.url.path().ends_with(":batchUpdate"))
1651            .expect("no batchUpdate request was sent");
1652        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1653        let repeat_cell = &body["requests"][0]["repeatCell"];
1654        assert_eq!(
1655            repeat_cell["cell"]["userEnteredFormat"]["textFormat"]["bold"],
1656            true
1657        );
1658        assert!(
1659            repeat_cell["cell"].get("userEnteredValue").is_none(),
1660            "repeatCell must never carry a value: {repeat_cell}"
1661        );
1662    }
1663
1664    #[tokio::test]
1665    async fn merge_cells_dry_run_lists_discarded_cells() {
1666        let server = wiremock::MockServer::start().await;
1667        let (drive, sheets) = clients(&server).await;
1668        mount_file(
1669            "sheet-1",
1670            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1671            &["folder-1"],
1672        )
1673        .mount(&server)
1674        .await;
1675        mount_folder("folder-1").mount(&server).await;
1676        mount_workbook().mount(&server).await;
1677        wiremock::Mock::given(wiremock::matchers::method("GET"))
1678            .and(wiremock::matchers::path(
1679                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
1680            ))
1681            .respond_with(
1682                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1683                    "values": [["keep", "gone"]]
1684                })),
1685            )
1686            .mount(&server)
1687            .await;
1688        let rules = vec![allow_rule("folder-1")];
1689
1690        let opts = FormatOptions {
1691            spreadsheet_id: "sheet-1".to_string(),
1692            verb: FormatVerb::MergeCells {
1693                sheet: Some("Q1".to_string()),
1694                range: Some("A1:B2".to_string()),
1695                merge_type: "MERGE_ALL".to_string(),
1696            },
1697            dry_run: true,
1698            lease_token: None,
1699            ledger_path: std::path::PathBuf::new(),
1700        };
1701        let outcome = format(&drive, &sheets, &opts, &rules).await;
1702        match outcome.result {
1703            FormatResult::WouldChange {
1704                discarded_cells, ..
1705            } => {
1706                assert_eq!(discarded_cells, vec!["B1: gone"]);
1707            }
1708            other => panic!("expected WouldChange, got {other:?}"),
1709        }
1710    }
1711
1712    #[tokio::test]
1713    async fn merge_cells_dry_run_keeps_the_full_discard_list_past_the_render_limit() {
1714        let server = wiremock::MockServer::start().await;
1715        let (drive, sheets) = clients(&server).await;
1716        mount_file(
1717            "sheet-1",
1718            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1719            &["folder-1"],
1720        )
1721        .mount(&server)
1722        .await;
1723        mount_folder("folder-1").mount(&server).await;
1724        mount_workbook().mount(&server).await;
1725        // 202 rows, one column: row 1 is the kept top-left, rows 2..=202
1726        // are 201 discarded cells — one past `MERGE_DISCARD_RENDER_LIMIT`.
1727        let values: Vec<Vec<String>> = (1..=202).map(|row| vec![format!("v{row}")]).collect();
1728        wiremock::Mock::given(wiremock::matchers::method("GET"))
1729            .and(wiremock::matchers::path(
1730                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:A202",
1731            ))
1732            .respond_with(
1733                wiremock::ResponseTemplate::new(200)
1734                    .set_body_json(serde_json::json!({ "values": values })),
1735            )
1736            .mount(&server)
1737            .await;
1738        let rules = vec![allow_rule("folder-1")];
1739
1740        let opts = FormatOptions {
1741            spreadsheet_id: "sheet-1".to_string(),
1742            verb: FormatVerb::MergeCells {
1743                sheet: Some("Q1".to_string()),
1744                range: Some("A1:A202".to_string()),
1745                merge_type: "MERGE_ALL".to_string(),
1746            },
1747            dry_run: true,
1748            lease_token: None,
1749            ledger_path: std::path::PathBuf::new(),
1750        };
1751        let outcome = format(&drive, &sheets, &opts, &rules).await;
1752        let summary = match outcome.result {
1753            FormatResult::WouldChange {
1754                summary,
1755                discarded_cells,
1756            } => {
1757                assert_eq!(discarded_cells.len(), 201);
1758                assert_eq!(discarded_cells[0], "A2: v2");
1759                assert_eq!(discarded_cells[200], "A202: v202");
1760                summary
1761            }
1762            other => panic!("expected WouldChange, got {other:?}"),
1763        };
1764        assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
1765        assert!(summary.contains("A201: v201"), "{summary}");
1766        assert!(!summary.contains("A202: v202"), "{summary}");
1767        assert!(
1768            summary.ends_with(
1769                "… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
1770            ),
1771            "{summary}"
1772        );
1773    }
1774
1775    #[test]
1776    fn parse_hex_color_accepts_with_and_without_hash() {
1777        let a = parse_hex_color("#FF8800").unwrap();
1778        let b = parse_hex_color("ff8800").unwrap();
1779        assert!((a.red - b.red).abs() < f32::EPSILON);
1780        assert!((a.red - 1.0).abs() < 0.01);
1781        assert!((a.green - 0x88 as f32 / 255.0).abs() < 0.01);
1782        assert!((a.blue - 0.0).abs() < 0.01);
1783    }
1784
1785    #[test]
1786    fn parse_hex_color_rejects_wrong_length_and_non_hex() {
1787        assert!(parse_hex_color("#FFF").is_err());
1788        assert!(parse_hex_color("#GGGGGG").is_err());
1789        assert!(parse_hex_color("").is_err());
1790    }
1791
1792    #[test]
1793    fn normalize_hex_for_display_always_carries_exactly_one_hash() {
1794        // The two shapes `parse_hex_color` accepts must display identically,
1795        // so `structure.rs`'s `update-sheet-properties` text never echoes a
1796        // bare `FF8800` for one user and `#FF8800` for another (issue #1835).
1797        assert_eq!(normalize_hex_for_display("#FF8800"), "#FF8800");
1798        assert_eq!(normalize_hex_for_display("FF8800"), "#FF8800");
1799        // Only the *leading* `#` is stripped, and only once — `strip_prefix`
1800        // is not a trim, so a doubled prefix keeps its second `#`.
1801        assert_eq!(normalize_hex_for_display("##FF8800"), "##FF8800");
1802        assert_eq!(normalize_hex_for_display(""), "#");
1803    }
1804
1805    #[test]
1806    fn format_hex_color_round_trips_parse_hex_color() {
1807        for hex in ["#FF8800", "#000000", "#FFFFFF", "#4A86E8"] {
1808            let color = parse_hex_color(hex).unwrap();
1809            assert_eq!(format_hex_color(color), hex);
1810        }
1811    }
1812
1813    #[test]
1814    fn build_cell_format_refuses_an_empty_flag_set() {
1815        let err = build_cell_format(&CellFormatFlags::default()).unwrap_err();
1816        assert!(err.contains("at least one property"), "{err}");
1817    }
1818
1819    #[test]
1820    fn build_cell_format_masks_exactly_the_flags_given() {
1821        let flags = CellFormatFlags {
1822            bold: Some(true),
1823            background: Some("#FF0000".to_string()),
1824            ..Default::default()
1825        };
1826        let (format, fields) = build_cell_format(&flags).unwrap();
1827        assert_eq!(format.text_format.unwrap().bold, Some(true));
1828        assert!(format.background_color_style.is_some());
1829        assert_eq!(
1830            fields,
1831            "userEnteredFormat.textFormat.bold,userEnteredFormat.backgroundColorStyle"
1832        );
1833    }
1834
1835    #[test]
1836    fn build_cell_format_requires_number_format_pattern_and_type_together() {
1837        let flags = CellFormatFlags {
1838            number_format_pattern: Some("0.00".to_string()),
1839            ..Default::default()
1840        };
1841        let err = build_cell_format(&flags).unwrap_err();
1842        assert!(err.contains("together"), "{err}");
1843    }
1844
1845    #[test]
1846    fn discarded_from_values_skips_the_top_left_and_blanks() {
1847        let values: ValueRange = serde_json::from_value(serde_json::json!({
1848            "values": [["keep", "b1", ""], ["a2", null, "c2"]]
1849        }))
1850        .unwrap();
1851        let discarded = discarded_from_values(&values, 0, 0);
1852        assert_eq!(discarded, vec!["B1: b1", "A2: a2", "C2: c2"]);
1853    }
1854
1855    #[test]
1856    fn discarded_from_values_offsets_addresses_by_the_ranges_own_start() {
1857        // A read of 'Q1'!C5:D10 is 0-indexed within that range, so an
1858        // address computed with no offset would misreport D5 as B1.
1859        let values: ValueRange = serde_json::from_value(serde_json::json!({
1860            "values": [["keep", "d5"]]
1861        }))
1862        .unwrap();
1863        let discarded = discarded_from_values(&values, 4, 2);
1864        assert_eq!(discarded, vec!["D5: d5"]);
1865    }
1866
1867    #[test]
1868    fn merge_cells_summary_lists_every_discard_up_to_the_render_limit() {
1869        let discarded: Vec<String> = (1..=200).map(|row| format!("A{row}: v{row}")).collect();
1870        let summary = describe_effect(&merge_cells_verb("A1:A201"), &discarded).unwrap();
1871        assert!(!summary.contains('…'), "{summary}");
1872        assert!(summary.contains("discarding 200 cell(s)"), "{summary}");
1873        assert!(summary.ends_with(&discarded.join("; ")), "{summary}");
1874    }
1875
1876    #[test]
1877    fn merge_cells_summary_elides_past_the_render_limit_but_keeps_the_exact_count() {
1878        let discarded: Vec<String> = (1..=201).map(|row| format!("A{row}: v{row}")).collect();
1879        let summary = describe_effect(&merge_cells_verb("A1:A202"), &discarded).unwrap();
1880        assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
1881        assert!(summary.contains("A200: v200"), "{summary}");
1882        assert!(!summary.contains("A201: v201"), "{summary}");
1883        assert!(
1884            summary.ends_with(
1885                "… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
1886            ),
1887            "{summary}"
1888        );
1889    }
1890
1891    #[tokio::test]
1892    async fn merge_cells_refuses_an_open_ended_range() {
1893        let server = wiremock::MockServer::start().await;
1894        let (drive, sheets) = clients(&server).await;
1895        mount_file(
1896            "sheet-1",
1897            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1898            &["folder-1"],
1899        )
1900        .mount(&server)
1901        .await;
1902        mount_folder("folder-1").mount(&server).await;
1903        mount_workbook().mount(&server).await;
1904        let rules = vec![allow_rule("folder-1")];
1905
1906        let opts = FormatOptions {
1907            spreadsheet_id: "sheet-1".to_string(),
1908            verb: FormatVerb::MergeCells {
1909                sheet: Some("Q1".to_string()),
1910                range: Some("A:A".to_string()),
1911                merge_type: "MERGE_ALL".to_string(),
1912            },
1913            dry_run: true,
1914            lease_token: None,
1915            ledger_path: std::path::PathBuf::new(),
1916        };
1917        let outcome = format(&drive, &sheets, &opts, &rules).await;
1918        match outcome.result {
1919            FormatResult::RefusedInvalidRange { detail } => {
1920                assert!(detail.contains("open-ended"), "{detail}");
1921            }
1922            other => panic!("expected RefusedInvalidRange, got {other:?}"),
1923        }
1924        // No values.get mock is mounted, so a stray read would 404 and the
1925        // outcome would be `Failed` instead — the absence of that failure
1926        // is itself the assertion that the refusal happened before any
1927        // read of the (unbounded) target's values.
1928    }
1929
1930    #[tokio::test]
1931    async fn merge_cells_refuses_a_range_over_the_cell_budget_before_reading_it() {
1932        for dry_run in [true, false] {
1933            let server = wiremock::MockServer::start().await;
1934            let (drive, sheets) = clients(&server).await;
1935            mount_file(
1936                "sheet-1",
1937                crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1938                &["folder-1"],
1939            )
1940            .mount(&server)
1941            .await;
1942            mount_folder("folder-1").mount(&server).await;
1943            mount_workbook().mount(&server).await;
1944            wiremock::Mock::given(wiremock::matchers::method("GET"))
1945                .and(wiremock::matchers::path_regex(
1946                    r"^/v4/spreadsheets/sheet-1/values/",
1947                ))
1948                .respond_with(wiremock::ResponseTemplate::new(200))
1949                .expect(0)
1950                .mount(&server)
1951                .await;
1952            mount_batch_update(serde_json::json!({}))
1953                .expect(0)
1954                .mount(&server)
1955                .await;
1956            let dir = tempfile::tempdir().unwrap();
1957            let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1958            let rules = vec![allow_rule("folder-1")];
1959
1960            // 702 columns x 1000 rows = 702,000 cells. `opts()` seeds a
1961            // live, matching lease, so the real run would reach the gate.
1962            let opts = opts(merge_cells_verb("A1:ZZ1000"), dry_run);
1963            let outcome = format(&drive, &sheets, &opts, &rules).await;
1964            match outcome.result {
1965                FormatResult::RefusedInvalidRange { detail } => {
1966                    assert_eq!(
1967                        detail,
1968                        "'A1:ZZ1000' covers 702000 cells, over the 50000-cell budget for \
1969                         merge-cells; narrow --range",
1970                        "dry_run={dry_run}"
1971                    );
1972                }
1973                other => panic!("dry_run={dry_run}: expected RefusedInvalidRange, got {other:?}"),
1974            }
1975            // Refused before the lease gate, so no `pending` record opens.
1976            assert!(audit.records().is_empty(), "{:?}", audit.records());
1977            server.verify().await;
1978        }
1979    }
1980
1981    #[tokio::test]
1982    async fn merge_cells_accepts_a_range_exactly_at_the_cell_budget() {
1983        let server = wiremock::MockServer::start().await;
1984        let (drive, sheets) = clients(&server).await;
1985        mount_file(
1986            "sheet-1",
1987            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1988            &["folder-1"],
1989        )
1990        .mount(&server)
1991        .await;
1992        mount_folder("folder-1").mount(&server).await;
1993        mount_workbook().mount(&server).await;
1994        // 10 columns x 5000 rows = exactly `MAX_CELLS_PER_MERGE`.
1995        wiremock::Mock::given(wiremock::matchers::method("GET"))
1996            .and(wiremock::matchers::path(
1997                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:J5000",
1998            ))
1999            .respond_with(
2000                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2001                    "values": [["keep", "gone"]]
2002                })),
2003            )
2004            .expect(1)
2005            .mount(&server)
2006            .await;
2007        let rules = vec![allow_rule("folder-1")];
2008
2009        let opts = opts(merge_cells_verb("A1:J5000"), true);
2010        let outcome = format(&drive, &sheets, &opts, &rules).await;
2011        match outcome.result {
2012            FormatResult::WouldChange {
2013                discarded_cells, ..
2014            } => assert_eq!(discarded_cells, vec!["B1: gone"]),
2015            other => panic!("expected WouldChange, got {other:?}"),
2016        }
2017    }
2018
2019    // ── Verb helpers ─────────────────────────────────────────────────
2020
2021    fn opts(verb: FormatVerb, dry_run: bool) -> FormatOptions {
2022        let ledger_path = tempfile::tempdir()
2023            .unwrap()
2024            .keep()
2025            .join("lease-ledger.jsonl");
2026        let token = seed_lease(&ledger_path, "sheet-1", "1");
2027        FormatOptions {
2028            spreadsheet_id: "sheet-1".to_string(),
2029            verb,
2030            dry_run,
2031            lease_token: Some(token),
2032            ledger_path,
2033        }
2034    }
2035
2036    fn update_borders_verb(sides: BorderSides, style: &str, color: Option<&str>) -> FormatVerb {
2037        FormatVerb::UpdateBorders {
2038            sheet: Some("Q1".to_string()),
2039            range: Some("A1:B2".to_string()),
2040            sides,
2041            style: style.to_string(),
2042            color: color.map(str::to_string),
2043        }
2044    }
2045
2046    fn merge_cells_verb(range: &str) -> FormatVerb {
2047        FormatVerb::MergeCells {
2048            sheet: Some("Q1".to_string()),
2049            range: Some(range.to_string()),
2050            merge_type: "MERGE_ALL".to_string(),
2051        }
2052    }
2053
2054    fn unmerge_cells_verb() -> FormatVerb {
2055        FormatVerb::UnmergeCells {
2056            sheet: Some("Q1".to_string()),
2057            range: Some("A1:B2".to_string()),
2058        }
2059    }
2060
2061    fn auto_resize_verb(sheet: &str, start: i64, end: i64) -> FormatVerb {
2062        FormatVerb::AutoResizeDimension {
2063            sheet: sheet.to_string(),
2064            dimension: Dimension::Rows,
2065            start,
2066            end,
2067        }
2068    }
2069
2070    fn update_dimension_verb(sheet: &str, start: i64, end: i64, pixel_size: i64) -> FormatVerb {
2071        FormatVerb::UpdateDimensionProperties {
2072            sheet: sheet.to_string(),
2073            dimension: Dimension::Columns,
2074            start,
2075            end,
2076            pixel_size,
2077        }
2078    }
2079
2080    fn find_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
2081        let batch = requests
2082            .iter()
2083            .find(|r| r.url.path().ends_with(":batchUpdate"))
2084            .expect("no batchUpdate request was sent");
2085        serde_json::from_slice(&batch.body).unwrap()
2086    }
2087
2088    // ── FormatVerb / BorderSides pure-function coverage ─────────────────
2089
2090    #[test]
2091    fn border_sides_any_is_true_iff_at_least_one_side_is_set() {
2092        assert!(!BorderSides::default().any());
2093        assert!(BorderSides {
2094            top: true,
2095            ..Default::default()
2096        }
2097        .any());
2098        assert!(BorderSides {
2099            bottom: true,
2100            ..Default::default()
2101        }
2102        .any());
2103        assert!(BorderSides {
2104            left: true,
2105            ..Default::default()
2106        }
2107        .any());
2108        assert!(BorderSides {
2109            right: true,
2110            ..Default::default()
2111        }
2112        .any());
2113        assert!(BorderSides {
2114            inner_horizontal: true,
2115            ..Default::default()
2116        }
2117        .any());
2118        assert!(BorderSides {
2119            inner_vertical: true,
2120            ..Default::default()
2121        }
2122        .any());
2123    }
2124
2125    #[test]
2126    fn log_operation_and_label_are_distinct_for_every_verb() {
2127        let verbs = [
2128            format_cells_opts(false).verb,
2129            update_borders_verb(BorderSides::default(), "SOLID", None),
2130            merge_cells_verb("A1:B2"),
2131            unmerge_cells_verb(),
2132            auto_resize_verb("Q1", 1, 2),
2133            update_dimension_verb("Q1", 1, 2, 10),
2134        ];
2135        let ops: HashSet<&str> = verbs.iter().map(FormatVerb::log_operation).collect();
2136        assert_eq!(ops.len(), verbs.len());
2137        let labels: HashSet<&str> = verbs.iter().map(FormatVerb::label).collect();
2138        assert_eq!(labels.len(), verbs.len());
2139    }
2140
2141    #[test]
2142    fn sheet_and_range_is_none_only_for_the_dimension_span_verbs() {
2143        assert_eq!(
2144            merge_cells_verb("A1:B2").sheet_and_range(),
2145            Some((Some("Q1"), Some("A1:B2")))
2146        );
2147        assert!(auto_resize_verb("Q1", 1, 2).sheet_and_range().is_none());
2148        assert!(update_dimension_verb("Q1", 1, 2, 10)
2149            .sheet_and_range()
2150            .is_none());
2151    }
2152
2153    #[test]
2154    fn log_status_is_distinct_for_every_variant() {
2155        let statuses = [
2156            FormatResult::WouldChange {
2157                summary: "x".to_string(),
2158                discarded_cells: Vec::new(),
2159            }
2160            .log_status(),
2161            FormatResult::RefusedNotASpreadsheet {
2162                mime_type: "application/pdf".to_string(),
2163            }
2164            .log_status(),
2165            FormatResult::RefusedShortcut.log_status(),
2166            FormatResult::RefusedNoVisibleParents.log_status(),
2167            FormatResult::RefusedSheetNotFound {
2168                title: "x".to_string(),
2169                available: Vec::new(),
2170            }
2171            .log_status(),
2172            FormatResult::RefusedInvalidRange {
2173                detail: "x".to_string(),
2174            }
2175            .log_status(),
2176            FormatResult::Blocked { decided_by: None }.log_status(),
2177            FormatResult::RefusedNoLease.log_status(),
2178            FormatResult::RefusedLeaseExpired.log_status(),
2179            FormatResult::RefusedLeaseWrongFile.log_status(),
2180            FormatResult::RefusedLeaseStale.log_status(),
2181            FormatResult::Changed {
2182                summary: "x".to_string(),
2183                discarded_cells: Vec::new(),
2184            }
2185            .log_status(),
2186            FormatResult::AppliedReplyUnreadable {
2187                summary: "x".to_string(),
2188                discarded_cells: Vec::new(),
2189                detail: "x".to_string(),
2190            }
2191            .log_status(),
2192            FormatResult::Failed {
2193                detail: "x".to_string(),
2194            }
2195            .log_status(),
2196        ];
2197        let unique: HashSet<&str> = statuses.iter().copied().collect();
2198        assert_eq!(unique.len(), statuses.len());
2199        assert!(statuses.iter().all(|s| !s.is_empty()));
2200    }
2201
2202    #[test]
2203    fn record_attempt_handles_a_would_change_outcome_without_panicking() {
2204        // `record_attempt` is only ever called for `!dry_run`, and
2205        // `WouldChange` only ever occurs when `dry_run` is true, so the real
2206        // pipeline never exercises this combination — call it directly, the
2207        // same bypass `structure.rs`'s `record_attempt` edge-case tests use.
2208        let opts = format_cells_opts(false);
2209        let outcome = FormatOutcome {
2210            spreadsheet_id: "sheet-1".to_string(),
2211            file_name: Some("Budget".to_string()),
2212            resolved_folder_id: None,
2213            verb: opts.verb.clone(),
2214            result: FormatResult::WouldChange {
2215                summary: "set bold".to_string(),
2216                discarded_cells: vec!["B1: gone".to_string()],
2217            },
2218        };
2219        record_attempt(&outcome, &opts, Duration::from_millis(1));
2220    }
2221
2222    #[test]
2223    fn write_jsonl_serializes_the_outcome_as_one_json_line() {
2224        let outcome = FormatOutcome {
2225            spreadsheet_id: "sheet-1".to_string(),
2226            file_name: Some("Budget".to_string()),
2227            resolved_folder_id: None,
2228            verb: format_cells_opts(false).verb,
2229            result: FormatResult::Changed {
2230                summary: "set bold".to_string(),
2231                discarded_cells: Vec::new(),
2232            },
2233        };
2234        let mut buf = Vec::new();
2235        outcome.write_jsonl(&mut buf).unwrap();
2236        let text = String::from_utf8(buf).unwrap();
2237        assert_eq!(text.matches('\n').count(), 1);
2238        let value: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
2239        assert_eq!(value["spreadsheet_id"], "sheet-1");
2240        assert_eq!(value["result"]["status"], "changed");
2241    }
2242
2243    // ── build_cell_format: remaining flags ───────────────────────────────
2244
2245    #[test]
2246    fn build_cell_format_covers_every_remaining_flag() {
2247        let flags = CellFormatFlags {
2248            italic: Some(true),
2249            strikethrough: Some(true),
2250            underline: Some(true),
2251            font_size: Some(14),
2252            text_color: Some("#112233".to_string()),
2253            horizontal_align: Some("CENTER".to_string()),
2254            vertical_align: Some("MIDDLE".to_string()),
2255            number_format_pattern: Some("0.00".to_string()),
2256            number_format_type: Some("NUMBER".to_string()),
2257            wrap: Some("WRAP".to_string()),
2258            font_family: Some("Arial".to_string()),
2259            text_rotation: Some(TextRotationFlag::Angle(45)),
2260            hyperlink_display_type: Some("PLAIN_TEXT".to_string()),
2261            padding_top: Some(1),
2262            padding_right: Some(2),
2263            padding_bottom: Some(3),
2264            padding_left: Some(4),
2265            text_direction: Some("RIGHT_TO_LEFT".to_string()),
2266            ..Default::default()
2267        };
2268        let (format, fields) = build_cell_format(&flags).unwrap();
2269        let text_format = format.text_format.unwrap();
2270        assert_eq!(text_format.italic, Some(true));
2271        assert_eq!(text_format.strikethrough, Some(true));
2272        assert_eq!(text_format.underline, Some(true));
2273        assert_eq!(text_format.font_size, Some(14));
2274        assert!(text_format.foreground_color_style.is_some());
2275        assert_eq!(text_format.font_family.as_deref(), Some("Arial"));
2276        assert_eq!(format.horizontal_alignment.as_deref(), Some("CENTER"));
2277        assert_eq!(format.vertical_alignment.as_deref(), Some("MIDDLE"));
2278        assert!(format.number_format.is_some());
2279        assert_eq!(format.wrap_strategy.as_deref(), Some("WRAP"));
2280        let text_rotation = format.text_rotation.unwrap();
2281        assert_eq!(text_rotation.angle, Some(45));
2282        assert_eq!(text_rotation.vertical, None);
2283        assert_eq!(format.hyperlink_display_type.as_deref(), Some("PLAIN_TEXT"));
2284        let padding = format.padding.unwrap();
2285        assert_eq!(padding.top, Some(1));
2286        assert_eq!(padding.right, Some(2));
2287        assert_eq!(padding.bottom, Some(3));
2288        assert_eq!(padding.left, Some(4));
2289        assert_eq!(format.text_direction.as_deref(), Some("RIGHT_TO_LEFT"));
2290        for expected in [
2291            "userEnteredFormat.textFormat.italic",
2292            "userEnteredFormat.textFormat.strikethrough",
2293            "userEnteredFormat.textFormat.underline",
2294            "userEnteredFormat.textFormat.fontSize",
2295            "userEnteredFormat.textFormat.foregroundColorStyle",
2296            "userEnteredFormat.textFormat.fontFamily",
2297            "userEnteredFormat.horizontalAlignment",
2298            "userEnteredFormat.verticalAlignment",
2299            "userEnteredFormat.numberFormat",
2300            "userEnteredFormat.wrapStrategy",
2301            "userEnteredFormat.textRotation",
2302            "userEnteredFormat.hyperlinkDisplayType",
2303            "userEnteredFormat.padding",
2304            "userEnteredFormat.textDirection",
2305        ] {
2306            assert!(fields.contains(expected), "{fields} missing {expected}");
2307        }
2308    }
2309
2310    #[test]
2311    fn build_cell_format_sets_text_rotation_vertical() {
2312        let flags = CellFormatFlags {
2313            text_rotation: Some(TextRotationFlag::Vertical),
2314            ..Default::default()
2315        };
2316        let (format, fields) = build_cell_format(&flags).unwrap();
2317        let text_rotation = format.text_rotation.unwrap();
2318        assert_eq!(text_rotation.angle, None);
2319        assert_eq!(text_rotation.vertical, Some(true));
2320        assert_eq!(fields, "userEnteredFormat.textRotation");
2321    }
2322
2323    #[test]
2324    fn build_cell_format_accepts_negative_text_rotation_angle() {
2325        let flags = CellFormatFlags {
2326            text_rotation: Some(TextRotationFlag::Angle(-45)),
2327            ..Default::default()
2328        };
2329        let (format, _fields) = build_cell_format(&flags).unwrap();
2330        let text_rotation = format.text_rotation.unwrap();
2331        assert_eq!(text_rotation.angle, Some(-45));
2332    }
2333
2334    #[test]
2335    fn build_cell_format_rejects_out_of_range_text_rotation_angle() {
2336        for angle in [91, -91, i64::MAX, i64::MIN] {
2337            let flags = CellFormatFlags {
2338                text_rotation: Some(TextRotationFlag::Angle(angle)),
2339                ..Default::default()
2340            };
2341            let err = build_cell_format(&flags).unwrap_err();
2342            assert!(err.contains("-90 and 90"), "{angle}: {err}");
2343        }
2344    }
2345
2346    #[test]
2347    fn build_cell_format_rejects_out_of_range_padding() {
2348        for flags in [
2349            CellFormatFlags {
2350                padding_top: Some(-1),
2351                ..Default::default()
2352            },
2353            CellFormatFlags {
2354                padding_top: Some(i64::from(i32::MAX) + 1),
2355                ..Default::default()
2356            },
2357        ] {
2358            let err = build_cell_format(&flags).unwrap_err();
2359            assert!(err.contains("--padding-top"), "{err}");
2360        }
2361    }
2362
2363    // ── build_request: defensive unreachable branches ────────────────────
2364
2365    #[test]
2366    #[should_panic(expected = "FormatCells to a Range")]
2367    fn build_request_panics_if_format_cells_resolves_to_a_dimension() {
2368        let verb = format_cells_opts(false).verb;
2369        let resolved = ResolvedTarget::Dimension {
2370            range: DimensionRange {
2371                sheet_id: 0,
2372                dimension: Dimension::Rows,
2373                start_index: 0,
2374                end_index: 1,
2375            },
2376        };
2377        let _ = build_request(&verb, &resolved);
2378    }
2379
2380    #[test]
2381    #[should_panic(expected = "UpdateBorders to a Range")]
2382    fn build_request_panics_if_update_borders_resolves_to_a_dimension() {
2383        let verb = update_borders_verb(
2384            BorderSides {
2385                top: true,
2386                ..Default::default()
2387            },
2388            "SOLID",
2389            None,
2390        );
2391        let resolved = ResolvedTarget::Dimension {
2392            range: DimensionRange {
2393                sheet_id: 0,
2394                dimension: Dimension::Rows,
2395                start_index: 0,
2396                end_index: 1,
2397            },
2398        };
2399        let _ = build_request(&verb, &resolved);
2400    }
2401
2402    #[test]
2403    #[should_panic(expected = "MergeCells to a Range")]
2404    fn build_request_panics_if_merge_cells_resolves_to_a_dimension() {
2405        let resolved = ResolvedTarget::Dimension {
2406            range: DimensionRange {
2407                sheet_id: 0,
2408                dimension: Dimension::Rows,
2409                start_index: 0,
2410                end_index: 1,
2411            },
2412        };
2413        let _ = build_request(&merge_cells_verb("A1:B2"), &resolved);
2414    }
2415
2416    #[test]
2417    #[should_panic(expected = "UnmergeCells to a Range")]
2418    fn build_request_panics_if_unmerge_cells_resolves_to_a_dimension() {
2419        let resolved = ResolvedTarget::Dimension {
2420            range: DimensionRange {
2421                sheet_id: 0,
2422                dimension: Dimension::Rows,
2423                start_index: 0,
2424                end_index: 1,
2425            },
2426        };
2427        let _ = build_request(&unmerge_cells_verb(), &resolved);
2428    }
2429
2430    #[test]
2431    #[should_panic(expected = "AutoResizeDimension to a Dimension")]
2432    fn build_request_panics_if_auto_resize_resolves_to_a_range() {
2433        let resolved = ResolvedTarget::Range {
2434            sheet_title: "Q1".to_string(),
2435            grid: GridRange::default(),
2436        };
2437        let _ = build_request(&auto_resize_verb("Q1", 1, 2), &resolved);
2438    }
2439
2440    #[test]
2441    #[should_panic(expected = "UpdateDimensionProperties to a Dimension")]
2442    fn build_request_panics_if_update_dimension_properties_resolves_to_a_range() {
2443        let resolved = ResolvedTarget::Range {
2444            sheet_title: "Q1".to_string(),
2445            grid: GridRange::default(),
2446        };
2447        let _ = build_request(&update_dimension_verb("Q1", 1, 2, 10), &resolved);
2448    }
2449
2450    // ── read_discarded_cells: direct-call edge cases ─────────────────────
2451
2452    #[tokio::test]
2453    async fn read_discarded_cells_returns_empty_for_a_non_range_target() {
2454        let server = wiremock::MockServer::start().await;
2455        let (_drive, sheets) = clients(&server).await;
2456        let api = SheetsApi::new(&sheets);
2457        let resolved = ResolvedTarget::Dimension {
2458            range: DimensionRange {
2459                sheet_id: 0,
2460                dimension: Dimension::Rows,
2461                start_index: 0,
2462                end_index: 1,
2463            },
2464        };
2465        let cells = read_discarded_cells(&api, "sheet-1", &resolved)
2466            .await
2467            .unwrap();
2468        assert!(cells.is_empty());
2469    }
2470
2471    #[tokio::test]
2472    async fn read_discarded_cells_skips_the_read_for_a_single_cell_range() {
2473        let server = wiremock::MockServer::start().await;
2474        let (_drive, sheets) = clients(&server).await;
2475        let api = SheetsApi::new(&sheets);
2476        // No values.get mock mounted: a stray call would 404.
2477        let resolved = ResolvedTarget::Range {
2478            sheet_title: "Q1".to_string(),
2479            grid: GridRange {
2480                sheet_id: 0,
2481                start_row_index: Some(0),
2482                end_row_index: Some(1),
2483                start_column_index: Some(0),
2484                end_column_index: Some(1),
2485            },
2486        };
2487        let cells = read_discarded_cells(&api, "sheet-1", &resolved)
2488            .await
2489            .unwrap();
2490        assert!(cells.is_empty());
2491    }
2492
2493    // ── format_inner: paths before the workbook fetch ────────────────────
2494
2495    #[tokio::test]
2496    async fn a_conflicting_sheet_and_range_is_refused_before_any_drive_call() {
2497        let server = wiremock::MockServer::start().await;
2498        let (drive, sheets) = clients(&server).await;
2499        // No file/workbook mock at all: proves the refusal happens first.
2500        let rules: Vec<FolderPermissionRule> = Vec::new();
2501        let verb = FormatVerb::FormatCells {
2502            sheet: Some("Other".to_string()),
2503            range: Some("Sheet1!A1:B2".to_string()),
2504            format: Box::new(CellFormatFlags {
2505                bold: Some(true),
2506                ..Default::default()
2507            }),
2508        };
2509
2510        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2511        match outcome.result {
2512            FormatResult::RefusedInvalidRange { detail } => {
2513                assert!(detail.contains("already names a sheet"), "{detail}");
2514            }
2515            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2516        }
2517        assert!(outcome.file_name.is_none());
2518    }
2519
2520    #[tokio::test]
2521    async fn a_metadata_fetch_failure_is_reported_as_failed() {
2522        let server = wiremock::MockServer::start().await;
2523        let (drive, sheets) = clients(&server).await;
2524        wiremock::Mock::given(wiremock::matchers::method("GET"))
2525            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2526            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2527            .mount(&server)
2528            .await;
2529        let rules: Vec<FolderPermissionRule> = Vec::new();
2530
2531        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2532        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2533        assert!(outcome.file_name.is_none());
2534    }
2535
2536    #[tokio::test]
2537    async fn a_non_spreadsheet_target_is_refused_by_mime_type() {
2538        let server = wiremock::MockServer::start().await;
2539        let (drive, sheets) = clients(&server).await;
2540        mount_file("sheet-1", "application/pdf", &["folder-1"])
2541            .mount(&server)
2542            .await;
2543        let rules = vec![allow_rule("folder-1")];
2544
2545        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2546        match outcome.result {
2547            FormatResult::RefusedNotASpreadsheet { mime_type } => {
2548                assert_eq!(mime_type, "application/pdf");
2549            }
2550            other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
2551        }
2552        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2553    }
2554
2555    #[tokio::test]
2556    async fn a_shortcut_target_is_refused() {
2557        let server = wiremock::MockServer::start().await;
2558        let (drive, sheets) = clients(&server).await;
2559        mount_file(
2560            "sheet-1",
2561            "application/vnd.google-apps.shortcut",
2562            &["folder-1"],
2563        )
2564        .mount(&server)
2565        .await;
2566        let rules = vec![allow_rule("folder-1")];
2567
2568        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2569        assert!(matches!(outcome.result, FormatResult::RefusedShortcut));
2570    }
2571
2572    #[tokio::test]
2573    async fn a_target_with_no_visible_parents_is_refused() {
2574        let server = wiremock::MockServer::start().await;
2575        let (drive, sheets) = clients(&server).await;
2576        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2577            .mount(&server)
2578            .await;
2579        let rules: Vec<FolderPermissionRule> = Vec::new();
2580
2581        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2582        assert!(matches!(
2583            outcome.result,
2584            FormatResult::RefusedNoVisibleParents
2585        ));
2586    }
2587
2588    #[tokio::test]
2589    async fn a_gate_ancestor_fetch_failure_is_reported_as_failed() {
2590        let server = wiremock::MockServer::start().await;
2591        let (drive, sheets) = clients(&server).await;
2592        mount_file(
2593            "sheet-1",
2594            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2595            &["folder-1"],
2596        )
2597        .mount(&server)
2598        .await;
2599        wiremock::Mock::given(wiremock::matchers::method("GET"))
2600            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2601            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2602            .mount(&server)
2603            .await;
2604        let rules = vec![allow_rule("folder-1")];
2605
2606        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2607        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2608        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2609    }
2610
2611    #[tokio::test]
2612    async fn a_workbook_fetch_failure_is_reported_as_failed() {
2613        let server = wiremock::MockServer::start().await;
2614        let (drive, sheets) = clients(&server).await;
2615        mount_file(
2616            "sheet-1",
2617            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2618            &["folder-1"],
2619        )
2620        .mount(&server)
2621        .await;
2622        mount_folder("folder-1").mount(&server).await;
2623        wiremock::Mock::given(wiremock::matchers::method("GET"))
2624            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2625            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2626            .mount(&server)
2627            .await;
2628        let rules = vec![allow_rule("folder-1")];
2629
2630        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
2631        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2632    }
2633
2634    // ── format_inner: target-resolution errors ───────────────────────────
2635
2636    #[tokio::test]
2637    async fn format_cells_reports_sheet_not_found_for_a_range_target() {
2638        let server = wiremock::MockServer::start().await;
2639        let (drive, sheets) = clients(&server).await;
2640        mount_file(
2641            "sheet-1",
2642            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2643            &["folder-1"],
2644        )
2645        .mount(&server)
2646        .await;
2647        mount_folder("folder-1").mount(&server).await;
2648        mount_workbook().mount(&server).await;
2649        let rules = vec![allow_rule("folder-1")];
2650        let verb = FormatVerb::FormatCells {
2651            sheet: Some("Nope".to_string()),
2652            range: Some("A1:B2".to_string()),
2653            format: Box::new(CellFormatFlags {
2654                bold: Some(true),
2655                ..Default::default()
2656            }),
2657        };
2658
2659        let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2660        match outcome.result {
2661            FormatResult::RefusedSheetNotFound { title, available } => {
2662                assert_eq!(title, "Nope");
2663                assert_eq!(available, vec!["Q1".to_string()]);
2664            }
2665            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2666        }
2667    }
2668
2669    #[tokio::test]
2670    async fn format_cells_reports_an_invalid_a1_token() {
2671        let server = wiremock::MockServer::start().await;
2672        let (drive, sheets) = clients(&server).await;
2673        mount_file(
2674            "sheet-1",
2675            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2676            &["folder-1"],
2677        )
2678        .mount(&server)
2679        .await;
2680        mount_folder("folder-1").mount(&server).await;
2681        mount_workbook().mount(&server).await;
2682        let rules = vec![allow_rule("folder-1")];
2683        let verb = FormatVerb::FormatCells {
2684            sheet: Some("Q1".to_string()),
2685            range: Some("definitely bogus".to_string()),
2686            format: Box::new(CellFormatFlags {
2687                bold: Some(true),
2688                ..Default::default()
2689            }),
2690        };
2691
2692        let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2693        match outcome.result {
2694            FormatResult::RefusedInvalidRange { detail } => {
2695                assert!(detail.contains("not a recognised A1 range"), "{detail}");
2696            }
2697            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2698        }
2699    }
2700
2701    #[tokio::test]
2702    async fn dimension_verb_reports_sheet_not_found() {
2703        let server = wiremock::MockServer::start().await;
2704        let (drive, sheets) = clients(&server).await;
2705        mount_file(
2706            "sheet-1",
2707            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2708            &["folder-1"],
2709        )
2710        .mount(&server)
2711        .await;
2712        mount_folder("folder-1").mount(&server).await;
2713        mount_workbook().mount(&server).await;
2714        let rules = vec![allow_rule("folder-1")];
2715
2716        let outcome = format(
2717            &drive,
2718            &sheets,
2719            &opts(auto_resize_verb("Nope", 1, 2), true),
2720            &rules,
2721        )
2722        .await;
2723        match outcome.result {
2724            FormatResult::RefusedSheetNotFound { title, available } => {
2725                assert_eq!(title, "Nope");
2726                assert_eq!(available, vec!["Q1".to_string()]);
2727            }
2728            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2729        }
2730    }
2731
2732    #[tokio::test]
2733    async fn dimension_verbs_reject_an_invalid_start_end_span() {
2734        let server = wiremock::MockServer::start().await;
2735        let (drive, sheets) = clients(&server).await;
2736        mount_file(
2737            "sheet-1",
2738            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2739            &["folder-1"],
2740        )
2741        .mount(&server)
2742        .await;
2743        mount_folder("folder-1").mount(&server).await;
2744        mount_workbook().mount(&server).await;
2745        let rules = vec![allow_rule("folder-1")];
2746
2747        for verb in [
2748            auto_resize_verb("Q1", 0, 5),
2749            update_dimension_verb("Q1", 5, 3, 10),
2750        ] {
2751            let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
2752            match outcome.result {
2753                FormatResult::RefusedInvalidRange { detail } => {
2754                    assert!(detail.contains("--start"), "{detail}");
2755                }
2756                other => panic!("expected RefusedInvalidRange, got {other:?}"),
2757            }
2758        }
2759    }
2760
2761    // ── format_inner: merge-cells discarded-values read ──────────────────
2762
2763    #[tokio::test]
2764    async fn merge_cells_reports_a_values_read_failure_as_failed() {
2765        let server = wiremock::MockServer::start().await;
2766        let (drive, sheets) = clients(&server).await;
2767        mount_file(
2768            "sheet-1",
2769            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2770            &["folder-1"],
2771        )
2772        .mount(&server)
2773        .await;
2774        mount_folder("folder-1").mount(&server).await;
2775        mount_workbook().mount(&server).await;
2776        wiremock::Mock::given(wiremock::matchers::method("GET"))
2777            .and(wiremock::matchers::path(
2778                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2779            ))
2780            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2781            .mount(&server)
2782            .await;
2783        let rules = vec![allow_rule("folder-1")];
2784
2785        let outcome = format(
2786            &drive,
2787            &sheets,
2788            &opts(merge_cells_verb("A1:B2"), true),
2789            &rules,
2790        )
2791        .await;
2792        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
2793    }
2794
2795    #[tokio::test]
2796    async fn merge_cells_single_cell_range_has_no_discards_and_skips_the_values_read() {
2797        let server = wiremock::MockServer::start().await;
2798        let (drive, sheets) = clients(&server).await;
2799        mount_file(
2800            "sheet-1",
2801            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2802            &["folder-1"],
2803        )
2804        .mount(&server)
2805        .await;
2806        mount_folder("folder-1").mount(&server).await;
2807        mount_workbook().mount(&server).await;
2808        // No values.get mock mounted: a stray call would 404.
2809        let rules = vec![allow_rule("folder-1")];
2810
2811        let outcome = format(
2812            &drive,
2813            &sheets,
2814            &opts(merge_cells_verb("A1:A1"), true),
2815            &rules,
2816        )
2817        .await;
2818        match outcome.result {
2819            FormatResult::WouldChange {
2820                summary,
2821                discarded_cells,
2822            } => {
2823                assert!(discarded_cells.is_empty());
2824                assert_eq!(summary, "merge (MERGE_ALL)");
2825            }
2826            other => panic!("expected WouldChange, got {other:?}"),
2827        }
2828    }
2829
2830    // ── format_inner: describe_effect / build_request errors ─────────────
2831
2832    #[tokio::test]
2833    async fn update_borders_refuses_when_no_side_is_selected() {
2834        let server = wiremock::MockServer::start().await;
2835        let (drive, sheets) = clients(&server).await;
2836        mount_file(
2837            "sheet-1",
2838            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2839            &["folder-1"],
2840        )
2841        .mount(&server)
2842        .await;
2843        mount_folder("folder-1").mount(&server).await;
2844        mount_workbook().mount(&server).await;
2845        let rules = vec![allow_rule("folder-1")];
2846        let verb = update_borders_verb(BorderSides::default(), "SOLID", None);
2847
2848        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2849        match outcome.result {
2850            FormatResult::RefusedInvalidRange { detail } => {
2851                assert!(detail.contains("at least one side"), "{detail}");
2852            }
2853            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2854        }
2855    }
2856
2857    #[tokio::test]
2858    async fn update_borders_refuses_inner_horizontal_on_a_single_row() {
2859        let server = wiremock::MockServer::start().await;
2860        let (drive, sheets) = clients(&server).await;
2861        mount_file(
2862            "sheet-1",
2863            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2864            &["folder-1"],
2865        )
2866        .mount(&server)
2867        .await;
2868        mount_folder("folder-1").mount(&server).await;
2869        mount_workbook().mount(&server).await;
2870        let rules = vec![allow_rule("folder-1")];
2871        let verb = FormatVerb::UpdateBorders {
2872            sheet: Some("Q1".to_string()),
2873            range: Some("A1:B1".to_string()),
2874            sides: BorderSides {
2875                inner_horizontal: true,
2876                ..Default::default()
2877            },
2878            style: "SOLID".to_string(),
2879            color: None,
2880        };
2881
2882        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2883        match outcome.result {
2884            FormatResult::RefusedInvalidRange { detail } => {
2885                assert!(detail.contains("--inner-horizontal"), "{detail}");
2886                assert!(detail.contains("more than one row"), "{detail}");
2887            }
2888            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2889        }
2890    }
2891
2892    #[tokio::test]
2893    async fn update_borders_refuses_inner_vertical_on_a_single_column() {
2894        let server = wiremock::MockServer::start().await;
2895        let (drive, sheets) = clients(&server).await;
2896        mount_file(
2897            "sheet-1",
2898            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2899            &["folder-1"],
2900        )
2901        .mount(&server)
2902        .await;
2903        mount_folder("folder-1").mount(&server).await;
2904        mount_workbook().mount(&server).await;
2905        let rules = vec![allow_rule("folder-1")];
2906        let verb = FormatVerb::UpdateBorders {
2907            sheet: Some("Q1".to_string()),
2908            range: Some("A1:A2".to_string()),
2909            sides: BorderSides {
2910                inner_vertical: true,
2911                ..Default::default()
2912            },
2913            style: "SOLID".to_string(),
2914            color: None,
2915        };
2916
2917        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2918        match outcome.result {
2919            FormatResult::RefusedInvalidRange { detail } => {
2920                assert!(detail.contains("--inner-vertical"), "{detail}");
2921                assert!(detail.contains("more than one column"), "{detail}");
2922            }
2923            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2924        }
2925    }
2926
2927    #[tokio::test]
2928    async fn update_borders_rejects_a_malformed_color_at_request_build_time() {
2929        let server = wiremock::MockServer::start().await;
2930        let (drive, sheets) = clients(&server).await;
2931        mount_file(
2932            "sheet-1",
2933            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2934            &["folder-1"],
2935        )
2936        .mount(&server)
2937        .await;
2938        mount_folder("folder-1").mount(&server).await;
2939        mount_workbook().mount(&server).await;
2940        // No batchUpdate mock mounted: `describe_effect` doesn't validate
2941        // the color (only `sides.any()`), so this proves the request-build
2942        // step's own `parse_hex_color` call is the one that catches it.
2943        let dir = tempfile::tempdir().unwrap();
2944        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2945        let rules = vec![allow_rule("folder-1")];
2946        let sides = BorderSides {
2947            top: true,
2948            ..Default::default()
2949        };
2950        let verb = update_borders_verb(sides, "SOLID", Some("ZZZZZZ"));
2951
2952        // `opts()` seeds a live, matching lease, so this exercises the real
2953        // gate — not just a verb that never reaches it.
2954        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2955        match outcome.result {
2956            FormatResult::RefusedInvalidRange { detail } => {
2957                assert!(detail.contains("not a color"), "{detail}");
2958            }
2959            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2960        }
2961
2962        // #1688: `build_request` now runs before the gate, so a request
2963        // that can never be issued must never open a `pending` audit
2964        // record in the first place — there is nothing to conclude it.
2965        assert!(audit.records().is_empty(), "{:?}", audit.records());
2966    }
2967
2968    // ── format_inner: full non-dry-run round trips per verb ───────────────
2969
2970    #[tokio::test]
2971    async fn update_borders_sets_every_selected_side() {
2972        let server = wiremock::MockServer::start().await;
2973        let (drive, sheets) = clients(&server).await;
2974        mount_file(
2975            "sheet-1",
2976            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2977            &["folder-1"],
2978        )
2979        .mount(&server)
2980        .await;
2981        mount_folder("folder-1").mount(&server).await;
2982        mount_workbook().mount(&server).await;
2983        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
2984            .mount(&server)
2985            .await;
2986        let rules = vec![allow_rule("folder-1")];
2987        let sides = BorderSides {
2988            top: true,
2989            bottom: true,
2990            left: true,
2991            right: true,
2992            ..Default::default()
2993        };
2994        let verb = update_borders_verb(sides, "DASHED", Some("00FF00"));
2995
2996        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
2997        match &outcome.result {
2998            FormatResult::Changed { summary, .. } => {
2999                assert!(summary.contains("top, bottom, left, right"), "{summary}");
3000            }
3001            other => panic!("expected Changed, got {other:?}"),
3002        }
3003
3004        let requests = server.received_requests().await.unwrap();
3005        let body = find_batch_update(&requests);
3006        let update_borders = &body["requests"][0]["updateBorders"];
3007        for side in ["top", "bottom", "left", "right"] {
3008            assert_eq!(update_borders[side]["style"], "DASHED", "{update_borders}");
3009        }
3010    }
3011
3012    #[tokio::test]
3013    async fn update_borders_sets_inner_horizontal_and_inner_vertical() {
3014        let server = wiremock::MockServer::start().await;
3015        let (drive, sheets) = clients(&server).await;
3016        mount_file(
3017            "sheet-1",
3018            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3019            &["folder-1"],
3020        )
3021        .mount(&server)
3022        .await;
3023        mount_folder("folder-1").mount(&server).await;
3024        mount_workbook().mount(&server).await;
3025        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3026            .mount(&server)
3027            .await;
3028        let rules = vec![allow_rule("folder-1")];
3029        let sides = BorderSides {
3030            inner_horizontal: true,
3031            inner_vertical: true,
3032            ..Default::default()
3033        };
3034        let verb = update_borders_verb(sides, "DOTTED", Some("00FF00"));
3035
3036        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
3037        match &outcome.result {
3038            FormatResult::Changed { summary, .. } => {
3039                assert!(
3040                    summary.contains("inner-horizontal, inner-vertical"),
3041                    "{summary}"
3042                );
3043            }
3044            other => panic!("expected Changed, got {other:?}"),
3045        }
3046
3047        let requests = server.received_requests().await.unwrap();
3048        let body = find_batch_update(&requests);
3049        let update_borders = &body["requests"][0]["updateBorders"];
3050        for side in ["innerHorizontal", "innerVertical"] {
3051            assert_eq!(update_borders[side]["style"], "DOTTED", "{update_borders}");
3052        }
3053        assert!(update_borders.get("top").is_none());
3054    }
3055
3056    #[tokio::test]
3057    async fn update_borders_defaults_to_black_when_no_color_is_given() {
3058        let server = wiremock::MockServer::start().await;
3059        let (drive, sheets) = clients(&server).await;
3060        mount_file(
3061            "sheet-1",
3062            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3063            &["folder-1"],
3064        )
3065        .mount(&server)
3066        .await;
3067        mount_folder("folder-1").mount(&server).await;
3068        mount_workbook().mount(&server).await;
3069        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3070            .mount(&server)
3071            .await;
3072        let rules = vec![allow_rule("folder-1")];
3073        let sides = BorderSides {
3074            top: true,
3075            ..Default::default()
3076        };
3077        let verb = update_borders_verb(sides, "SOLID", None);
3078
3079        let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
3080        assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3081
3082        let requests = server.received_requests().await.unwrap();
3083        let body = find_batch_update(&requests);
3084        let color = &body["requests"][0]["updateBorders"]["top"]["colorStyle"]["rgbColor"];
3085        assert_eq!(color["red"], 0.0);
3086        assert_eq!(color["green"], 0.0);
3087        assert_eq!(color["blue"], 0.0);
3088    }
3089
3090    #[tokio::test]
3091    async fn merge_cells_real_run_sends_merge_type_and_discards_are_still_reported() {
3092        let server = wiremock::MockServer::start().await;
3093        let (drive, sheets) = clients(&server).await;
3094        mount_file(
3095            "sheet-1",
3096            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3097            &["folder-1"],
3098        )
3099        .mount(&server)
3100        .await;
3101        mount_folder("folder-1").mount(&server).await;
3102        mount_workbook().mount(&server).await;
3103        wiremock::Mock::given(wiremock::matchers::method("GET"))
3104            .and(wiremock::matchers::path(
3105                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3106            ))
3107            .respond_with(
3108                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3109                    "values": [["keep", "gone"]]
3110                })),
3111            )
3112            .mount(&server)
3113            .await;
3114        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3115            .mount(&server)
3116            .await;
3117        let rules = vec![allow_rule("folder-1")];
3118
3119        let outcome = format(
3120            &drive,
3121            &sheets,
3122            &opts(merge_cells_verb("A1:B2"), false),
3123            &rules,
3124        )
3125        .await;
3126        match &outcome.result {
3127            FormatResult::Changed {
3128                summary,
3129                discarded_cells,
3130            } => {
3131                assert_eq!(discarded_cells, &vec!["B1: gone".to_string()]);
3132                assert!(summary.contains("discarding 1 cell"), "{summary}");
3133            }
3134            other => panic!("expected Changed, got {other:?}"),
3135        }
3136
3137        let requests = server.received_requests().await.unwrap();
3138        let body = find_batch_update(&requests);
3139        assert_eq!(body["requests"][0]["mergeCells"]["mergeType"], "MERGE_ALL");
3140    }
3141
3142    #[tokio::test]
3143    async fn unmerge_cells_sends_the_composed_range() {
3144        let server = wiremock::MockServer::start().await;
3145        let (drive, sheets) = clients(&server).await;
3146        mount_file(
3147            "sheet-1",
3148            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3149            &["folder-1"],
3150        )
3151        .mount(&server)
3152        .await;
3153        mount_folder("folder-1").mount(&server).await;
3154        mount_workbook().mount(&server).await;
3155        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3156            .mount(&server)
3157            .await;
3158        let rules = vec![allow_rule("folder-1")];
3159
3160        let outcome = format(&drive, &sheets, &opts(unmerge_cells_verb(), false), &rules).await;
3161        match &outcome.result {
3162            FormatResult::Changed { summary, .. } => assert_eq!(summary, "unmerge"),
3163            other => panic!("expected Changed, got {other:?}"),
3164        }
3165
3166        let requests = server.received_requests().await.unwrap();
3167        let body = find_batch_update(&requests);
3168        assert!(body["requests"][0].get("unmergeCells").is_some());
3169    }
3170
3171    #[tokio::test]
3172    async fn auto_resize_dimension_sends_the_resolved_sheet_id_and_span() {
3173        let server = wiremock::MockServer::start().await;
3174        let (drive, sheets) = clients(&server).await;
3175        mount_file(
3176            "sheet-1",
3177            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3178            &["folder-1"],
3179        )
3180        .mount(&server)
3181        .await;
3182        mount_folder("folder-1").mount(&server).await;
3183        mount_workbook().mount(&server).await;
3184        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3185            .mount(&server)
3186            .await;
3187        let rules = vec![allow_rule("folder-1")];
3188
3189        let outcome = format(
3190            &drive,
3191            &sheets,
3192            &opts(auto_resize_verb("Q1", 2, 5), false),
3193            &rules,
3194        )
3195        .await;
3196        match &outcome.result {
3197            FormatResult::Changed { summary, .. } => {
3198                assert!(summary.contains("row(s)"), "{summary}");
3199            }
3200            other => panic!("expected Changed, got {other:?}"),
3201        }
3202
3203        let requests = server.received_requests().await.unwrap();
3204        let body = find_batch_update(&requests);
3205        let dims = &body["requests"][0]["autoResizeDimensions"]["dimensions"];
3206        assert_eq!(dims["sheetId"], 0);
3207        assert_eq!(dims["startIndex"], 1);
3208        assert_eq!(dims["endIndex"], 5);
3209    }
3210
3211    #[tokio::test]
3212    async fn update_dimension_properties_sends_the_pixel_size() {
3213        let server = wiremock::MockServer::start().await;
3214        let (drive, sheets) = clients(&server).await;
3215        mount_file(
3216            "sheet-1",
3217            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3218            &["folder-1"],
3219        )
3220        .mount(&server)
3221        .await;
3222        mount_folder("folder-1").mount(&server).await;
3223        mount_workbook().mount(&server).await;
3224        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3225            .mount(&server)
3226            .await;
3227        let rules = vec![allow_rule("folder-1")];
3228
3229        let outcome = format(
3230            &drive,
3231            &sheets,
3232            &opts(update_dimension_verb("Q1", 1, 3, 120), false),
3233            &rules,
3234        )
3235        .await;
3236        match &outcome.result {
3237            FormatResult::Changed { summary, .. } => assert!(summary.contains("120"), "{summary}"),
3238            other => panic!("expected Changed, got {other:?}"),
3239        }
3240
3241        let requests = server.received_requests().await.unwrap();
3242        let body = find_batch_update(&requests);
3243        let request = &body["requests"][0]["updateDimensionProperties"];
3244        assert_eq!(request["properties"]["pixelSize"], 120);
3245        assert_eq!(request["fields"], "pixelSize");
3246    }
3247
3248    #[tokio::test]
3249    async fn format_cells_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3250        let server = wiremock::MockServer::start().await;
3251        let (drive, sheets) = clients(&server).await;
3252        mount_file(
3253            "sheet-1",
3254            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3255            &["folder-1"],
3256        )
3257        .mount(&server)
3258        .await;
3259        mount_folder("folder-1").mount(&server).await;
3260        mount_workbook().mount(&server).await;
3261        // 2xx, so the change WAS applied — but the body is not the reply shape.
3262        wiremock::Mock::given(wiremock::matchers::method("POST"))
3263            .and(wiremock::matchers::path(
3264                "/v4/spreadsheets/sheet-1:batchUpdate",
3265            ))
3266            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3267            .expect(1)
3268            .mount(&server)
3269            .await;
3270        let rules = vec![allow_rule("folder-1")];
3271
3272        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3273        assert!(
3274            matches!(outcome.result, FormatResult::AppliedReplyUnreadable { .. }),
3275            "{:?}",
3276            outcome.result
3277        );
3278        assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3279        assert!(
3280            describe(&outcome).starts_with("Applied, but the reply could not be read"),
3281            "{}",
3282            describe(&outcome)
3283        );
3284    }
3285
3286    #[test]
3287    fn describe_lines_renders_applied_reply_unreadable_as_an_idempotent_single_line() {
3288        let outcome = FormatOutcome {
3289            spreadsheet_id: "sheet-1".to_string(),
3290            file_name: Some("Budget".to_string()),
3291            resolved_folder_id: None,
3292            verb: format_cells_opts(false).verb,
3293            result: FormatResult::AppliedReplyUnreadable {
3294                summary: "set bold on 'Q1'!A1:B2".to_string(),
3295                discarded_cells: Vec::new(),
3296                detail: "bad reply".to_string(),
3297            },
3298        };
3299        assert_eq!(
3300            describe_lines(&outcome),
3301            vec![
3302                "Applied, but the reply could not be read: set bold on 'Q1'!A1:B2 in 'Budget' — \
3303                 check the spreadsheet to confirm (bad reply)"
3304                    .to_string()
3305            ]
3306        );
3307    }
3308
3309    /// An applied write whose reply was unreadable still logs everything
3310    /// `Changed` does — the discarded cells were read before the write, so
3311    /// they are not lost with the reply.
3312    #[test]
3313    fn an_unreadable_reply_logs_the_summary_and_discarded_cells_like_changed() {
3314        let opts = format_cells_opts(false);
3315        let record = |result| {
3316            mutation_record(
3317                &FormatOutcome {
3318                    spreadsheet_id: "sheet-1".to_string(),
3319                    file_name: Some("Budget".to_string()),
3320                    resolved_folder_id: None,
3321                    verb: opts.verb.clone(),
3322                    result,
3323                },
3324                &opts,
3325                Duration::from_millis(1),
3326            )
3327        };
3328        let changed = record(FormatResult::Changed {
3329            summary: "merge cells".to_string(),
3330            discarded_cells: vec!["B1: gone".to_string()],
3331        });
3332        let unreadable = record(FormatResult::AppliedReplyUnreadable {
3333            summary: "merge cells".to_string(),
3334            discarded_cells: vec!["B1: gone".to_string()],
3335            detail: "bad reply".to_string(),
3336        });
3337        assert_eq!(unreadable.fields_changed.as_deref(), Some("merge cells"));
3338        assert_eq!(unreadable.fields_changed, changed.fields_changed);
3339        assert_eq!(unreadable.discarded_cells, vec!["B1: gone".to_string()]);
3340        assert_eq!(unreadable.discarded_cells, changed.discarded_cells);
3341        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3342        assert_eq!(unreadable.status, "applied-reply-unreadable");
3343    }
3344
3345    #[tokio::test]
3346    async fn a_batch_update_failure_is_reported_as_failed() {
3347        let server = wiremock::MockServer::start().await;
3348        let (drive, sheets) = clients(&server).await;
3349        mount_file(
3350            "sheet-1",
3351            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3352            &["folder-1"],
3353        )
3354        .mount(&server)
3355        .await;
3356        mount_folder("folder-1").mount(&server).await;
3357        mount_workbook().mount(&server).await;
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(500).set_body_string("boom"))
3363            .mount(&server)
3364            .await;
3365        let rules = vec![allow_rule("folder-1")];
3366
3367        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3368        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3369    }
3370
3371    // ── describe_lines ────────────────────────────────────────────────
3372
3373    fn every_format_result() -> Vec<FormatResult> {
3374        vec![
3375            FormatResult::WouldChange {
3376                summary: "set bold".to_string(),
3377                discarded_cells: vec!["B1: gone".to_string()],
3378            },
3379            FormatResult::RefusedNotASpreadsheet {
3380                mime_type: "application/pdf".to_string(),
3381            },
3382            FormatResult::RefusedShortcut,
3383            FormatResult::RefusedNoVisibleParents,
3384            FormatResult::RefusedSheetNotFound {
3385                title: "Q9".to_string(),
3386                available: vec!["Q1".to_string()],
3387            },
3388            FormatResult::RefusedSheetNotFound {
3389                title: "Q9".to_string(),
3390                available: Vec::new(),
3391            },
3392            FormatResult::RefusedInvalidRange {
3393                detail: "bad range".to_string(),
3394            },
3395            FormatResult::Blocked {
3396                decided_by: Some(DecidingRule::Folder {
3397                    folder_id: "folder-1".to_string(),
3398                    depth: 0,
3399                }),
3400            },
3401            FormatResult::Blocked { decided_by: None },
3402            FormatResult::RefusedNoLease,
3403            FormatResult::RefusedLeaseExpired,
3404            FormatResult::RefusedLeaseWrongFile,
3405            FormatResult::RefusedLeaseStale,
3406            FormatResult::Changed {
3407                summary: "set bold".to_string(),
3408                discarded_cells: Vec::new(),
3409            },
3410            FormatResult::AppliedReplyUnreadable {
3411                summary: "set bold".to_string(),
3412                discarded_cells: vec!["B1: gone".to_string()],
3413                detail: "bad reply".to_string(),
3414            },
3415            FormatResult::Failed {
3416                detail: "boom".to_string(),
3417            },
3418        ]
3419    }
3420
3421    #[test]
3422    fn describe_lines_covers_every_result_with_no_control_characters() {
3423        for file_name in [Some("Budget".to_string()), None] {
3424            for result in every_format_result() {
3425                // Compile-time exhaustiveness: a new variant fails to match.
3426                match &result {
3427                    FormatResult::WouldChange { .. }
3428                    | FormatResult::RefusedNotASpreadsheet { .. }
3429                    | FormatResult::RefusedShortcut
3430                    | FormatResult::RefusedNoVisibleParents
3431                    | FormatResult::RefusedSheetNotFound { .. }
3432                    | FormatResult::RefusedInvalidRange { .. }
3433                    | FormatResult::Blocked { .. }
3434                    | FormatResult::RefusedNoLease
3435                    | FormatResult::RefusedLeaseExpired
3436                    | FormatResult::RefusedLeaseWrongFile
3437                    | FormatResult::RefusedLeaseStale
3438                    | FormatResult::Changed { .. }
3439                    | FormatResult::AppliedReplyUnreadable { .. }
3440                    | FormatResult::Failed { .. } => {}
3441                }
3442                let outcome = FormatOutcome {
3443                    spreadsheet_id: "sheet-1".to_string(),
3444                    file_name: file_name.clone(),
3445                    resolved_folder_id: None,
3446                    verb: format_cells_opts(false).verb,
3447                    result,
3448                };
3449                let lines = describe_lines(&outcome);
3450                assert_eq!(lines.len(), 1, "{:?}", outcome.result);
3451                for line in &lines {
3452                    assert!(
3453                        !line.chars().any(char::is_control),
3454                        "{line:?} for {:?}",
3455                        outcome.result
3456                    );
3457                }
3458                assert_eq!(describe(&outcome), lines.join("\n"));
3459            }
3460        }
3461    }
3462
3463    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
3464
3465    #[tokio::test]
3466    async fn refuses_without_a_lease_when_the_rule_requires_one() {
3467        let server = wiremock::MockServer::start().await;
3468        let (drive, sheets) = clients(&server).await;
3469        mount_file(
3470            "sheet-1",
3471            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3472            &["folder-1"],
3473        )
3474        .mount(&server)
3475        .await;
3476        mount_folder("folder-1").mount(&server).await;
3477        mount_workbook().mount(&server).await;
3478        // No batchUpdate mock mounted — a refusal must make zero mutating
3479        // calls.
3480        let rules = vec![allow_rule("folder-1")];
3481
3482        let opts = FormatOptions {
3483            lease_token: None,
3484            ..format_cells_opts(false)
3485        };
3486        let outcome = format(&drive, &sheets, &opts, &rules).await;
3487        assert!(matches!(outcome.result, FormatResult::RefusedNoLease));
3488        assert_eq!(outcome.result.log_status(), "refused-no-lease");
3489    }
3490
3491    #[tokio::test]
3492    async fn reports_a_lock_acquisition_failure_as_failed() {
3493        // A pre-existing lock file simulates another `drive lease`
3494        // operation genuinely in progress — reported as an operational
3495        // failure, not folded into `RefusedLeaseExpired`.
3496        let server = wiremock::MockServer::start().await;
3497        let (drive, sheets) = clients(&server).await;
3498        mount_file(
3499            "sheet-1",
3500            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3501            &["folder-1"],
3502        )
3503        .mount(&server)
3504        .await;
3505        mount_folder("folder-1").mount(&server).await;
3506        mount_workbook().mount(&server).await;
3507        let rules = vec![allow_rule("folder-1")];
3508
3509        let opts = format_cells_opts(false);
3510        // Under `flock` (issue #1687), a busy lock now waits rather than
3511        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
3512        // held `LedgerLock` no longer reproduces an immediate failure here.
3513        // A directory at the lock path does: opening it for write fails
3514        // outright with an I/O error, which is never retried.
3515        let mut lock_path = opts.ledger_path.clone().into_os_string();
3516        lock_path.push(".lock");
3517        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
3518
3519        let outcome = format(&drive, &sheets, &opts, &rules).await;
3520        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3521    }
3522
3523    #[tokio::test]
3524    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
3525        // The gate's own resolve step succeeds off the first `files.get`,
3526        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
3527        // fails — the change must report `Failed` and never reach
3528        // `batchUpdate`.
3529        let server = wiremock::MockServer::start().await;
3530        let (drive, sheets) = clients(&server).await;
3531        mount_file(
3532            "sheet-1",
3533            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3534            &["folder-1"],
3535        )
3536        .up_to_n_times(1)
3537        .with_priority(1)
3538        .mount(&server)
3539        .await;
3540        wiremock::Mock::given(wiremock::matchers::method("GET"))
3541            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3542            .respond_with(wiremock::ResponseTemplate::new(500))
3543            .with_priority(2)
3544            .mount(&server)
3545            .await;
3546        mount_folder("folder-1").mount(&server).await;
3547        mount_workbook().mount(&server).await;
3548        wiremock::Mock::given(wiremock::matchers::method("POST"))
3549            .and(wiremock::matchers::path(
3550                "/v4/spreadsheets/sheet-1:batchUpdate",
3551            ))
3552            .respond_with(wiremock::ResponseTemplate::new(200))
3553            .expect(0)
3554            .mount(&server)
3555            .await;
3556        let rules = vec![allow_rule("folder-1")];
3557
3558        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3559        assert!(matches!(outcome.result, FormatResult::Failed { .. }));
3560    }
3561
3562    #[tokio::test]
3563    async fn refuses_an_unknown_lease_token() {
3564        let server = wiremock::MockServer::start().await;
3565        let (drive, sheets) = clients(&server).await;
3566        mount_file(
3567            "sheet-1",
3568            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3569            &["folder-1"],
3570        )
3571        .mount(&server)
3572        .await;
3573        mount_folder("folder-1").mount(&server).await;
3574        mount_workbook().mount(&server).await;
3575        let rules = vec![allow_rule("folder-1")];
3576        let ledger_path = tempfile::tempdir()
3577            .unwrap()
3578            .keep()
3579            .join("lease-ledger.jsonl");
3580        // Never seeded — the ledger exists nowhere near this token.
3581
3582        let opts = FormatOptions {
3583            lease_token: Some("bogus-token".to_string()),
3584            ledger_path,
3585            ..format_cells_opts(false)
3586        };
3587        let outcome = format(&drive, &sheets, &opts, &rules).await;
3588        assert!(matches!(outcome.result, FormatResult::RefusedLeaseExpired));
3589    }
3590
3591    #[tokio::test]
3592    async fn refuses_an_expired_lease() {
3593        let server = wiremock::MockServer::start().await;
3594        let (drive, sheets) = clients(&server).await;
3595        mount_file(
3596            "sheet-1",
3597            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3598            &["folder-1"],
3599        )
3600        .mount(&server)
3601        .await;
3602        mount_folder("folder-1").mount(&server).await;
3603        mount_workbook().mount(&server).await;
3604        let rules = vec![allow_rule("folder-1")];
3605        let ledger_path = tempfile::tempdir()
3606            .unwrap()
3607            .keep()
3608            .join("lease-ledger.jsonl");
3609        let token = "expired-token".to_string();
3610        let mut ledger = crate::drive::lease::ledger::LeaseLedger::default();
3611        ledger.insert(crate::drive::lease::ledger::LeaseRecord {
3612            token: token.clone(),
3613            file_id: "sheet-1".to_string(),
3614            version: "1".to_string(),
3615            modified_time: None,
3616            backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
3617                path: std::path::PathBuf::from("/tmp/test-backup"),
3618                sha256: "deadbeef".to_string(),
3619                size: 0,
3620            },
3621            acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
3622            expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
3623            released_at: None,
3624            superseded_by: None,
3625            restored_at: None,
3626            restored_sheet_id: None,
3627        });
3628        ledger.save(&ledger_path).unwrap();
3629
3630        let opts = FormatOptions {
3631            lease_token: Some(token),
3632            ledger_path,
3633            ..format_cells_opts(false)
3634        };
3635        let outcome = format(&drive, &sheets, &opts, &rules).await;
3636        assert!(matches!(outcome.result, FormatResult::RefusedLeaseExpired));
3637    }
3638
3639    #[tokio::test]
3640    async fn refuses_a_lease_bound_to_a_different_file() {
3641        let server = wiremock::MockServer::start().await;
3642        let (drive, sheets) = clients(&server).await;
3643        mount_file(
3644            "sheet-1",
3645            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3646            &["folder-1"],
3647        )
3648        .mount(&server)
3649        .await;
3650        mount_folder("folder-1").mount(&server).await;
3651        mount_workbook().mount(&server).await;
3652        let rules = vec![allow_rule("folder-1")];
3653        let ledger_path = tempfile::tempdir()
3654            .unwrap()
3655            .keep()
3656            .join("lease-ledger.jsonl");
3657        // Seeded for a *different* spreadsheet id.
3658        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
3659
3660        let opts = FormatOptions {
3661            lease_token: Some(token),
3662            ledger_path,
3663            ..format_cells_opts(false)
3664        };
3665        let outcome = format(&drive, &sheets, &opts, &rules).await;
3666        assert!(matches!(
3667            outcome.result,
3668            FormatResult::RefusedLeaseWrongFile
3669        ));
3670    }
3671
3672    #[tokio::test]
3673    async fn refuses_a_stale_lease_when_the_file_has_moved() {
3674        let server = wiremock::MockServer::start().await;
3675        let (drive, sheets) = clients(&server).await;
3676        // `mount_file` always returns version "1"; the lease below was
3677        // acquired against version "0" — a foreign edit landed since.
3678        mount_file(
3679            "sheet-1",
3680            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3681            &["folder-1"],
3682        )
3683        .mount(&server)
3684        .await;
3685        mount_folder("folder-1").mount(&server).await;
3686        mount_workbook().mount(&server).await;
3687        let rules = vec![allow_rule("folder-1")];
3688        let ledger_path = tempfile::tempdir()
3689            .unwrap()
3690            .keep()
3691            .join("lease-ledger.jsonl");
3692        let token = seed_lease(&ledger_path, "sheet-1", "0");
3693
3694        let opts = FormatOptions {
3695            lease_token: Some(token),
3696            ledger_path,
3697            ..format_cells_opts(false)
3698        };
3699        let outcome = format(&drive, &sheets, &opts, &rules).await;
3700        assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
3701    }
3702
3703    #[tokio::test]
3704    async fn require_lease_false_writes_without_a_lease() {
3705        let server = wiremock::MockServer::start().await;
3706        let (drive, sheets) = clients(&server).await;
3707        mount_file(
3708            "sheet-1",
3709            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3710            &["folder-1"],
3711        )
3712        .mount(&server)
3713        .await;
3714        mount_folder("folder-1").mount(&server).await;
3715        mount_workbook().mount(&server).await;
3716        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3717            .mount(&server)
3718            .await;
3719        let rule = FolderPermissionRule {
3720            folder_id: Some("folder-1".to_string()),
3721            file_id: None,
3722            recursive: true,
3723            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3724            deny: HashSet::default(),
3725            require_lease: false,
3726        };
3727
3728        // No lease token presented at all, and no ledger exists.
3729        let opts = FormatOptions {
3730            lease_token: None,
3731            ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
3732            ..format_cells_opts(false)
3733        };
3734        let outcome = format(&drive, &sheets, &opts, &[rule]).await;
3735        assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3736    }
3737
3738    #[tokio::test]
3739    async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
3740        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
3741        // not the *meaning* — a token volunteered anyway is checked exactly
3742        // like a required one, including staleness.
3743        let server = wiremock::MockServer::start().await;
3744        let (drive, sheets) = clients(&server).await;
3745        // Live version "1" (`mount_file`'s default) but the lease was
3746        // acquired at "0" — the file has moved since.
3747        mount_file(
3748            "sheet-1",
3749            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3750            &["folder-1"],
3751        )
3752        .mount(&server)
3753        .await;
3754        mount_folder("folder-1").mount(&server).await;
3755        mount_workbook().mount(&server).await;
3756        // No batchUpdate mock mounted — a refusal must make zero mutating
3757        // calls.
3758        let rule = FolderPermissionRule {
3759            folder_id: Some("folder-1".to_string()),
3760            file_id: None,
3761            recursive: true,
3762            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3763            deny: HashSet::default(),
3764            require_lease: false,
3765        };
3766        let ledger_path = tempfile::tempdir()
3767            .unwrap()
3768            .keep()
3769            .join("lease-ledger.jsonl");
3770        let token = seed_lease(&ledger_path, "sheet-1", "0");
3771
3772        let opts = FormatOptions {
3773            lease_token: Some(token),
3774            ledger_path,
3775            ..format_cells_opts(false)
3776        };
3777        let outcome = format(&drive, &sheets, &opts, &[rule]).await;
3778        assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
3779    }
3780
3781    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
3782
3783    #[tokio::test]
3784    async fn a_leased_format_concludes_its_audit_pair_with_allowed() {
3785        let server = wiremock::MockServer::start().await;
3786        let (drive, sheets) = clients(&server).await;
3787        mount_file(
3788            "sheet-1",
3789            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3790            &["folder-1"],
3791        )
3792        .mount(&server)
3793        .await;
3794        mount_folder("folder-1").mount(&server).await;
3795        mount_workbook().mount(&server).await;
3796        mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
3797            .mount(&server)
3798            .await;
3799        let dir = tempfile::tempdir().unwrap();
3800        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
3801        let rules = vec![allow_rule("folder-1")];
3802
3803        let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
3804        assert!(matches!(outcome.result, FormatResult::Changed { .. }));
3805
3806        let records = audit.records();
3807        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
3808        // The verb, not the engine — the same `["drive", <log_operation>]`
3809        // this write's `drivemutation` record carries, so an auditor can
3810        // see which verb ran without joining back to `log.jsonl`.
3811        assert_eq!(records[0].command, ["drive", "sheets-format-cells"]);
3812    }
3813
3814    fn borders_verb() -> FormatVerb {
3815        FormatVerb::UpdateBorders {
3816            sheet: Some("Q1".to_string()),
3817            range: Some("A1:B2".to_string()),
3818            sides: BorderSides {
3819                top: true,
3820                left: true,
3821                ..BorderSides::default()
3822            },
3823            style: "SOLID_MEDIUM".to_string(),
3824            color: None,
3825        }
3826    }
3827
3828    fn lines_for(verb: FormatVerb, result: FormatResult) -> Vec<String> {
3829        describe_lines(&FormatOutcome {
3830            spreadsheet_id: "sheet-1".to_string(),
3831            file_name: Some("Budget".to_string()),
3832            resolved_folder_id: None,
3833            verb,
3834            result,
3835        })
3836    }
3837
3838    #[test]
3839    fn update_borders_summary_starts_with_a_verb() {
3840        let summary = describe_effect(&borders_verb(), &[]).unwrap();
3841        assert_eq!(summary, "set top, left border(s), style SOLID_MEDIUM");
3842        let lines = lines_for(
3843            borders_verb(),
3844            FormatResult::WouldChange {
3845                summary,
3846                discarded_cells: Vec::new(),
3847            },
3848        );
3849        assert_eq!(
3850            lines,
3851            ["Would set top, left border(s), style SOLID_MEDIUM in 'Budget'"]
3852        );
3853    }
3854
3855    #[test]
3856    fn dimension_summaries_name_the_sheet_and_the_span() {
3857        let auto_resize = FormatVerb::AutoResizeDimension {
3858            sheet: "Q1".to_string(),
3859            dimension: Dimension::Columns,
3860            start: 2,
3861            end: 4,
3862        };
3863        assert_eq!(
3864            describe_effect(&auto_resize, &[]).unwrap(),
3865            "auto-resize column(s) B:D of 'Q1'"
3866        );
3867        let apostrophe = FormatVerb::AutoResizeDimension {
3868            sheet: "Bob's".to_string(),
3869            dimension: Dimension::Rows,
3870            start: 1,
3871            end: 1,
3872        };
3873        assert_eq!(
3874            describe_effect(&apostrophe, &[]).unwrap(),
3875            "auto-resize row(s) 1:1 of 'Bob''s'"
3876        );
3877        let pixel_size = FormatVerb::UpdateDimensionProperties {
3878            sheet: "Q1".to_string(),
3879            dimension: Dimension::Rows,
3880            start: 2,
3881            end: 5,
3882            pixel_size: 40,
3883        };
3884        assert_eq!(
3885            describe_effect(&pixel_size, &[]).unwrap(),
3886            "set the pixelSize of row(s) 2:5 of 'Q1' to 40"
3887        );
3888    }
3889
3890    #[test]
3891    fn a_real_run_reads_as_past_tense_prose_not_the_hyphenated_verb() {
3892        let auto_resize = FormatVerb::AutoResizeDimension {
3893            sheet: "Q1".to_string(),
3894            dimension: Dimension::Rows,
3895            start: 1,
3896            end: 3,
3897        };
3898        let cases = [
3899            (
3900                borders_verb(),
3901                "set top, left border(s), style SOLID_MEDIUM",
3902                "Set top, left border(s), style SOLID_MEDIUM in 'Budget'",
3903            ),
3904            (
3905                merge_cells_verb("A1:B2"),
3906                "merge (MERGE_ALL)",
3907                "Merged cells (MERGE_ALL) in 'Budget'",
3908            ),
3909            (
3910                unmerge_cells_verb(),
3911                "unmerge",
3912                "Unmerged cells in 'Budget'",
3913            ),
3914            (
3915                auto_resize,
3916                "auto-resize row(s) 1:3 of 'Q1'",
3917                "Auto-resized row(s) 1:3 of 'Q1' in 'Budget'",
3918            ),
3919        ];
3920        // The two remaining verbs, from `describe_effect` itself, so
3921        // rewording either summary away from its "set" opening fails here
3922        // rather than silently falling back to "Applied:".
3923        for verb in [
3924            format_cells_opts(false).verb,
3925            FormatVerb::UpdateDimensionProperties {
3926                sheet: "Q1".to_string(),
3927                dimension: Dimension::Columns,
3928                start: 1,
3929                end: 1,
3930                pixel_size: 80,
3931            },
3932        ] {
3933            let summary = describe_effect(&verb, &[]).unwrap();
3934            let lines = lines_for(
3935                verb,
3936                FormatResult::Changed {
3937                    summary,
3938                    discarded_cells: Vec::new(),
3939                },
3940            );
3941            assert!(lines[0].starts_with("Set "), "{lines:?}");
3942        }
3943        for (verb, summary, expected) in cases {
3944            let lines = lines_for(
3945                verb,
3946                FormatResult::Changed {
3947                    summary: summary.to_string(),
3948                    discarded_cells: Vec::new(),
3949                },
3950            );
3951            assert_eq!(lines, [expected]);
3952        }
3953    }
3954
3955    #[test]
3956    fn a_summary_without_its_verbs_imperative_falls_back_to_applied() {
3957        let lines = lines_for(
3958            borders_verb(),
3959            FormatResult::Changed {
3960                summary: "x".to_string(),
3961                discarded_cells: Vec::new(),
3962            },
3963        );
3964        assert_eq!(lines, ["Applied: x in 'Budget'"]);
3965    }
3966}