Skip to main content

omni_dev/drive/sheets/
banding.rs

1//! Banded ranges — alternating row/column colors — via
2//! `spreadsheets.batchUpdate` (issue #1832,
3//! [ADR-0082](../../../docs/adrs/adr-0082-banded-ranges.md)).
4//!
5//! Gated by [`DriveOperation::SheetsStructure`] — every mutating verb here
6//! reaches the same operation as `unmerge-cells`/`clear-data-validation`
7//! (ADR-0078's original reasoning): a banding is presentation applied to a
8//! range, and removing one destroys no data.
9//!
10//! A banded range, like a filter view, carries a server-assigned
11//! `bandedRangeId` — the stable handle `list-bandings` discovers and
12//! `update-banding`/`delete-banding` take directly via
13//! `--banded-range-id`, mirroring `filter.rs`'s filter-view addressing
14//! rather than `protection.rs`'s range-equality match (there is no
15//! ambiguous-match case to handle: ids are unique by construction).
16//!
17//! **Two documented cuts, not silent gaps**, matching `filter.rs`'s own
18//! framing:
19//!
20//! - **Only the modern `*ColorStyle` fields are modelled** — never the
21//!   deprecated plain `Color` fields. The `themeColor` arm within a
22//!   `ColorStyle` has no CLI flag surface, same precedent as `format-cells`
23//!   (`format.rs`'s `parse_hex_color`, reused here unchanged) — but it is
24//!   read and losslessly preserved: `build_update` clones the axis's
25//!   existing `BandingProperties` and only overwrites the bands a flag
26//!   named, so an existing theme-colored band on the same axis survives a
27//!   partial `update-banding` untouched (issue #2020).
28//! - **At most one of `rowProperties`/`columnProperties` per call,
29//!   selected by `--axis`.** The API allows both simultaneously on one
30//!   `BandedRange` (a checkerboard effect), but that would double every
31//!   color flag for a combination nobody asked for.
32//!
33//! Shape mirrors `filter.rs`: compose a target, resolve it against a
34//! freshly-fetched workbook, gate, dry-run, mutate, log.
35
36use std::path::PathBuf;
37use std::time::{Duration, Instant};
38
39use serde::Serialize;
40
41use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
42use crate::drive::client::DriveClient;
43use crate::drive::files_api::FilesApi;
44use crate::drive::lease::check::{
45    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
46    LeasedWrite,
47};
48use crate::drive::sheets::a1;
49use crate::drive::sheets::api::{
50    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
51};
52use crate::drive::sheets::client::SheetsClient;
53use crate::drive::sheets::format::parse_hex_color;
54use crate::drive::sheets::grid_range;
55use crate::drive::sheets::target_gate;
56use crate::drive::sheets::types::{
57    AddBandingRequest, BandedRange, BandingProperties, BatchUpdateRequestItem, BatchUpdateResponse,
58    ColorStyle, DeleteBandingRequest, GridRange, Spreadsheet, UpdateBandingRequest,
59};
60use crate::drive::types::SheetTargetRefusal;
61use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
62use crate::request_log::{self, DriveMutationOutcome};
63
64/// Which axis a banding alternates along — selects
65/// [`BandedRange::row_properties`] vs [`BandedRange::column_properties`].
66///
67/// Deliberately carries no `clap` dependency — the CLI-layer mirror
68/// `crate::cli::drive::sheets::banding::BandingAxisArg` is a
69/// `clap::ValueEnum`, converted into this at the CLI boundary, the same
70/// split `conditional_format.rs`'s `GradientMidTypeArg`/`GradientPointType`
71/// uses.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum BandingAxis {
74    /// Alternate by row (the common case — the Sheets UI's default).
75    Rows,
76    /// Alternate by column.
77    Columns,
78}
79
80impl BandingAxis {
81    const fn label(self) -> &'static str {
82        match self {
83            Self::Rows => "row",
84            Self::Columns => "column",
85        }
86    }
87
88    const fn field_name(self) -> &'static str {
89        match self {
90            Self::Rows => "rowProperties",
91            Self::Columns => "columnProperties",
92        }
93    }
94}
95
96/// Which mutation to perform.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum BandingVerb {
99    /// Add a new banded range.
100    AddBanding {
101        /// The sheet to band.
102        sheet: String,
103        /// The banded A1 range, optionally carrying its own `Sheet!` prefix.
104        range: String,
105        /// Which axis to alternate along.
106        axis: BandingAxis,
107        /// The header row/column's color, `#RRGGBB`, if distinct from the
108        /// alternating bands.
109        header_color: Option<String>,
110        /// The first band's color, `#RRGGBB`.
111        first_band_color: String,
112        /// The second (alternating) band's color, `#RRGGBB`.
113        second_band_color: String,
114        /// The footer row/column's color, `#RRGGBB`, if distinct from the
115        /// alternating bands.
116        footer_color: Option<String>,
117    },
118    /// Change an existing banded range's range and/or colors.
119    UpdateBanding {
120        /// Which banded range to change, discovered via `list-bandings`.
121        banded_range_id: i64,
122        /// A sheet title, supplying a prefix for a bare `range`, when
123        /// changing the banded range.
124        sheet: Option<String>,
125        /// The new banded A1 range, when changing it.
126        range: Option<String>,
127        /// Which axis's properties to update.
128        axis: BandingAxis,
129        /// The new header color, `#RRGGBB`, when changing it.
130        header_color: Option<String>,
131        /// The new first-band color, `#RRGGBB`, when changing it.
132        first_band_color: Option<String>,
133        /// The new second-band color, `#RRGGBB`, when changing it.
134        second_band_color: Option<String>,
135        /// The new footer color, `#RRGGBB`, when changing it.
136        footer_color: Option<String>,
137    },
138    /// Remove a banded range.
139    DeleteBanding {
140        /// Which banded range to remove, discovered via `list-bandings`.
141        banded_range_id: i64,
142    },
143}
144
145impl BandingVerb {
146    const fn log_operation(&self) -> &'static str {
147        match self {
148            Self::AddBanding { .. } => "sheets-add-banding",
149            Self::UpdateBanding { .. } => "sheets-update-banding",
150            Self::DeleteBanding { .. } => "sheets-delete-banding",
151        }
152    }
153
154    const fn label(&self) -> &'static str {
155        match self {
156            Self::AddBanding { .. } => "add-banding",
157            Self::UpdateBanding { .. } => "update-banding",
158            Self::DeleteBanding { .. } => "delete-banding",
159        }
160    }
161}
162
163/// Per-call options.
164#[derive(Debug, Clone)]
165pub struct BandingOptions {
166    /// Spreadsheet id.
167    pub spreadsheet_id: String,
168    /// Which mutation to perform.
169    pub verb: BandingVerb,
170    /// Classify and describe only; never call `batchUpdate`.
171    pub dry_run: bool,
172    /// The lease token presented via `--lease`. Checked only when the
173    /// deciding rule requires one
174    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
175    /// `None` is only ever valid when it does not.
176    pub lease_token: Option<String>,
177    /// Path to the lease ledger the token is checked against. Production
178    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
179    /// result; tests pass a path under a `tempdir`.
180    pub ledger_path: PathBuf,
181}
182
183/// What happened (or, under `--dry-run`, would happen).
184#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
185#[serde(tag = "status", rename_all = "kebab-case")]
186pub enum BandingResult {
187    /// `--dry-run`, and the gate would allow it.
188    WouldChange {
189        /// A human-readable summary of the effect.
190        summary: String,
191    },
192    /// The target is not a Google Sheet.
193    RefusedNotASpreadsheet {
194        /// The target's actual MIME type.
195        mime_type: String,
196    },
197    /// The target is a shortcut, which we do not follow.
198    RefusedShortcut,
199    /// The target has no parents this account can see.
200    RefusedNoVisibleParents,
201    /// The named sheet does not exist in this workbook.
202    RefusedSheetNotFound {
203        /// The title that was not found.
204        title: String,
205        /// The titles that do exist.
206        available: Vec<String>,
207    },
208    /// The verb's own arguments were invalid — a bad `--sheet`/`--range`
209    /// pair, a malformed color, or (for `update-banding`) nothing to
210    /// change.
211    RefusedInvalidRange {
212        /// What was wrong and why.
213        detail: String,
214    },
215    /// `update-banding`/`delete-banding` named a `--banded-range-id` that
216    /// does not exist in this workbook.
217    RefusedBandedRangeNotFound {
218        /// The id that was not found.
219        banded_range_id: i64,
220    },
221    /// The folder write-permission gate refused it.
222    Blocked {
223        /// The rule that decided the refusal, if any.
224        decided_by: Option<DecidingRule>,
225    },
226    /// No `--lease` was presented, and the deciding rule requires one
227    /// (ADR-0080 §9).
228    RefusedNoLease,
229    /// The presented lease has expired, or was never a token this ledger
230    /// knows about.
231    RefusedLeaseExpired,
232    /// The presented lease is bound to a different file id.
233    RefusedLeaseWrongFile,
234    /// The file has moved since the lease's recorded `version` — the
235    /// staleness check (ADR-0080 §6).
236    RefusedLeaseStale,
237    /// The mutation succeeded.
238    Changed {
239        /// Same summary as [`Self::WouldChange`].
240        summary: String,
241        /// The banded range's stable id — server-assigned for
242        /// `add-banding`, otherwise the one resolved against.
243        banded_range_id: Option<i64>,
244    },
245    /// The API answered 2xx — the mutation **was applied** — but its reply
246    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: a
247    /// retry here would duplicate the banded range. The server-assigned id of
248    /// an added banding was in that reply, so it is unknown.
249    AppliedReplyUnreadable {
250        /// Same summary as [`Self::WouldChange`].
251        summary: String,
252        /// The banded range id resolved before the call — `Some` for
253        /// `update-banding`/`delete-banding`, `None` for `add-banding`. Never
254        /// the server-assigned id of an added banding (that was in the reply).
255        banded_range_id: Option<i64>,
256        /// Why the reply could not be read.
257        detail: String,
258    },
259    /// An API or validation error.
260    Failed {
261        /// A human-readable summary of what failed.
262        detail: String,
263    },
264}
265
266impl FromLeaseRefusal for BandingResult {
267    fn from_no_lease() -> Self {
268        Self::RefusedNoLease
269    }
270    fn from_lease_expired() -> Self {
271        Self::RefusedLeaseExpired
272    }
273    fn from_lease_wrong_file() -> Self {
274        Self::RefusedLeaseWrongFile
275    }
276    fn from_lease_stale() -> Self {
277        Self::RefusedLeaseStale
278    }
279    fn from_lease_failed(detail: String) -> Self {
280        Self::Failed { detail }
281    }
282}
283
284impl BandingResult {
285    fn log_status(&self) -> &'static str {
286        match self {
287            Self::WouldChange { .. } => "would-change",
288            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
289            Self::RefusedShortcut => "refused-shortcut",
290            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
291            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
292            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
293            Self::RefusedBandedRangeNotFound { .. } => "refused-banded-range-not-found",
294            Self::Blocked { .. } => "blocked",
295            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
296            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
297            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
298            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
299            Self::Changed { .. } => "changed",
300            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
301            Self::Failed { .. } => "failed",
302        }
303    }
304}
305
306/// The full outcome of one attempt.
307#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
308pub struct BandingOutcome {
309    /// The spreadsheet acted on.
310    pub spreadsheet_id: String,
311    /// Its Drive file name, when the metadata fetch got that far.
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub file_name: Option<String>,
314    /// The folder the gate evaluated against.
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub resolved_folder_id: Option<String>,
317    /// The sheet the banded range is (or would be) on, once known.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub sheet_id: Option<i64>,
320    /// Which mutation was attempted. Not serialised.
321    #[serde(skip)]
322    pub verb: BandingVerb,
323    /// What happened.
324    pub result: BandingResult,
325}
326
327impl JsonlSerialize for BandingOutcome {
328    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
329        write_scalar_jsonl(self, out)
330    }
331}
332
333/// Runs one banding mutation, logging every attempt that isn't a dry run.
334pub async fn banding(
335    drive: &DriveClient,
336    sheets: &SheetsClient,
337    opts: &BandingOptions,
338    rules: &[FolderPermissionRule],
339) -> BandingOutcome {
340    let started = Instant::now();
341    let outcome = banding_inner(drive, sheets, opts, rules).await;
342    if !opts.dry_run {
343        record_attempt(&outcome, opts, started.elapsed());
344    }
345    outcome
346}
347
348async fn banding_inner(
349    drive: &DriveClient,
350    sheets: &SheetsClient,
351    opts: &BandingOptions,
352    rules: &[FolderPermissionRule],
353) -> BandingOutcome {
354    let bare = |result| BandingOutcome {
355        spreadsheet_id: opts.spreadsheet_id.clone(),
356        file_name: None,
357        resolved_folder_id: None,
358        sheet_id: None,
359        verb: opts.verb.clone(),
360        result,
361    };
362
363    if let Err(detail) = validate_verb(&opts.verb) {
364        return bare(BandingResult::RefusedInvalidRange { detail });
365    }
366    let composed_range = match compose_target(&opts.verb) {
367        Ok(composed) => composed,
368        Err(detail) => return bare(BandingResult::RefusedInvalidRange { detail }),
369    };
370
371    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
372        drive,
373        &opts.spreadsheet_id,
374        DriveOperation::SheetsStructure,
375        rules,
376    )
377    .await
378    {
379        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
380            return bare(BandingResult::Failed { detail })
381        }
382        target_gate::TargetGateOutcome::Refused { target, refusal } => {
383            let result = match refusal {
384                SheetTargetRefusal::Shortcut => BandingResult::RefusedShortcut,
385                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
386                    BandingResult::RefusedNotASpreadsheet { mime_type }
387                }
388                SheetTargetRefusal::NoVisibleParents => BandingResult::RefusedNoVisibleParents,
389            };
390            return BandingOutcome {
391                spreadsheet_id: opts.spreadsheet_id.clone(),
392                file_name: Some(target.name),
393                resolved_folder_id: None,
394                sheet_id: None,
395                verb: opts.verb.clone(),
396                result,
397            };
398        }
399        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
400            return BandingOutcome {
401                spreadsheet_id: opts.spreadsheet_id.clone(),
402                file_name: Some(target.name),
403                resolved_folder_id: None,
404                sheet_id: None,
405                verb: opts.verb.clone(),
406                result: BandingResult::Failed { detail },
407            };
408        }
409        target_gate::TargetGateOutcome::Gated {
410            target,
411            decision,
412            resolved_folder_id,
413            requires_lease,
414        } => (target, decision, resolved_folder_id, requires_lease),
415    };
416
417    let pre_gated = |result| BandingOutcome {
418        spreadsheet_id: opts.spreadsheet_id.clone(),
419        file_name: Some(target.name.clone()),
420        resolved_folder_id: resolved_folder_id.clone(),
421        sheet_id: None,
422        verb: opts.verb.clone(),
423        result,
424    };
425
426    if decision.verdict == write_gate::Verdict::Deny {
427        return pre_gated(BandingResult::Blocked {
428            decided_by: decision.decided_by,
429        });
430    }
431
432    let api = SheetsApi::new(sheets);
433    let workbook = match api.get_spreadsheet_with_banding(&opts.spreadsheet_id).await {
434        Ok(workbook) => workbook,
435        Err(err) => {
436            return pre_gated(BandingResult::Failed {
437                detail: format!("{err:#}"),
438            })
439        }
440    };
441
442    // `update-banding --range` with no `--sheet` means the banded range's
443    // own sheet.
444    let composed_range = match (&opts.verb, composed_range) {
445        (
446            BandingVerb::UpdateBanding {
447                banded_range_id,
448                sheet: None,
449                ..
450            },
451            Some(composed),
452        ) => {
453            // An unknown id is reported as such here, before the bare range
454            // could be refused for naming no sheet.
455            let current_sheet = match find_existing_banded_range(&workbook, *banded_range_id) {
456                Ok(banded) => banded.range.as_ref().map(|range| range.sheet_id),
457                Err(result) => return pre_gated(result),
458            };
459            Some(grid_range::default_sheet_prefix(
460                &workbook,
461                composed,
462                current_sheet,
463            ))
464        }
465        (_, composed) => composed,
466    };
467    let resolved_target =
468        match resolve_sheet_target(&workbook, &opts.verb, composed_range.as_deref()) {
469            Ok(resolved) => resolved,
470            Err(result) => return pre_gated(result),
471        };
472
473    let existing = match &opts.verb {
474        BandingVerb::UpdateBanding {
475            banded_range_id, ..
476        }
477        | BandingVerb::DeleteBanding { banded_range_id } => {
478            match find_existing_banded_range(&workbook, *banded_range_id) {
479                Ok(existing) => Some(existing),
480                Err(result) => return pre_gated(result),
481            }
482        }
483        BandingVerb::AddBanding { .. } => None,
484    };
485
486    let sheet_id = resolved_target.map(|grid| grid.sheet_id).or_else(|| {
487        existing
488            .and_then(|existing| existing.range.as_ref())
489            .map(|range| range.sheet_id)
490    });
491
492    let gated = |result| BandingOutcome {
493        spreadsheet_id: opts.spreadsheet_id.clone(),
494        file_name: Some(target.name.clone()),
495        resolved_folder_id: resolved_folder_id.clone(),
496        sheet_id,
497        verb: opts.verb.clone(),
498        result,
499    };
500
501    // Checked before the `--dry-run` branch (unlike `build_request`'s color
502    // parsing) because it depends only on the workbook just read, and a
503    // preview that approves a request Sheets is certain to reject is the
504    // #1935 bug this check exists to close.
505    if let Err(detail) = validate_new_axis_colors(&opts.verb, existing) {
506        return gated(BandingResult::RefusedInvalidRange { detail });
507    }
508
509    let summary = describe_effect(&opts.verb);
510
511    if opts.dry_run {
512        return gated(BandingResult::WouldChange { summary });
513    }
514
515    // Built before the gate, not after — see `format.rs::format_inner`'s
516    // doc comment for the full `#1688` ordering reasoning, shared verbatim
517    // by every leased engine: the gate must be the last fallible step
518    // before the mutating call, so `parse_hex_color`'s validation happens
519    // here, inside `build_request`, rather than earlier.
520    let (request, existing_id) = match build_request(&opts.verb, resolved_target, existing) {
521        Ok(built) => built,
522        Err(detail) => return gated(BandingResult::RefusedInvalidRange { detail }),
523    };
524
525    let files_api = FilesApi::new(drive);
526    let leased = LeasedWrite {
527        log_prefix: "drive sheets banding",
528        operation: opts.verb.log_operation(),
529        ledger_path: &opts.ledger_path,
530        file_id: &opts.spreadsheet_id,
531    };
532    let lease_grant = match gate_optional_leased_write(
533        leased,
534        &files_api,
535        requires_lease,
536        opts.lease_token.as_deref(),
537    )
538    .await
539    {
540        Ok(grant) => grant,
541        Err(err) => return gated(err.into_result()),
542    };
543
544    let result = match conclude_native_leased_write(
545        leased,
546        &lease_grant,
547        &files_api,
548        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
549        |err| format!("{err:#}"),
550    )
551    .await
552    {
553        Ok(BatchUpdateOutcome::Applied(response)) => {
554            let banded_range_id = added_banded_range_id(&response).or(existing_id);
555            BandingResult::Changed {
556                summary,
557                banded_range_id,
558            }
559        }
560        // NotIdempotent: a second `add-banding` would add a second banded range.
561        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
562            BandingResult::AppliedReplyUnreadable {
563                summary,
564                banded_range_id: existing_id,
565                detail,
566            }
567        }
568        Err(err) => BandingResult::Failed {
569            detail: format!("{err:#}"),
570        },
571    };
572    drop(lease_grant);
573    gated(result)
574}
575
576/// Composes the `--sheet`/`--range` pair a verb carries into one string,
577/// exactly like `filter.rs`/`protection.rs`/`format.rs`. `DeleteBanding`
578/// never calls this — it carries no range flags at all.
579fn compose_target(verb: &BandingVerb) -> Result<Option<String>, String> {
580    match verb {
581        BandingVerb::AddBanding { sheet, range, .. } => a1::compose(Some(sheet), Some(range))
582            .map(Some)
583            .map_err(|err| err.to_string()),
584        BandingVerb::UpdateBanding { sheet, range, .. } => {
585            match (sheet.as_deref(), range.as_deref()) {
586                (None, None) => Ok(None),
587                (sheet, range) => a1::compose(sheet, range)
588                    .map(Some)
589                    .map_err(|err| err.to_string()),
590            }
591        }
592        BandingVerb::DeleteBanding { .. } => Ok(None),
593    }
594}
595
596/// Rejects a verb whose own arguments are internally inconsistent, cheaply,
597/// before ever fetching the workbook.
598fn validate_verb(verb: &BandingVerb) -> Result<(), String> {
599    if let BandingVerb::UpdateBanding {
600        sheet,
601        range,
602        header_color,
603        first_band_color,
604        second_band_color,
605        footer_color,
606        ..
607    } = verb
608    {
609        let nothing_to_change = sheet.is_none()
610            && range.is_none()
611            && header_color.is_none()
612            && first_band_color.is_none()
613            && second_band_color.is_none()
614            && footer_color.is_none();
615        if nothing_to_change {
616            return Err(
617                "nothing to change: pass --sheet/--range, --header-color, --first-band-color, \
618                 --second-band-color, or --footer-color"
619                    .to_string(),
620            );
621        }
622    }
623    Ok(())
624}
625
626/// Resolves the sheet and [`GridRange`] a verb targets, once, so callers
627/// never need to re-parse the same `--sheet`/`--range` composition a second
628/// time. `AddBanding` always resolves one; `UpdateBanding` may target no
629/// sheet at all (leaving the existing banded range's range untouched),
630/// hence the `Option`; `DeleteBanding` never does.
631fn resolve_sheet_target(
632    workbook: &Spreadsheet,
633    verb: &BandingVerb,
634    composed: Option<&str>,
635) -> Result<Option<GridRange>, BandingResult> {
636    match verb {
637        BandingVerb::AddBanding { .. } => {
638            let composed = composed.unwrap_or_default();
639            let (_, grid) = grid_range::resolve_grid_range(
640                workbook,
641                composed,
642                |detail| BandingResult::RefusedInvalidRange { detail },
643                |title, available| BandingResult::RefusedSheetNotFound { title, available },
644            )?;
645            Ok(Some(grid))
646        }
647        BandingVerb::UpdateBanding { .. } => match composed {
648            Some(composed) => {
649                let (_, grid) = grid_range::resolve_grid_range(
650                    workbook,
651                    composed,
652                    |detail| BandingResult::RefusedInvalidRange { detail },
653                    |title, available| BandingResult::RefusedSheetNotFound { title, available },
654                )?;
655                Ok(Some(grid))
656            }
657            None => Ok(None),
658        },
659        BandingVerb::DeleteBanding { .. } => Ok(None),
660    }
661}
662
663/// Finds the one banded range whose id exactly matches `banded_range_id`.
664/// Banded ranges are directly id-addressed (the id comes from
665/// `list-bandings`), so unlike `protection.rs::find_existing_protection`
666/// there is no ambiguous-match case — ids are unique by construction.
667fn find_existing_banded_range(
668    workbook: &Spreadsheet,
669    banded_range_id: i64,
670) -> Result<&BandedRange, BandingResult> {
671    workbook
672        .sheets
673        .iter()
674        .flat_map(|sheet| sheet.banded_ranges.iter())
675        .find(|banded| banded.banded_range_id == Some(banded_range_id))
676        .ok_or(BandingResult::RefusedBandedRangeNotFound { banded_range_id })
677}
678
679/// Refuses an `update-banding` color change on an axis the banded range
680/// does not band yet unless both band colors are given (#1935).
681///
682/// `build_update` merges changed colors onto the axis's *existing*
683/// properties, but an axis with no properties has nothing to merge onto:
684/// the request then creates that axis's [`BandingProperties`] from
685/// scratch, and Sheets requires both `firstBandColor` and
686/// `secondBandColor` for a new one (HTTP 400 "secondBandColor must be
687/// specified"). A range-only change sends no axis properties, so it is
688/// never affected.
689fn validate_new_axis_colors(
690    verb: &BandingVerb,
691    existing: Option<&BandedRange>,
692) -> Result<(), String> {
693    let BandingVerb::UpdateBanding {
694        axis,
695        header_color,
696        first_band_color,
697        second_band_color,
698        footer_color,
699        ..
700    } = verb
701    else {
702        return Ok(());
703    };
704    let Some(existing) = existing else {
705        return Ok(());
706    };
707    let axis_exists = match axis {
708        BandingAxis::Rows => existing.row_properties.is_some(),
709        BandingAxis::Columns => existing.column_properties.is_some(),
710    };
711    let colors_changed = header_color.is_some()
712        || first_band_color.is_some()
713        || second_band_color.is_some()
714        || footer_color.is_some();
715    if axis_exists || !colors_changed {
716        return Ok(());
717    }
718    let missing: Vec<&str> = [
719        ("--first-band-color", first_band_color.is_none()),
720        ("--second-band-color", second_band_color.is_none()),
721    ]
722    .into_iter()
723    .filter_map(|(flag, is_missing)| is_missing.then_some(flag))
724    .collect();
725    if missing.is_empty() {
726        return Ok(());
727    }
728    let label = axis.label();
729    Err(format!(
730        "banded range {} has no {label} banding, so setting {label} colors adds it from \
731         scratch, which requires both --first-band-color and --second-band-color; missing: {}",
732        existing
733            .banded_range_id
734            .map_or_else(|| "?".to_string(), |id| id.to_string()),
735        missing.join(", ")
736    ))
737}
738
739/// Parses a `#RRGGBB` flag into a [`ColorStyle`], via `format.rs`'s shared
740/// [`parse_hex_color`].
741fn parse_color_style(hex: &str) -> Result<ColorStyle, String> {
742    Ok(ColorStyle::rgb(parse_hex_color(hex)?))
743}
744
745/// Builds the request for `verb`. Placed after the `--dry-run` branch in
746/// `banding_inner` deliberately — see that function's comment just before
747/// the call site.
748fn build_request(
749    verb: &BandingVerb,
750    resolved: Option<GridRange>,
751    existing: Option<&BandedRange>,
752) -> Result<(BatchUpdateRequestItem, Option<i64>), String> {
753    match verb {
754        BandingVerb::AddBanding {
755            axis,
756            header_color,
757            first_band_color,
758            second_band_color,
759            footer_color,
760            ..
761        } => {
762            let Some(range) = resolved else {
763                unreachable!("resolved is resolved for AddBanding above") // omni-dev: coverage ignore-line reason="resolve_sheet_target always resolves AddBanding to a range or has already returned its refusal; this else-arm exists only to unwrap the shared Option"
764            };
765            let properties = BandingProperties {
766                header_color_style: header_color.as_deref().map(parse_color_style).transpose()?,
767                first_band_color_style: Some(parse_color_style(first_band_color)?),
768                second_band_color_style: Some(parse_color_style(second_band_color)?),
769                footer_color_style: footer_color.as_deref().map(parse_color_style).transpose()?,
770            };
771            let mut banded_range = BandedRange {
772                banded_range_id: None,
773                range: Some(range),
774                row_properties: None,
775                column_properties: None,
776            };
777            match axis {
778                BandingAxis::Rows => banded_range.row_properties = Some(properties),
779                BandingAxis::Columns => banded_range.column_properties = Some(properties),
780            }
781            Ok((
782                BatchUpdateRequestItem::AddBanding(AddBandingRequest { banded_range }),
783                None,
784            ))
785        }
786        BandingVerb::UpdateBanding {
787            banded_range_id,
788            axis,
789            header_color,
790            first_band_color,
791            second_band_color,
792            footer_color,
793            ..
794        } => {
795            let Some(existing) = existing else {
796                unreachable!("existing is resolved for UpdateBanding above") // omni-dev: coverage ignore-line reason="find_existing_banded_range returns Some for UpdateBanding or has already returned RefusedBandedRangeNotFound; this else-arm exists only to unwrap the shared Option"
797            };
798            let update = build_update(
799                existing,
800                *banded_range_id,
801                *axis,
802                resolved,
803                header_color.as_deref(),
804                first_band_color.as_deref(),
805                second_band_color.as_deref(),
806                footer_color.as_deref(),
807            )?;
808            Ok((
809                BatchUpdateRequestItem::UpdateBanding(update),
810                Some(*banded_range_id),
811            ))
812        }
813        BandingVerb::DeleteBanding { banded_range_id } => Ok((
814            BatchUpdateRequestItem::DeleteBanding(DeleteBandingRequest {
815                banded_range_id: *banded_range_id,
816            }),
817            Some(*banded_range_id),
818        )),
819    }
820}
821
822/// Builds the `updateBanding` request, merging any changed color onto the
823/// selected axis's *existing* properties — the full resulting state, since
824/// Sheets' `fields` mask replaces a named field (`rowProperties`/
825/// `columnProperties`) wholesale, never merging per-sub-field, matching
826/// `filter.rs::build_update`'s `sortSpecs`/`criteria` reasoning. An unset
827/// color flag leaves that color untouched; the axis field is included in
828/// the mask only when at least one color actually changed.
829#[allow(clippy::too_many_arguments)]
830fn build_update(
831    existing: &BandedRange,
832    banded_range_id: i64,
833    axis: BandingAxis,
834    range: Option<GridRange>,
835    header_color: Option<&str>,
836    first_band_color: Option<&str>,
837    second_band_color: Option<&str>,
838    footer_color: Option<&str>,
839) -> Result<UpdateBandingRequest, String> {
840    let mut fields = Vec::new();
841    let mut banded_range = BandedRange {
842        banded_range_id: Some(banded_range_id),
843        range: None,
844        row_properties: None,
845        column_properties: None,
846    };
847    if let Some(range) = range {
848        banded_range.range = Some(range);
849        fields.push("range");
850    }
851
852    let properties_changed = header_color.is_some()
853        || first_band_color.is_some()
854        || second_band_color.is_some()
855        || footer_color.is_some();
856    let mut properties = match axis {
857        BandingAxis::Rows => existing.row_properties.clone().unwrap_or_default(),
858        BandingAxis::Columns => existing.column_properties.clone().unwrap_or_default(),
859    };
860    if let Some(hex) = header_color {
861        properties.header_color_style = Some(parse_color_style(hex)?);
862    }
863    if let Some(hex) = first_band_color {
864        properties.first_band_color_style = Some(parse_color_style(hex)?);
865    }
866    if let Some(hex) = second_band_color {
867        properties.second_band_color_style = Some(parse_color_style(hex)?);
868    }
869    if let Some(hex) = footer_color {
870        properties.footer_color_style = Some(parse_color_style(hex)?);
871    }
872    if properties_changed {
873        match axis {
874            BandingAxis::Rows => banded_range.row_properties = Some(properties),
875            BandingAxis::Columns => banded_range.column_properties = Some(properties),
876        }
877        fields.push(axis.field_name());
878    }
879
880    Ok(UpdateBandingRequest {
881        banded_range,
882        fields: fields.join(","),
883    })
884}
885
886fn added_banded_range_id(response: &BatchUpdateResponse) -> Option<i64> {
887    response
888        .replies
889        .iter()
890        .find_map(|reply| reply.add_banding.as_ref())
891        .and_then(|added| added.banded_range.as_ref())
892        .and_then(|banded| banded.banded_range_id)
893}
894
895fn describe_effect(verb: &BandingVerb) -> String {
896    match verb {
897        BandingVerb::AddBanding { axis, .. } => format!("add {} banding", axis.label()),
898        BandingVerb::UpdateBanding {
899            banded_range_id,
900            axis,
901            ..
902        } => format!("update {} banding id {banded_range_id}", axis.label()),
903        BandingVerb::DeleteBanding { banded_range_id } => {
904            format!("delete banding id {banded_range_id}")
905        }
906    }
907}
908
909fn record_attempt(outcome: &BandingOutcome, opts: &BandingOptions, duration: Duration) {
910    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
911}
912
913/// The request-log record for one attempt. Split from [`record_attempt`] so
914/// the logged fields are unit-testable.
915fn mutation_record(
916    outcome: &BandingOutcome,
917    opts: &BandingOptions,
918    duration: Duration,
919) -> DriveMutationOutcome {
920    let error = match &outcome.result {
921        BandingResult::Failed { detail } | BandingResult::AppliedReplyUnreadable { detail, .. } => {
922            Some(detail.clone())
923        }
924        _ => None,
925    };
926    let decided_by = match &outcome.result {
927        BandingResult::Blocked { decided_by } => decided_by.as_ref(),
928        _ => None,
929    };
930    let decided_by = write_gate::decided_by_log_fields(decided_by);
931    let banded_range_id = match &outcome.result {
932        BandingResult::Changed {
933            banded_range_id, ..
934        }
935        | BandingResult::AppliedReplyUnreadable {
936            banded_range_id, ..
937        } => *banded_range_id,
938        _ => None,
939    };
940    let fields_changed = match &outcome.result {
941        BandingResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
942        _ => None,
943    };
944
945    DriveMutationOutcome {
946        operation: opts.verb.log_operation(),
947        file_id: outcome.spreadsheet_id.clone(),
948        file_name: outcome.file_name.clone().unwrap_or_default(),
949        status: outcome.result.log_status().to_string(),
950        resolved_folder_id: outcome.resolved_folder_id.clone(),
951        decided_by_folder_id: decided_by.folder_id,
952        decided_by_depth: decided_by.depth,
953        decided_by_file_id: decided_by.file_id,
954        sheet_id: outcome.sheet_id,
955        banded_range_id,
956        fields_changed,
957        error,
958        duration,
959        ..Default::default()
960    }
961}
962
963/// Renders an outcome as human-readable text.
964#[must_use]
965pub fn describe(outcome: &BandingOutcome) -> String {
966    describe_lines(outcome).join("\n")
967}
968
969/// Renders an outcome as its individual lines, none of which contains a
970/// newline.
971#[must_use]
972pub fn describe_lines(outcome: &BandingOutcome) -> Vec<String> {
973    let verb = &outcome.verb;
974    let book = outcome.file_name.as_deref().map_or_else(
975        || format!("'{}'", outcome.spreadsheet_id),
976        |n| format!("'{n}'"),
977    );
978    match &outcome.result {
979        BandingResult::WouldChange { summary } => vec![format!("Would {summary} in {book}")],
980        BandingResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
981            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
982             `drive sheets {}` only works on spreadsheets",
983            verb.label()
984        )],
985        BandingResult::RefusedShortcut => vec![format!(
986            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
987            verb.label()
988        )],
989        BandingResult::RefusedNoVisibleParents => vec![format!(
990            "Refused: {book} has no parent folder visible to this account, so no folder \
991             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
992             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
993        )],
994        BandingResult::RefusedSheetNotFound { title, available } => {
995            vec![grid_range::no_sheet_refusal(&book, title, available)]
996        }
997        BandingResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
998        BandingResult::RefusedBandedRangeNotFound { banded_range_id } => vec![format!(
999            "Refused: {book} has no banded range with id {banded_range_id}; run \
1000             `drive sheets list-bandings` to see what exists"
1001        )],
1002        BandingResult::Blocked { decided_by } => vec![match decided_by {
1003            Some(rule) => format!(
1004                "Blocked: {} on {book} refused by rule on {} {}{}",
1005                verb.label(),
1006                rule.kind_label(),
1007                rule.id(),
1008                rule.depth_suffix()
1009            ),
1010            None => format!(
1011                "Blocked: {} on {book} refused by default policy (no matching rule for \
1012                 sheets-structure)",
1013                verb.label()
1014            ),
1015        }],
1016        BandingResult::RefusedNoLease => LeaseGateRefusal::NoLease
1017            .describe_line(&outcome.spreadsheet_id, &book)
1018            .into_iter()
1019            .collect(),
1020        BandingResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1021            .describe_line(&outcome.spreadsheet_id, &book)
1022            .into_iter()
1023            .collect(),
1024        BandingResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1025            .describe_line(&outcome.spreadsheet_id, &book)
1026            .into_iter()
1027            .collect(),
1028        BandingResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1029            .describe_line(&outcome.spreadsheet_id, &book)
1030            .into_iter()
1031            .collect(),
1032        BandingResult::Changed {
1033            summary,
1034            banded_range_id,
1035        } => {
1036            // The update/delete summaries already name the id they were
1037            // given; only an add learns its id from the response.
1038            let id = banded_range_id
1039                .filter(|_| matches!(outcome.verb, BandingVerb::AddBanding { .. }))
1040                .map_or_else(String::new, |id| format!(" (id {id})"));
1041            vec![format!("Applied: {summary}{id} in {book}")]
1042        }
1043        BandingResult::AppliedReplyUnreadable {
1044            summary, detail, ..
1045        } => {
1046            let mut lines = vec![applied_reply_unreadable_line(
1047                summary,
1048                &book,
1049                RetryHint::NotIdempotent,
1050                detail,
1051            )];
1052            if matches!(verb, BandingVerb::AddBanding { .. }) {
1053                lines.push(
1054                    "  the new banded range's id was in the unreadable reply; run \
1055                     `list-bandings` to find it"
1056                        .to_string(),
1057                );
1058            }
1059            lines
1060        }
1061        BandingResult::Failed { detail } => vec![format!("Failed: {detail}")],
1062    }
1063}
1064
1065#[cfg(test)]
1066#[allow(clippy::unwrap_used, clippy::expect_used)]
1067mod tests {
1068    use super::*;
1069    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1070    use crate::drive::sheets::client::SHEETS_API_URL;
1071    use crate::drive::sheets::types::Sheet;
1072    use crate::drive::test_support::seed_lease;
1073    use crate::test_support::env::MapEnv;
1074    use crate::utils::secret::Secret;
1075    use std::collections::HashSet;
1076
1077    // ── pure helpers ─────────────────────────────────────────────────────
1078
1079    #[test]
1080    fn log_operation_and_label_are_distinct_for_every_verb() {
1081        let verbs = [
1082            BandingVerb::AddBanding {
1083                sheet: "Q1".to_string(),
1084                range: "A1:D10".to_string(),
1085                axis: BandingAxis::Rows,
1086                header_color: None,
1087                first_band_color: "#FFFFFF".to_string(),
1088                second_band_color: "#EEEEEE".to_string(),
1089                footer_color: None,
1090            },
1091            BandingVerb::UpdateBanding {
1092                banded_range_id: 1,
1093                sheet: None,
1094                range: None,
1095                axis: BandingAxis::Rows,
1096                header_color: Some("#000000".to_string()),
1097                first_band_color: None,
1098                second_band_color: None,
1099                footer_color: None,
1100            },
1101            BandingVerb::DeleteBanding { banded_range_id: 1 },
1102        ];
1103        let mut operations: Vec<&str> = verbs.iter().map(BandingVerb::log_operation).collect();
1104        let mut labels: Vec<&str> = verbs.iter().map(BandingVerb::label).collect();
1105        operations.sort_unstable();
1106        operations.dedup();
1107        labels.sort_unstable();
1108        labels.dedup();
1109        assert_eq!(operations.len(), verbs.len());
1110        assert_eq!(labels.len(), verbs.len());
1111    }
1112
1113    #[test]
1114    fn validate_verb_rejects_update_banding_with_nothing_to_change() {
1115        let verb = BandingVerb::UpdateBanding {
1116            banded_range_id: 1,
1117            sheet: None,
1118            range: None,
1119            axis: BandingAxis::Rows,
1120            header_color: None,
1121            first_band_color: None,
1122            second_band_color: None,
1123            footer_color: None,
1124        };
1125        let err = validate_verb(&verb).unwrap_err();
1126        assert!(err.contains("nothing to change"), "{err}");
1127    }
1128
1129    #[test]
1130    fn validate_verb_accepts_update_banding_with_only_a_color_change() {
1131        let verb = BandingVerb::UpdateBanding {
1132            banded_range_id: 1,
1133            sheet: None,
1134            range: None,
1135            axis: BandingAxis::Rows,
1136            header_color: Some("#000000".to_string()),
1137            first_band_color: None,
1138            second_band_color: None,
1139            footer_color: None,
1140        };
1141        validate_verb(&verb).unwrap();
1142    }
1143
1144    #[test]
1145    fn banding_axis_label_and_field_name_cover_both_axes() {
1146        assert_eq!(BandingAxis::Rows.label(), "row");
1147        assert_eq!(BandingAxis::Columns.label(), "column");
1148        assert_eq!(BandingAxis::Rows.field_name(), "rowProperties");
1149        assert_eq!(BandingAxis::Columns.field_name(), "columnProperties");
1150    }
1151
1152    fn sheet_with_banding(sheet_id: i64, banded_range_id: i64) -> Sheet {
1153        Sheet {
1154            banded_ranges: vec![BandedRange {
1155                banded_range_id: Some(banded_range_id),
1156                range: Some(GridRange {
1157                    sheet_id,
1158                    start_row_index: Some(0),
1159                    end_row_index: Some(10),
1160                    start_column_index: Some(0),
1161                    end_column_index: Some(4),
1162                }),
1163                row_properties: Some(BandingProperties {
1164                    header_color_style: None,
1165                    first_band_color_style: Some(ColorStyle::rgb(
1166                        crate::drive::sheets::types::Color {
1167                            red: 1.0,
1168                            green: 1.0,
1169                            blue: 1.0,
1170                        },
1171                    )),
1172                    second_band_color_style: Some(ColorStyle::rgb(
1173                        crate::drive::sheets::types::Color {
1174                            red: 0.0,
1175                            green: 0.0,
1176                            blue: 0.0,
1177                        },
1178                    )),
1179                    footer_color_style: None,
1180                }),
1181                column_properties: None,
1182            }],
1183            ..Default::default()
1184        }
1185    }
1186
1187    #[test]
1188    fn find_existing_banded_range_returns_not_found_for_unknown_id() {
1189        let workbook = Spreadsheet {
1190            sheets: vec![sheet_with_banding(0, 7)],
1191            ..Default::default()
1192        };
1193        let err = find_existing_banded_range(&workbook, 99).unwrap_err();
1194        assert_eq!(
1195            err,
1196            BandingResult::RefusedBandedRangeNotFound {
1197                banded_range_id: 99
1198            }
1199        );
1200    }
1201
1202    #[test]
1203    fn find_existing_banded_range_finds_the_matching_id() {
1204        let workbook = Spreadsheet {
1205            sheets: vec![sheet_with_banding(0, 7)],
1206            ..Default::default()
1207        };
1208        let found = find_existing_banded_range(&workbook, 7).unwrap();
1209        assert_eq!(found.banded_range_id, Some(7));
1210    }
1211
1212    #[test]
1213    fn build_request_add_banding_sets_row_properties_for_the_rows_axis() {
1214        let range = GridRange {
1215            sheet_id: 0,
1216            start_row_index: Some(0),
1217            end_row_index: Some(10),
1218            start_column_index: Some(0),
1219            end_column_index: Some(4),
1220        };
1221        let verb = BandingVerb::AddBanding {
1222            sheet: "Q1".to_string(),
1223            range: "A1:D10".to_string(),
1224            axis: BandingAxis::Rows,
1225            header_color: Some("#111111".to_string()),
1226            first_band_color: "#FFFFFF".to_string(),
1227            second_band_color: "#EEEEEE".to_string(),
1228            footer_color: None,
1229        };
1230        let (request, existing_id) = build_request(&verb, Some(range), None).unwrap();
1231        assert_eq!(existing_id, None);
1232        let BatchUpdateRequestItem::AddBanding(add) = request else {
1233            panic!("expected AddBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns AddBanding for a BandingVerb::AddBanding verb"
1234        };
1235        assert_eq!(add.banded_range.banded_range_id, None);
1236        assert_eq!(add.banded_range.range, Some(range));
1237        assert!(add.banded_range.row_properties.is_some());
1238        assert!(add.banded_range.column_properties.is_none());
1239        let properties = add.banded_range.row_properties.unwrap();
1240        assert!(properties.header_color_style.is_some());
1241        assert!(properties.footer_color_style.is_none());
1242    }
1243
1244    #[test]
1245    fn build_request_add_banding_sets_column_properties_for_the_columns_axis() {
1246        let range = GridRange {
1247            sheet_id: 0,
1248            ..Default::default()
1249        };
1250        let verb = BandingVerb::AddBanding {
1251            sheet: "Q1".to_string(),
1252            range: "A:D".to_string(),
1253            axis: BandingAxis::Columns,
1254            header_color: None,
1255            first_band_color: "#FFFFFF".to_string(),
1256            second_band_color: "#EEEEEE".to_string(),
1257            footer_color: None,
1258        };
1259        let (request, _) = build_request(&verb, Some(range), None).unwrap();
1260        let BatchUpdateRequestItem::AddBanding(add) = request else {
1261            panic!("expected AddBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns AddBanding for a BandingVerb::AddBanding verb"
1262        };
1263        assert!(add.banded_range.row_properties.is_none());
1264        assert!(add.banded_range.column_properties.is_some());
1265    }
1266
1267    #[test]
1268    fn build_request_add_banding_rejects_a_malformed_color() {
1269        let range = GridRange {
1270            sheet_id: 0,
1271            ..Default::default()
1272        };
1273        let verb = BandingVerb::AddBanding {
1274            sheet: "Q1".to_string(),
1275            range: "A1:D10".to_string(),
1276            axis: BandingAxis::Rows,
1277            header_color: None,
1278            first_band_color: "not-a-color".to_string(),
1279            second_band_color: "#EEEEEE".to_string(),
1280            footer_color: None,
1281        };
1282        let err = build_request(&verb, Some(range), None).unwrap_err();
1283        assert!(err.contains("not a color"), "{err}");
1284    }
1285
1286    #[test]
1287    fn build_request_update_banding_merges_onto_the_existing_axis_without_touching_unset_colors() {
1288        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1289        let verb = BandingVerb::UpdateBanding {
1290            banded_range_id: 7,
1291            sheet: None,
1292            range: None,
1293            axis: BandingAxis::Rows,
1294            header_color: Some("#123456".to_string()),
1295            first_band_color: None,
1296            second_band_color: None,
1297            footer_color: None,
1298        };
1299        let (request, existing_id) = build_request(&verb, None, Some(&existing)).unwrap();
1300        assert_eq!(existing_id, Some(7));
1301        let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1302            panic!("expected UpdateBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateBanding for a BandingVerb::UpdateBanding verb"
1303        };
1304        assert_eq!(update.fields, "rowProperties");
1305        assert!(update.banded_range.range.is_none());
1306        let properties = update.banded_range.row_properties.unwrap();
1307        assert!(properties.header_color_style.is_some());
1308        // The existing first/second band colors survive untouched — only
1309        // the header color was named.
1310        assert_eq!(
1311            properties.first_band_color_style,
1312            existing.row_properties.unwrap().first_band_color_style
1313        );
1314    }
1315
1316    #[test]
1317    fn build_request_update_banding_preserves_an_existing_theme_colored_band() {
1318        // Issue #2020: the axis's *existing* properties are cloned whole
1319        // and only the named colors are overwritten (see `build_update`'s
1320        // doc comment) — so before `ColorStyle` round-tripped `themeColor`,
1321        // this path would have silently rewritten a theme-colored band to
1322        // black rather than merely failing to parse it.
1323        let mut sheet = sheet_with_banding(0, 7);
1324        sheet.banded_ranges[0]
1325            .row_properties
1326            .as_mut()
1327            .unwrap()
1328            .first_band_color_style = Some(ColorStyle::theme("ACCENT1"));
1329        let existing = sheet.banded_ranges.remove(0);
1330        let verb = BandingVerb::UpdateBanding {
1331            banded_range_id: 7,
1332            sheet: None,
1333            range: None,
1334            axis: BandingAxis::Rows,
1335            header_color: Some("#123456".to_string()),
1336            first_band_color: None,
1337            second_band_color: None,
1338            footer_color: None,
1339        };
1340        let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1341        let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1342            panic!("expected UpdateBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateBanding for a BandingVerb::UpdateBanding verb"
1343        };
1344        let properties = update.banded_range.row_properties.unwrap();
1345        let serialized = serde_json::to_value(&properties).unwrap();
1346        let first_band = properties.first_band_color_style.unwrap();
1347        assert_eq!(first_band.theme_color, Some("ACCENT1".to_string()));
1348        assert_eq!(first_band.rgb_color, None);
1349
1350        let first_band_json = &serialized["firstBandColorStyle"];
1351        assert_eq!(first_band_json["themeColor"], "ACCENT1");
1352        assert!(
1353            first_band_json.get("rgbColor").is_none(),
1354            "a preserved theme-colored band must not gain a rgbColor field: {serialized}"
1355        );
1356    }
1357
1358    #[test]
1359    fn build_request_update_banding_changes_every_color_field() {
1360        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1361        let verb = BandingVerb::UpdateBanding {
1362            banded_range_id: 7,
1363            sheet: None,
1364            range: None,
1365            axis: BandingAxis::Rows,
1366            header_color: Some("#123456".to_string()),
1367            first_band_color: Some("#111111".to_string()),
1368            second_band_color: Some("#222222".to_string()),
1369            footer_color: Some("#333333".to_string()),
1370        };
1371        let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1372        let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1373            panic!("expected UpdateBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateBanding for a BandingVerb::UpdateBanding verb"
1374        };
1375        assert_eq!(update.fields, "rowProperties");
1376        let properties = update.banded_range.row_properties.unwrap();
1377        assert!(properties.header_color_style.is_some());
1378        assert!(properties.first_band_color_style.is_some());
1379        assert!(properties.second_band_color_style.is_some());
1380        assert!(properties.footer_color_style.is_some());
1381    }
1382
1383    #[test]
1384    fn build_request_update_banding_sets_column_properties_for_the_columns_axis() {
1385        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1386        let verb = BandingVerb::UpdateBanding {
1387            banded_range_id: 7,
1388            sheet: None,
1389            range: None,
1390            axis: BandingAxis::Columns,
1391            header_color: Some("#123456".to_string()),
1392            first_band_color: Some("#111111".to_string()),
1393            second_band_color: Some("#222222".to_string()),
1394            footer_color: None,
1395        };
1396        let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
1397        let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1398            panic!("expected UpdateBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateBanding for a BandingVerb::UpdateBanding verb"
1399        };
1400        assert_eq!(update.fields, "columnProperties");
1401        assert!(update.banded_range.row_properties.is_none());
1402        let properties = update.banded_range.column_properties.unwrap();
1403        assert!(properties.first_band_color_style.is_some());
1404        assert!(properties.second_band_color_style.is_some());
1405    }
1406
1407    fn band_color_update_verb(
1408        axis: BandingAxis,
1409        first_band_color: Option<&str>,
1410        second_band_color: Option<&str>,
1411    ) -> BandingVerb {
1412        BandingVerb::UpdateBanding {
1413            banded_range_id: 7,
1414            sheet: None,
1415            range: None,
1416            axis,
1417            header_color: None,
1418            first_band_color: first_band_color.map(str::to_string),
1419            second_band_color: second_band_color.map(str::to_string),
1420            footer_color: None,
1421        }
1422    }
1423
1424    #[test]
1425    fn validate_new_axis_colors_refuses_a_new_axis_with_only_one_band_color() {
1426        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1427        let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), None);
1428        let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
1429        assert!(detail.contains("no column banding"), "{detail}");
1430        assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
1431    }
1432
1433    #[test]
1434    fn validate_new_axis_colors_names_both_missing_flags_for_a_header_only_change() {
1435        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1436        let verb = BandingVerb::UpdateBanding {
1437            banded_range_id: 7,
1438            sheet: None,
1439            range: None,
1440            axis: BandingAxis::Columns,
1441            header_color: Some("#123456".to_string()),
1442            first_band_color: None,
1443            second_band_color: None,
1444            footer_color: None,
1445        };
1446        let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
1447        assert!(
1448            detail.ends_with("missing: --first-band-color, --second-band-color"),
1449            "{detail}"
1450        );
1451    }
1452
1453    #[test]
1454    fn validate_new_axis_colors_accepts_a_new_axis_with_both_band_colors() {
1455        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1456        let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), Some("#FEDCBA"));
1457        assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1458    }
1459
1460    #[test]
1461    fn validate_new_axis_colors_accepts_one_color_on_an_existing_axis() {
1462        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1463        let verb = band_color_update_verb(BandingAxis::Rows, Some("#ABCDEF"), None);
1464        assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1465    }
1466
1467    #[test]
1468    fn validate_new_axis_colors_ignores_a_range_only_change_on_a_new_axis() {
1469        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1470        let verb = BandingVerb::UpdateBanding {
1471            banded_range_id: 7,
1472            sheet: Some("Q1".to_string()),
1473            range: Some("A1:D20".to_string()),
1474            axis: BandingAxis::Columns,
1475            header_color: None,
1476            first_band_color: None,
1477            second_band_color: None,
1478            footer_color: None,
1479        };
1480        assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
1481    }
1482
1483    #[test]
1484    fn validate_new_axis_colors_ignores_other_verbs() {
1485        let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
1486        assert!(validate_new_axis_colors(&verb, None).is_ok());
1487    }
1488
1489    #[test]
1490    fn build_request_update_banding_with_only_a_range_change_carries_no_properties() {
1491        let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
1492        let range = GridRange {
1493            sheet_id: 0,
1494            start_row_index: Some(5),
1495            end_row_index: Some(15),
1496            start_column_index: Some(0),
1497            end_column_index: Some(4),
1498        };
1499        let verb = BandingVerb::UpdateBanding {
1500            banded_range_id: 7,
1501            sheet: Some("Q1".to_string()),
1502            range: Some("A6:D15".to_string()),
1503            axis: BandingAxis::Rows,
1504            header_color: None,
1505            first_band_color: None,
1506            second_band_color: None,
1507            footer_color: None,
1508        };
1509        let (request, _) = build_request(&verb, Some(range), Some(&existing)).unwrap();
1510        let BatchUpdateRequestItem::UpdateBanding(update) = request else {
1511            panic!("expected UpdateBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateBanding for a BandingVerb::UpdateBanding verb"
1512        };
1513        assert_eq!(update.fields, "range");
1514        assert_eq!(update.banded_range.range, Some(range));
1515        assert!(update.banded_range.row_properties.is_none());
1516    }
1517
1518    #[test]
1519    fn build_request_delete_banding_carries_the_id() {
1520        let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
1521        let (request, existing_id) = build_request(&verb, None, None).unwrap();
1522        assert_eq!(existing_id, Some(7));
1523        let BatchUpdateRequestItem::DeleteBanding(delete) = request else {
1524            panic!("expected DeleteBanding"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns DeleteBanding for a BandingVerb::DeleteBanding verb"
1525        };
1526        assert_eq!(delete.banded_range_id, 7);
1527    }
1528
1529    // ── end-to-end (wiremock) ────────────────────────────────────────────
1530
1531    fn test_credentials() -> DriveCredentials {
1532        DriveCredentials {
1533            client_id: "client-1".to_string(),
1534            client_secret: Secret::new("secret-1"),
1535            refresh_token: Secret::new("refresh-1"),
1536            scope: DriveGrantedScopes::READONLY,
1537        }
1538    }
1539
1540    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1541        wiremock::Mock::given(wiremock::matchers::method("POST"))
1542            .and(wiremock::matchers::path("/token"))
1543            .respond_with(
1544                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1545                    "access_token": "test-token", "expires_in": 3600,
1546                })),
1547            )
1548            .mount(server)
1549            .await;
1550        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1551        crate::drive::client::test_support::replace_session(
1552            &mut drive,
1553            &test_credentials(),
1554            &format!("{}/token", server.uri()),
1555        );
1556        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1557        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1558        (drive, sheets)
1559    }
1560
1561    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1562        let parents: Vec<&str> = parents.to_vec();
1563        wiremock::Mock::given(wiremock::matchers::method("GET"))
1564            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1565            .respond_with(
1566                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1567                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1568                    "version": "1",
1569                })),
1570            )
1571    }
1572
1573    fn mount_folder(id: &str) -> wiremock::Mock {
1574        mount_file(id, "application/vnd.google-apps.folder", &[])
1575    }
1576
1577    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1578        let ledger_path = tempfile::tempdir()
1579            .unwrap()
1580            .keep()
1581            .join("lease-ledger.jsonl");
1582        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1583        (Some(token), ledger_path)
1584    }
1585
1586    fn allow_rule(folder: &str) -> FolderPermissionRule {
1587        FolderPermissionRule {
1588            folder_id: Some(folder.to_string()),
1589            file_id: None,
1590            recursive: true,
1591            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1592            deny: HashSet::default(),
1593            require_lease: true,
1594        }
1595    }
1596
1597    fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
1598        wiremock::Mock::given(wiremock::matchers::method("GET"))
1599            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1600            .respond_with(
1601                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1602                    "spreadsheetId": "sheet-1",
1603                    "properties": {"title": "Budget"},
1604                    "sheets": sheets,
1605                })),
1606            )
1607    }
1608
1609    #[tokio::test]
1610    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1611        let server = wiremock::MockServer::start().await;
1612        let (drive, sheets) = clients(&server).await;
1613        mount_file(
1614            "sheet-1",
1615            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1616            &["folder-1"],
1617        )
1618        .mount(&server)
1619        .await;
1620        mount_folder("folder-1").mount(&server).await;
1621        let rules: Vec<FolderPermissionRule> = Vec::new();
1622        let opts = BandingOptions {
1623            spreadsheet_id: "sheet-1".to_string(),
1624            verb: BandingVerb::AddBanding {
1625                sheet: "Q1".to_string(),
1626                range: "A1:D10".to_string(),
1627                axis: BandingAxis::Rows,
1628                header_color: None,
1629                first_band_color: "#FFFFFF".to_string(),
1630                second_band_color: "#EEEEEE".to_string(),
1631                footer_color: None,
1632            },
1633            dry_run: false,
1634            lease_token: None,
1635            ledger_path: std::path::PathBuf::new(),
1636        };
1637        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1638        assert!(matches!(outcome.result, BandingResult::Blocked { .. }));
1639    }
1640
1641    #[tokio::test]
1642    async fn add_banding_dry_run_reports_would_change_and_makes_no_batch_update_call() {
1643        let server = wiremock::MockServer::start().await;
1644        let (drive, sheets) = clients(&server).await;
1645        mount_file(
1646            "sheet-1",
1647            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1648            &["folder-1"],
1649        )
1650        .mount(&server)
1651        .await;
1652        mount_folder("folder-1").mount(&server).await;
1653        mount_workbook(serde_json::json!([
1654            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1655        ]))
1656        .mount(&server)
1657        .await;
1658        let rules = vec![allow_rule("folder-1")];
1659        let opts = BandingOptions {
1660            spreadsheet_id: "sheet-1".to_string(),
1661            verb: BandingVerb::AddBanding {
1662                sheet: "Q1".to_string(),
1663                range: "A1:D10".to_string(),
1664                axis: BandingAxis::Rows,
1665                header_color: None,
1666                first_band_color: "#FFFFFF".to_string(),
1667                second_band_color: "#EEEEEE".to_string(),
1668                footer_color: None,
1669            },
1670            dry_run: true,
1671            lease_token: None,
1672            ledger_path: std::path::PathBuf::new(),
1673        };
1674        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1675        assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
1676    }
1677
1678    #[tokio::test]
1679    async fn add_banding_sends_an_add_banding_request_and_reports_the_assigned_id() {
1680        let server = wiremock::MockServer::start().await;
1681        let (drive, sheets) = clients(&server).await;
1682        mount_file(
1683            "sheet-1",
1684            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1685            &["folder-1"],
1686        )
1687        .mount(&server)
1688        .await;
1689        mount_folder("folder-1").mount(&server).await;
1690        mount_workbook(serde_json::json!([
1691            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1692        ]))
1693        .mount(&server)
1694        .await;
1695        wiremock::Mock::given(wiremock::matchers::method("POST"))
1696            .and(wiremock::matchers::path(
1697                "/v4/spreadsheets/sheet-1:batchUpdate",
1698            ))
1699            .respond_with(
1700                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1701                    "replies": [{"addBanding": {"bandedRange": {"bandedRangeId": 42}}}]
1702                })),
1703            )
1704            .mount(&server)
1705            .await;
1706        let rules = vec![allow_rule("folder-1")];
1707        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1708        let opts = BandingOptions {
1709            spreadsheet_id: "sheet-1".to_string(),
1710            verb: BandingVerb::AddBanding {
1711                sheet: "Q1".to_string(),
1712                range: "A1:D10".to_string(),
1713                axis: BandingAxis::Rows,
1714                header_color: None,
1715                first_band_color: "#FFFFFF".to_string(),
1716                second_band_color: "#EEEEEE".to_string(),
1717                footer_color: None,
1718            },
1719            dry_run: false,
1720            lease_token,
1721            ledger_path,
1722        };
1723        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1724        assert_eq!(
1725            outcome.result,
1726            BandingResult::Changed {
1727                summary: "add row banding".to_string(),
1728                banded_range_id: Some(42),
1729            }
1730        );
1731        assert_eq!(outcome.sheet_id, Some(0));
1732    }
1733
1734    #[tokio::test]
1735    async fn add_banding_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1736        let server = wiremock::MockServer::start().await;
1737        let (drive, sheets) = clients(&server).await;
1738        mount_file(
1739            "sheet-1",
1740            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1741            &["folder-1"],
1742        )
1743        .mount(&server)
1744        .await;
1745        mount_folder("folder-1").mount(&server).await;
1746        mount_workbook(serde_json::json!([
1747            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1748        ]))
1749        .mount(&server)
1750        .await;
1751        // 2xx, so the banding WAS added — but the body is not the reply shape.
1752        wiremock::Mock::given(wiremock::matchers::method("POST"))
1753            .and(wiremock::matchers::path(
1754                "/v4/spreadsheets/sheet-1:batchUpdate",
1755            ))
1756            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1757            .expect(1)
1758            .mount(&server)
1759            .await;
1760        let rules = vec![allow_rule("folder-1")];
1761        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1762        let opts = BandingOptions {
1763            spreadsheet_id: "sheet-1".to_string(),
1764            verb: add_verb(),
1765            dry_run: false,
1766            lease_token,
1767            ledger_path,
1768        };
1769        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1770        assert!(
1771            matches!(outcome.result, BandingResult::AppliedReplyUnreadable { .. }),
1772            "{:?}",
1773            outcome.result
1774        );
1775        assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
1776        assert!(
1777            describe(&outcome).starts_with("Applied, but the reply could not be read"),
1778            "{}",
1779            describe(&outcome)
1780        );
1781    }
1782
1783    #[tokio::test]
1784    async fn update_banding_refuses_an_unknown_id() {
1785        let server = wiremock::MockServer::start().await;
1786        let (drive, sheets) = clients(&server).await;
1787        mount_file(
1788            "sheet-1",
1789            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1790            &["folder-1"],
1791        )
1792        .mount(&server)
1793        .await;
1794        mount_folder("folder-1").mount(&server).await;
1795        mount_workbook(serde_json::json!([
1796            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}, "bandedRanges": []},
1797        ]))
1798        .mount(&server)
1799        .await;
1800        let rules = vec![allow_rule("folder-1")];
1801        let opts = BandingOptions {
1802            spreadsheet_id: "sheet-1".to_string(),
1803            verb: BandingVerb::UpdateBanding {
1804                banded_range_id: 99,
1805                sheet: None,
1806                range: None,
1807                axis: BandingAxis::Rows,
1808                header_color: Some("#000000".to_string()),
1809                first_band_color: None,
1810                second_band_color: None,
1811                footer_color: None,
1812            },
1813            dry_run: false,
1814            lease_token: None,
1815            ledger_path: std::path::PathBuf::new(),
1816        };
1817        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1818        assert_eq!(
1819            outcome.result,
1820            BandingResult::RefusedBandedRangeNotFound {
1821                banded_range_id: 99
1822            }
1823        );
1824    }
1825
1826    #[tokio::test]
1827    async fn update_banding_sends_an_update_banding_request_and_reports_the_id() {
1828        let server = wiremock::MockServer::start().await;
1829        let (drive, sheets) = clients(&server).await;
1830        mount_file(
1831            "sheet-1",
1832            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1833            &["folder-1"],
1834        )
1835        .mount(&server)
1836        .await;
1837        mount_folder("folder-1").mount(&server).await;
1838        mount_workbook(serde_json::json!([
1839            {
1840                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
1841                "bandedRanges": [{
1842                    "bandedRangeId": 7,
1843                    "range": {"sheetId": 0},
1844                    "rowProperties": {
1845                        "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
1846                        "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
1847                    },
1848                }],
1849            },
1850        ]))
1851        .mount(&server)
1852        .await;
1853        wiremock::Mock::given(wiremock::matchers::method("POST"))
1854            .and(wiremock::matchers::path(
1855                "/v4/spreadsheets/sheet-1:batchUpdate",
1856            ))
1857            .respond_with(
1858                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1859                    "replies": [{}]
1860                })),
1861            )
1862            .mount(&server)
1863            .await;
1864        let rules = vec![allow_rule("folder-1")];
1865        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1866        let opts = BandingOptions {
1867            spreadsheet_id: "sheet-1".to_string(),
1868            verb: BandingVerb::UpdateBanding {
1869                banded_range_id: 7,
1870                sheet: None,
1871                range: None,
1872                axis: BandingAxis::Rows,
1873                header_color: Some("#000000".to_string()),
1874                first_band_color: None,
1875                second_band_color: None,
1876                footer_color: None,
1877            },
1878            dry_run: false,
1879            lease_token,
1880            ledger_path,
1881        };
1882        let outcome = banding(&drive, &sheets, &opts, &rules).await;
1883        assert_eq!(
1884            outcome.result,
1885            BandingResult::Changed {
1886                summary: "update row banding id 7".to_string(),
1887                banded_range_id: Some(7),
1888            }
1889        );
1890        assert_eq!(outcome.sheet_id, Some(0));
1891    }
1892
1893    /// Mounts the drive metadata and a workbook whose banded range 7 bands
1894    /// rows only — the #1935 reproduction's starting state. `batch_update`
1895    /// mounts a `batchUpdate` responder expected exactly that many times.
1896    async fn mount_row_only_banding(server: &wiremock::MockServer, batch_updates: u64) {
1897        mount_file(
1898            "sheet-1",
1899            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1900            &["folder-1"],
1901        )
1902        .mount(server)
1903        .await;
1904        mount_folder("folder-1").mount(server).await;
1905        mount_workbook(serde_json::json!([
1906            {
1907                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
1908                "bandedRanges": [{
1909                    "bandedRangeId": 7,
1910                    "range": {"sheetId": 0},
1911                    "rowProperties": {
1912                        "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
1913                        "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
1914                    },
1915                }],
1916            },
1917        ]))
1918        .mount(server)
1919        .await;
1920        wiremock::Mock::given(wiremock::matchers::method("POST"))
1921            .and(wiremock::matchers::path(
1922                "/v4/spreadsheets/sheet-1:batchUpdate",
1923            ))
1924            .respond_with(
1925                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1926                    "replies": [{}]
1927                })),
1928            )
1929            .expect(batch_updates)
1930            .mount(server)
1931            .await;
1932    }
1933
1934    async fn run_update_on_row_only_banding(
1935        axis: BandingAxis,
1936        first_band_color: Option<&str>,
1937        second_band_color: Option<&str>,
1938        dry_run: bool,
1939        batch_updates: u64,
1940    ) -> (BandingOutcome, wiremock::MockServer) {
1941        let server = wiremock::MockServer::start().await;
1942        let (drive, sheets) = clients(&server).await;
1943        mount_row_only_banding(&server, batch_updates).await;
1944        let rules = vec![allow_rule("folder-1")];
1945        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1946        let opts = BandingOptions {
1947            spreadsheet_id: "sheet-1".to_string(),
1948            verb: band_color_update_verb(axis, first_band_color, second_band_color),
1949            dry_run,
1950            lease_token,
1951            ledger_path,
1952        };
1953        (banding(&drive, &sheets, &opts, &rules).await, server)
1954    }
1955
1956    /// `update-banding --range` with no `--sheet` resolves against the
1957    /// banded range's own sheet rather than being refused (#1941).
1958    #[tokio::test]
1959    async fn update_banding_range_without_sheet_defaults_to_the_banded_ranges_sheet() {
1960        let server = wiremock::MockServer::start().await;
1961        let (drive, sheets) = clients(&server).await;
1962        mount_row_only_banding(&server, 0).await;
1963        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1964        let opts = BandingOptions {
1965            spreadsheet_id: "sheet-1".to_string(),
1966            verb: BandingVerb::UpdateBanding {
1967                banded_range_id: 7,
1968                sheet: None,
1969                range: Some("A1:D20".to_string()),
1970                axis: BandingAxis::Rows,
1971                header_color: None,
1972                first_band_color: None,
1973                second_band_color: None,
1974                footer_color: None,
1975            },
1976            dry_run: true,
1977            lease_token,
1978            ledger_path,
1979        };
1980        let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
1981        assert!(
1982            matches!(outcome.result, BandingResult::WouldChange { .. }),
1983            "{:?}",
1984            outcome.result
1985        );
1986        assert_eq!(outcome.sheet_id, Some(0));
1987    }
1988
1989    /// An unknown id is reported as not found, not as a bare `--range`
1990    /// naming no sheet.
1991    #[tokio::test]
1992    async fn update_banding_range_without_sheet_reports_an_unknown_id_as_not_found() {
1993        let server = wiremock::MockServer::start().await;
1994        let (drive, sheets) = clients(&server).await;
1995        mount_row_only_banding(&server, 0).await;
1996        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1997        let opts = BandingOptions {
1998            spreadsheet_id: "sheet-1".to_string(),
1999            verb: BandingVerb::UpdateBanding {
2000                banded_range_id: 99,
2001                sheet: None,
2002                range: Some("A1:D20".to_string()),
2003                axis: BandingAxis::Rows,
2004                header_color: None,
2005                first_band_color: None,
2006                second_band_color: None,
2007                footer_color: None,
2008            },
2009            dry_run: true,
2010            lease_token,
2011            ledger_path,
2012        };
2013        let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
2014        assert!(
2015            matches!(
2016                outcome.result,
2017                BandingResult::RefusedBandedRangeNotFound { .. }
2018            ),
2019            "{:?}",
2020            outcome.result
2021        );
2022    }
2023
2024    fn assert_refused_missing_second_band_color(outcome: &BandingOutcome) {
2025        let BandingResult::RefusedInvalidRange { detail } = &outcome.result else {
2026            panic!("expected RefusedInvalidRange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; only reached when the refusal regresses"
2027        };
2028        assert!(detail.contains("no column banding"), "{detail}");
2029        assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
2030    }
2031
2032    #[tokio::test]
2033    async fn update_banding_dry_run_refuses_a_new_axis_with_only_one_band_color() {
2034        let (outcome, server) =
2035            run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, true, 0)
2036                .await;
2037        assert_refused_missing_second_band_color(&outcome);
2038        assert_eq!(outcome.sheet_id, Some(0));
2039        server.verify().await;
2040    }
2041
2042    #[tokio::test]
2043    async fn update_banding_refuses_a_new_axis_with_only_one_band_color_without_calling_the_api() {
2044        let (outcome, server) =
2045            run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, false, 0)
2046                .await;
2047        assert_refused_missing_second_band_color(&outcome);
2048        server.verify().await;
2049    }
2050
2051    #[tokio::test]
2052    async fn update_banding_adds_a_new_axis_when_both_band_colors_are_given() {
2053        let (outcome, server) = run_update_on_row_only_banding(
2054            BandingAxis::Columns,
2055            Some("#ABCDEF"),
2056            Some("#FEDCBA"),
2057            false,
2058            1,
2059        )
2060        .await;
2061        assert_eq!(
2062            outcome.result,
2063            BandingResult::Changed {
2064                summary: "update column banding id 7".to_string(),
2065                banded_range_id: Some(7),
2066            }
2067        );
2068        let requests = server.received_requests().await.unwrap();
2069        let body: serde_json::Value = requests
2070            .iter()
2071            .find(|r| r.url.path().ends_with(":batchUpdate"))
2072            .unwrap()
2073            .body_json()
2074            .unwrap();
2075        let update = &body["requests"][0]["updateBanding"];
2076        assert_eq!(update["fields"], "columnProperties");
2077        let columns = &update["bandedRange"]["columnProperties"];
2078        assert!(columns["firstBandColorStyle"].is_object(), "{update}");
2079        assert!(columns["secondBandColorStyle"].is_object(), "{update}");
2080        server.verify().await;
2081    }
2082
2083    #[tokio::test]
2084    async fn update_banding_changes_one_color_on_an_existing_axis() {
2085        let (outcome, server) =
2086            run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, false, 1)
2087                .await;
2088        assert_eq!(
2089            outcome.result,
2090            BandingResult::Changed {
2091                summary: "update row banding id 7".to_string(),
2092                banded_range_id: Some(7),
2093            }
2094        );
2095        server.verify().await;
2096    }
2097
2098    #[tokio::test]
2099    async fn update_banding_dry_run_accepts_one_color_on_an_existing_axis() {
2100        let (outcome, server) =
2101            run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, true, 0).await;
2102        assert_eq!(
2103            outcome.result,
2104            BandingResult::WouldChange {
2105                summary: "update row banding id 7".to_string(),
2106            }
2107        );
2108        server.verify().await;
2109    }
2110
2111    #[tokio::test]
2112    async fn delete_banding_sends_a_delete_banding_request() {
2113        let server = wiremock::MockServer::start().await;
2114        let (drive, sheets) = clients(&server).await;
2115        mount_file(
2116            "sheet-1",
2117            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2118            &["folder-1"],
2119        )
2120        .mount(&server)
2121        .await;
2122        mount_folder("folder-1").mount(&server).await;
2123        mount_workbook(serde_json::json!([
2124            {
2125                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2126                "bandedRanges": [{
2127                    "bandedRangeId": 7,
2128                    "range": {"sheetId": 0},
2129                    "rowProperties": {
2130                        "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2131                        "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2132                    },
2133                }],
2134            },
2135        ]))
2136        .mount(&server)
2137        .await;
2138        wiremock::Mock::given(wiremock::matchers::method("POST"))
2139            .and(wiremock::matchers::path(
2140                "/v4/spreadsheets/sheet-1:batchUpdate",
2141            ))
2142            .respond_with(
2143                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2144                    "replies": [{}]
2145                })),
2146            )
2147            .mount(&server)
2148            .await;
2149        let rules = vec![allow_rule("folder-1")];
2150        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2151        let opts = BandingOptions {
2152            spreadsheet_id: "sheet-1".to_string(),
2153            verb: BandingVerb::DeleteBanding { banded_range_id: 7 },
2154            dry_run: false,
2155            lease_token,
2156            ledger_path,
2157        };
2158        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2159        assert_eq!(
2160            outcome.result,
2161            BandingResult::Changed {
2162                summary: "delete banding id 7".to_string(),
2163                banded_range_id: Some(7),
2164            }
2165        );
2166        assert_eq!(outcome.sheet_id, Some(0));
2167    }
2168
2169    // ── validate_verb/compose_target errors, reached through banding()
2170    // rather than by calling the pure helpers directly ─────────────────────
2171
2172    #[tokio::test]
2173    async fn update_banding_end_to_end_refuses_nothing_to_change() {
2174        let server = wiremock::MockServer::start().await;
2175        let (drive, sheets) = clients(&server).await;
2176        let rules: Vec<FolderPermissionRule> = Vec::new();
2177        let opts = BandingOptions {
2178            spreadsheet_id: "sheet-1".to_string(),
2179            verb: BandingVerb::UpdateBanding {
2180                banded_range_id: 1,
2181                sheet: None,
2182                range: None,
2183                axis: BandingAxis::Rows,
2184                header_color: None,
2185                first_band_color: None,
2186                second_band_color: None,
2187                footer_color: None,
2188            },
2189            dry_run: false,
2190            lease_token: None,
2191            ledger_path: std::path::PathBuf::new(),
2192        };
2193        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2194        assert!(matches!(
2195            outcome.result,
2196            BandingResult::RefusedInvalidRange { .. }
2197        ));
2198    }
2199
2200    #[tokio::test]
2201    async fn add_banding_end_to_end_rejects_conflicting_sheet_and_range() {
2202        let server = wiremock::MockServer::start().await;
2203        let (drive, sheets) = clients(&server).await;
2204        let rules: Vec<FolderPermissionRule> = Vec::new();
2205        let opts = BandingOptions {
2206            spreadsheet_id: "sheet-1".to_string(),
2207            verb: BandingVerb::AddBanding {
2208                sheet: "Other".to_string(),
2209                range: "Sheet1!A1:B2".to_string(),
2210                axis: BandingAxis::Rows,
2211                header_color: None,
2212                first_band_color: "#FFFFFF".to_string(),
2213                second_band_color: "#EEEEEE".to_string(),
2214                footer_color: None,
2215            },
2216            dry_run: false,
2217            lease_token: None,
2218            ledger_path: std::path::PathBuf::new(),
2219        };
2220        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2221        assert!(matches!(
2222            outcome.result,
2223            BandingResult::RefusedInvalidRange { .. }
2224        ));
2225    }
2226
2227    // ── target_gate outcomes other than a granted gate ──────────────────
2228
2229    #[tokio::test]
2230    async fn a_metadata_fetch_failure_surfaces_as_failed() {
2231        let server = wiremock::MockServer::start().await;
2232        let (drive, sheets) = clients(&server).await;
2233        wiremock::Mock::given(wiremock::matchers::method("GET"))
2234            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2235            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2236            .mount(&server)
2237            .await;
2238        let rules: Vec<FolderPermissionRule> = Vec::new();
2239        let opts = BandingOptions {
2240            spreadsheet_id: "sheet-1".to_string(),
2241            verb: BandingVerb::AddBanding {
2242                sheet: "Q1".to_string(),
2243                range: "A1:D10".to_string(),
2244                axis: BandingAxis::Rows,
2245                header_color: None,
2246                first_band_color: "#FFFFFF".to_string(),
2247                second_band_color: "#EEEEEE".to_string(),
2248                footer_color: None,
2249            },
2250            dry_run: false,
2251            lease_token: None,
2252            ledger_path: std::path::PathBuf::new(),
2253        };
2254        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2255        assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2256    }
2257
2258    #[tokio::test]
2259    async fn a_shortcut_target_is_refused() {
2260        let server = wiremock::MockServer::start().await;
2261        let (drive, sheets) = clients(&server).await;
2262        mount_file(
2263            "sheet-1",
2264            "application/vnd.google-apps.shortcut",
2265            &["folder-1"],
2266        )
2267        .mount(&server)
2268        .await;
2269        let rules = vec![allow_rule("folder-1")];
2270        let opts = BandingOptions {
2271            spreadsheet_id: "sheet-1".to_string(),
2272            verb: BandingVerb::AddBanding {
2273                sheet: "Q1".to_string(),
2274                range: "A1:D10".to_string(),
2275                axis: BandingAxis::Rows,
2276                header_color: None,
2277                first_band_color: "#FFFFFF".to_string(),
2278                second_band_color: "#EEEEEE".to_string(),
2279                footer_color: None,
2280            },
2281            dry_run: false,
2282            lease_token: None,
2283            ledger_path: std::path::PathBuf::new(),
2284        };
2285        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2286        assert!(matches!(outcome.result, BandingResult::RefusedShortcut));
2287    }
2288
2289    #[tokio::test]
2290    async fn a_non_spreadsheet_target_is_refused() {
2291        let server = wiremock::MockServer::start().await;
2292        let (drive, sheets) = clients(&server).await;
2293        mount_file(
2294            "sheet-1",
2295            "application/vnd.google-apps.document",
2296            &["folder-1"],
2297        )
2298        .mount(&server)
2299        .await;
2300        let rules = vec![allow_rule("folder-1")];
2301        let opts = BandingOptions {
2302            spreadsheet_id: "sheet-1".to_string(),
2303            verb: BandingVerb::AddBanding {
2304                sheet: "Q1".to_string(),
2305                range: "A1:D10".to_string(),
2306                axis: BandingAxis::Rows,
2307                header_color: None,
2308                first_band_color: "#FFFFFF".to_string(),
2309                second_band_color: "#EEEEEE".to_string(),
2310                footer_color: None,
2311            },
2312            dry_run: false,
2313            lease_token: None,
2314            ledger_path: std::path::PathBuf::new(),
2315        };
2316        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2317        assert!(matches!(
2318            outcome.result,
2319            BandingResult::RefusedNotASpreadsheet { .. }
2320        ));
2321    }
2322
2323    #[tokio::test]
2324    async fn a_target_with_no_visible_parents_is_refused() {
2325        let server = wiremock::MockServer::start().await;
2326        let (drive, sheets) = clients(&server).await;
2327        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2328            .mount(&server)
2329            .await;
2330        let rules: Vec<FolderPermissionRule> = Vec::new();
2331        let opts = BandingOptions {
2332            spreadsheet_id: "sheet-1".to_string(),
2333            verb: BandingVerb::AddBanding {
2334                sheet: "Q1".to_string(),
2335                range: "A1:D10".to_string(),
2336                axis: BandingAxis::Rows,
2337                header_color: None,
2338                first_band_color: "#FFFFFF".to_string(),
2339                second_band_color: "#EEEEEE".to_string(),
2340                footer_color: None,
2341            },
2342            dry_run: false,
2343            lease_token: None,
2344            ledger_path: std::path::PathBuf::new(),
2345        };
2346        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2347        assert!(matches!(
2348            outcome.result,
2349            BandingResult::RefusedNoVisibleParents
2350        ));
2351    }
2352
2353    #[tokio::test]
2354    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2355        let server = wiremock::MockServer::start().await;
2356        let (drive, sheets) = clients(&server).await;
2357        mount_file(
2358            "sheet-1",
2359            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2360            &["folder-1"],
2361        )
2362        .mount(&server)
2363        .await;
2364        wiremock::Mock::given(wiremock::matchers::method("GET"))
2365            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2366            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2367            .mount(&server)
2368            .await;
2369        let rules = vec![allow_rule("folder-1")];
2370        let opts = BandingOptions {
2371            spreadsheet_id: "sheet-1".to_string(),
2372            verb: BandingVerb::AddBanding {
2373                sheet: "Q1".to_string(),
2374                range: "A1:D10".to_string(),
2375                axis: BandingAxis::Rows,
2376                header_color: None,
2377                first_band_color: "#FFFFFF".to_string(),
2378                second_band_color: "#EEEEEE".to_string(),
2379                footer_color: None,
2380            },
2381            dry_run: false,
2382            lease_token: None,
2383            ledger_path: std::path::PathBuf::new(),
2384        };
2385        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2386        assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2387    }
2388
2389    #[tokio::test]
2390    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
2391        let server = wiremock::MockServer::start().await;
2392        let (drive, sheets) = clients(&server).await;
2393        mount_file(
2394            "sheet-1",
2395            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2396            &["folder-1"],
2397        )
2398        .mount(&server)
2399        .await;
2400        mount_folder("folder-1").mount(&server).await;
2401        wiremock::Mock::given(wiremock::matchers::method("GET"))
2402            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2403            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2404            .mount(&server)
2405            .await;
2406        let rules = vec![allow_rule("folder-1")];
2407        let opts = BandingOptions {
2408            spreadsheet_id: "sheet-1".to_string(),
2409            verb: BandingVerb::AddBanding {
2410                sheet: "Q1".to_string(),
2411                range: "A1:D10".to_string(),
2412                axis: BandingAxis::Rows,
2413                header_color: None,
2414                first_band_color: "#FFFFFF".to_string(),
2415                second_band_color: "#EEEEEE".to_string(),
2416                footer_color: None,
2417            },
2418            dry_run: false,
2419            lease_token: None,
2420            ledger_path: std::path::PathBuf::new(),
2421        };
2422        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2423        assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2424    }
2425
2426    // ── resolve_sheet_target's own error branches, reached through
2427    // banding() rather than by calling it directly ─────────────────────
2428
2429    #[tokio::test]
2430    async fn add_banding_reports_sheet_not_found_for_an_unknown_sheet() {
2431        let server = wiremock::MockServer::start().await;
2432        let (drive, sheets) = clients(&server).await;
2433        mount_file(
2434            "sheet-1",
2435            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2436            &["folder-1"],
2437        )
2438        .mount(&server)
2439        .await;
2440        mount_folder("folder-1").mount(&server).await;
2441        mount_workbook(serde_json::json!([
2442            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2443        ]))
2444        .mount(&server)
2445        .await;
2446        let rules = vec![allow_rule("folder-1")];
2447        let opts = BandingOptions {
2448            spreadsheet_id: "sheet-1".to_string(),
2449            verb: BandingVerb::AddBanding {
2450                sheet: "Missing".to_string(),
2451                range: "A1:D10".to_string(),
2452                axis: BandingAxis::Rows,
2453                header_color: None,
2454                first_band_color: "#FFFFFF".to_string(),
2455                second_band_color: "#EEEEEE".to_string(),
2456                footer_color: None,
2457            },
2458            dry_run: false,
2459            lease_token: None,
2460            ledger_path: std::path::PathBuf::new(),
2461        };
2462        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2463        assert!(matches!(
2464            outcome.result,
2465            BandingResult::RefusedSheetNotFound { .. }
2466        ));
2467    }
2468
2469    #[tokio::test]
2470    async fn add_banding_reports_invalid_range_for_a_malformed_range() {
2471        let server = wiremock::MockServer::start().await;
2472        let (drive, sheets) = clients(&server).await;
2473        mount_file(
2474            "sheet-1",
2475            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2476            &["folder-1"],
2477        )
2478        .mount(&server)
2479        .await;
2480        mount_folder("folder-1").mount(&server).await;
2481        mount_workbook(serde_json::json!([
2482            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2483        ]))
2484        .mount(&server)
2485        .await;
2486        let rules = vec![allow_rule("folder-1")];
2487        let opts = BandingOptions {
2488            spreadsheet_id: "sheet-1".to_string(),
2489            verb: BandingVerb::AddBanding {
2490                sheet: "Q1".to_string(),
2491                range: "not-a-range".to_string(),
2492                axis: BandingAxis::Rows,
2493                header_color: None,
2494                first_band_color: "#FFFFFF".to_string(),
2495                second_band_color: "#EEEEEE".to_string(),
2496                footer_color: None,
2497            },
2498            dry_run: false,
2499            lease_token: None,
2500            ledger_path: std::path::PathBuf::new(),
2501        };
2502        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2503        assert!(matches!(
2504            outcome.result,
2505            BandingResult::RefusedInvalidRange { .. }
2506        ));
2507    }
2508
2509    #[tokio::test]
2510    async fn update_banding_reports_sheet_not_found_when_re_pointing() {
2511        let server = wiremock::MockServer::start().await;
2512        let (drive, sheets) = clients(&server).await;
2513        mount_file(
2514            "sheet-1",
2515            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2516            &["folder-1"],
2517        )
2518        .mount(&server)
2519        .await;
2520        mount_folder("folder-1").mount(&server).await;
2521        mount_workbook(serde_json::json!([
2522            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2523        ]))
2524        .mount(&server)
2525        .await;
2526        let rules = vec![allow_rule("folder-1")];
2527        let opts = BandingOptions {
2528            spreadsheet_id: "sheet-1".to_string(),
2529            verb: BandingVerb::UpdateBanding {
2530                banded_range_id: 7,
2531                sheet: Some("Missing".to_string()),
2532                range: Some("A1:B2".to_string()),
2533                axis: BandingAxis::Rows,
2534                header_color: None,
2535                first_band_color: None,
2536                second_band_color: None,
2537                footer_color: None,
2538            },
2539            dry_run: false,
2540            lease_token: None,
2541            ledger_path: std::path::PathBuf::new(),
2542        };
2543        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2544        assert!(matches!(
2545            outcome.result,
2546            BandingResult::RefusedSheetNotFound { .. }
2547        ));
2548    }
2549
2550    #[tokio::test]
2551    async fn update_banding_reports_invalid_range_for_a_malformed_range_when_re_pointing() {
2552        let server = wiremock::MockServer::start().await;
2553        let (drive, sheets) = clients(&server).await;
2554        mount_file(
2555            "sheet-1",
2556            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2557            &["folder-1"],
2558        )
2559        .mount(&server)
2560        .await;
2561        mount_folder("folder-1").mount(&server).await;
2562        mount_workbook(serde_json::json!([
2563            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2564        ]))
2565        .mount(&server)
2566        .await;
2567        let rules = vec![allow_rule("folder-1")];
2568        let opts = BandingOptions {
2569            spreadsheet_id: "sheet-1".to_string(),
2570            verb: BandingVerb::UpdateBanding {
2571                banded_range_id: 7,
2572                sheet: Some("Q1".to_string()),
2573                range: Some("not-a-range".to_string()),
2574                axis: BandingAxis::Rows,
2575                header_color: None,
2576                first_band_color: None,
2577                second_band_color: None,
2578                footer_color: None,
2579            },
2580            dry_run: false,
2581            lease_token: None,
2582            ledger_path: std::path::PathBuf::new(),
2583        };
2584        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2585        assert!(matches!(
2586            outcome.result,
2587            BandingResult::RefusedInvalidRange { .. }
2588        ));
2589    }
2590
2591    #[tokio::test]
2592    async fn update_banding_with_a_new_range_succeeds() {
2593        let server = wiremock::MockServer::start().await;
2594        let (drive, sheets) = clients(&server).await;
2595        mount_file(
2596            "sheet-1",
2597            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2598            &["folder-1"],
2599        )
2600        .mount(&server)
2601        .await;
2602        mount_folder("folder-1").mount(&server).await;
2603        mount_workbook(serde_json::json!([
2604            {
2605                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2606                "bandedRanges": [{
2607                    "bandedRangeId": 7,
2608                    "range": {"sheetId": 0},
2609                    "rowProperties": {
2610                        "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2611                        "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2612                    },
2613                }],
2614            },
2615        ]))
2616        .mount(&server)
2617        .await;
2618        let rules = vec![allow_rule("folder-1")];
2619        let opts = BandingOptions {
2620            spreadsheet_id: "sheet-1".to_string(),
2621            verb: BandingVerb::UpdateBanding {
2622                banded_range_id: 7,
2623                sheet: Some("Q1".to_string()),
2624                range: Some("A1:B2".to_string()),
2625                axis: BandingAxis::Rows,
2626                header_color: None,
2627                first_band_color: None,
2628                second_band_color: None,
2629                footer_color: None,
2630            },
2631            dry_run: true,
2632            lease_token: None,
2633            ledger_path: std::path::PathBuf::new(),
2634        };
2635        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2636        assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
2637    }
2638
2639    // ── build_request's own error branch, reached through banding()
2640    // rather than by calling build_request directly ─────────────────────
2641
2642    #[tokio::test]
2643    async fn update_banding_end_to_end_rejects_a_malformed_color() {
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(serde_json::json!([
2655            {
2656                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2657                "bandedRanges": [{
2658                    "bandedRangeId": 7,
2659                    "range": {"sheetId": 0},
2660                    "rowProperties": {
2661                        "firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
2662                        "secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
2663                    },
2664                }],
2665            },
2666        ]))
2667        .mount(&server)
2668        .await;
2669        let rules = vec![allow_rule("folder-1")];
2670        let opts = BandingOptions {
2671            spreadsheet_id: "sheet-1".to_string(),
2672            verb: BandingVerb::UpdateBanding {
2673                banded_range_id: 7,
2674                sheet: None,
2675                range: None,
2676                axis: BandingAxis::Rows,
2677                header_color: None,
2678                first_band_color: Some("not-a-color".to_string()),
2679                second_band_color: None,
2680                footer_color: None,
2681            },
2682            dry_run: false,
2683            lease_token: None,
2684            ledger_path: std::path::PathBuf::new(),
2685        };
2686        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2687        assert!(matches!(
2688            outcome.result,
2689            BandingResult::RefusedInvalidRange { .. }
2690        ));
2691    }
2692
2693    // ── a real batchUpdate rejection surfaces as Failed ────────────────
2694
2695    #[tokio::test]
2696    async fn a_batch_update_failure_surfaces_as_failed() {
2697        let server = wiremock::MockServer::start().await;
2698        let (drive, sheets) = clients(&server).await;
2699        mount_file(
2700            "sheet-1",
2701            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2702            &["folder-1"],
2703        )
2704        .mount(&server)
2705        .await;
2706        mount_folder("folder-1").mount(&server).await;
2707        mount_workbook(serde_json::json!([
2708            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2709        ]))
2710        .mount(&server)
2711        .await;
2712        wiremock::Mock::given(wiremock::matchers::method("POST"))
2713            .and(wiremock::matchers::path(
2714                "/v4/spreadsheets/sheet-1:batchUpdate",
2715            ))
2716            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
2717            .mount(&server)
2718            .await;
2719        let rules = vec![allow_rule("folder-1")];
2720        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2721        let opts = BandingOptions {
2722            spreadsheet_id: "sheet-1".to_string(),
2723            verb: BandingVerb::AddBanding {
2724                sheet: "Q1".to_string(),
2725                range: "A1:D10".to_string(),
2726                axis: BandingAxis::Rows,
2727                header_color: None,
2728                first_band_color: "#FFFFFF".to_string(),
2729                second_band_color: "#EEEEEE".to_string(),
2730                footer_color: None,
2731            },
2732            dry_run: false,
2733            lease_token,
2734            ledger_path,
2735        };
2736        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2737        assert!(matches!(outcome.result, BandingResult::Failed { .. }));
2738    }
2739
2740    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
2741
2742    #[tokio::test]
2743    async fn refuses_without_a_lease_when_the_rule_requires_one() {
2744        let server = wiremock::MockServer::start().await;
2745        let (drive, sheets) = clients(&server).await;
2746        mount_file(
2747            "sheet-1",
2748            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2749            &["folder-1"],
2750        )
2751        .mount(&server)
2752        .await;
2753        mount_folder("folder-1").mount(&server).await;
2754        mount_workbook(serde_json::json!([
2755            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2756        ]))
2757        .mount(&server)
2758        .await;
2759        let rules = vec![allow_rule("folder-1")];
2760        let opts = BandingOptions {
2761            spreadsheet_id: "sheet-1".to_string(),
2762            verb: BandingVerb::AddBanding {
2763                sheet: "Q1".to_string(),
2764                range: "A1:D10".to_string(),
2765                axis: BandingAxis::Rows,
2766                header_color: None,
2767                first_band_color: "#FFFFFF".to_string(),
2768                second_band_color: "#EEEEEE".to_string(),
2769                footer_color: None,
2770            },
2771            dry_run: false,
2772            lease_token: None,
2773            ledger_path: std::path::PathBuf::new(),
2774        };
2775        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2776        assert!(matches!(outcome.result, BandingResult::RefusedNoLease));
2777    }
2778
2779    #[tokio::test]
2780    async fn refuses_an_unknown_lease_token() {
2781        let server = wiremock::MockServer::start().await;
2782        let (drive, sheets) = clients(&server).await;
2783        mount_file(
2784            "sheet-1",
2785            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2786            &["folder-1"],
2787        )
2788        .mount(&server)
2789        .await;
2790        mount_folder("folder-1").mount(&server).await;
2791        mount_workbook(serde_json::json!([
2792            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2793        ]))
2794        .mount(&server)
2795        .await;
2796        let rules = vec![allow_rule("folder-1")];
2797        let ledger_path = tempfile::tempdir()
2798            .unwrap()
2799            .keep()
2800            .join("lease-ledger.jsonl");
2801        let opts = BandingOptions {
2802            spreadsheet_id: "sheet-1".to_string(),
2803            verb: BandingVerb::AddBanding {
2804                sheet: "Q1".to_string(),
2805                range: "A1:D10".to_string(),
2806                axis: BandingAxis::Rows,
2807                header_color: None,
2808                first_band_color: "#FFFFFF".to_string(),
2809                second_band_color: "#EEEEEE".to_string(),
2810                footer_color: None,
2811            },
2812            dry_run: false,
2813            lease_token: Some("bogus-token".to_string()),
2814            ledger_path,
2815        };
2816        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2817        assert!(matches!(outcome.result, BandingResult::RefusedLeaseExpired));
2818    }
2819
2820    #[tokio::test]
2821    async fn refuses_a_lease_bound_to_a_different_file() {
2822        let server = wiremock::MockServer::start().await;
2823        let (drive, sheets) = clients(&server).await;
2824        mount_file(
2825            "sheet-1",
2826            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2827            &["folder-1"],
2828        )
2829        .mount(&server)
2830        .await;
2831        mount_folder("folder-1").mount(&server).await;
2832        mount_workbook(serde_json::json!([
2833            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2834        ]))
2835        .mount(&server)
2836        .await;
2837        let rules = vec![allow_rule("folder-1")];
2838        let ledger_path = tempfile::tempdir()
2839            .unwrap()
2840            .keep()
2841            .join("lease-ledger.jsonl");
2842        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2843        let opts = BandingOptions {
2844            spreadsheet_id: "sheet-1".to_string(),
2845            verb: BandingVerb::AddBanding {
2846                sheet: "Q1".to_string(),
2847                range: "A1:D10".to_string(),
2848                axis: BandingAxis::Rows,
2849                header_color: None,
2850                first_band_color: "#FFFFFF".to_string(),
2851                second_band_color: "#EEEEEE".to_string(),
2852                footer_color: None,
2853            },
2854            dry_run: false,
2855            lease_token: Some(token),
2856            ledger_path,
2857        };
2858        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2859        assert!(matches!(
2860            outcome.result,
2861            BandingResult::RefusedLeaseWrongFile
2862        ));
2863    }
2864
2865    #[tokio::test]
2866    async fn refuses_a_stale_lease_when_the_file_has_moved() {
2867        let server = wiremock::MockServer::start().await;
2868        let (drive, sheets) = clients(&server).await;
2869        mount_file(
2870            "sheet-1",
2871            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2872            &["folder-1"],
2873        )
2874        .mount(&server)
2875        .await;
2876        mount_folder("folder-1").mount(&server).await;
2877        mount_workbook(serde_json::json!([
2878            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2879        ]))
2880        .mount(&server)
2881        .await;
2882        let rules = vec![allow_rule("folder-1")];
2883        let ledger_path = tempfile::tempdir()
2884            .unwrap()
2885            .keep()
2886            .join("lease-ledger.jsonl");
2887        let token = seed_lease(&ledger_path, "sheet-1", "0");
2888        let opts = BandingOptions {
2889            spreadsheet_id: "sheet-1".to_string(),
2890            verb: BandingVerb::AddBanding {
2891                sheet: "Q1".to_string(),
2892                range: "A1:D10".to_string(),
2893                axis: BandingAxis::Rows,
2894                header_color: None,
2895                first_band_color: "#FFFFFF".to_string(),
2896                second_band_color: "#EEEEEE".to_string(),
2897                footer_color: None,
2898            },
2899            dry_run: false,
2900            lease_token: Some(token),
2901            ledger_path,
2902        };
2903        let outcome = banding(&drive, &sheets, &opts, &rules).await;
2904        assert!(matches!(outcome.result, BandingResult::RefusedLeaseStale));
2905    }
2906
2907    // ── describe/describe_lines (pure) ──────────────────────────────────
2908
2909    fn outcome_with(
2910        verb: BandingVerb,
2911        file_name: Option<&str>,
2912        result: BandingResult,
2913    ) -> BandingOutcome {
2914        BandingOutcome {
2915            spreadsheet_id: "sheet-1".to_string(),
2916            file_name: file_name.map(str::to_string),
2917            resolved_folder_id: None,
2918            sheet_id: None,
2919            verb,
2920            result,
2921        }
2922    }
2923
2924    fn add_verb() -> BandingVerb {
2925        BandingVerb::AddBanding {
2926            sheet: "Q1".to_string(),
2927            range: "A1:D10".to_string(),
2928            axis: BandingAxis::Rows,
2929            header_color: None,
2930            first_band_color: "#FFFFFF".to_string(),
2931            second_band_color: "#EEEEEE".to_string(),
2932            footer_color: None,
2933        }
2934    }
2935
2936    fn update_verb() -> BandingVerb {
2937        BandingVerb::UpdateBanding {
2938            banded_range_id: 7,
2939            sheet: None,
2940            range: None,
2941            axis: BandingAxis::Rows,
2942            header_color: Some("#000000".to_string()),
2943            first_band_color: None,
2944            second_band_color: None,
2945            footer_color: None,
2946        }
2947    }
2948
2949    fn delete_verb() -> BandingVerb {
2950        BandingVerb::DeleteBanding { banded_range_id: 7 }
2951    }
2952
2953    #[test]
2954    fn describe_lines_renders_would_change() {
2955        let out = outcome_with(
2956            add_verb(),
2957            Some("Budget"),
2958            BandingResult::WouldChange {
2959                summary: "add row banding".to_string(),
2960            },
2961        );
2962        assert_eq!(describe(&out), "Would add row banding in 'Budget'");
2963    }
2964
2965    #[test]
2966    fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2967        let out = outcome_with(
2968            add_verb(),
2969            None,
2970            BandingResult::RefusedNotASpreadsheet {
2971                mime_type: "text/plain".to_string(),
2972            },
2973        );
2974        let text = describe(&out);
2975        assert!(text.contains("'sheet-1'"), "{text}");
2976        assert!(text.contains("add-banding"), "{text}");
2977        assert!(text.contains("text/plain"), "{text}");
2978    }
2979
2980    #[test]
2981    fn describe_lines_renders_shortcut() {
2982        let out = outcome_with(
2983            delete_verb(),
2984            Some("Budget"),
2985            BandingResult::RefusedShortcut,
2986        );
2987        let text = describe(&out);
2988        assert!(text.contains("shortcut"), "{text}");
2989        assert!(text.contains("delete-banding"), "{text}");
2990    }
2991
2992    #[test]
2993    fn describe_lines_renders_no_visible_parents() {
2994        let out = outcome_with(
2995            add_verb(),
2996            Some("Budget"),
2997            BandingResult::RefusedNoVisibleParents,
2998        );
2999        assert!(describe(&out).contains("sheets-structure"));
3000    }
3001
3002    #[test]
3003    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
3004        let none = outcome_with(
3005            add_verb(),
3006            Some("Budget"),
3007            BandingResult::RefusedSheetNotFound {
3008                title: "Q2".to_string(),
3009                available: Vec::new(),
3010            },
3011        );
3012        assert!(describe(&none).contains("Available: none"));
3013
3014        let some = outcome_with(
3015            add_verb(),
3016            Some("Budget"),
3017            BandingResult::RefusedSheetNotFound {
3018                title: "Q2".to_string(),
3019                available: vec!["Q1".to_string(), "Q3".to_string()],
3020            },
3021        );
3022        assert!(describe(&some).contains("'Q1', 'Q3'"));
3023    }
3024
3025    #[test]
3026    fn describe_lines_renders_invalid_range_and_banded_range_not_found() {
3027        let invalid = outcome_with(
3028            add_verb(),
3029            Some("Budget"),
3030            BandingResult::RefusedInvalidRange {
3031                detail: "bad range".to_string(),
3032            },
3033        );
3034        assert_eq!(describe(&invalid), "Refused: bad range");
3035
3036        let not_found = outcome_with(
3037            update_verb(),
3038            Some("Budget"),
3039            BandingResult::RefusedBandedRangeNotFound {
3040                banded_range_id: 99,
3041            },
3042        );
3043        let text = describe(&not_found);
3044        assert!(text.contains("no banded range with id 99"), "{text}");
3045        assert!(text.contains("list-bandings"), "{text}");
3046    }
3047
3048    #[test]
3049    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
3050        let folder_rule = outcome_with(
3051            update_verb(),
3052            Some("Budget"),
3053            BandingResult::Blocked {
3054                decided_by: Some(DecidingRule::Folder {
3055                    folder_id: "folder-1".to_string(),
3056                    depth: 2,
3057                }),
3058            },
3059        );
3060        let text = describe(&folder_rule);
3061        assert!(text.contains("update-banding"), "{text}");
3062        assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
3063
3064        let default_policy = outcome_with(
3065            delete_verb(),
3066            Some("Budget"),
3067            BandingResult::Blocked { decided_by: None },
3068        );
3069        assert!(describe(&default_policy).contains("default policy"));
3070    }
3071
3072    #[test]
3073    fn describe_lines_renders_every_lease_refusal() {
3074        for (result, needle) in [
3075            (
3076                BandingResult::RefusedNoLease,
3077                "requires a Drive write lease",
3078            ),
3079            (
3080                BandingResult::RefusedLeaseExpired,
3081                "expired, released, or unknown",
3082            ),
3083            (
3084                BandingResult::RefusedLeaseWrongFile,
3085                "acquired for a different file",
3086            ),
3087            (
3088                BandingResult::RefusedLeaseStale,
3089                "changed since the lease was acquired",
3090            ),
3091        ] {
3092            let out = outcome_with(add_verb(), Some("Budget"), result);
3093            let text = describe(&out);
3094            assert!(text.contains(needle), "{text}");
3095            assert!(text.contains("drive lease acquire sheet-1"), "{text}");
3096        }
3097    }
3098
3099    #[test]
3100    fn describe_lines_renders_changed_with_and_without_an_id() {
3101        let with_id = outcome_with(
3102            add_verb(),
3103            Some("Budget"),
3104            BandingResult::Changed {
3105                summary: "add row banding".to_string(),
3106                banded_range_id: Some(42),
3107            },
3108        );
3109        assert!(describe(&with_id).contains("(id 42)"));
3110
3111        let without_id = outcome_with(
3112            update_verb(),
3113            Some("Budget"),
3114            BandingResult::Changed {
3115                summary: "update row banding id 7".to_string(),
3116                banded_range_id: None,
3117            },
3118        );
3119        let text = describe(&without_id);
3120        assert!(!text.contains("(id"), "{text}");
3121    }
3122
3123    /// An update or delete already names its id in the summary, so a real
3124    /// run carrying the same id back must not repeat it in parentheses.
3125    #[test]
3126    fn describe_lines_names_an_update_or_delete_id_once() {
3127        for (verb, summary) in [
3128            (update_verb(), "update row banding id 7"),
3129            (delete_verb(), "delete banding id 7"),
3130        ] {
3131            let out = outcome_with(
3132                verb,
3133                Some("Budget"),
3134                BandingResult::Changed {
3135                    summary: summary.to_string(),
3136                    banded_range_id: Some(7),
3137                },
3138            );
3139            assert_eq!(describe(&out), format!("Applied: {summary} in 'Budget'"));
3140        }
3141    }
3142
3143    #[test]
3144    fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
3145        let added = outcome_with(
3146            add_verb(),
3147            Some("Budget"),
3148            BandingResult::AppliedReplyUnreadable {
3149                summary: "add row banding".to_string(),
3150                banded_range_id: None,
3151                detail: "bad reply".to_string(),
3152            },
3153        );
3154        assert_eq!(
3155            describe_lines(&added),
3156            vec![
3157                "Applied, but the reply could not be read: add row banding in 'Budget' — \
3158                 do not retry; check the spreadsheet first (bad reply)"
3159                    .to_string(),
3160                "  the new banded range's id was in the unreadable reply; run \
3161                 `list-bandings` to find it"
3162                    .to_string(),
3163            ]
3164        );
3165
3166        let deleted = outcome_with(
3167            delete_verb(),
3168            Some("Budget"),
3169            BandingResult::AppliedReplyUnreadable {
3170                summary: "delete banding id 7".to_string(),
3171                banded_range_id: Some(7),
3172                detail: "bad reply".to_string(),
3173            },
3174        );
3175        // The summary already names the id, exactly as `Changed` relies on.
3176        assert_eq!(
3177            describe_lines(&deleted),
3178            vec![
3179                "Applied, but the reply could not be read: delete banding id 7 in 'Budget' — \
3180                 do not retry; check the spreadsheet first (bad reply)"
3181                    .to_string()
3182            ]
3183        );
3184    }
3185
3186    #[test]
3187    fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
3188        let opts = BandingOptions {
3189            spreadsheet_id: "sheet-1".to_string(),
3190            verb: delete_verb(),
3191            dry_run: false,
3192            lease_token: None,
3193            ledger_path: std::path::PathBuf::new(),
3194        };
3195        let record = |result| {
3196            let mut out = outcome_with(delete_verb(), Some("Budget"), result);
3197            out.sheet_id = Some(3);
3198            mutation_record(&out, &opts, Duration::ZERO)
3199        };
3200        let changed = record(BandingResult::Changed {
3201            summary: "delete banding id 7".to_string(),
3202            banded_range_id: Some(7),
3203        });
3204        let unreadable = record(BandingResult::AppliedReplyUnreadable {
3205            summary: "delete banding id 7".to_string(),
3206            banded_range_id: Some(7),
3207            detail: "bad reply".to_string(),
3208        });
3209        assert_eq!(unreadable.sheet_id, Some(3));
3210        assert_eq!(unreadable.banded_range_id, changed.banded_range_id);
3211        assert_eq!(unreadable.status, "applied-reply-unreadable");
3212        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3213        assert_eq!(
3214            unreadable.fields_changed.as_deref(),
3215            Some("delete banding id 7")
3216        );
3217    }
3218
3219    #[test]
3220    fn describe_lines_renders_failed() {
3221        let out = outcome_with(
3222            add_verb(),
3223            Some("Budget"),
3224            BandingResult::Failed {
3225                detail: "boom".to_string(),
3226            },
3227        );
3228        assert_eq!(describe(&out), "Failed: boom");
3229    }
3230
3231    // ── write_jsonl / log_status ─────────────────────────────────────────
3232
3233    #[test]
3234    fn write_jsonl_emits_one_line_of_json() {
3235        let outcome = outcome_with(
3236            add_verb(),
3237            Some("Budget"),
3238            BandingResult::Changed {
3239                summary: "add row banding".to_string(),
3240                banded_range_id: Some(1),
3241            },
3242        );
3243        let mut buf = Vec::new();
3244        outcome.write_jsonl(&mut buf).unwrap();
3245        let text = String::from_utf8(buf).unwrap();
3246        assert_eq!(text.matches('\n').count(), 1);
3247        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3248        assert_eq!(parsed["result"]["status"], "changed");
3249    }
3250
3251    #[test]
3252    fn banding_result_log_status_names_every_variant() {
3253        assert_eq!(
3254            BandingResult::WouldChange {
3255                summary: String::new(),
3256            }
3257            .log_status(),
3258            "would-change"
3259        );
3260        assert_eq!(
3261            BandingResult::RefusedNotASpreadsheet {
3262                mime_type: String::new(),
3263            }
3264            .log_status(),
3265            "refused-not-a-spreadsheet"
3266        );
3267        assert_eq!(
3268            BandingResult::RefusedShortcut.log_status(),
3269            "refused-shortcut"
3270        );
3271        assert_eq!(
3272            BandingResult::RefusedNoVisibleParents.log_status(),
3273            "refused-no-visible-parents"
3274        );
3275        assert_eq!(
3276            BandingResult::RefusedSheetNotFound {
3277                title: String::new(),
3278                available: Vec::new(),
3279            }
3280            .log_status(),
3281            "refused-sheet-not-found"
3282        );
3283        assert_eq!(
3284            BandingResult::RefusedInvalidRange {
3285                detail: String::new(),
3286            }
3287            .log_status(),
3288            "refused-invalid-range"
3289        );
3290        assert_eq!(
3291            BandingResult::RefusedBandedRangeNotFound { banded_range_id: 1 }.log_status(),
3292            "refused-banded-range-not-found"
3293        );
3294        assert_eq!(
3295            BandingResult::Blocked { decided_by: None }.log_status(),
3296            "blocked"
3297        );
3298        assert_eq!(
3299            BandingResult::RefusedNoLease.log_status(),
3300            LeaseGateRefusal::NoLease.log_status()
3301        );
3302        assert_eq!(
3303            BandingResult::RefusedLeaseExpired.log_status(),
3304            LeaseGateRefusal::Expired.log_status()
3305        );
3306        assert_eq!(
3307            BandingResult::RefusedLeaseWrongFile.log_status(),
3308            LeaseGateRefusal::WrongFile.log_status()
3309        );
3310        assert_eq!(
3311            BandingResult::RefusedLeaseStale.log_status(),
3312            LeaseGateRefusal::Stale.log_status()
3313        );
3314        assert_eq!(
3315            BandingResult::Changed {
3316                summary: String::new(),
3317                banded_range_id: None,
3318            }
3319            .log_status(),
3320            "changed"
3321        );
3322        assert_eq!(
3323            BandingResult::AppliedReplyUnreadable {
3324                summary: String::new(),
3325                banded_range_id: None,
3326                detail: String::new(),
3327            }
3328            .log_status(),
3329            "applied-reply-unreadable"
3330        );
3331        assert_eq!(
3332            BandingResult::Failed {
3333                detail: String::new(),
3334            }
3335            .log_status(),
3336            "failed"
3337        );
3338    }
3339}