Skip to main content

omni_dev/drive/sheets/
conditional_format.rs

1//! Conditional formatting via `spreadsheets.batchUpdate` (issue #1793,
2//! [ADR-0081](../../../docs/adrs/adr-0081.md) §1).
3//!
4//! Three mutating verbs, all gated by [`DriveOperation::SheetsStructure`] —
5//! presentational, destroys no data, exactly like `format-cells`/
6//! `set-data-validation`. `list-conditional-formats` is a plain read,
7//! ungated like `list-protections` — its own module lives entirely in
8//! `crate::cli::drive::sheets::conditional_format`, this module has no
9//! "list" verb of its own. Shape mirrors `format.rs`/`validation.rs`/
10//! `protection.rs`: compose a target, resolve it against a freshly-fetched
11//! workbook, gate, dry-run, mutate, log.
12//!
13//! **Index-addressed, not id-addressed.** Unlike a [`crate::drive::sheets::types::ProtectedRange`],
14//! a conditional format rule has no stable id — it is one entry of a
15//! [`crate::drive::sheets::types::Sheet`]'s ordered `conditional_formats`
16//! list, and `update-conditional-format`/`delete-conditional-format`'s
17//! `--index` names an ordinal position that shifts when an earlier rule is
18//! deleted. `list-conditional-formats` exists to make that position
19//! discoverable immediately before acting on it (the same reason
20//! `list-protections` exists), and `--dry-run` on `update`/`delete` echoes
21//! the rule *currently* at the given index — both states, "currently" and
22//! "to" — so a stale index is visible before it's acted on.
23//!
24//! **A curated condition/rule surface, not full `BooleanRule`/`GradientRule`
25//! coverage**, mirroring `validation.rs`'s own stance. [`FormatCondition`]
26//! is a separate, smaller enum from `validation::Condition` — duplicated
27//! rather than shared, since the two surfaces support an overlapping but
28//! different set of condition types: this one drops `ONE_OF_LIST`/
29//! `ONE_OF_RANGE`/`CHECKBOX` (dropdown-only, meaningless for a format
30//! trigger) and adds `BLANK`/`NOT_BLANK` (`--cell-empty`/`--cell-not-empty`,
31//! meaningful only as a format trigger). [`GradientRule`](crate::drive::sheets::types::GradientRule)'s
32//! two endpoints are always anchored `MIN`/`MAX`; Sheets also allows an
33//! endpoint anchored at an explicit `NUMBER`/`PERCENT`/`PERCENTILE` value,
34//! which this crate doesn't build — `docs/drive.md` names both gaps.
35//! `update-conditional-format` only replaces the rule at an index; Sheets
36//! also supports moving a rule from one index to another without changing
37//! its content, which this crate doesn't build either.
38
39use std::path::PathBuf;
40use std::time::{Duration, Instant};
41
42use serde::Serialize;
43
44use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
45use crate::drive::client::DriveClient;
46use crate::drive::files_api::FilesApi;
47use crate::drive::lease::check::{
48    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
49    LeasedWrite,
50};
51use crate::drive::sheets::a1;
52use crate::drive::sheets::api::{
53    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
54};
55use crate::drive::sheets::client::SheetsClient;
56use crate::drive::sheets::date_value::{
57    reject_blank, reject_blank_date, reject_empty, reject_invalid_date_between, reject_nan,
58    reject_reversed_range, DateValue,
59};
60use crate::drive::sheets::format::parse_hex_color;
61use crate::drive::sheets::grid_range;
62use crate::drive::sheets::target_gate;
63use crate::drive::sheets::types::{
64    AddConditionalFormatRuleRequest, BatchUpdateRequestItem, BooleanCondition, BooleanRule,
65    CellFormat, ColorStyle, ConditionValue, ConditionalFormatRule,
66    DeleteConditionalFormatRuleRequest, GradientRule, GridRange, InterpolationPoint, Sheet,
67    Spreadsheet, TextFormat, UpdateConditionalFormatRuleRequest,
68};
69use crate::drive::types::SheetTargetRefusal;
70use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
71use crate::request_log::{self, DriveMutationOutcome};
72
73/// The condition shapes a `BooleanRule` can trigger on.
74///
75/// See the module doc's "curated condition/rule surface" paragraph for how
76/// this differs from `validation::Condition`.
77#[derive(Debug, Clone, PartialEq)]
78pub enum FormatCondition {
79    /// A number in `[min, max]` inclusive.
80    NumberBetween(f64, f64),
81    /// A number outside `[min, max]`.
82    NumberNotBetween(f64, f64),
83    /// A number strictly greater than this.
84    NumberGreater(f64),
85    /// A number greater than or equal to this.
86    NumberGreaterEq(f64),
87    /// A number strictly less than this.
88    NumberLess(f64),
89    /// A number less than or equal to this.
90    NumberLessEq(f64),
91    /// A number equal to this.
92    NumberEq(f64),
93    /// A number not equal to this.
94    NumberNotEq(f64),
95    /// Text containing this substring.
96    TextContains(String),
97    /// Text not containing this substring.
98    TextNotContains(String),
99    /// Text starting with this substring.
100    TextStartsWith(String),
101    /// Text ending with this substring.
102    TextEndsWith(String),
103    /// Text equal to this.
104    TextEq(String),
105    /// A date after this one.
106    DateAfter(DateValue),
107    /// A date before this one.
108    DateBefore(DateValue),
109    /// A date equal to this one.
110    DateOn(DateValue),
111    /// A date in `[start, end]` inclusive (absolute dates only).
112    DateBetween(String, String),
113    /// The cell is empty.
114    CellEmpty,
115    /// The cell is not empty.
116    CellNotEmpty,
117    /// A custom formula that must evaluate truthy.
118    CustomFormula(String),
119}
120
121impl FormatCondition {
122    /// The Sheets API `condition_type` string.
123    const fn condition_type_str(&self) -> &'static str {
124        match self {
125            Self::NumberBetween(..) => "NUMBER_BETWEEN",
126            Self::NumberNotBetween(..) => "NUMBER_NOT_BETWEEN",
127            Self::NumberGreater(_) => "NUMBER_GREATER",
128            Self::NumberGreaterEq(_) => "NUMBER_GREATER_THAN_EQ",
129            Self::NumberLess(_) => "NUMBER_LESS",
130            Self::NumberLessEq(_) => "NUMBER_LESS_THAN_EQ",
131            Self::NumberEq(_) => "NUMBER_EQ",
132            Self::NumberNotEq(_) => "NUMBER_NOT_EQ",
133            Self::TextContains(_) => "TEXT_CONTAINS",
134            Self::TextNotContains(_) => "TEXT_NOT_CONTAINS",
135            Self::TextStartsWith(_) => "TEXT_STARTS_WITH",
136            Self::TextEndsWith(_) => "TEXT_ENDS_WITH",
137            Self::TextEq(_) => "TEXT_EQ",
138            Self::DateAfter(_) => "DATE_AFTER",
139            Self::DateBefore(_) => "DATE_BEFORE",
140            Self::DateOn(_) => "DATE_EQ",
141            Self::DateBetween(..) => "DATE_BETWEEN",
142            Self::CellEmpty => "BLANK",
143            Self::CellNotEmpty => "NOT_BLANK",
144            Self::CustomFormula(_) => "CUSTOM_FORMULA",
145        }
146    }
147
148    fn into_boolean_condition(self) -> BooleanCondition {
149        let value = |s: String| ConditionValue {
150            user_entered_value: Some(s),
151            relative_date: None,
152        };
153        let condition_type = self.condition_type_str().to_string();
154        let values = match self {
155            Self::NumberBetween(min, max) | Self::NumberNotBetween(min, max) => {
156                vec![value(min.to_string()), value(max.to_string())]
157            }
158            Self::NumberGreater(n)
159            | Self::NumberGreaterEq(n)
160            | Self::NumberLess(n)
161            | Self::NumberLessEq(n)
162            | Self::NumberEq(n)
163            | Self::NumberNotEq(n) => vec![value(n.to_string())],
164            Self::TextContains(text)
165            | Self::TextNotContains(text)
166            | Self::TextStartsWith(text)
167            | Self::TextEndsWith(text)
168            | Self::TextEq(text) => vec![value(text)],
169            Self::DateAfter(date) | Self::DateBefore(date) | Self::DateOn(date) => {
170                vec![date.into_condition_value()]
171            }
172            Self::DateBetween(start, end) => vec![value(start), value(end)],
173            Self::CellEmpty | Self::CellNotEmpty => Vec::new(),
174            Self::CustomFormula(formula) => vec![value(formula)],
175        };
176        BooleanCondition {
177            condition_type,
178            values,
179        }
180    }
181}
182
183/// The mutable subset of a cell's format a `BooleanRule` can apply.
184///
185/// Deliberately smaller than `format-cells`' full `CellFormatFlags`: only
186/// the three properties most conditional-format rules actually set.
187#[derive(Debug, Clone, Default, PartialEq, Eq)]
188pub struct FormatEffect {
189    /// Cell background color, `#RRGGBB`.
190    pub background: Option<String>,
191    /// Text color, `#RRGGBB`.
192    pub text_color: Option<String>,
193    /// Bold text.
194    pub bold: Option<bool>,
195}
196
197impl FormatEffect {
198    fn into_cell_format(self) -> Result<CellFormat, String> {
199        let mut format = CellFormat::default();
200        if let Some(hex) = self.background {
201            format.background_color_style = Some(ColorStyle::rgb(
202                parse_hex_color(&hex).map_err(|e| format!("--background: {e}"))?,
203            ));
204        }
205        if self.text_color.is_some() || self.bold.is_some() {
206            let mut text_format = TextFormat::default();
207            if let Some(hex) = self.text_color {
208                text_format.foreground_color_style = Some(ColorStyle::rgb(
209                    parse_hex_color(&hex).map_err(|e| format!("--text-color: {e}"))?,
210                ));
211            }
212            text_format.bold = self.bold;
213            format.text_format = Some(text_format);
214        }
215        Ok(format)
216    }
217}
218
219/// One of Sheets' `GradientRule` midpoint anchor types — `MIN`/`MAX` are
220/// reserved for the two fixed endpoints and never appear here (see the
221/// module doc's gradient cut).
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum GradientPointType {
224    /// Anchored at an absolute cell value.
225    Number,
226    /// Anchored at a percentage of the range's value spread.
227    Percent,
228    /// Anchored at a percentile of the range's values.
229    Percentile,
230}
231
232impl GradientPointType {
233    const fn as_sheets_str(self) -> &'static str {
234        match self {
235            Self::Number => "NUMBER",
236            Self::Percent => "PERCENT",
237            Self::Percentile => "PERCENTILE",
238        }
239    }
240}
241
242/// A `GradientRule`'s optional midpoint.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct GradientMidpoint {
245    /// The midpoint's color, `#RRGGBB`.
246    pub color: String,
247    /// What kind of value the midpoint is anchored to.
248    pub point_type: GradientPointType,
249    /// The threshold value, untouched — Sheets parses it at evaluation
250    /// time.
251    pub value: String,
252}
253
254/// A `GradientRule` this crate can build — see the module doc's gradient
255/// cut for what's deliberately excluded.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct GradientSpec {
258    /// The color at the low end of the scale (anchored `MIN`).
259    pub min_color: String,
260    /// The color at the high end of the scale (anchored `MAX`).
261    pub max_color: String,
262    /// An optional midpoint.
263    pub mid: Option<GradientMidpoint>,
264}
265
266impl GradientSpec {
267    fn into_gradient_rule(self) -> Result<GradientRule, String> {
268        let min =
269            parse_hex_color(&self.min_color).map_err(|e| format!("--gradient-min-color: {e}"))?;
270        let max =
271            parse_hex_color(&self.max_color).map_err(|e| format!("--gradient-max-color: {e}"))?;
272        let midpoint = match self.mid {
273            Some(mid) => Some(InterpolationPoint {
274                color_style: ColorStyle::rgb(
275                    parse_hex_color(&mid.color)
276                        .map_err(|e| format!("--gradient-mid-color: {e}"))?,
277                ),
278                point_type: mid.point_type.as_sheets_str().to_string(),
279                value: Some(mid.value),
280            }),
281            None => None,
282        };
283        let endpoint = |rgb_color, point_type: &str| InterpolationPoint {
284            color_style: ColorStyle::rgb(rgb_color),
285            point_type: point_type.to_string(),
286            value: None,
287        };
288        Ok(GradientRule {
289            minpoint: Some(endpoint(min, "MIN")),
290            midpoint,
291            maxpoint: Some(endpoint(max, "MAX")),
292        })
293    }
294}
295
296/// The rule content `add-conditional-format`/`update-conditional-format`
297/// build — exactly one of a condition-triggered format or a color-scale
298/// gradient, matching `ConditionalFormatRule`'s own union.
299#[derive(Debug, Clone, PartialEq)]
300pub enum FormatRule {
301    /// A `BooleanRule`: `format` applies to every cell for which
302    /// `condition` evaluates true.
303    Boolean {
304        /// What triggers the format.
305        condition: FormatCondition,
306        /// The format to apply.
307        format: FormatEffect,
308    },
309    /// A `GradientRule`: a color scale across the range's values.
310    Gradient(GradientSpec),
311}
312
313impl FormatRule {
314    fn into_conditional_format_rule(
315        self,
316        ranges: Vec<GridRange>,
317    ) -> Result<ConditionalFormatRule, String> {
318        match self {
319            Self::Boolean { condition, format } => Ok(ConditionalFormatRule {
320                ranges,
321                boolean_rule: Some(BooleanRule {
322                    condition: condition.into_boolean_condition(),
323                    format: format.into_cell_format()?,
324                }),
325                gradient_rule: None,
326            }),
327            Self::Gradient(spec) => Ok(ConditionalFormatRule {
328                ranges,
329                boolean_rule: None,
330                gradient_rule: Some(spec.into_gradient_rule()?),
331            }),
332        }
333    }
334}
335
336/// Which mutation to perform.
337#[derive(Debug, Clone, PartialEq)]
338pub enum ConditionalFormatVerb {
339    /// Add a new rule.
340    AddConditionalFormat {
341        /// The sheet every range belongs to.
342        sheet: String,
343        /// The range(s) the rule applies to, at least one.
344        ranges: Vec<String>,
345        /// Where to insert the rule. `None` appends (the sheet's current
346        /// `conditional_formats.len()`).
347        index: Option<i64>,
348        /// The rule to add.
349        rule: FormatRule,
350    },
351    /// Replace the rule at an index.
352    UpdateConditionalFormat {
353        /// The sheet every range belongs to.
354        sheet: String,
355        /// The rule's new range(s), at least one.
356        ranges: Vec<String>,
357        /// Which rule, by ordinal position, to replace.
358        index: usize,
359        /// The rule's new content.
360        rule: FormatRule,
361    },
362    /// Remove the rule at an index.
363    DeleteConditionalFormat {
364        /// The sheet the rule belongs to.
365        sheet: String,
366        /// Which rule, by ordinal position, to remove.
367        index: usize,
368    },
369}
370
371impl ConditionalFormatVerb {
372    const fn log_operation(&self) -> &'static str {
373        match self {
374            Self::AddConditionalFormat { .. } => "sheets-add-conditional-format",
375            Self::UpdateConditionalFormat { .. } => "sheets-update-conditional-format",
376            Self::DeleteConditionalFormat { .. } => "sheets-delete-conditional-format",
377        }
378    }
379
380    const fn label(&self) -> &'static str {
381        match self {
382            Self::AddConditionalFormat { .. } => "add-conditional-format",
383            Self::UpdateConditionalFormat { .. } => "update-conditional-format",
384            Self::DeleteConditionalFormat { .. } => "delete-conditional-format",
385        }
386    }
387
388    fn sheet(&self) -> &str {
389        match self {
390            Self::AddConditionalFormat { sheet, .. }
391            | Self::UpdateConditionalFormat { sheet, .. }
392            | Self::DeleteConditionalFormat { sheet, .. } => sheet,
393        }
394    }
395
396    fn ranges(&self) -> &[String] {
397        match self {
398            Self::AddConditionalFormat { ranges, .. }
399            | Self::UpdateConditionalFormat { ranges, .. } => ranges,
400            Self::DeleteConditionalFormat { .. } => &[],
401        }
402    }
403
404    fn rule(&self) -> Option<&FormatRule> {
405        match self {
406            Self::AddConditionalFormat { rule, .. }
407            | Self::UpdateConditionalFormat { rule, .. } => Some(rule),
408            Self::DeleteConditionalFormat { .. } => None,
409        }
410    }
411}
412
413/// Per-call options.
414#[derive(Debug, Clone)]
415pub struct ConditionalFormatOptions {
416    /// Spreadsheet id.
417    pub spreadsheet_id: String,
418    /// Which mutation to perform.
419    pub verb: ConditionalFormatVerb,
420    /// Classify and describe only; never call `batchUpdate`.
421    pub dry_run: bool,
422    /// The lease token presented via `--lease`. Checked only when the
423    /// deciding rule requires one.
424    pub lease_token: Option<String>,
425    /// Path to the lease ledger the token is checked against.
426    pub ledger_path: PathBuf,
427}
428
429/// What happened (or, under `--dry-run`, would happen).
430#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
431#[serde(tag = "status", rename_all = "kebab-case")]
432pub enum ConditionalFormatResult {
433    /// `--dry-run`, and the gate would allow it.
434    WouldChange {
435        /// A human-readable summary of the effect.
436        summary: String,
437    },
438    /// The target is not a Google Sheet.
439    RefusedNotASpreadsheet {
440        /// The target's actual MIME type.
441        mime_type: String,
442    },
443    /// The target is a shortcut, which we do not follow.
444    RefusedShortcut,
445    /// The target has no parents this account can see.
446    RefusedNoVisibleParents,
447    /// The named sheet does not exist in this workbook.
448    RefusedSheetNotFound {
449        /// The title that was not found.
450        title: String,
451        /// The titles that do exist.
452        available: Vec<String>,
453    },
454    /// The `--sheet`/`--range` pair, or the rule's own arguments, was
455    /// invalid.
456    RefusedInvalidRange {
457        /// What was wrong and why.
458        detail: String,
459    },
460    /// `--index` does not name a valid position on this sheet:
461    /// `update-conditional-format`/`delete-conditional-format`'s `--index`
462    /// doesn't name an existing rule (valid range `0..count`), or
463    /// `add-conditional-format`'s explicit `--index` is negative or greater
464    /// than the sheet's current rule count (valid range `0..=count`).
465    RefusedIndexOutOfBounds {
466        /// The sheet the index was checked against.
467        sheet: String,
468        /// The index that was out of bounds. `i64`, not `usize`, since
469        /// `add-conditional-format`'s `--index` accepts (and must be able to
470        /// report) a negative value.
471        index: i64,
472        /// How many rules the sheet actually has.
473        count: usize,
474    },
475    /// The folder write-permission gate refused it.
476    Blocked {
477        /// The rule that decided the refusal, if any.
478        decided_by: Option<DecidingRule>,
479    },
480    /// No `--lease` was presented, and the deciding rule requires one.
481    RefusedNoLease,
482    /// The presented lease has expired, or was never a token this ledger
483    /// knows about.
484    RefusedLeaseExpired,
485    /// The presented lease is bound to a different file id.
486    RefusedLeaseWrongFile,
487    /// The file has moved since the lease's recorded `version`.
488    RefusedLeaseStale,
489    /// The mutation succeeded.
490    Changed {
491        /// Same summary as [`Self::WouldChange`].
492        summary: String,
493    },
494    /// The API answered 2xx — the mutation **was applied** — but its reply
495    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: the
496    /// rule change happened, and a retry could duplicate a rule or act on a
497    /// shifted index.
498    AppliedReplyUnreadable {
499        /// Same summary as [`Self::Changed`].
500        summary: String,
501        /// Why the reply could not be read.
502        detail: String,
503    },
504    /// An API or validation error.
505    Failed {
506        /// A human-readable summary of what failed.
507        detail: String,
508    },
509}
510
511impl FromLeaseRefusal for ConditionalFormatResult {
512    fn from_no_lease() -> Self {
513        Self::RefusedNoLease
514    }
515    fn from_lease_expired() -> Self {
516        Self::RefusedLeaseExpired
517    }
518    fn from_lease_wrong_file() -> Self {
519        Self::RefusedLeaseWrongFile
520    }
521    fn from_lease_stale() -> Self {
522        Self::RefusedLeaseStale
523    }
524    fn from_lease_failed(detail: String) -> Self {
525        Self::Failed { detail }
526    }
527}
528
529impl ConditionalFormatResult {
530    fn log_status(&self) -> &'static str {
531        match self {
532            Self::WouldChange { .. } => "would-change",
533            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
534            Self::RefusedShortcut => "refused-shortcut",
535            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
536            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
537            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
538            Self::RefusedIndexOutOfBounds { .. } => "refused-index-out-of-bounds",
539            Self::Blocked { .. } => "blocked",
540            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
541            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
542            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
543            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
544            Self::Changed { .. } => "changed",
545            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
546            Self::Failed { .. } => "failed",
547        }
548    }
549}
550
551/// The full outcome of one attempt.
552#[derive(Debug, Clone, Serialize, PartialEq)]
553pub struct ConditionalFormatOutcome {
554    /// The spreadsheet acted on.
555    pub spreadsheet_id: String,
556    /// Its Drive file name, when the metadata fetch got that far.
557    #[serde(skip_serializing_if = "Option::is_none")]
558    pub file_name: Option<String>,
559    /// The folder the gate evaluated against.
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub resolved_folder_id: Option<String>,
562    /// Which mutation was attempted. Not serialised.
563    #[serde(skip)]
564    pub verb: ConditionalFormatVerb,
565    /// What happened.
566    pub result: ConditionalFormatResult,
567}
568
569impl JsonlSerialize for ConditionalFormatOutcome {
570    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
571        write_scalar_jsonl(self, out)
572    }
573}
574
575/// Runs one conditional-format mutation, logging every attempt that isn't a
576/// dry run.
577pub async fn conditional_format(
578    drive: &DriveClient,
579    sheets: &SheetsClient,
580    opts: &ConditionalFormatOptions,
581    rules: &[FolderPermissionRule],
582) -> ConditionalFormatOutcome {
583    let started = Instant::now();
584    let outcome = conditional_format_inner(drive, sheets, opts, rules).await;
585    if !opts.dry_run {
586        record_attempt(&outcome, opts, started.elapsed());
587    }
588    outcome
589}
590
591async fn conditional_format_inner(
592    drive: &DriveClient,
593    sheets: &SheetsClient,
594    opts: &ConditionalFormatOptions,
595    rules: &[FolderPermissionRule],
596) -> ConditionalFormatOutcome {
597    let bare = |result| ConditionalFormatOutcome {
598        spreadsheet_id: opts.spreadsheet_id.clone(),
599        file_name: None,
600        resolved_folder_id: None,
601        verb: opts.verb.clone(),
602        result,
603    };
604
605    if opts.verb.ranges().is_empty() && opts.verb.rule().is_some() {
606        return bare(ConditionalFormatResult::RefusedInvalidRange {
607            detail: format!("{} needs at least one --range", opts.verb.label()),
608        });
609    }
610
611    let composed_ranges: Vec<String> = match opts
612        .verb
613        .ranges()
614        .iter()
615        .map(|range| a1::compose(Some(opts.verb.sheet()), Some(range)))
616        .collect::<anyhow::Result<Vec<String>>>()
617    {
618        Ok(composed) => composed,
619        Err(err) => {
620            return bare(ConditionalFormatResult::RefusedInvalidRange {
621                detail: err.to_string(),
622            })
623        }
624    };
625
626    if let Some(rule) = opts.verb.rule() {
627        if let Err(detail) = validate_format_rule(rule) {
628            return bare(ConditionalFormatResult::RefusedInvalidRange { detail });
629        }
630    }
631
632    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
633        drive,
634        &opts.spreadsheet_id,
635        DriveOperation::SheetsStructure,
636        rules,
637    )
638    .await
639    {
640        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
641            return bare(ConditionalFormatResult::Failed { detail })
642        }
643        target_gate::TargetGateOutcome::Refused { target, refusal } => {
644            let result = match refusal {
645                SheetTargetRefusal::Shortcut => ConditionalFormatResult::RefusedShortcut,
646                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
647                    ConditionalFormatResult::RefusedNotASpreadsheet { mime_type }
648                }
649                SheetTargetRefusal::NoVisibleParents => {
650                    ConditionalFormatResult::RefusedNoVisibleParents
651                }
652            };
653            return ConditionalFormatOutcome {
654                spreadsheet_id: opts.spreadsheet_id.clone(),
655                file_name: Some(target.name),
656                resolved_folder_id: None,
657                verb: opts.verb.clone(),
658                result,
659            };
660        }
661        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
662            return ConditionalFormatOutcome {
663                spreadsheet_id: opts.spreadsheet_id.clone(),
664                file_name: Some(target.name),
665                resolved_folder_id: None,
666                verb: opts.verb.clone(),
667                result: ConditionalFormatResult::Failed { detail },
668            };
669        }
670        target_gate::TargetGateOutcome::Gated {
671            target,
672            decision,
673            resolved_folder_id,
674            requires_lease,
675        } => (target, decision, resolved_folder_id, requires_lease),
676    };
677
678    let gated = |result| ConditionalFormatOutcome {
679        spreadsheet_id: opts.spreadsheet_id.clone(),
680        file_name: Some(target.name.clone()),
681        resolved_folder_id: resolved_folder_id.clone(),
682        verb: opts.verb.clone(),
683        result,
684    };
685
686    if decision.verdict == write_gate::Verdict::Deny {
687        return gated(ConditionalFormatResult::Blocked {
688            decided_by: decision.decided_by,
689        });
690    }
691
692    let api = SheetsApi::new(sheets);
693    let workbook = match api
694        .get_spreadsheet_with_conditional_formats(&opts.spreadsheet_id)
695        .await
696    {
697        Ok(workbook) => workbook,
698        Err(err) => {
699            return gated(ConditionalFormatResult::Failed {
700                detail: format!("{err:#}"),
701            })
702        }
703    };
704
705    let sheet_id =
706        match grid_range::find_sheet_id(&workbook, opts.verb.sheet(), |title, available| {
707            ConditionalFormatResult::RefusedSheetNotFound { title, available }
708        }) {
709            Ok(id) => id,
710            Err(result) => return gated(result),
711        };
712
713    let mut grids = Vec::new();
714    for composed in &composed_ranges {
715        match grid_range::resolve_grid_range(
716            &workbook,
717            composed,
718            |detail| ConditionalFormatResult::RefusedInvalidRange { detail },
719            |title, available| ConditionalFormatResult::RefusedSheetNotFound { title, available },
720        ) {
721            Ok((_, grid)) => grids.push(grid),
722            Err(result) => return gated(result),
723        }
724    }
725
726    let sheet = find_sheet_by_id(&workbook, sheet_id);
727    let existing_count = sheet.map_or(0, |s| s.conditional_formats.len());
728
729    let (resolved_index, existing_summary) = match &opts.verb {
730        ConditionalFormatVerb::AddConditionalFormat { index, .. } => match index {
731            Some(explicit) => {
732                // Valid positions are `0..=existing_count` inclusive — unlike
733                // update/delete, `existing_count` itself is valid here (it
734                // appends). A negative or too-large explicit `--index`
735                // reaches the same friendly refusal update/delete give for
736                // an out-of-bounds index, instead of an opaque API error.
737                let in_bounds = usize::try_from(*explicit).is_ok_and(|i| i <= existing_count);
738                if !in_bounds {
739                    return gated(ConditionalFormatResult::RefusedIndexOutOfBounds {
740                        sheet: opts.verb.sheet().to_string(),
741                        index: *explicit,
742                        count: existing_count,
743                    });
744                }
745                (*explicit, None)
746            }
747            None => (i64::try_from(existing_count).unwrap_or(i64::MAX), None),
748        },
749        ConditionalFormatVerb::UpdateConditionalFormat { index, .. }
750        | ConditionalFormatVerb::DeleteConditionalFormat { index, .. } => {
751            let idx = *index;
752            match sheet.and_then(|s| s.conditional_formats.get(idx)) {
753                Some(existing) => (
754                    i64::try_from(idx).unwrap_or(i64::MAX),
755                    Some(describe_existing_rule(existing)),
756                ),
757                None => {
758                    return gated(ConditionalFormatResult::RefusedIndexOutOfBounds {
759                        sheet: opts.verb.sheet().to_string(),
760                        index: i64::try_from(idx).unwrap_or(i64::MAX),
761                        count: existing_count,
762                    })
763                }
764            }
765        }
766    };
767
768    // Built before the lease gate, not after — the same "gate must be the
769    // last fallible step before the mutating call" discipline
770    // `validation.rs::build_request`'s doc comment explains (#1688): unlike
771    // that one, converting a `FormatRule` to the wire shape IS fallible
772    // (hex-color parsing), so it must happen here, before
773    // `gate_optional_leased_write`, not after.
774    //
775    // The description and the wire request are built together, per verb, in
776    // one match rather than via a separate `Option<ConditionalFormatRule>`
777    // matched a second time below — that shape needed a `(verb, new_rule)`
778    // tuple match with an `unreachable!()` arm for the impossible
779    // (Add/Update, None) case, relying on a runtime invariant instead of the
780    // type system.
781    let (summary, request) = match &opts.verb {
782        ConditionalFormatVerb::AddConditionalFormat { rule, .. } => {
783            let built = match rule.clone().into_conditional_format_rule(grids) {
784                Ok(built) => built,
785                Err(detail) => {
786                    return gated(ConditionalFormatResult::RefusedInvalidRange { detail })
787                }
788            };
789            let summary = format!(
790                "add conditional format ({})",
791                describe_existing_rule(&built)
792            );
793            let request =
794                BatchUpdateRequestItem::AddConditionalFormatRule(AddConditionalFormatRuleRequest {
795                    rule: built,
796                    index: resolved_index,
797                });
798            (summary, request)
799        }
800        ConditionalFormatVerb::UpdateConditionalFormat { rule, .. } => {
801            let built = match rule.clone().into_conditional_format_rule(grids) {
802                Ok(built) => built,
803                Err(detail) => {
804                    return gated(ConditionalFormatResult::RefusedInvalidRange { detail })
805                }
806            };
807            let current = existing_summary.as_deref().unwrap_or("unknown");
808            let summary = format!(
809                "update conditional format rule at index {resolved_index} (currently: \
810                 {current}) to ({})",
811                describe_existing_rule(&built)
812            );
813            let request = BatchUpdateRequestItem::UpdateConditionalFormatRule(
814                UpdateConditionalFormatRuleRequest {
815                    rule: built,
816                    index: resolved_index,
817                },
818            );
819            (summary, request)
820        }
821        ConditionalFormatVerb::DeleteConditionalFormat { .. } => {
822            let current = existing_summary.as_deref().unwrap_or("unknown");
823            let summary = format!(
824                "delete conditional format rule at index {resolved_index} (currently: \
825                 {current})"
826            );
827            let request = BatchUpdateRequestItem::DeleteConditionalFormatRule(
828                DeleteConditionalFormatRuleRequest {
829                    sheet_id,
830                    index: resolved_index,
831                },
832            );
833            (summary, request)
834        }
835    };
836
837    if opts.dry_run {
838        return gated(ConditionalFormatResult::WouldChange { summary });
839    }
840
841    // The lease check sits here: after the permission gate and the
842    // `--dry-run` branch, before the mutating call — see
843    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
844    // shared verbatim by every leased engine.
845    let files_api = FilesApi::new(drive);
846    let leased = LeasedWrite {
847        log_prefix: "drive sheets conditional_format",
848        operation: opts.verb.log_operation(),
849        ledger_path: &opts.ledger_path,
850        file_id: &opts.spreadsheet_id,
851    };
852    let lease_grant = match gate_optional_leased_write(
853        leased,
854        &files_api,
855        requires_lease,
856        opts.lease_token.as_deref(),
857    )
858    .await
859    {
860        Ok(grant) => grant,
861        Err(err) => return gated(err.into_result()),
862    };
863
864    let result = match conclude_native_leased_write(
865        leased,
866        &lease_grant,
867        &files_api,
868        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
869        |err| format!("{err:#}"),
870    )
871    .await
872    {
873        Ok(BatchUpdateOutcome::Applied(_response)) => ConditionalFormatResult::Changed { summary },
874        // `RetryHint::NotIdempotent`: a second add duplicates the rule, and a
875        // second update/delete addresses rules by index (#2021).
876        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
877            ConditionalFormatResult::AppliedReplyUnreadable { summary, detail }
878        }
879        Err(err) => ConditionalFormatResult::Failed {
880            detail: format!("{err:#}"),
881        },
882    };
883    drop(lease_grant);
884    gated(result)
885}
886
887fn find_sheet_by_id(workbook: &Spreadsheet, sheet_id: i64) -> Option<&Sheet> {
888    workbook
889        .sheets
890        .iter()
891        .find(|s| s.sheet_id() == Some(sheet_id))
892}
893
894fn validate_format_rule(rule: &FormatRule) -> Result<(), String> {
895    match rule {
896        FormatRule::Boolean { condition, format } => {
897            validate_condition(condition)?;
898            validate_format_effect(format)
899        }
900        FormatRule::Gradient(spec) => validate_gradient(spec),
901    }
902}
903
904fn validate_condition(condition: &FormatCondition) -> Result<(), String> {
905    // Exhaustive over `FormatCondition`: a new variant forces a new arm
906    // here at compile time.
907    match condition {
908        FormatCondition::NumberBetween(min, max) => {
909            reject_reversed_range(*min, *max, "--number-between")
910        }
911        FormatCondition::NumberNotBetween(min, max) => {
912            reject_reversed_range(*min, *max, "--number-not-between")
913        }
914        FormatCondition::NumberGreater(n) => reject_nan(*n, "--number-greater"),
915        FormatCondition::NumberGreaterEq(n) => reject_nan(*n, "--number-greater-eq"),
916        FormatCondition::NumberLess(n) => reject_nan(*n, "--number-less"),
917        FormatCondition::NumberLessEq(n) => reject_nan(*n, "--number-less-eq"),
918        FormatCondition::NumberEq(n) => reject_nan(*n, "--number-eq"),
919        FormatCondition::NumberNotEq(n) => reject_nan(*n, "--number-not-eq"),
920        FormatCondition::TextContains(text) => reject_empty(text, "--text-contains"),
921        FormatCondition::TextNotContains(text) => reject_empty(text, "--text-not-contains"),
922        FormatCondition::TextStartsWith(text) => reject_empty(text, "--text-starts-with"),
923        FormatCondition::TextEndsWith(text) => reject_empty(text, "--text-ends-with"),
924        FormatCondition::TextEq(text) => reject_empty(text, "--text-eq"),
925        FormatCondition::DateAfter(date) => reject_blank_date(date, "--date-after"),
926        FormatCondition::DateBefore(date) => reject_blank_date(date, "--date-before"),
927        FormatCondition::DateOn(date) => reject_blank_date(date, "--date-on"),
928        FormatCondition::DateBetween(start, end) => reject_invalid_date_between(start, end),
929        FormatCondition::CellEmpty | FormatCondition::CellNotEmpty => Ok(()),
930        FormatCondition::CustomFormula(formula) => reject_blank(formula, "--custom-formula"),
931    }
932}
933
934fn validate_format_effect(effect: &FormatEffect) -> Result<(), String> {
935    if let Some(hex) = &effect.background {
936        parse_hex_color(hex).map_err(|e| format!("--background: {e}"))?;
937    }
938    if let Some(hex) = &effect.text_color {
939        parse_hex_color(hex).map_err(|e| format!("--text-color: {e}"))?;
940    }
941    if effect.background.is_none() && effect.text_color.is_none() && effect.bold.is_none() {
942        return Err(
943            "a boolean conditional format rule needs at least one of --background/\
944             --text-color/--bold"
945                .to_string(),
946        );
947    }
948    Ok(())
949}
950
951fn validate_gradient(spec: &GradientSpec) -> Result<(), String> {
952    parse_hex_color(&spec.min_color).map_err(|e| format!("--gradient-min-color: {e}"))?;
953    parse_hex_color(&spec.max_color).map_err(|e| format!("--gradient-max-color: {e}"))?;
954    if let Some(mid) = &spec.mid {
955        parse_hex_color(&mid.color).map_err(|e| format!("--gradient-mid-color: {e}"))?;
956        if mid.value.trim().is_empty() {
957            return Err("--gradient-mid-value must not be empty".to_string());
958        }
959    }
960    Ok(())
961}
962
963/// Describes a [`ConditionalFormatRule`] in its wire shape — the "currently"
964/// side of `update`/`delete`'s dry-run echo, the "to"/new-rule side of
965/// `add`/`update`'s own summary (built from the same wire shape right after
966/// [`FormatRule::into_conditional_format_rule`] converts it), and reused
967/// verbatim by `list-conditional-formats`' CLI rendering (issue #1793) — one
968/// function for every surface that describes a rule, so none of them can
969/// describe the same rule differently.
970pub(crate) fn describe_existing_rule(rule: &ConditionalFormatRule) -> String {
971    if let Some(boolean_rule) = &rule.boolean_rule {
972        let cond = boolean_rule
973            .condition
974            .condition_type
975            .to_ascii_lowercase()
976            .replace('_', " ");
977        let mut parts = Vec::new();
978        if boolean_rule.format.background_color_style.is_some() {
979            parts.push("background");
980        }
981        if let Some(text_format) = &boolean_rule.format.text_format {
982            if text_format.foreground_color_style.is_some() {
983                parts.push("text color");
984            }
985            if text_format.bold == Some(true) {
986                parts.push("bold");
987            }
988        }
989        let effect = if parts.is_empty() {
990            "format".to_string()
991        } else {
992            parts.join("+")
993        };
994        format!("boolean rule ({cond} -> {effect})")
995    } else if let Some(gradient_rule) = &rule.gradient_rule {
996        // The midpoint always has an anchor worth naming; an endpoint only
997        // when it isn't this crate's own `MIN`/`MAX` (a Sheets UI rule can
998        // anchor it at a value).
999        let mut anchors = String::new();
1000        for (name, point, builtin) in [
1001            ("min", &gradient_rule.minpoint, Some("MIN")),
1002            ("mid", &gradient_rule.midpoint, None),
1003            ("max", &gradient_rule.maxpoint, Some("MAX")),
1004        ] {
1005            let Some(point) = point else { continue };
1006            if builtin.is_some_and(|t| point.point_type == t && point.value.is_none()) {
1007                continue;
1008            }
1009            let point_type = point.point_type.to_ascii_lowercase();
1010            anchors.push_str(&match &point.value {
1011                Some(value) => format!(", {name} {point_type} at {value}"),
1012                None => format!(", {name} {point_type}"),
1013            });
1014        }
1015        format!("gradient rule (min/max colors{anchors})")
1016    } else {
1017        "rule (unrecognized type)".to_string()
1018    }
1019}
1020
1021fn record_attempt(
1022    outcome: &ConditionalFormatOutcome,
1023    opts: &ConditionalFormatOptions,
1024    duration: Duration,
1025) {
1026    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1027}
1028
1029/// The request-log record for `outcome`, split from [`record_attempt`] so a
1030/// test can read the fields it carries.
1031fn mutation_record(
1032    outcome: &ConditionalFormatOutcome,
1033    opts: &ConditionalFormatOptions,
1034    duration: Duration,
1035) -> DriveMutationOutcome {
1036    let error = match &outcome.result {
1037        ConditionalFormatResult::Failed { detail }
1038        | ConditionalFormatResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
1039        _ => None,
1040    };
1041    let decided_by = match &outcome.result {
1042        ConditionalFormatResult::Blocked { decided_by } => decided_by.as_ref(),
1043        _ => None,
1044    };
1045    let decided_by = write_gate::decided_by_log_fields(decided_by);
1046    // The write happened, so name what it was even though the reply is gone.
1047    let fields_changed = match &outcome.result {
1048        ConditionalFormatResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1049        _ => None,
1050    };
1051
1052    DriveMutationOutcome {
1053        operation: opts.verb.log_operation(),
1054        file_id: outcome.spreadsheet_id.clone(),
1055        file_name: outcome.file_name.clone().unwrap_or_default(),
1056        status: outcome.result.log_status().to_string(),
1057        resolved_folder_id: outcome.resolved_folder_id.clone(),
1058        decided_by_folder_id: decided_by.folder_id,
1059        decided_by_depth: decided_by.depth,
1060        decided_by_file_id: decided_by.file_id,
1061        fields_changed,
1062        error,
1063        duration,
1064        ..Default::default()
1065    }
1066}
1067
1068/// Renders an outcome as human-readable text.
1069#[must_use]
1070pub fn describe(outcome: &ConditionalFormatOutcome) -> String {
1071    describe_lines(outcome).join("\n")
1072}
1073
1074/// Renders an outcome as its individual lines, none of which contains a
1075/// newline.
1076#[must_use]
1077pub fn describe_lines(outcome: &ConditionalFormatOutcome) -> Vec<String> {
1078    let verb = &outcome.verb;
1079    let book = outcome.file_name.as_deref().map_or_else(
1080        || format!("'{}'", outcome.spreadsheet_id),
1081        |n| format!("'{n}'"),
1082    );
1083    match &outcome.result {
1084        ConditionalFormatResult::WouldChange { summary } => {
1085            vec![format!("Would {summary} in {book}")]
1086        }
1087        ConditionalFormatResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1088            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1089             `drive sheets {}` only works on spreadsheets",
1090            verb.label()
1091        )],
1092        ConditionalFormatResult::RefusedShortcut => vec![format!(
1093            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1094            verb.label()
1095        )],
1096        ConditionalFormatResult::RefusedNoVisibleParents => vec![format!(
1097            "Refused: {book} has no parent folder visible to this account, so no folder \
1098             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1099             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1100        )],
1101        ConditionalFormatResult::RefusedSheetNotFound { title, available } => {
1102            vec![grid_range::no_sheet_refusal(&book, title, available)]
1103        }
1104        ConditionalFormatResult::RefusedInvalidRange { detail } => {
1105            vec![format!("Refused: {detail}")]
1106        }
1107        ConditionalFormatResult::RefusedIndexOutOfBounds {
1108            sheet,
1109            index,
1110            count,
1111        } => {
1112            // `add-conditional-format` may append at `count` (valid range
1113            // `0..=count`); `update`/`delete` may only replace/remove an
1114            // existing rule (valid range `0..count`).
1115            let max_valid = match verb {
1116                ConditionalFormatVerb::AddConditionalFormat { .. } => *count,
1117                ConditionalFormatVerb::UpdateConditionalFormat { .. }
1118                | ConditionalFormatVerb::DeleteConditionalFormat { .. } => count.saturating_sub(1),
1119            };
1120            vec![format!(
1121                "Refused: sheet '{sheet}' has {count} conditional format rule(s) (valid \
1122                 indices 0-{max_valid}); index {index} is out of bounds. Run `drive sheets \
1123                 list-conditional-formats` to see the current indices."
1124            )]
1125        }
1126        ConditionalFormatResult::Blocked { decided_by } => vec![match decided_by {
1127            Some(rule) => format!(
1128                "Blocked: {} on {book} refused by rule on {} {}{}",
1129                verb.label(),
1130                rule.kind_label(),
1131                rule.id(),
1132                rule.depth_suffix()
1133            ),
1134            None => format!(
1135                "Blocked: {} on {book} refused by default policy (no matching rule for \
1136                 sheets-structure)",
1137                verb.label()
1138            ),
1139        }],
1140        ConditionalFormatResult::RefusedNoLease => LeaseGateRefusal::NoLease
1141            .describe_line(&outcome.spreadsheet_id, &book)
1142            .into_iter()
1143            .collect(),
1144        ConditionalFormatResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1145            .describe_line(&outcome.spreadsheet_id, &book)
1146            .into_iter()
1147            .collect(),
1148        ConditionalFormatResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1149            .describe_line(&outcome.spreadsheet_id, &book)
1150            .into_iter()
1151            .collect(),
1152        ConditionalFormatResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1153            .describe_line(&outcome.spreadsheet_id, &book)
1154            .into_iter()
1155            .collect(),
1156        ConditionalFormatResult::Changed { summary } => {
1157            vec![format!("Applied: {summary} in {book}")]
1158        }
1159        ConditionalFormatResult::AppliedReplyUnreadable { summary, detail } => {
1160            vec![applied_reply_unreadable_line(
1161                summary,
1162                &book,
1163                RetryHint::NotIdempotent,
1164                detail,
1165            )]
1166        }
1167        ConditionalFormatResult::Failed { detail } => vec![format!("Failed: {detail}")],
1168    }
1169}
1170
1171#[cfg(test)]
1172#[allow(clippy::unwrap_used, clippy::expect_used)]
1173mod tests {
1174    use super::*;
1175    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1176    use crate::drive::sheets::client::SHEETS_API_URL;
1177    use crate::drive::test_support::seed_lease;
1178    use crate::test_support::env::MapEnv;
1179    use crate::utils::secret::Secret;
1180    use std::collections::HashSet;
1181
1182    // ── FormatCondition ──────────────────────────────────────────────
1183
1184    #[test]
1185    fn condition_type_str_covers_every_condition() {
1186        let types = [
1187            FormatCondition::NumberBetween(1.0, 2.0).condition_type_str(),
1188            FormatCondition::NumberNotBetween(1.0, 2.0).condition_type_str(),
1189            FormatCondition::NumberGreater(1.0).condition_type_str(),
1190            FormatCondition::NumberGreaterEq(1.0).condition_type_str(),
1191            FormatCondition::NumberLess(1.0).condition_type_str(),
1192            FormatCondition::NumberLessEq(1.0).condition_type_str(),
1193            FormatCondition::NumberEq(1.0).condition_type_str(),
1194            FormatCondition::NumberNotEq(1.0).condition_type_str(),
1195            FormatCondition::TextContains("x".to_string()).condition_type_str(),
1196            FormatCondition::TextNotContains("x".to_string()).condition_type_str(),
1197            FormatCondition::TextStartsWith("x".to_string()).condition_type_str(),
1198            FormatCondition::TextEndsWith("x".to_string()).condition_type_str(),
1199            FormatCondition::TextEq("x".to_string()).condition_type_str(),
1200            FormatCondition::DateAfter(DateValue::Absolute("d".to_string())).condition_type_str(),
1201            FormatCondition::DateBefore(DateValue::Absolute("d".to_string())).condition_type_str(),
1202            FormatCondition::DateOn(DateValue::Absolute("d".to_string())).condition_type_str(),
1203            FormatCondition::DateBetween("a".to_string(), "b".to_string()).condition_type_str(),
1204            FormatCondition::CellEmpty.condition_type_str(),
1205            FormatCondition::CellNotEmpty.condition_type_str(),
1206            FormatCondition::CustomFormula("=TRUE".to_string()).condition_type_str(),
1207        ];
1208        let unique: HashSet<&&str> = types.iter().collect();
1209        assert_eq!(unique.len(), types.len());
1210    }
1211
1212    #[test]
1213    fn cell_empty_and_cell_not_empty_have_no_values() {
1214        let built = FormatCondition::CellEmpty.into_boolean_condition();
1215        assert_eq!(built.condition_type, "BLANK");
1216        assert!(built.values.is_empty());
1217        let built = FormatCondition::CellNotEmpty.into_boolean_condition();
1218        assert_eq!(built.condition_type, "NOT_BLANK");
1219        assert!(built.values.is_empty());
1220    }
1221
1222    /// Serialises `rule` as the `addConditionalFormatRule` request the
1223    /// engine sends, so a test can pin the exact wire body.
1224    fn add_request_json(rule: FormatRule) -> serde_json::Value {
1225        let rule = rule
1226            .into_conditional_format_rule(vec![GridRange::default()])
1227            .unwrap();
1228        serde_json::to_value(BatchUpdateRequestItem::AddConditionalFormatRule(
1229            AddConditionalFormatRuleRequest { rule, index: 0 },
1230        ))
1231        .unwrap()
1232    }
1233
1234    #[test]
1235    fn cell_empty_and_cell_not_empty_send_the_sheets_blank_condition_types() {
1236        // Issue #1943: `CELL_EMPTY`/`CELL_NOT_EMPTY` aren't `ConditionType`
1237        // values, and Sheets rejected them with a 400.
1238        for (condition, expected) in [
1239            (FormatCondition::CellEmpty, "BLANK"),
1240            (FormatCondition::CellNotEmpty, "NOT_BLANK"),
1241        ] {
1242            let body = add_request_json(FormatRule::Boolean {
1243                condition,
1244                format: FormatEffect {
1245                    background: Some("#CCCCCC".to_string()),
1246                    ..FormatEffect::default()
1247                },
1248            });
1249            assert_eq!(
1250                body["addConditionalFormatRule"]["rule"]["booleanRule"]["condition"],
1251                serde_json::json!({"type": expected}),
1252            );
1253        }
1254    }
1255
1256    #[test]
1257    fn a_two_point_gradient_serialises_as_min_and_max_interpolation_points() {
1258        // Issue #1944: the body used to carry invented
1259        // `minColorStyle`/`maxColorStyle` fields, which Sheets rejected.
1260        let body = add_request_json(FormatRule::Gradient(GradientSpec {
1261            min_color: "#FFFFFF".to_string(),
1262            max_color: "#00FF00".to_string(),
1263            mid: None,
1264        }));
1265        assert_eq!(
1266            body["addConditionalFormatRule"]["rule"]["gradientRule"],
1267            serde_json::json!({
1268                "minpoint": {
1269                    "colorStyle": {"rgbColor": {"red": 1.0, "green": 1.0, "blue": 1.0}},
1270                    "type": "MIN",
1271                },
1272                "maxpoint": {
1273                    "colorStyle": {"rgbColor": {"red": 0.0, "green": 1.0, "blue": 0.0}},
1274                    "type": "MAX",
1275                },
1276            }),
1277        );
1278    }
1279
1280    #[test]
1281    fn a_three_point_gradient_serialises_its_midpoint_with_a_value() {
1282        let body = add_request_json(FormatRule::Gradient(GradientSpec {
1283            min_color: "#FFFFFF".to_string(),
1284            max_color: "#00FF00".to_string(),
1285            mid: Some(GradientMidpoint {
1286                color: "#FF0000".to_string(),
1287                point_type: GradientPointType::Percent,
1288                value: "50".to_string(),
1289            }),
1290        }));
1291        let gradient = &body["addConditionalFormatRule"]["rule"]["gradientRule"];
1292        assert_eq!(
1293            gradient["midpoint"],
1294            serde_json::json!({
1295                "colorStyle": {"rgbColor": {"red": 1.0, "green": 0.0, "blue": 0.0}},
1296                "type": "PERCENT",
1297                "value": "50",
1298            }),
1299        );
1300        assert_eq!(gradient["minpoint"]["type"], "MIN");
1301        assert_eq!(gradient["maxpoint"]["type"], "MAX");
1302    }
1303
1304    #[test]
1305    fn number_greater_builds_a_single_value() {
1306        let built = FormatCondition::NumberGreater(10.0).into_boolean_condition();
1307        assert_eq!(built.condition_type, "NUMBER_GREATER");
1308        assert_eq!(built.values[0].user_entered_value, Some("10".to_string()));
1309    }
1310
1311    #[test]
1312    fn date_condition_builds_a_relative_value() {
1313        let condition = FormatCondition::DateAfter(DateValue::parse("today".to_string()));
1314        let built = condition.into_boolean_condition();
1315        assert_eq!(built.condition_type, "DATE_AFTER");
1316        assert_eq!(built.values[0].user_entered_value, None);
1317        assert_eq!(built.values[0].relative_date, Some("TODAY".to_string()));
1318    }
1319
1320    #[test]
1321    fn into_boolean_condition_covers_every_remaining_variant() {
1322        // NumberGreater, DateAfter, CellEmpty and CellNotEmpty are already
1323        // covered by the tests above; this exercises the rest of the
1324        // `values` match in `into_boolean_condition`.
1325        let cases: Vec<(FormatCondition, &str, usize)> = vec![
1326            (
1327                FormatCondition::NumberBetween(1.0, 2.0),
1328                "NUMBER_BETWEEN",
1329                2,
1330            ),
1331            (
1332                FormatCondition::NumberNotBetween(1.0, 2.0),
1333                "NUMBER_NOT_BETWEEN",
1334                2,
1335            ),
1336            (
1337                FormatCondition::NumberGreaterEq(1.0),
1338                "NUMBER_GREATER_THAN_EQ",
1339                1,
1340            ),
1341            (FormatCondition::NumberLess(1.0), "NUMBER_LESS", 1),
1342            (FormatCondition::NumberLessEq(1.0), "NUMBER_LESS_THAN_EQ", 1),
1343            (FormatCondition::NumberEq(1.0), "NUMBER_EQ", 1),
1344            (FormatCondition::NumberNotEq(1.0), "NUMBER_NOT_EQ", 1),
1345            (
1346                FormatCondition::TextContains("x".to_string()),
1347                "TEXT_CONTAINS",
1348                1,
1349            ),
1350            (
1351                FormatCondition::TextNotContains("x".to_string()),
1352                "TEXT_NOT_CONTAINS",
1353                1,
1354            ),
1355            (
1356                FormatCondition::TextStartsWith("x".to_string()),
1357                "TEXT_STARTS_WITH",
1358                1,
1359            ),
1360            (
1361                FormatCondition::TextEndsWith("x".to_string()),
1362                "TEXT_ENDS_WITH",
1363                1,
1364            ),
1365            (FormatCondition::TextEq("x".to_string()), "TEXT_EQ", 1),
1366            (
1367                FormatCondition::DateBefore(DateValue::Absolute("2024-01-01".to_string())),
1368                "DATE_BEFORE",
1369                1,
1370            ),
1371            (
1372                FormatCondition::DateOn(DateValue::Absolute("2024-01-01".to_string())),
1373                "DATE_EQ",
1374                1,
1375            ),
1376            (
1377                FormatCondition::DateBetween("2024-01-01".to_string(), "2024-01-02".to_string()),
1378                "DATE_BETWEEN",
1379                2,
1380            ),
1381            (
1382                FormatCondition::CustomFormula("=A1>0".to_string()),
1383                "CUSTOM_FORMULA",
1384                1,
1385            ),
1386        ];
1387        for (condition, expected_type, expected_len) in cases {
1388            let built = condition.into_boolean_condition();
1389            assert_eq!(built.condition_type, expected_type, "{expected_type}");
1390            assert_eq!(built.values.len(), expected_len, "{expected_type}");
1391        }
1392    }
1393
1394    // ── GradientSpec ──────────────────────────────────────────────────
1395
1396    #[test]
1397    fn into_gradient_rule_builds_each_midpoint_type() {
1398        for (point_type, expected) in [
1399            (GradientPointType::Number, "NUMBER"),
1400            (GradientPointType::Percent, "PERCENT"),
1401            (GradientPointType::Percentile, "PERCENTILE"),
1402        ] {
1403            let spec = GradientSpec {
1404                min_color: "#FFFFFF".to_string(),
1405                max_color: "#000000".to_string(),
1406                mid: Some(GradientMidpoint {
1407                    color: "#FF00FF".to_string(),
1408                    point_type,
1409                    value: "50".to_string(),
1410                }),
1411            };
1412            let built = spec.into_gradient_rule().unwrap();
1413            let midpoint = built.midpoint.expect("midpoint should be set");
1414            assert_eq!(midpoint.point_type, expected, "{expected}");
1415            assert_eq!(midpoint.value.as_deref(), Some("50"));
1416            assert_eq!(
1417                midpoint.color_style.rgb_color,
1418                Some(parse_hex_color("#FF00FF").unwrap())
1419            );
1420            let min = built.minpoint.expect("minpoint should be set");
1421            assert_eq!(min.point_type, "MIN");
1422            assert_eq!(min.value, None);
1423            assert_eq!(
1424                min.color_style.rgb_color,
1425                Some(parse_hex_color("#FFFFFF").unwrap())
1426            );
1427            let max = built.maxpoint.expect("maxpoint should be set");
1428            assert_eq!(max.point_type, "MAX");
1429            assert_eq!(max.value, None);
1430            assert_eq!(
1431                max.color_style.rgb_color,
1432                Some(parse_hex_color("#000000").unwrap())
1433            );
1434        }
1435    }
1436
1437    #[test]
1438    fn into_gradient_rule_propagates_a_malformed_midpoint_color() {
1439        // `validate_gradient` catches this before `into_gradient_rule` is
1440        // ever reached in the real flow, so this exercises the `?`
1441        // error-propagation arm directly, the same way
1442        // `into_cell_format_propagates_a_malformed_color` below does for
1443        // `FormatEffect`.
1444        let spec = GradientSpec {
1445            min_color: "#FFFFFF".to_string(),
1446            max_color: "#000000".to_string(),
1447            mid: Some(GradientMidpoint {
1448                color: "not-a-color".to_string(),
1449                point_type: GradientPointType::Number,
1450                value: "50".to_string(),
1451            }),
1452        };
1453        let err = spec.into_gradient_rule().unwrap_err();
1454        assert!(err.contains("--gradient-mid-color"), "{err}");
1455    }
1456
1457    #[test]
1458    fn into_cell_format_propagates_a_malformed_color() {
1459        // Like the gradient case above: `validate_format_effect` catches
1460        // this in the real flow, so this exercises `into_cell_format`'s two
1461        // `?` error-propagation arms (background, then text color)
1462        // directly.
1463        let err = FormatEffect {
1464            background: Some("not-a-color".to_string()),
1465            text_color: None,
1466            bold: None,
1467        }
1468        .into_cell_format()
1469        .unwrap_err();
1470        assert!(err.contains("--background"), "{err}");
1471
1472        let err = FormatEffect {
1473            background: None,
1474            text_color: Some("not-a-color".to_string()),
1475            bold: None,
1476        }
1477        .into_cell_format()
1478        .unwrap_err();
1479        assert!(err.contains("--text-color"), "{err}");
1480    }
1481
1482    #[test]
1483    fn into_gradient_rule_propagates_a_malformed_min_or_max_color() {
1484        let err = GradientSpec {
1485            min_color: "not-a-color".to_string(),
1486            max_color: "#000000".to_string(),
1487            mid: None,
1488        }
1489        .into_gradient_rule()
1490        .unwrap_err();
1491        assert!(err.contains("--gradient-min-color"), "{err}");
1492
1493        let err = GradientSpec {
1494            min_color: "#FFFFFF".to_string(),
1495            max_color: "not-a-color".to_string(),
1496            mid: None,
1497        }
1498        .into_gradient_rule()
1499        .unwrap_err();
1500        assert!(err.contains("--gradient-max-color"), "{err}");
1501    }
1502
1503    #[test]
1504    fn into_gradient_rule_with_no_midpoint_leaves_it_none() {
1505        // `mid: None` is the shape `mount_workbook`'s fixture and every
1506        // add/update test use, but none of them go through
1507        // `into_gradient_rule` itself (they all build a boolean rule) —
1508        // this hits the `None => None` arm directly.
1509        let built = GradientSpec {
1510            min_color: "#FFFFFF".to_string(),
1511            max_color: "#000000".to_string(),
1512            mid: None,
1513        }
1514        .into_gradient_rule()
1515        .unwrap();
1516        assert!(built.midpoint.is_none());
1517    }
1518
1519    #[test]
1520    fn into_conditional_format_rule_covers_both_variants_and_their_errors() {
1521        // No test anywhere in this file builds a `FormatRule::Gradient`
1522        // through the verb/engine flow (`add_verb` and friends only use
1523        // `FormatRule::Boolean`), so `into_conditional_format_rule`'s
1524        // `Gradient` arm — and its own `?` forwarding for both arms — is
1525        // otherwise never reached.
1526        let rule = FormatRule::Boolean {
1527            condition: FormatCondition::CellEmpty,
1528            format: FormatEffect {
1529                background: Some("#FF0000".to_string()),
1530                text_color: None,
1531                bold: None,
1532            },
1533        }
1534        .into_conditional_format_rule(vec![GridRange::default()])
1535        .unwrap();
1536        assert!(rule.boolean_rule.is_some());
1537        assert!(rule.gradient_rule.is_none());
1538
1539        let err = FormatRule::Boolean {
1540            condition: FormatCondition::CellEmpty,
1541            format: FormatEffect {
1542                background: Some("not-a-color".to_string()),
1543                text_color: None,
1544                bold: None,
1545            },
1546        }
1547        .into_conditional_format_rule(vec![GridRange::default()])
1548        .unwrap_err();
1549        assert!(err.contains("--background"), "{err}");
1550
1551        let rule = FormatRule::Gradient(GradientSpec {
1552            min_color: "#FFFFFF".to_string(),
1553            max_color: "#000000".to_string(),
1554            mid: None,
1555        })
1556        .into_conditional_format_rule(vec![GridRange::default()])
1557        .unwrap();
1558        assert!(rule.gradient_rule.is_some());
1559        assert!(rule.boolean_rule.is_none());
1560
1561        let err = FormatRule::Gradient(GradientSpec {
1562            min_color: "not-a-color".to_string(),
1563            max_color: "#000000".to_string(),
1564            mid: None,
1565        })
1566        .into_conditional_format_rule(vec![GridRange::default()])
1567        .unwrap_err();
1568        assert!(err.contains("--gradient-min-color"), "{err}");
1569    }
1570
1571    // ── validate_format_rule ─────────────────────────────────────────
1572
1573    #[test]
1574    fn validate_format_rule_short_circuits_on_a_condition_error() {
1575        // The one existing wiremock test that reaches `validate_format_rule`
1576        // (`add_rejects_a_malformed_background_color_before_the_lease_gate`)
1577        // always uses `FormatCondition::CellEmpty`, which never fails, so
1578        // the condition-side `?`'s error arm is otherwise never taken.
1579        let err = validate_format_rule(&FormatRule::Boolean {
1580            condition: FormatCondition::NumberBetween(10.0, 1.0),
1581            format: FormatEffect {
1582                background: Some("#FF0000".to_string()),
1583                text_color: None,
1584                bold: None,
1585            },
1586        })
1587        .unwrap_err();
1588        assert!(err.contains("must not exceed"), "{err}");
1589
1590        validate_format_rule(&FormatRule::Boolean {
1591            condition: FormatCondition::CellEmpty,
1592            format: FormatEffect {
1593                background: Some("#FF0000".to_string()),
1594                text_color: None,
1595                bold: None,
1596            },
1597        })
1598        .unwrap();
1599    }
1600
1601    #[test]
1602    fn validate_format_rule_dispatches_gradient_specs_to_validate_gradient() {
1603        // No test anywhere in this file calls `validate_format_rule` with a
1604        // `FormatRule::Gradient` — every wiremock verb builder uses
1605        // `FormatRule::Boolean` — so this arm is otherwise dead.
1606        validate_format_rule(&FormatRule::Gradient(GradientSpec {
1607            min_color: "#FFFFFF".to_string(),
1608            max_color: "#000000".to_string(),
1609            mid: None,
1610        }))
1611        .unwrap();
1612
1613        let err = validate_format_rule(&FormatRule::Gradient(GradientSpec {
1614            min_color: "not-a-color".to_string(),
1615            max_color: "#000000".to_string(),
1616            mid: None,
1617        }))
1618        .unwrap_err();
1619        assert!(err.contains("--gradient-min-color"), "{err}");
1620    }
1621
1622    // ── validate_condition / validate_format_effect / validate_gradient ─
1623
1624    #[test]
1625    fn validate_condition_rejects_a_reversed_number_between() {
1626        let err = validate_condition(&FormatCondition::NumberBetween(10.0, 1.0)).unwrap_err();
1627        assert!(err.contains("must not exceed"), "{err}");
1628    }
1629
1630    #[test]
1631    fn validate_condition_accepts_cell_empty() {
1632        validate_condition(&FormatCondition::CellEmpty).unwrap();
1633        validate_condition(&FormatCondition::CellNotEmpty).unwrap();
1634    }
1635
1636    #[test]
1637    fn validate_condition_rejects_a_reversed_number_not_between() {
1638        let err = validate_condition(&FormatCondition::NumberNotBetween(10.0, 1.0)).unwrap_err();
1639        assert!(err.contains("--number-not-between"), "{err}");
1640        assert!(err.contains("must not exceed"), "{err}");
1641    }
1642
1643    #[test]
1644    fn validate_condition_rejects_nan_across_every_single_number_flag() {
1645        let cases = [
1646            (FormatCondition::NumberGreater(f64::NAN), "--number-greater"),
1647            (
1648                FormatCondition::NumberGreaterEq(f64::NAN),
1649                "--number-greater-eq",
1650            ),
1651            (FormatCondition::NumberLess(f64::NAN), "--number-less"),
1652            (FormatCondition::NumberLessEq(f64::NAN), "--number-less-eq"),
1653            (FormatCondition::NumberEq(f64::NAN), "--number-eq"),
1654            (FormatCondition::NumberNotEq(f64::NAN), "--number-not-eq"),
1655        ];
1656        for (condition, flag) in cases {
1657            let err = validate_condition(&condition).unwrap_err();
1658            assert!(err.contains(flag), "{err}");
1659            assert!(err.contains("NaN"), "{err}");
1660        }
1661        // A non-NaN value on each of those flags is accepted.
1662        validate_condition(&FormatCondition::NumberGreater(1.0)).unwrap();
1663        validate_condition(&FormatCondition::NumberGreaterEq(1.0)).unwrap();
1664        validate_condition(&FormatCondition::NumberLess(1.0)).unwrap();
1665        validate_condition(&FormatCondition::NumberLessEq(1.0)).unwrap();
1666        validate_condition(&FormatCondition::NumberEq(1.0)).unwrap();
1667        validate_condition(&FormatCondition::NumberNotEq(1.0)).unwrap();
1668    }
1669
1670    #[test]
1671    fn validate_condition_rejects_an_empty_string_across_every_text_flag() {
1672        let cases = [
1673            (
1674                FormatCondition::TextContains(String::new()),
1675                "--text-contains",
1676            ),
1677            (
1678                FormatCondition::TextNotContains(String::new()),
1679                "--text-not-contains",
1680            ),
1681            (
1682                FormatCondition::TextStartsWith(String::new()),
1683                "--text-starts-with",
1684            ),
1685            (
1686                FormatCondition::TextEndsWith(String::new()),
1687                "--text-ends-with",
1688            ),
1689            (FormatCondition::TextEq(String::new()), "--text-eq"),
1690        ];
1691        for (condition, flag) in cases {
1692            let err = validate_condition(&condition).unwrap_err();
1693            assert!(err.contains(flag), "{err}");
1694        }
1695        // A non-empty value on each of those flags is accepted.
1696        validate_condition(&FormatCondition::TextContains("x".to_string())).unwrap();
1697        validate_condition(&FormatCondition::TextNotContains("x".to_string())).unwrap();
1698        validate_condition(&FormatCondition::TextStartsWith("x".to_string())).unwrap();
1699        validate_condition(&FormatCondition::TextEndsWith("x".to_string())).unwrap();
1700        validate_condition(&FormatCondition::TextEq("x".to_string())).unwrap();
1701    }
1702
1703    #[test]
1704    fn validate_condition_rejects_a_blank_date_across_every_single_date_flag() {
1705        let blank = DateValue::Absolute(String::new());
1706        let cases = [
1707            (FormatCondition::DateAfter(blank.clone()), "--date-after"),
1708            (FormatCondition::DateBefore(blank.clone()), "--date-before"),
1709            (FormatCondition::DateOn(blank), "--date-on"),
1710        ];
1711        for (condition, flag) in cases {
1712            let err = validate_condition(&condition).unwrap_err();
1713            assert!(err.contains(flag), "{err}");
1714        }
1715        // A non-blank date is accepted.
1716        let today = DateValue::parse("today".to_string());
1717        validate_condition(&FormatCondition::DateAfter(today.clone())).unwrap();
1718        validate_condition(&FormatCondition::DateBefore(today.clone())).unwrap();
1719        validate_condition(&FormatCondition::DateOn(today)).unwrap();
1720    }
1721
1722    #[test]
1723    fn validate_condition_rejects_an_invalid_or_reversed_date_between() {
1724        let err = validate_condition(&FormatCondition::DateBetween(
1725            "2024-01-01".to_string(),
1726            String::new(),
1727        ))
1728        .unwrap_err();
1729        assert!(err.contains("--date-between"), "{err}");
1730
1731        let err = validate_condition(&FormatCondition::DateBetween(
1732            "2024-06-01".to_string(),
1733            "2024-01-01".to_string(),
1734        ))
1735        .unwrap_err();
1736        assert!(err.contains("--date-between"), "{err}");
1737
1738        validate_condition(&FormatCondition::DateBetween(
1739            "2024-01-01".to_string(),
1740            "2024-06-01".to_string(),
1741        ))
1742        .unwrap();
1743    }
1744
1745    #[test]
1746    fn validate_condition_rejects_a_blank_custom_formula() {
1747        let err =
1748            validate_condition(&FormatCondition::CustomFormula("   ".to_string())).unwrap_err();
1749        assert!(err.contains("--custom-formula"), "{err}");
1750
1751        validate_condition(&FormatCondition::CustomFormula("=A1>0".to_string())).unwrap();
1752    }
1753
1754    #[test]
1755    fn validate_format_effect_rejects_no_fields_set() {
1756        let err = validate_format_effect(&FormatEffect::default()).unwrap_err();
1757        assert!(err.contains("at least one of"), "{err}");
1758    }
1759
1760    #[test]
1761    fn validate_format_effect_accepts_background_only() {
1762        validate_format_effect(&FormatEffect {
1763            background: Some("#FF0000".to_string()),
1764            text_color: None,
1765            bold: None,
1766        })
1767        .unwrap();
1768    }
1769
1770    #[test]
1771    fn validate_format_effect_rejects_a_malformed_color() {
1772        let err = validate_format_effect(&FormatEffect {
1773            background: Some("not-a-color".to_string()),
1774            text_color: None,
1775            bold: None,
1776        })
1777        .unwrap_err();
1778        assert!(err.contains("--background"), "{err}");
1779    }
1780
1781    #[test]
1782    fn validate_gradient_accepts_min_max_only() {
1783        validate_gradient(&GradientSpec {
1784            min_color: "#FFFFFF".to_string(),
1785            max_color: "#00FF00".to_string(),
1786            mid: None,
1787        })
1788        .unwrap();
1789    }
1790
1791    #[test]
1792    fn validate_gradient_rejects_a_malformed_midpoint_color() {
1793        let err = validate_gradient(&GradientSpec {
1794            min_color: "#FFFFFF".to_string(),
1795            max_color: "#00FF00".to_string(),
1796            mid: Some(GradientMidpoint {
1797                color: "bad".to_string(),
1798                point_type: GradientPointType::Percent,
1799                value: "50".to_string(),
1800            }),
1801        })
1802        .unwrap_err();
1803        assert!(err.contains("--gradient-mid-color"), "{err}");
1804    }
1805
1806    #[test]
1807    fn validate_gradient_rejects_a_blank_midpoint_value() {
1808        let err = validate_gradient(&GradientSpec {
1809            min_color: "#FFFFFF".to_string(),
1810            max_color: "#00FF00".to_string(),
1811            mid: Some(GradientMidpoint {
1812                color: "#000000".to_string(),
1813                point_type: GradientPointType::Number,
1814                value: "  ".to_string(),
1815            }),
1816        })
1817        .unwrap_err();
1818        assert!(err.contains("--gradient-mid-value"), "{err}");
1819    }
1820
1821    // ── describe ──────────────────────────────────────────────────────
1822
1823    #[test]
1824    fn describe_existing_rule_covers_boolean_and_gradient() {
1825        let boolean = ConditionalFormatRule {
1826            ranges: vec![GridRange::default()],
1827            boolean_rule: Some(BooleanRule {
1828                condition: BooleanCondition {
1829                    condition_type: "NUMBER_GREATER".to_string(),
1830                    values: Vec::new(),
1831                },
1832                format: CellFormat::default(),
1833            }),
1834            gradient_rule: None,
1835        };
1836        assert!(describe_existing_rule(&boolean).contains("number greater"));
1837
1838        let gradient = ConditionalFormatRule {
1839            ranges: vec![GridRange::default()],
1840            boolean_rule: None,
1841            gradient_rule: Some(GradientRule::default()),
1842        };
1843        assert!(describe_existing_rule(&gradient).contains("gradient rule"));
1844    }
1845
1846    #[test]
1847    fn describe_existing_rule_joins_multiple_boolean_format_parts() {
1848        let rule = ConditionalFormatRule {
1849            ranges: vec![GridRange::default()],
1850            boolean_rule: Some(BooleanRule {
1851                condition: BooleanCondition {
1852                    condition_type: "BLANK".to_string(),
1853                    values: Vec::new(),
1854                },
1855                format: CellFormat {
1856                    background_color_style: Some(ColorStyle::rgb(
1857                        parse_hex_color("#FF0000").unwrap(),
1858                    )),
1859                    text_format: Some(TextFormat {
1860                        foreground_color_style: Some(ColorStyle::rgb(
1861                            parse_hex_color("#00FF00").unwrap(),
1862                        )),
1863                        bold: Some(true),
1864                        ..Default::default()
1865                    }),
1866                    ..Default::default()
1867                },
1868            }),
1869            gradient_rule: None,
1870        };
1871        let described = describe_existing_rule(&rule);
1872        assert!(
1873            described.contains("background+text color+bold"),
1874            "{described}"
1875        );
1876    }
1877
1878    #[test]
1879    fn describe_existing_rule_includes_the_gradient_midpoint() {
1880        let rule = ConditionalFormatRule {
1881            ranges: vec![GridRange::default()],
1882            boolean_rule: None,
1883            gradient_rule: Some(GradientRule {
1884                minpoint: Some(point("MIN", None)),
1885                midpoint: Some(point("PERCENT", Some("50"))),
1886                maxpoint: Some(point("MAX", None)),
1887            }),
1888        };
1889        assert_eq!(
1890            describe_existing_rule(&rule),
1891            "gradient rule (min/max colors, mid percent at 50)"
1892        );
1893    }
1894
1895    fn point(point_type: &str, value: Option<&str>) -> InterpolationPoint {
1896        InterpolationPoint {
1897            color_style: ColorStyle::default(),
1898            point_type: point_type.to_string(),
1899            value: value.map(str::to_string),
1900        }
1901    }
1902
1903    #[test]
1904    fn describe_existing_rule_names_a_value_anchored_endpoint() {
1905        // A Sheets UI rule can anchor an endpoint at a value, which this
1906        // crate never builds; don't describe it as a plain min/max scale.
1907        let rule = ConditionalFormatRule {
1908            ranges: vec![GridRange::default()],
1909            boolean_rule: None,
1910            gradient_rule: Some(GradientRule {
1911                minpoint: Some(point("NUMBER", Some("10"))),
1912                midpoint: None,
1913                maxpoint: Some(point("PERCENTILE", Some("90"))),
1914            }),
1915        };
1916        assert_eq!(
1917            describe_existing_rule(&rule),
1918            "gradient rule (min/max colors, min number at 10, max percentile at 90)"
1919        );
1920    }
1921
1922    #[test]
1923    fn describe_existing_rule_falls_back_for_an_unrecognized_rule_type() {
1924        // Not a shape the real API would send (a `ConditionalFormatRule`
1925        // with neither variant of its union set), but the struct allows it,
1926        // so the defensive fallback is worth pinning directly.
1927        let rule = ConditionalFormatRule {
1928            ranges: vec![GridRange::default()],
1929            boolean_rule: None,
1930            gradient_rule: None,
1931        };
1932        assert_eq!(describe_existing_rule(&rule), "rule (unrecognized type)");
1933    }
1934
1935    // ── log_operation / label ────────────────────────────────────────
1936
1937    #[test]
1938    fn log_operation_and_label_cover_every_verb() {
1939        let add = ConditionalFormatVerb::AddConditionalFormat {
1940            sheet: "Q1".to_string(),
1941            ranges: vec!["A1".to_string()],
1942            index: None,
1943            rule: FormatRule::Boolean {
1944                condition: FormatCondition::CellEmpty,
1945                format: FormatEffect::default(),
1946            },
1947        };
1948        let update = ConditionalFormatVerb::UpdateConditionalFormat {
1949            sheet: "Q1".to_string(),
1950            ranges: vec!["A1".to_string()],
1951            index: 0,
1952            rule: FormatRule::Boolean {
1953                condition: FormatCondition::CellEmpty,
1954                format: FormatEffect::default(),
1955            },
1956        };
1957        let delete = ConditionalFormatVerb::DeleteConditionalFormat {
1958            sheet: "Q1".to_string(),
1959            index: 0,
1960        };
1961        assert_eq!(add.log_operation(), "sheets-add-conditional-format");
1962        assert_eq!(update.log_operation(), "sheets-update-conditional-format");
1963        assert_eq!(delete.log_operation(), "sheets-delete-conditional-format");
1964        assert_eq!(add.label(), "add-conditional-format");
1965        assert_eq!(update.label(), "update-conditional-format");
1966        assert_eq!(delete.label(), "delete-conditional-format");
1967    }
1968
1969    // ── ConditionalFormatResult: FromLeaseRefusal / log_status ─────────
1970
1971    #[test]
1972    fn from_lease_refusal_maps_every_variant() {
1973        assert_eq!(
1974            <ConditionalFormatResult as FromLeaseRefusal>::from_no_lease(),
1975            ConditionalFormatResult::RefusedNoLease
1976        );
1977        assert_eq!(
1978            <ConditionalFormatResult as FromLeaseRefusal>::from_lease_expired(),
1979            ConditionalFormatResult::RefusedLeaseExpired
1980        );
1981        assert_eq!(
1982            <ConditionalFormatResult as FromLeaseRefusal>::from_lease_wrong_file(),
1983            ConditionalFormatResult::RefusedLeaseWrongFile
1984        );
1985        assert_eq!(
1986            <ConditionalFormatResult as FromLeaseRefusal>::from_lease_stale(),
1987            ConditionalFormatResult::RefusedLeaseStale
1988        );
1989        assert_eq!(
1990            <ConditionalFormatResult as FromLeaseRefusal>::from_lease_failed("boom".to_string()),
1991            ConditionalFormatResult::Failed {
1992                detail: "boom".to_string()
1993            }
1994        );
1995    }
1996
1997    #[test]
1998    fn log_status_covers_every_result_variant() {
1999        let cases = [
2000            (
2001                ConditionalFormatResult::WouldChange {
2002                    summary: String::new(),
2003                },
2004                "would-change",
2005            ),
2006            (
2007                ConditionalFormatResult::RefusedNotASpreadsheet {
2008                    mime_type: String::new(),
2009                },
2010                "refused-not-a-spreadsheet",
2011            ),
2012            (ConditionalFormatResult::RefusedShortcut, "refused-shortcut"),
2013            (
2014                ConditionalFormatResult::RefusedNoVisibleParents,
2015                "refused-no-visible-parents",
2016            ),
2017            (
2018                ConditionalFormatResult::RefusedSheetNotFound {
2019                    title: String::new(),
2020                    available: Vec::new(),
2021                },
2022                "refused-sheet-not-found",
2023            ),
2024            (
2025                ConditionalFormatResult::RefusedInvalidRange {
2026                    detail: String::new(),
2027                },
2028                "refused-invalid-range",
2029            ),
2030            (
2031                ConditionalFormatResult::RefusedIndexOutOfBounds {
2032                    sheet: String::new(),
2033                    index: 0,
2034                    count: 0,
2035                },
2036                "refused-index-out-of-bounds",
2037            ),
2038            (
2039                ConditionalFormatResult::Blocked { decided_by: None },
2040                "blocked",
2041            ),
2042            (ConditionalFormatResult::RefusedNoLease, "refused-no-lease"),
2043            (
2044                ConditionalFormatResult::RefusedLeaseExpired,
2045                "refused-lease-expired",
2046            ),
2047            (
2048                ConditionalFormatResult::RefusedLeaseWrongFile,
2049                "refused-lease-wrong-file",
2050            ),
2051            (
2052                ConditionalFormatResult::RefusedLeaseStale,
2053                "refused-lease-stale",
2054            ),
2055            (
2056                ConditionalFormatResult::Changed {
2057                    summary: String::new(),
2058                },
2059                "changed",
2060            ),
2061            (
2062                ConditionalFormatResult::AppliedReplyUnreadable {
2063                    summary: String::new(),
2064                    detail: String::new(),
2065                },
2066                "applied-reply-unreadable",
2067            ),
2068            (
2069                ConditionalFormatResult::Failed {
2070                    detail: String::new(),
2071                },
2072                "failed",
2073            ),
2074        ];
2075        for (result, expected) in cases {
2076            assert_eq!(result.log_status(), expected, "{result:?}");
2077        }
2078    }
2079
2080    #[test]
2081    fn write_jsonl_delegates_to_write_scalar_jsonl() {
2082        let outcome = ConditionalFormatOutcome {
2083            spreadsheet_id: "sheet-1".to_string(),
2084            file_name: Some("Budget".to_string()),
2085            resolved_folder_id: None,
2086            verb: add_verb(),
2087            result: ConditionalFormatResult::Changed {
2088                summary: "x".to_string(),
2089            },
2090        };
2091        let mut buf = Vec::new();
2092        outcome.write_jsonl(&mut buf).unwrap();
2093        let text = String::from_utf8(buf).unwrap();
2094        assert!(text.contains("\"status\":\"changed\""), "{text}");
2095        assert!(text.contains("\"spreadsheet_id\":\"sheet-1\""), "{text}");
2096    }
2097
2098    // ── describe_lines ───────────────────────────────────────────────
2099
2100    fn describe_outcome(
2101        verb: ConditionalFormatVerb,
2102        result: ConditionalFormatResult,
2103    ) -> ConditionalFormatOutcome {
2104        ConditionalFormatOutcome {
2105            spreadsheet_id: "sheet-1".to_string(),
2106            file_name: Some("Budget".to_string()),
2107            resolved_folder_id: None,
2108            verb,
2109            result,
2110        }
2111    }
2112
2113    #[test]
2114    fn describe_lines_renders_would_change_and_changed() {
2115        let outcome = describe_outcome(
2116            add_verb(),
2117            ConditionalFormatResult::WouldChange {
2118                summary: "add conditional format (boolean rule)".to_string(),
2119            },
2120        );
2121        let lines = describe_lines(&outcome);
2122        assert_eq!(lines.len(), 1);
2123        assert!(
2124            lines[0].contains("Would add conditional format"),
2125            "{lines:?}"
2126        );
2127        assert!(lines[0].contains("'Budget'"), "{lines:?}");
2128
2129        let outcome = describe_outcome(
2130            add_verb(),
2131            ConditionalFormatResult::Changed {
2132                summary: "add conditional format (boolean rule)".to_string(),
2133            },
2134        );
2135        let lines = describe_lines(&outcome);
2136        assert_eq!(lines.len(), 1);
2137        assert!(
2138            lines[0].contains("Applied: add conditional format"),
2139            "{lines:?}"
2140        );
2141    }
2142
2143    #[test]
2144    fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
2145        let outcome = ConditionalFormatOutcome {
2146            spreadsheet_id: "sheet-1".to_string(),
2147            file_name: None,
2148            resolved_folder_id: None,
2149            verb: add_verb(),
2150            result: ConditionalFormatResult::Changed {
2151                summary: "add conditional format".to_string(),
2152            },
2153        };
2154        let lines = describe_lines(&outcome);
2155        assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
2156    }
2157
2158    #[test]
2159    fn describe_lines_renders_refused_not_a_spreadsheet() {
2160        let outcome = describe_outcome(
2161            add_verb(),
2162            ConditionalFormatResult::RefusedNotASpreadsheet {
2163                mime_type: "text/plain".to_string(),
2164            },
2165        );
2166        let lines = describe_lines(&outcome);
2167        assert_eq!(lines.len(), 1);
2168        assert!(lines[0].contains("not a Google Sheet"), "{lines:?}");
2169        assert!(lines[0].contains("text/plain"), "{lines:?}");
2170        assert!(lines[0].contains("add-conditional-format"), "{lines:?}");
2171    }
2172
2173    #[test]
2174    fn describe_lines_renders_refused_shortcut() {
2175        let outcome = describe_outcome(add_verb(), ConditionalFormatResult::RefusedShortcut);
2176        let lines = describe_lines(&outcome);
2177        assert_eq!(lines.len(), 1);
2178        assert!(lines[0].contains("is a shortcut"), "{lines:?}");
2179    }
2180
2181    #[test]
2182    fn describe_lines_renders_refused_no_visible_parents() {
2183        let outcome =
2184            describe_outcome(add_verb(), ConditionalFormatResult::RefusedNoVisibleParents);
2185        let lines = describe_lines(&outcome);
2186        assert_eq!(lines.len(), 1);
2187        assert!(lines[0].contains("no parent folder visible"), "{lines:?}");
2188        assert!(lines[0].contains("sheets-structure"), "{lines:?}");
2189    }
2190
2191    #[test]
2192    fn describe_lines_renders_refused_sheet_not_found() {
2193        let outcome = describe_outcome(
2194            add_verb(),
2195            ConditionalFormatResult::RefusedSheetNotFound {
2196                title: "Missing".to_string(),
2197                available: vec!["Q1".to_string(), "Q2".to_string()],
2198            },
2199        );
2200        let lines = describe_lines(&outcome);
2201        assert_eq!(lines.len(), 1);
2202        assert!(lines[0].contains("no sheet titled 'Missing'"), "{lines:?}");
2203        assert!(lines[0].contains("'Q1', 'Q2'"), "{lines:?}");
2204
2205        let outcome = describe_outcome(
2206            add_verb(),
2207            ConditionalFormatResult::RefusedSheetNotFound {
2208                title: "Missing".to_string(),
2209                available: Vec::new(),
2210            },
2211        );
2212        let lines = describe_lines(&outcome);
2213        assert!(lines[0].contains("Available: none"), "{lines:?}");
2214    }
2215
2216    #[test]
2217    fn describe_lines_renders_refused_invalid_range() {
2218        let outcome = describe_outcome(
2219            add_verb(),
2220            ConditionalFormatResult::RefusedInvalidRange {
2221                detail: "bad range".to_string(),
2222            },
2223        );
2224        let lines = describe_lines(&outcome);
2225        assert_eq!(lines, vec!["Refused: bad range".to_string()]);
2226    }
2227
2228    #[test]
2229    fn describe_lines_renders_index_out_of_bounds_for_add_with_full_range() {
2230        // `add-conditional-format` may append at `count`, so its valid
2231        // range is `0..=count` — max_valid == count.
2232        let outcome = describe_outcome(
2233            add_verb(),
2234            ConditionalFormatResult::RefusedIndexOutOfBounds {
2235                sheet: "Q1".to_string(),
2236                index: 5,
2237                count: 3,
2238            },
2239        );
2240        let lines = describe_lines(&outcome);
2241        assert_eq!(lines.len(), 1);
2242        assert!(lines[0].contains("valid indices 0-3"), "{lines:?}");
2243        assert!(lines[0].contains("index 5 is out of bounds"), "{lines:?}");
2244    }
2245
2246    #[test]
2247    fn describe_lines_renders_index_out_of_bounds_for_update_and_delete_with_shrunk_range() {
2248        // `update`/`delete` may only act on an existing rule, so their
2249        // valid range is `0..count` — max_valid == count - 1.
2250        let update_verb = ConditionalFormatVerb::UpdateConditionalFormat {
2251            sheet: "Q1".to_string(),
2252            ranges: vec!["A1".to_string()],
2253            index: 5,
2254            rule: FormatRule::Boolean {
2255                condition: FormatCondition::CellEmpty,
2256                format: FormatEffect::default(),
2257            },
2258        };
2259        let outcome = describe_outcome(
2260            update_verb,
2261            ConditionalFormatResult::RefusedIndexOutOfBounds {
2262                sheet: "Q1".to_string(),
2263                index: 5,
2264                count: 3,
2265            },
2266        );
2267        let lines = describe_lines(&outcome);
2268        assert!(lines[0].contains("valid indices 0-2"), "{lines:?}");
2269
2270        let delete_verb = ConditionalFormatVerb::DeleteConditionalFormat {
2271            sheet: "Q1".to_string(),
2272            index: 5,
2273        };
2274        let outcome = describe_outcome(
2275            delete_verb,
2276            ConditionalFormatResult::RefusedIndexOutOfBounds {
2277                sheet: "Q1".to_string(),
2278                index: 5,
2279                count: 3,
2280            },
2281        );
2282        let lines = describe_lines(&outcome);
2283        assert!(lines[0].contains("valid indices 0-2"), "{lines:?}");
2284
2285        // The `count == 0` edge case: `saturating_sub` keeps max_valid at 0
2286        // rather than underflowing.
2287        let delete_verb = ConditionalFormatVerb::DeleteConditionalFormat {
2288            sheet: "Q1".to_string(),
2289            index: 0,
2290        };
2291        let outcome = describe_outcome(
2292            delete_verb,
2293            ConditionalFormatResult::RefusedIndexOutOfBounds {
2294                sheet: "Q1".to_string(),
2295                index: 0,
2296                count: 0,
2297            },
2298        );
2299        let lines = describe_lines(&outcome);
2300        assert!(lines[0].contains("valid indices 0-0"), "{lines:?}");
2301    }
2302
2303    #[test]
2304    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2305        let outcome = describe_outcome(
2306            add_verb(),
2307            ConditionalFormatResult::Blocked {
2308                decided_by: Some(DecidingRule::Folder {
2309                    folder_id: "folder-1".to_string(),
2310                    depth: 2,
2311                }),
2312            },
2313        );
2314        let lines = describe_lines(&outcome);
2315        assert_eq!(lines.len(), 1);
2316        assert!(
2317            lines[0].contains("Blocked: add-conditional-format"),
2318            "{lines:?}"
2319        );
2320        assert!(lines[0].contains("rule on folder folder-1"), "{lines:?}");
2321        assert!(lines[0].contains("(depth 2)"), "{lines:?}");
2322
2323        let outcome = describe_outcome(
2324            add_verb(),
2325            ConditionalFormatResult::Blocked { decided_by: None },
2326        );
2327        let lines = describe_lines(&outcome);
2328        assert_eq!(lines.len(), 1);
2329        assert!(lines[0].contains("refused by default policy"), "{lines:?}");
2330        assert!(lines[0].contains("sheets-structure"), "{lines:?}");
2331    }
2332
2333    #[test]
2334    fn describe_lines_renders_every_lease_refusal() {
2335        for result in [
2336            ConditionalFormatResult::RefusedNoLease,
2337            ConditionalFormatResult::RefusedLeaseExpired,
2338            ConditionalFormatResult::RefusedLeaseWrongFile,
2339            ConditionalFormatResult::RefusedLeaseStale,
2340        ] {
2341            let outcome = describe_outcome(add_verb(), result.clone());
2342            let lines = describe_lines(&outcome);
2343            assert_eq!(lines.len(), 1, "{result:?} -> {lines:?}");
2344            assert!(lines[0].starts_with("Refused:"), "{result:?} -> {lines:?}");
2345        }
2346    }
2347
2348    #[test]
2349    fn an_unreadable_reply_logs_the_summary_as_fields_changed() {
2350        let opts = ConditionalFormatOptions {
2351            spreadsheet_id: "sheet-1".to_string(),
2352            verb: add_verb(),
2353            dry_run: false,
2354            lease_token: None,
2355            ledger_path: std::path::PathBuf::new(),
2356        };
2357        let record = mutation_record(
2358            &describe_outcome(
2359                add_verb(),
2360                ConditionalFormatResult::AppliedReplyUnreadable {
2361                    summary: "add conditional format (boolean rule)".to_string(),
2362                    detail: "bad reply".to_string(),
2363                },
2364            ),
2365            &opts,
2366            Duration::from_millis(1),
2367        );
2368        assert_eq!(
2369            record.fields_changed.as_deref(),
2370            Some("add conditional format (boolean rule)")
2371        );
2372        assert_eq!(record.error.as_deref(), Some("bad reply"));
2373        assert_eq!(record.status, "applied-reply-unreadable");
2374    }
2375
2376    #[test]
2377    fn describe_lines_renders_applied_reply_unreadable_as_not_idempotent() {
2378        let outcome = describe_outcome(
2379            add_verb(),
2380            ConditionalFormatResult::AppliedReplyUnreadable {
2381                summary: "add conditional format (boolean rule)".to_string(),
2382                detail: "bad reply".to_string(),
2383            },
2384        );
2385        assert_eq!(
2386            describe_lines(&outcome),
2387            vec![
2388                "Applied, but the reply could not be read: add conditional format (boolean \
2389                 rule) in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2390                    .to_string()
2391            ]
2392        );
2393    }
2394
2395    #[test]
2396    fn describe_lines_renders_failed() {
2397        let outcome = describe_outcome(
2398            add_verb(),
2399            ConditionalFormatResult::Failed {
2400                detail: "boom".to_string(),
2401            },
2402        );
2403        let lines = describe_lines(&outcome);
2404        assert_eq!(lines, vec!["Failed: boom".to_string()]);
2405    }
2406
2407    // ── integration (wiremock) ───────────────────────────────────────
2408
2409    fn test_credentials() -> DriveCredentials {
2410        DriveCredentials {
2411            client_id: "client-1".to_string(),
2412            client_secret: Secret::new("secret-1"),
2413            refresh_token: Secret::new("refresh-1"),
2414            scope: DriveGrantedScopes::READONLY,
2415        }
2416    }
2417
2418    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2419        wiremock::Mock::given(wiremock::matchers::method("POST"))
2420            .and(wiremock::matchers::path("/token"))
2421            .respond_with(
2422                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2423                    "access_token": "test-token", "expires_in": 3600,
2424                })),
2425            )
2426            .mount(server)
2427            .await;
2428        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2429        crate::drive::client::test_support::replace_session(
2430            &mut drive,
2431            &test_credentials(),
2432            &format!("{}/token", server.uri()),
2433        );
2434        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2435        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2436        (drive, sheets)
2437    }
2438
2439    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2440        let parents: Vec<&str> = parents.to_vec();
2441        wiremock::Mock::given(wiremock::matchers::method("GET"))
2442            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2443            .respond_with(
2444                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2445                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2446                    "version": "1",
2447                })),
2448            )
2449    }
2450
2451    fn mount_folder(id: &str) -> wiremock::Mock {
2452        mount_file(id, "application/vnd.google-apps.folder", &[])
2453    }
2454
2455    fn allow_rule(folder: &str) -> FolderPermissionRule {
2456        FolderPermissionRule {
2457            folder_id: Some(folder.to_string()),
2458            file_id: None,
2459            recursive: true,
2460            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2461            deny: HashSet::default(),
2462            require_lease: true,
2463        }
2464    }
2465
2466    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2467        let ledger_path = tempfile::tempdir()
2468            .unwrap()
2469            .keep()
2470            .join("lease-ledger.jsonl");
2471        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2472        (Some(token), ledger_path)
2473    }
2474
2475    /// A workbook with one sheet ("Q1") holding two existing conditional
2476    /// format rules at indices 0 and 1.
2477    fn mount_workbook() -> wiremock::Mock {
2478        wiremock::Mock::given(wiremock::matchers::method("GET"))
2479            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2480            .respond_with(
2481                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2482                    "spreadsheetId": "sheet-1",
2483                    "properties": {"title": "Budget"},
2484                    "sheets": [
2485                        {
2486                            "properties": {"sheetId": 0, "title": "Q1", "index": 0},
2487                            "conditionalFormats": [
2488                                {
2489                                    "ranges": [{"sheetId": 0, "startRowIndex": 0, "endRowIndex": 1}],
2490                                    "booleanRule": {
2491                                        "condition": {"type": "BLANK", "values": []},
2492                                        "format": {"backgroundColorStyle": {"rgbColor": {"red": 1.0, "green": 0.0, "blue": 0.0}}},
2493                                    },
2494                                },
2495                                {
2496                                    "ranges": [{"sheetId": 0, "startRowIndex": 1, "endRowIndex": 2}],
2497                                    "gradientRule": {
2498                                        "minpoint": {"colorStyle": {"rgbColor": {"red": 1.0, "green": 1.0, "blue": 1.0}}, "type": "MIN"},
2499                                        "maxpoint": {"colorStyle": {"rgbColor": {"red": 0.0, "green": 1.0, "blue": 0.0}}, "type": "MAX"},
2500                                    },
2501                                },
2502                            ],
2503                        },
2504                    ],
2505                })),
2506            )
2507    }
2508
2509    fn add_verb() -> ConditionalFormatVerb {
2510        ConditionalFormatVerb::AddConditionalFormat {
2511            sheet: "Q1".to_string(),
2512            ranges: vec!["A1:A10".to_string()],
2513            index: None,
2514            rule: FormatRule::Boolean {
2515                condition: FormatCondition::NumberGreater(100.0),
2516                format: FormatEffect {
2517                    background: Some("#00FF00".to_string()),
2518                    text_color: None,
2519                    bold: None,
2520                },
2521            },
2522        }
2523    }
2524
2525    #[tokio::test]
2526    async fn a_denied_gate_blocks_before_any_batch_update_call() {
2527        let server = wiremock::MockServer::start().await;
2528        let (drive, sheets) = clients(&server).await;
2529        mount_file(
2530            "sheet-1",
2531            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2532            &["folder-1"],
2533        )
2534        .mount(&server)
2535        .await;
2536        mount_folder("folder-1").mount(&server).await;
2537        let rules: Vec<FolderPermissionRule> = Vec::new();
2538        let opts = ConditionalFormatOptions {
2539            spreadsheet_id: "sheet-1".to_string(),
2540            verb: add_verb(),
2541            dry_run: false,
2542            lease_token: None,
2543            ledger_path: std::path::PathBuf::new(),
2544        };
2545        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2546        assert!(matches!(
2547            outcome.result,
2548            ConditionalFormatResult::Blocked { .. }
2549        ));
2550    }
2551
2552    #[tokio::test]
2553    async fn add_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2554        let server = wiremock::MockServer::start().await;
2555        let (drive, sheets) = clients(&server).await;
2556        mount_file(
2557            "sheet-1",
2558            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2559            &["folder-1"],
2560        )
2561        .mount(&server)
2562        .await;
2563        mount_folder("folder-1").mount(&server).await;
2564        mount_workbook().mount(&server).await;
2565        // 2xx, so the rule WAS added — but the body is not the reply shape.
2566        wiremock::Mock::given(wiremock::matchers::method("POST"))
2567            .and(wiremock::matchers::path(
2568                "/v4/spreadsheets/sheet-1:batchUpdate",
2569            ))
2570            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2571            .expect(1)
2572            .mount(&server)
2573            .await;
2574        let rules = vec![allow_rule("folder-1")];
2575        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2576        let opts = ConditionalFormatOptions {
2577            spreadsheet_id: "sheet-1".to_string(),
2578            verb: add_verb(),
2579            dry_run: false,
2580            lease_token,
2581            ledger_path,
2582        };
2583        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2584        assert_eq!(
2585            outcome.result.log_status(),
2586            "applied-reply-unreadable",
2587            "{:?}",
2588            outcome.result
2589        );
2590        assert!(
2591            describe(&outcome).starts_with("Applied, but the reply could not be read"),
2592            "{}",
2593            describe(&outcome)
2594        );
2595    }
2596
2597    #[tokio::test]
2598    async fn add_sends_a_boolean_rule_and_appends_at_the_current_length() {
2599        let server = wiremock::MockServer::start().await;
2600        let (drive, sheets) = clients(&server).await;
2601        mount_file(
2602            "sheet-1",
2603            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2604            &["folder-1"],
2605        )
2606        .mount(&server)
2607        .await;
2608        mount_folder("folder-1").mount(&server).await;
2609        mount_workbook().mount(&server).await;
2610        wiremock::Mock::given(wiremock::matchers::method("POST"))
2611            .and(wiremock::matchers::path(
2612                "/v4/spreadsheets/sheet-1:batchUpdate",
2613            ))
2614            .respond_with(
2615                wiremock::ResponseTemplate::new(200)
2616                    .set_body_json(serde_json::json!({"replies": [{}]})),
2617            )
2618            .mount(&server)
2619            .await;
2620        let rules = vec![allow_rule("folder-1")];
2621        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2622        let opts = ConditionalFormatOptions {
2623            spreadsheet_id: "sheet-1".to_string(),
2624            verb: add_verb(),
2625            dry_run: false,
2626            lease_token,
2627            ledger_path,
2628        };
2629        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2630        assert!(
2631            matches!(outcome.result, ConditionalFormatResult::Changed { .. }),
2632            "{:?}",
2633            outcome.result
2634        );
2635
2636        let requests = server.received_requests().await.unwrap();
2637        let batch = requests
2638            .iter()
2639            .find(|r| r.url.path().ends_with(":batchUpdate"))
2640            .unwrap();
2641        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2642        let added = &body["requests"][0]["addConditionalFormatRule"];
2643        // The mocked workbook already has 2 rules, so a caller-less --index
2644        // appends at position 2.
2645        assert_eq!(added["index"], 2);
2646        assert_eq!(
2647            added["rule"]["booleanRule"]["condition"]["type"],
2648            "NUMBER_GREATER"
2649        );
2650    }
2651
2652    #[tokio::test]
2653    async fn add_refuses_an_explicit_out_of_bounds_or_negative_index() {
2654        let server = wiremock::MockServer::start().await;
2655        let (drive, sheets) = clients(&server).await;
2656        mount_file(
2657            "sheet-1",
2658            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2659            &["folder-1"],
2660        )
2661        .mount(&server)
2662        .await;
2663        mount_folder("folder-1").mount(&server).await;
2664        mount_workbook().mount(&server).await;
2665        let rules = vec![allow_rule("folder-1")];
2666
2667        for bad_index in [-1, 3] {
2668            let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2669            let verb = match add_verb() {
2670                ConditionalFormatVerb::AddConditionalFormat {
2671                    sheet,
2672                    ranges,
2673                    rule,
2674                    ..
2675                } => ConditionalFormatVerb::AddConditionalFormat {
2676                    sheet,
2677                    ranges,
2678                    index: Some(bad_index),
2679                    rule,
2680                },
2681                other => other,
2682            };
2683            let opts = ConditionalFormatOptions {
2684                spreadsheet_id: "sheet-1".to_string(),
2685                verb,
2686                dry_run: false,
2687                lease_token,
2688                ledger_path,
2689            };
2690            let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2691            match outcome.result {
2692                ConditionalFormatResult::RefusedIndexOutOfBounds {
2693                    sheet,
2694                    index,
2695                    count,
2696                } => {
2697                    assert_eq!(sheet, "Q1");
2698                    assert_eq!(index, bad_index);
2699                    // The mocked workbook has 2 existing rules; valid
2700                    // explicit indices for add are 0..=2.
2701                    assert_eq!(count, 2);
2702                }
2703                other => panic!("expected RefusedIndexOutOfBounds, got {other:?}"),
2704            }
2705        }
2706
2707        // Neither attempt reached the API.
2708        let requests = server.received_requests().await.unwrap();
2709        assert!(!requests
2710            .iter()
2711            .any(|r| r.url.path().ends_with(":batchUpdate")));
2712    }
2713
2714    #[tokio::test]
2715    async fn delete_sends_the_sheet_id_and_index() {
2716        let server = wiremock::MockServer::start().await;
2717        let (drive, sheets) = clients(&server).await;
2718        mount_file(
2719            "sheet-1",
2720            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2721            &["folder-1"],
2722        )
2723        .mount(&server)
2724        .await;
2725        mount_folder("folder-1").mount(&server).await;
2726        mount_workbook().mount(&server).await;
2727        wiremock::Mock::given(wiremock::matchers::method("POST"))
2728            .and(wiremock::matchers::path(
2729                "/v4/spreadsheets/sheet-1:batchUpdate",
2730            ))
2731            .respond_with(
2732                wiremock::ResponseTemplate::new(200)
2733                    .set_body_json(serde_json::json!({"replies": [{}]})),
2734            )
2735            .mount(&server)
2736            .await;
2737        let rules = vec![allow_rule("folder-1")];
2738        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2739        let opts = ConditionalFormatOptions {
2740            spreadsheet_id: "sheet-1".to_string(),
2741            verb: ConditionalFormatVerb::DeleteConditionalFormat {
2742                sheet: "Q1".to_string(),
2743                index: 1,
2744            },
2745            dry_run: false,
2746            lease_token,
2747            ledger_path,
2748        };
2749        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2750        assert!(
2751            matches!(outcome.result, ConditionalFormatResult::Changed { .. }),
2752            "{:?}",
2753            outcome.result
2754        );
2755
2756        let requests = server.received_requests().await.unwrap();
2757        let batch = requests
2758            .iter()
2759            .find(|r| r.url.path().ends_with(":batchUpdate"))
2760            .unwrap();
2761        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2762        let deleted = &body["requests"][0]["deleteConditionalFormatRule"];
2763        assert_eq!(deleted["sheetId"], 0);
2764        assert_eq!(deleted["index"], 1);
2765    }
2766
2767    #[tokio::test]
2768    async fn delete_refuses_an_out_of_bounds_index() {
2769        let server = wiremock::MockServer::start().await;
2770        let (drive, sheets) = clients(&server).await;
2771        mount_file(
2772            "sheet-1",
2773            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2774            &["folder-1"],
2775        )
2776        .mount(&server)
2777        .await;
2778        mount_folder("folder-1").mount(&server).await;
2779        mount_workbook().mount(&server).await;
2780        let rules = vec![allow_rule("folder-1")];
2781        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2782        let opts = ConditionalFormatOptions {
2783            spreadsheet_id: "sheet-1".to_string(),
2784            verb: ConditionalFormatVerb::DeleteConditionalFormat {
2785                sheet: "Q1".to_string(),
2786                index: 5,
2787            },
2788            dry_run: false,
2789            lease_token,
2790            ledger_path,
2791        };
2792        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2793        match outcome.result {
2794            ConditionalFormatResult::RefusedIndexOutOfBounds {
2795                sheet,
2796                index,
2797                count,
2798            } => {
2799                assert_eq!(sheet, "Q1");
2800                assert_eq!(index, 5);
2801                assert_eq!(count, 2);
2802            }
2803            other => panic!("expected RefusedIndexOutOfBounds, got {other:?}"),
2804        }
2805
2806        // No batchUpdate call was made — the workbook GET is the only
2807        // request beyond the metadata/folder fetches.
2808        let requests = server.received_requests().await.unwrap();
2809        assert!(!requests
2810            .iter()
2811            .any(|r| r.url.path().ends_with(":batchUpdate")));
2812    }
2813
2814    #[tokio::test]
2815    async fn update_dry_run_echoes_the_rule_currently_at_the_index() {
2816        let server = wiremock::MockServer::start().await;
2817        let (drive, sheets) = clients(&server).await;
2818        mount_file(
2819            "sheet-1",
2820            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2821            &["folder-1"],
2822        )
2823        .mount(&server)
2824        .await;
2825        mount_folder("folder-1").mount(&server).await;
2826        mount_workbook().mount(&server).await;
2827        let rules = vec![allow_rule("folder-1")];
2828        let opts = ConditionalFormatOptions {
2829            spreadsheet_id: "sheet-1".to_string(),
2830            verb: ConditionalFormatVerb::UpdateConditionalFormat {
2831                sheet: "Q1".to_string(),
2832                ranges: vec!["A1:A10".to_string()],
2833                index: 0,
2834                rule: FormatRule::Boolean {
2835                    condition: FormatCondition::NumberGreater(100.0),
2836                    format: FormatEffect {
2837                        background: Some("#00FF00".to_string()),
2838                        text_color: None,
2839                        bold: None,
2840                    },
2841                },
2842            },
2843            dry_run: true,
2844            lease_token: None,
2845            ledger_path: std::path::PathBuf::new(),
2846        };
2847        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2848        match outcome.result {
2849            ConditionalFormatResult::WouldChange { summary } => {
2850                assert!(
2851                    summary.contains("currently: boolean rule (blank"),
2852                    "{summary}"
2853                );
2854                assert!(summary.contains("number greater"), "{summary}");
2855            }
2856            other => panic!("expected WouldChange, got {other:?}"),
2857        }
2858
2859        // A dry run never calls batchUpdate.
2860        let requests = server.received_requests().await.unwrap();
2861        assert!(!requests
2862            .iter()
2863            .any(|r| r.url.path().ends_with(":batchUpdate")));
2864    }
2865
2866    #[tokio::test]
2867    async fn add_rejects_a_malformed_background_color_before_the_lease_gate() {
2868        let server = wiremock::MockServer::start().await;
2869        let (drive, sheets) = clients(&server).await;
2870        mount_file(
2871            "sheet-1",
2872            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2873            &["folder-1"],
2874        )
2875        .mount(&server)
2876        .await;
2877        mount_folder("folder-1").mount(&server).await;
2878        let rules = vec![allow_rule("folder-1")];
2879        let opts = ConditionalFormatOptions {
2880            spreadsheet_id: "sheet-1".to_string(),
2881            verb: ConditionalFormatVerb::AddConditionalFormat {
2882                sheet: "Q1".to_string(),
2883                ranges: vec!["A1:A10".to_string()],
2884                index: None,
2885                rule: FormatRule::Boolean {
2886                    condition: FormatCondition::CellEmpty,
2887                    format: FormatEffect {
2888                        background: Some("not-a-color".to_string()),
2889                        text_color: None,
2890                        bold: None,
2891                    },
2892                },
2893            },
2894            dry_run: false,
2895            lease_token: None,
2896            ledger_path: std::path::PathBuf::new(),
2897        };
2898        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2899        match outcome.result {
2900            ConditionalFormatResult::RefusedInvalidRange { detail } => {
2901                assert!(detail.contains("--background"), "{detail}");
2902            }
2903            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2904        }
2905    }
2906
2907    #[tokio::test]
2908    async fn add_refuses_an_empty_ranges_list_before_any_network_call() {
2909        // No mounts at all: an empty `--range` list is refused before the
2910        // metadata fetch, so nothing should reach the mock server.
2911        let server = wiremock::MockServer::start().await;
2912        let (drive, sheets) = clients(&server).await;
2913        let rules: Vec<FolderPermissionRule> = Vec::new();
2914        let opts = ConditionalFormatOptions {
2915            spreadsheet_id: "sheet-1".to_string(),
2916            verb: ConditionalFormatVerb::AddConditionalFormat {
2917                sheet: "Q1".to_string(),
2918                ranges: Vec::new(),
2919                index: None,
2920                rule: FormatRule::Boolean {
2921                    condition: FormatCondition::CellEmpty,
2922                    format: FormatEffect::default(),
2923                },
2924            },
2925            dry_run: false,
2926            lease_token: None,
2927            ledger_path: std::path::PathBuf::new(),
2928        };
2929        let outcome = conditional_format(&drive, &sheets, &opts, &rules).await;
2930        match outcome.result {
2931            ConditionalFormatResult::RefusedInvalidRange { detail } => {
2932                assert!(detail.contains("needs at least one --range"), "{detail}");
2933            }
2934            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2935        }
2936        let requests = server.received_requests().await.unwrap();
2937        assert!(requests.is_empty(), "{requests:?}");
2938    }
2939}