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