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