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