Skip to main content

omni_dev/drive/sheets/
validation.rs

1//! Data validation via `spreadsheets.batchUpdate` (issue #1643,
2//! [ADR-0077](../../../docs/adrs/adr-0077.md)).
3//!
4//! Two verbs, gated by [`DriveOperation::SheetsStructure`] — neither
5//! destroys data. `clear-data-validation` removes a constraint, not a
6//! value, the same way `format.rs::UnmergeCells` removes a merge without
7//! touching a cell's content.
8//!
9//! Shape mirrors `format.rs` exactly, down to reusing its target
10//! resolution: a range, composed from `--sheet`/`--range` and converted to
11//! a numeric [`GridRange`] via `grid_range::parse_grid_range`.
12//!
13//! **A curated condition surface, not full `BooleanCondition` coverage.**
14//! Sheets models a couple dozen condition types. Tranche 1 (#1643) shipped
15//! the four with the broadest use: `--one-of-list` (dropdown),
16//! `--number-between`, `--checkbox`, `--custom-formula`. Tranche 2 (#1792)
17//! adds every remaining type addressable with a flat flag: `ONE_OF_RANGE`,
18//! the numeric comparators and `NUMBER_NOT_BETWEEN`, the `TEXT_*`
19//! contains/starts/ends/eq family, the `DATE_*` after/before/on/between
20//! family, and `BLANK`/`NOT_BLANK`. **Absolute dates only** — unlike
21//! `conditional_format.rs`'s identically-shaped condition, Sheets rejects
22//! a relative keyword (`today`, `past-week`, ...) in data validation with
23//! a bare HTTP 400 ("relativeDate values are not supported"), so
24//! `validate_condition` refuses one locally instead (issue #1934).
25//! Still excluded, a documented cut rather than a
26//! silent gap: `TEXT_IS_EMAIL`, `TEXT_IS_URL`, `DATE_ON_OR_BEFORE`,
27//! `DATE_ON_OR_AFTER`, `DATE_NOT_BETWEEN`, `DATE_IS_VALID`, and every
28//! condition type meaningful only inside a conditional-format rule —
29//! `docs/drive.md` names the boundary, matching this issue's general stance
30//! on `CellFormat`.
31
32use std::path::PathBuf;
33use std::time::{Duration, Instant};
34
35use serde::Serialize;
36
37use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
38use crate::drive::client::DriveClient;
39use crate::drive::files_api::FilesApi;
40use crate::drive::lease::check::{
41    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
42    LeasedWrite,
43};
44use crate::drive::sheets::a1;
45use crate::drive::sheets::api::{
46    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
47};
48use crate::drive::sheets::client::SheetsClient;
49use crate::drive::sheets::date_value::{
50    reject_blank, reject_blank_date, reject_empty, reject_invalid_date_between, reject_nan,
51    reject_reversed_range, DateValue,
52};
53use crate::drive::sheets::grid_range;
54use crate::drive::sheets::target_gate;
55use crate::drive::sheets::types::{
56    BatchUpdateRequestItem, BooleanCondition, ConditionValue, DataValidationRule, GridRange,
57    SetDataValidationRequest,
58};
59use crate::drive::types::SheetTargetRefusal;
60use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
61use crate::request_log::{self, DriveMutationOutcome};
62
63/// The condition shapes `set-data-validation` builds.
64///
65/// Each variant carries exactly what its condition needs; there is no "raw
66/// condition type + values" escape hatch, following the same
67/// no-passthrough discipline as `format.rs::CellFormatFlags`.
68#[derive(Debug, Clone, PartialEq)]
69pub enum Condition {
70    /// A dropdown restricted to these exact values.
71    OneOfList(Vec<String>),
72    /// A dropdown sourced from a range of cells.
73    OneOfRange(String),
74    /// A number in `[min, max]` inclusive.
75    NumberBetween(f64, f64),
76    /// A number outside `[min, max]`.
77    NumberNotBetween(f64, f64),
78    /// A number strictly greater than this.
79    NumberGreater(f64),
80    /// A number greater than or equal to this.
81    NumberGreaterEq(f64),
82    /// A number strictly less than this.
83    NumberLess(f64),
84    /// A number less than or equal to this.
85    NumberLessEq(f64),
86    /// A number equal to this.
87    NumberEq(f64),
88    /// A number not equal to this.
89    NumberNotEq(f64),
90    /// Text containing this substring.
91    TextContains(String),
92    /// Text not containing this substring.
93    TextNotContains(String),
94    /// Text starting with this substring.
95    TextStartsWith(String),
96    /// Text ending with this substring.
97    TextEndsWith(String),
98    /// Text equal to this.
99    TextEq(String),
100    /// A date after this one.
101    DateAfter(DateValue),
102    /// A date before this one.
103    DateBefore(DateValue),
104    /// A date equal to this one.
105    DateOn(DateValue),
106    /// A date in `[start, end]` inclusive (absolute dates only).
107    DateBetween(String, String),
108    /// The cell must be empty.
109    Blank,
110    /// The cell must not be empty.
111    NotBlank,
112    /// `TRUE`/`FALSE` only.
113    Checkbox,
114    /// A custom formula that must evaluate truthy.
115    CustomFormula(String),
116}
117
118impl Condition {
119    /// The Sheets API `condition_type` string, and the `validation_type` the
120    /// request log records — one source of truth shared by
121    /// [`Self::into_boolean_condition`] and the request log's
122    /// `validation_type` field, so a typo fixed in one can't drift from the
123    /// other.
124    const fn condition_type_str(&self) -> &'static str {
125        match self {
126            Self::OneOfList(_) => "ONE_OF_LIST",
127            Self::OneOfRange(_) => "ONE_OF_RANGE",
128            Self::NumberBetween(..) => "NUMBER_BETWEEN",
129            Self::NumberNotBetween(..) => "NUMBER_NOT_BETWEEN",
130            Self::NumberGreater(_) => "NUMBER_GREATER",
131            Self::NumberGreaterEq(_) => "NUMBER_GREATER_THAN_EQ",
132            Self::NumberLess(_) => "NUMBER_LESS",
133            Self::NumberLessEq(_) => "NUMBER_LESS_THAN_EQ",
134            Self::NumberEq(_) => "NUMBER_EQ",
135            Self::NumberNotEq(_) => "NUMBER_NOT_EQ",
136            Self::TextContains(_) => "TEXT_CONTAINS",
137            Self::TextNotContains(_) => "TEXT_NOT_CONTAINS",
138            Self::TextStartsWith(_) => "TEXT_STARTS_WITH",
139            Self::TextEndsWith(_) => "TEXT_ENDS_WITH",
140            Self::TextEq(_) => "TEXT_EQ",
141            Self::DateAfter(_) => "DATE_AFTER",
142            Self::DateBefore(_) => "DATE_BEFORE",
143            Self::DateOn(_) => "DATE_EQ",
144            Self::DateBetween(..) => "DATE_BETWEEN",
145            Self::Blank => "BLANK",
146            Self::NotBlank => "NOT_BLANK",
147            Self::Checkbox => "BOOLEAN",
148            Self::CustomFormula(_) => "CUSTOM_FORMULA",
149        }
150    }
151
152    fn into_boolean_condition(self) -> BooleanCondition {
153        let value = |s: String| ConditionValue {
154            user_entered_value: Some(s),
155            relative_date: None,
156        };
157        let condition_type = self.condition_type_str().to_string();
158        let values = match self {
159            Self::OneOfList(items) => items.into_iter().map(value).collect(),
160            Self::OneOfRange(range) => vec![value(range)],
161            Self::NumberBetween(min, max) | Self::NumberNotBetween(min, max) => {
162                vec![value(min.to_string()), value(max.to_string())]
163            }
164            Self::NumberGreater(n)
165            | Self::NumberGreaterEq(n)
166            | Self::NumberLess(n)
167            | Self::NumberLessEq(n)
168            | Self::NumberEq(n)
169            | Self::NumberNotEq(n) => vec![value(n.to_string())],
170            Self::TextContains(text)
171            | Self::TextNotContains(text)
172            | Self::TextStartsWith(text)
173            | Self::TextEndsWith(text)
174            | Self::TextEq(text) => vec![value(text)],
175            Self::DateAfter(date) | Self::DateBefore(date) | Self::DateOn(date) => {
176                vec![date.into_condition_value()]
177            }
178            Self::DateBetween(start, end) => vec![value(start), value(end)],
179            Self::Blank | Self::NotBlank | Self::Checkbox => Vec::new(),
180            Self::CustomFormula(formula) => vec![value(formula)],
181        };
182        BooleanCondition {
183            condition_type,
184            values,
185        }
186    }
187}
188
189/// Which mutation to perform.
190#[derive(Debug, Clone, PartialEq)]
191pub enum ValidationVerb {
192    /// Set a validation rule on a range.
193    SetDataValidation {
194        /// A sheet title, supplying a prefix for a bare `range`.
195        sheet: Option<String>,
196        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
197        range: Option<String>,
198        /// The condition to enforce.
199        condition: Condition,
200        /// Tooltip shown on the cell.
201        input_message: Option<String>,
202        /// `false` (the default) rejects an invalid entry outright; `true`
203        /// only warns.
204        show_warning: bool,
205    },
206    /// Remove a range's validation rule.
207    ClearDataValidation {
208        /// A sheet title, supplying a prefix for a bare `range`.
209        sheet: Option<String>,
210        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
211        range: Option<String>,
212    },
213}
214
215impl ValidationVerb {
216    const fn log_operation(&self) -> &'static str {
217        match self {
218            Self::SetDataValidation { .. } => "sheets-set-data-validation",
219            Self::ClearDataValidation { .. } => "sheets-clear-data-validation",
220        }
221    }
222
223    const fn label(&self) -> &'static str {
224        match self {
225            Self::SetDataValidation { .. } => "set-data-validation",
226            Self::ClearDataValidation { .. } => "clear-data-validation",
227        }
228    }
229
230    fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
231        match self {
232            Self::SetDataValidation { sheet, range, .. }
233            | Self::ClearDataValidation { sheet, range } => (sheet.as_deref(), range.as_deref()),
234        }
235    }
236}
237
238/// Per-call options.
239#[derive(Debug, Clone)]
240pub struct ValidationOptions {
241    /// Spreadsheet id.
242    pub spreadsheet_id: String,
243    /// Which mutation to perform.
244    pub verb: ValidationVerb,
245    /// Classify and describe only; never call `batchUpdate`.
246    pub dry_run: bool,
247    /// The lease token presented via `--lease`. Checked only when the
248    /// deciding rule requires one
249    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
250    /// `None` is only ever valid when it does not.
251    pub lease_token: Option<String>,
252    /// Path to the lease ledger the token is checked against. Production
253    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
254    /// result; tests pass a path under a `tempdir`.
255    pub ledger_path: PathBuf,
256}
257
258/// What happened (or, under `--dry-run`, would happen).
259#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
260#[serde(tag = "status", rename_all = "kebab-case")]
261pub enum ValidationResult {
262    /// `--dry-run`, and the gate would allow it.
263    WouldChange {
264        /// A human-readable summary of the effect.
265        summary: String,
266        /// When `--range` runs past the sheet's current grid, the part of
267        /// it inside the grid — all Sheets applies the request to, since it
268        /// clamps the range silently rather than growing the sheet or
269        /// erroring (observed live, #1941). Omitted when the range fits.
270        #[serde(skip_serializing_if = "Option::is_none")]
271        clamped_to: Option<String>,
272    },
273    /// The target is not a Google Sheet.
274    RefusedNotASpreadsheet {
275        /// The target's actual MIME type.
276        mime_type: String,
277    },
278    /// The target is a shortcut, which we do not follow.
279    RefusedShortcut,
280    /// The target has no parents this account can see.
281    RefusedNoVisibleParents,
282    /// The named sheet does not exist in this workbook.
283    RefusedSheetNotFound {
284        /// The title that was not found.
285        title: String,
286        /// The titles that do exist.
287        available: Vec<String>,
288    },
289    /// The `--sheet`/`--range` pair, or the condition's own arguments, was
290    /// invalid.
291    RefusedInvalidRange {
292        /// What was wrong and why.
293        detail: String,
294    },
295    /// The folder write-permission gate refused it.
296    Blocked {
297        /// The rule that decided the refusal, if any.
298        decided_by: Option<DecidingRule>,
299    },
300    /// No `--lease` was presented, and the deciding rule requires one
301    /// (ADR-0080 §9).
302    RefusedNoLease,
303    /// The presented lease has expired, or was never a token this ledger
304    /// knows about.
305    RefusedLeaseExpired,
306    /// The presented lease is bound to a different file id.
307    RefusedLeaseWrongFile,
308    /// The file has moved since the lease's recorded `version` — the
309    /// staleness check (ADR-0080 §6).
310    RefusedLeaseStale,
311    /// The mutation succeeded.
312    Changed {
313        /// Same summary as [`Self::WouldChange`].
314        summary: String,
315        /// Same as [`Self::WouldChange`].
316        #[serde(skip_serializing_if = "Option::is_none")]
317        clamped_to: Option<String>,
318    },
319    /// The API answered 2xx — the mutation **was applied** — but its reply
320    /// could not be read (issue #2021). Distinct from [`Self::Failed`].
321    AppliedReplyUnreadable {
322        /// Same summary as [`Self::Changed`].
323        summary: String,
324        /// Why the reply could not be read.
325        detail: String,
326        /// Same as [`Self::Changed`] — plan-time, not from the reply.
327        #[serde(skip_serializing_if = "Option::is_none")]
328        clamped_to: Option<String>,
329    },
330    /// An API or validation error.
331    Failed {
332        /// A human-readable summary of what failed.
333        detail: String,
334    },
335}
336
337impl FromLeaseRefusal for ValidationResult {
338    fn from_no_lease() -> Self {
339        Self::RefusedNoLease
340    }
341    fn from_lease_expired() -> Self {
342        Self::RefusedLeaseExpired
343    }
344    fn from_lease_wrong_file() -> Self {
345        Self::RefusedLeaseWrongFile
346    }
347    fn from_lease_stale() -> Self {
348        Self::RefusedLeaseStale
349    }
350    fn from_lease_failed(detail: String) -> Self {
351        Self::Failed { detail }
352    }
353}
354
355impl ValidationResult {
356    fn log_status(&self) -> &'static str {
357        match self {
358            Self::WouldChange { .. } => "would-change",
359            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
360            Self::RefusedShortcut => "refused-shortcut",
361            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
362            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
363            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
364            Self::Blocked { .. } => "blocked",
365            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
366            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
367            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
368            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
369            Self::Changed { .. } => "changed",
370            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
371            Self::Failed { .. } => "failed",
372        }
373    }
374}
375
376/// The full outcome of one attempt.
377#[derive(Debug, Clone, Serialize, PartialEq)]
378pub struct ValidationOutcome {
379    /// The spreadsheet acted on.
380    pub spreadsheet_id: String,
381    /// Its Drive file name, when the metadata fetch got that far.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub file_name: Option<String>,
384    /// The folder the gate evaluated against.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub resolved_folder_id: Option<String>,
387    /// Which mutation was attempted. Not serialised.
388    #[serde(skip)]
389    pub verb: ValidationVerb,
390    /// What happened.
391    pub result: ValidationResult,
392}
393
394impl JsonlSerialize for ValidationOutcome {
395    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
396        write_scalar_jsonl(self, out)
397    }
398}
399
400/// Runs one data-validation mutation, logging every attempt that isn't a
401/// dry run.
402pub async fn validation(
403    drive: &DriveClient,
404    sheets: &SheetsClient,
405    opts: &ValidationOptions,
406    rules: &[FolderPermissionRule],
407) -> ValidationOutcome {
408    let started = Instant::now();
409    let outcome = validation_inner(drive, sheets, opts, rules).await;
410    if !opts.dry_run {
411        record_attempt(&outcome, opts, started.elapsed());
412    }
413    outcome
414}
415
416async fn validation_inner(
417    drive: &DriveClient,
418    sheets: &SheetsClient,
419    opts: &ValidationOptions,
420    rules: &[FolderPermissionRule],
421) -> ValidationOutcome {
422    let bare = |result| ValidationOutcome {
423        spreadsheet_id: opts.spreadsheet_id.clone(),
424        file_name: None,
425        resolved_folder_id: None,
426        verb: opts.verb.clone(),
427        result,
428    };
429
430    let (sheet, range) = opts.verb.sheet_and_range();
431    let composed_range = match a1::compose(sheet, range) {
432        Ok(composed) => composed,
433        Err(err) => {
434            return bare(ValidationResult::RefusedInvalidRange {
435                detail: err.to_string(),
436            })
437        }
438    };
439
440    if let ValidationVerb::SetDataValidation {
441        input_message: _,
442        condition,
443        ..
444    } = &opts.verb
445    {
446        if let Err(detail) = validate_condition(condition) {
447            return bare(ValidationResult::RefusedInvalidRange { detail });
448        }
449    }
450
451    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
452        drive,
453        &opts.spreadsheet_id,
454        DriveOperation::SheetsStructure,
455        rules,
456    )
457    .await
458    {
459        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
460            return bare(ValidationResult::Failed { detail })
461        }
462        target_gate::TargetGateOutcome::Refused { target, refusal } => {
463            let result = match refusal {
464                SheetTargetRefusal::Shortcut => ValidationResult::RefusedShortcut,
465                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
466                    ValidationResult::RefusedNotASpreadsheet { mime_type }
467                }
468                SheetTargetRefusal::NoVisibleParents => ValidationResult::RefusedNoVisibleParents,
469            };
470            return ValidationOutcome {
471                spreadsheet_id: opts.spreadsheet_id.clone(),
472                file_name: Some(target.name),
473                resolved_folder_id: None,
474                verb: opts.verb.clone(),
475                result,
476            };
477        }
478        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
479            return ValidationOutcome {
480                spreadsheet_id: opts.spreadsheet_id.clone(),
481                file_name: Some(target.name),
482                resolved_folder_id: None,
483                verb: opts.verb.clone(),
484                result: ValidationResult::Failed { detail },
485            };
486        }
487        target_gate::TargetGateOutcome::Gated {
488            target,
489            decision,
490            resolved_folder_id,
491            requires_lease,
492        } => (target, decision, resolved_folder_id, requires_lease),
493    };
494
495    let gated = |result| ValidationOutcome {
496        spreadsheet_id: opts.spreadsheet_id.clone(),
497        file_name: Some(target.name.clone()),
498        resolved_folder_id: resolved_folder_id.clone(),
499        verb: opts.verb.clone(),
500        result,
501    };
502
503    if decision.verdict == write_gate::Verdict::Deny {
504        return gated(ValidationResult::Blocked {
505            decided_by: decision.decided_by,
506        });
507    }
508
509    let api = SheetsApi::new(sheets);
510    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
511        Ok(workbook) => workbook,
512        Err(err) => {
513            return gated(ValidationResult::Failed {
514                detail: format!("{err:#}"),
515            })
516        }
517    };
518
519    let (sheet_title, grid) = match grid_range::resolve_grid_range(
520        &workbook,
521        &composed_range,
522        |detail| ValidationResult::RefusedInvalidRange { detail },
523        |title, available| ValidationResult::RefusedSheetNotFound { title, available },
524    ) {
525        Ok(resolved) => resolved,
526        Err(result) => return gated(result),
527    };
528
529    // Sheets clamps a range past the grid rather than growing the sheet
530    // or erroring, so a range wholly past it would change nothing and one
531    // partly past it changes less than it names. Refuse the first and name
532    // the clamped range for the second, in both modes.
533    let clamp = grid_range::clamp_to_sheet(&workbook, &grid)
534        .filter(|_| !grid_range::starts_past_grid(&workbook, &grid));
535    let clamped_to = match clamp {
536        None => {
537            return gated(ValidationResult::RefusedInvalidRange {
538                detail: format!(
539                    "{composed_range} lies wholly past the sheet's current grid, so there is \
540                     nothing for {} to apply to",
541                    opts.verb.label()
542                ),
543            })
544        }
545        Some(clamped) if clamped != grid => Some(
546            grid_range::bounded_range_to_a1(&sheet_title, &clamped)
547                .unwrap_or_else(|| grid_range::render_grid_range(&clamped)),
548        ),
549        Some(_) => None,
550    };
551
552    let summary = describe_effect(&opts.verb);
553
554    if opts.dry_run {
555        return gated(ValidationResult::WouldChange {
556            summary,
557            clamped_to,
558        });
559    }
560
561    // Built before the gate, not after — unlike its siblings this is
562    // infallible today (`ValidationVerb::into_boolean_condition` is an
563    // exhaustive, panic-free match), but the gate must still be the *last*
564    // fallible step before the mutating call (see `gate_leased_write`'s doc
565    // comment and `structure.rs`'s identical comment) so a future fallible
566    // verb here cannot fsync a `pending` audit record for a write that never
567    // happens (#1688).
568    let request = build_request(&opts.verb, grid);
569
570    // The lease check (ADR-0080 §9) sits here: after the permission gate
571    // and the `--dry-run` branch, before the mutating call — see
572    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
573    // shared verbatim by every leased engine. A fresh `files.get` immediately
574    // before `batchUpdate`, not a reuse of the metadata `target_gate::resolve`
575    // fetched before the (potentially slow) ancestor-chain walk and workbook
576    // fetch above.
577    let files_api = FilesApi::new(drive);
578    let leased = LeasedWrite {
579        log_prefix: "drive sheets validation",
580        operation: opts.verb.log_operation(),
581        ledger_path: &opts.ledger_path,
582        file_id: &opts.spreadsheet_id,
583    };
584    let lease_grant = match gate_optional_leased_write(
585        leased,
586        &files_api,
587        requires_lease,
588        opts.lease_token.as_deref(),
589    )
590    .await
591    {
592        Ok(grant) => grant,
593        Err(err) => return gated(err.into_result()),
594    };
595
596    let result = match conclude_native_leased_write(
597        leased,
598        &lease_grant,
599        &files_api,
600        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
601        |err| format!("{err:#}"),
602    )
603    .await
604    {
605        Ok(BatchUpdateOutcome::Applied(_)) => ValidationResult::Changed {
606            summary,
607            clamped_to,
608        },
609        // Idempotent: setting or clearing the same rule again leaves the
610        // same end state.
611        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
612            ValidationResult::AppliedReplyUnreadable {
613                summary,
614                detail,
615                clamped_to,
616            }
617        }
618        Err(err) => ValidationResult::Failed {
619            detail: format!("{err:#}"),
620        },
621    };
622    drop(lease_grant);
623    gated(result)
624}
625
626fn validate_condition(condition: &Condition) -> Result<(), String> {
627    // Exhaustive over `Condition`: a new variant forces a new arm here at
628    // compile time, rather than silently falling through unvalidated.
629    match condition {
630        Condition::OneOfList(items) => reject_empty_list(items, "--one-of-list"),
631        Condition::OneOfRange(range) => {
632            a1::validate_range(range).map_err(|err| format!("--one-of-range: {err:#}"))
633        }
634        Condition::NumberBetween(min, max) => reject_reversed_range(*min, *max, "--number-between"),
635        Condition::NumberNotBetween(min, max) => {
636            reject_reversed_range(*min, *max, "--number-not-between")
637        }
638        Condition::NumberGreater(n) => reject_nan(*n, "--number-greater"),
639        Condition::NumberGreaterEq(n) => reject_nan(*n, "--number-greater-eq"),
640        Condition::NumberLess(n) => reject_nan(*n, "--number-less"),
641        Condition::NumberLessEq(n) => reject_nan(*n, "--number-less-eq"),
642        Condition::NumberEq(n) => reject_nan(*n, "--number-eq"),
643        Condition::NumberNotEq(n) => reject_nan(*n, "--number-not-eq"),
644        // Unlike `reject_blank`, a whitespace-only search value is a
645        // legitimate thing to search for (e.g. `--text-contains ' '`), so
646        // only a truly empty string is rejected.
647        Condition::TextContains(text) => reject_empty(text, "--text-contains"),
648        Condition::TextNotContains(text) => reject_empty(text, "--text-not-contains"),
649        Condition::TextStartsWith(text) => reject_empty(text, "--text-starts-with"),
650        Condition::TextEndsWith(text) => reject_empty(text, "--text-ends-with"),
651        Condition::TextEq(text) => reject_empty(text, "--text-eq"),
652        Condition::DateAfter(date) => validate_single_date(date, "--date-after"),
653        Condition::DateBefore(date) => validate_single_date(date, "--date-before"),
654        Condition::DateOn(date) => validate_single_date(date, "--date-on"),
655        Condition::DateBetween(start, end) => reject_invalid_date_between(start, end),
656        Condition::Blank | Condition::NotBlank | Condition::Checkbox => Ok(()),
657        Condition::CustomFormula(formula) => {
658            reject_blank(formula, "--custom-formula")?;
659            // A formula with no leading `=` is accepted here and rejected
660            // by the API with a raw, unhelpful 400 once the request lands
661            // — give the hint locally instead (#1941).
662            if !formula.trim_start().starts_with('=') {
663                return Err(format!(
664                    "--custom-formula {formula:?} should start with '=', e.g. '=A1>0'; Sheets \
665                     rejects a custom formula with no leading '='"
666                ));
667            }
668            Ok(())
669        }
670    }
671}
672
673/// Validates one of `set-data-validation`'s single-value date flags
674/// (`--date-after`/`--date-before`/`--date-on`): rejects a blank date (the
675/// shared `reject_blank_date` check both this file and
676/// `conditional_format.rs` use identically), then rejects a relative
677/// keyword — Sheets accepts only an absolute date here, failing a relative
678/// one with "relativeDate values are not supported in data validation"
679/// (issue #1934, live-verified). That second check is deliberately local
680/// to this file rather than joining `date_value.rs`'s shared validators:
681/// `conditional_format.rs`'s identically-shaped `DateAfter`/`DateBefore`/
682/// `DateOn` genuinely does accept a relative keyword.
683fn validate_single_date(date: &DateValue, flag: &str) -> Result<(), String> {
684    reject_blank_date(date, flag)?;
685    if matches!(date, DateValue::Relative(_)) {
686        return Err(format!(
687            "{flag} only accepts an absolute date, not a relative keyword like 'today' — \
688             Sheets rejects relative dates in data validation (they work only in \
689             add-conditional-format/update-conditional-format)"
690        ));
691    }
692    Ok(())
693}
694
695fn reject_empty_list(items: &[String], flag: &str) -> Result<(), String> {
696    if items.is_empty() {
697        Err(format!("{flag} needs at least one value"))
698    } else {
699        Ok(())
700    }
701}
702
703fn describe_effect(verb: &ValidationVerb) -> String {
704    match verb {
705        ValidationVerb::SetDataValidation {
706            condition,
707            show_warning,
708            ..
709        } => {
710            let strictness = if *show_warning {
711                "warn only"
712            } else {
713                "reject invalid entries"
714            };
715            format!(
716                "set data validation ({}, {strictness})",
717                condition
718                    .condition_type_str()
719                    .to_ascii_lowercase()
720                    .replace('_', " ")
721            )
722        }
723        ValidationVerb::ClearDataValidation { .. } => "clear data validation".to_string(),
724    }
725}
726
727fn build_request(verb: &ValidationVerb, grid: GridRange) -> BatchUpdateRequestItem {
728    let rule = match verb {
729        ValidationVerb::SetDataValidation {
730            condition,
731            input_message,
732            show_warning,
733            ..
734        } => Some(DataValidationRule {
735            condition: condition.clone().into_boolean_condition(),
736            input_message: input_message.clone(),
737            strict: Some(!show_warning),
738        }),
739        ValidationVerb::ClearDataValidation { .. } => None,
740    };
741    BatchUpdateRequestItem::SetDataValidation(SetDataValidationRequest { range: grid, rule })
742}
743
744fn record_attempt(outcome: &ValidationOutcome, opts: &ValidationOptions, duration: Duration) {
745    request_log::record_drive_mutation(mutation_outcome(outcome, opts, duration));
746}
747
748/// The `drivemutation` record for one attempt. Split out from
749/// [`record_attempt`] so the logged fields are unit-testable.
750fn mutation_outcome(
751    outcome: &ValidationOutcome,
752    opts: &ValidationOptions,
753    duration: Duration,
754) -> DriveMutationOutcome {
755    let error = match &outcome.result {
756        ValidationResult::Failed { detail }
757        | ValidationResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
758        _ => None,
759    };
760    let fields_changed = match &outcome.result {
761        ValidationResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
762        _ => None,
763    };
764    let decided_by = match &outcome.result {
765        ValidationResult::Blocked { decided_by } => decided_by.as_ref(),
766        _ => None,
767    };
768    let decided_by = write_gate::decided_by_log_fields(decided_by);
769    let validation_type = match &opts.verb {
770        ValidationVerb::SetDataValidation { condition, .. } => {
771            Some(condition.condition_type_str().to_string())
772        }
773        ValidationVerb::ClearDataValidation { .. } => Some("cleared".to_string()),
774    };
775
776    DriveMutationOutcome {
777        operation: opts.verb.log_operation(),
778        file_id: outcome.spreadsheet_id.clone(),
779        file_name: outcome.file_name.clone().unwrap_or_default(),
780        status: outcome.result.log_status().to_string(),
781        resolved_folder_id: outcome.resolved_folder_id.clone(),
782        decided_by_folder_id: decided_by.folder_id,
783        decided_by_depth: decided_by.depth,
784        decided_by_file_id: decided_by.file_id,
785        validation_type,
786        fields_changed,
787        error,
788        duration,
789        ..Default::default()
790    }
791}
792
793/// The past-tense note that Sheets clamped a range running past the grid,
794/// shared by `Changed` and `AppliedReplyUnreadable` (the clamp is plan-time
795/// data, not read from the reply).
796fn clamped_applied_line(applied: &str) -> String {
797    format!(
798        "  the range ran past the sheet's current grid; Sheets clamped it, so this applied to \
799         {applied} only"
800    )
801}
802
803/// Renders an outcome as human-readable text.
804#[must_use]
805pub fn describe(outcome: &ValidationOutcome) -> String {
806    describe_lines(outcome).join("\n")
807}
808
809/// Renders an outcome as its individual lines, none of which contains a
810/// newline.
811#[must_use]
812pub fn describe_lines(outcome: &ValidationOutcome) -> Vec<String> {
813    let verb = &outcome.verb;
814    let book = outcome.file_name.as_deref().map_or_else(
815        || format!("'{}'", outcome.spreadsheet_id),
816        |n| format!("'{n}'"),
817    );
818    match &outcome.result {
819        ValidationResult::WouldChange {
820            summary,
821            clamped_to,
822        } => {
823            let mut lines = vec![format!("Would {summary} in {book}")];
824            lines.extend(clamped_to.as_ref().map(|applied| {
825                format!(
826                    "  the range runs past the sheet's current grid; Sheets clamps it, so this \
827                     would apply to {applied} only"
828                )
829            }));
830            lines
831        }
832        ValidationResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
833            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
834             `drive sheets {}` only works on spreadsheets",
835            verb.label()
836        )],
837        ValidationResult::RefusedShortcut => vec![format!(
838            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
839            verb.label()
840        )],
841        ValidationResult::RefusedNoVisibleParents => vec![format!(
842            "Refused: {book} has no parent folder visible to this account, so no folder \
843             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
844             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
845        )],
846        ValidationResult::RefusedSheetNotFound { title, available } => {
847            vec![grid_range::no_sheet_refusal(&book, title, available)]
848        }
849        ValidationResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
850        ValidationResult::Blocked { decided_by } => vec![match decided_by {
851            Some(rule) => format!(
852                "Blocked: {} on {book} refused by rule on {} {}{}",
853                verb.label(),
854                rule.kind_label(),
855                rule.id(),
856                rule.depth_suffix()
857            ),
858            None => format!(
859                "Blocked: {} on {book} refused by default policy (no matching rule for \
860                 sheets-structure)",
861                verb.label()
862            ),
863        }],
864        ValidationResult::RefusedNoLease => LeaseGateRefusal::NoLease
865            .describe_line(&outcome.spreadsheet_id, &book)
866            .into_iter()
867            .collect(),
868        ValidationResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
869            .describe_line(&outcome.spreadsheet_id, &book)
870            .into_iter()
871            .collect(),
872        ValidationResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
873            .describe_line(&outcome.spreadsheet_id, &book)
874            .into_iter()
875            .collect(),
876        ValidationResult::RefusedLeaseStale => LeaseGateRefusal::Stale
877            .describe_line(&outcome.spreadsheet_id, &book)
878            .into_iter()
879            .collect(),
880        ValidationResult::Changed {
881            summary,
882            clamped_to,
883        } => {
884            let mut lines = vec![format!("Applied: {summary} in {book}")];
885            lines.extend(clamped_to.as_deref().map(clamped_applied_line));
886            lines
887        }
888        ValidationResult::AppliedReplyUnreadable {
889            summary,
890            detail,
891            clamped_to,
892        } => {
893            let mut lines = vec![applied_reply_unreadable_line(
894                summary,
895                &book,
896                RetryHint::Idempotent,
897                detail,
898            )];
899            lines.extend(clamped_to.as_deref().map(clamped_applied_line));
900            lines
901        }
902        ValidationResult::Failed { detail } => vec![format!("Failed: {detail}")],
903    }
904}
905
906#[cfg(test)]
907#[allow(clippy::unwrap_used, clippy::expect_used)]
908mod tests {
909    use super::*;
910    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
911    use crate::drive::sheets::client::SHEETS_API_URL;
912    use crate::drive::test_support::seed_lease;
913    use crate::test_support::env::MapEnv;
914    use crate::utils::secret::Secret;
915    use std::collections::HashSet;
916
917    #[test]
918    fn one_of_list_builds_a_value_per_item() {
919        let condition = Condition::OneOfList(vec!["a".to_string(), "b".to_string()]);
920        let built = condition.into_boolean_condition();
921        assert_eq!(built.condition_type, "ONE_OF_LIST");
922        assert_eq!(built.values.len(), 2);
923        assert_eq!(built.values[0].user_entered_value, Some("a".to_string()));
924    }
925
926    #[test]
927    fn one_of_range_builds_a_single_value() {
928        let condition = Condition::OneOfRange("Sheet2!A1:A10".to_string());
929        let built = condition.into_boolean_condition();
930        assert_eq!(built.condition_type, "ONE_OF_RANGE");
931        assert_eq!(
932            built.values[0].user_entered_value,
933            Some("Sheet2!A1:A10".to_string())
934        );
935    }
936
937    #[test]
938    fn number_between_builds_two_values() {
939        let condition = Condition::NumberBetween(1.0, 10.0);
940        let built = condition.into_boolean_condition();
941        assert_eq!(built.condition_type, "NUMBER_BETWEEN");
942        assert_eq!(built.values[0].user_entered_value, Some("1".to_string()));
943        assert_eq!(built.values[1].user_entered_value, Some("10".to_string()));
944    }
945
946    #[test]
947    fn number_not_between_builds_two_values() {
948        let condition = Condition::NumberNotBetween(1.0, 10.0);
949        let built = condition.into_boolean_condition();
950        assert_eq!(built.condition_type, "NUMBER_NOT_BETWEEN");
951        assert_eq!(built.values.len(), 2);
952    }
953
954    #[test]
955    fn numeric_comparators_build_a_single_value_each() {
956        let cases = [
957            (Condition::NumberGreater(1.0), "NUMBER_GREATER"),
958            (Condition::NumberGreaterEq(1.0), "NUMBER_GREATER_THAN_EQ"),
959            (Condition::NumberLess(1.0), "NUMBER_LESS"),
960            (Condition::NumberLessEq(1.0), "NUMBER_LESS_THAN_EQ"),
961            (Condition::NumberEq(1.0), "NUMBER_EQ"),
962            (Condition::NumberNotEq(1.0), "NUMBER_NOT_EQ"),
963        ];
964        for (condition, expected_type) in cases {
965            let built = condition.into_boolean_condition();
966            assert_eq!(built.condition_type, expected_type);
967            assert_eq!(
968                built.values,
969                vec![ConditionValue {
970                    user_entered_value: Some("1".to_string()),
971                    relative_date: None,
972                }]
973            );
974        }
975    }
976
977    #[test]
978    fn text_conditions_build_a_single_value_each() {
979        let cases = [
980            (Condition::TextContains("x".to_string()), "TEXT_CONTAINS"),
981            (
982                Condition::TextNotContains("x".to_string()),
983                "TEXT_NOT_CONTAINS",
984            ),
985            (
986                Condition::TextStartsWith("x".to_string()),
987                "TEXT_STARTS_WITH",
988            ),
989            (Condition::TextEndsWith("x".to_string()), "TEXT_ENDS_WITH"),
990            (Condition::TextEq("x".to_string()), "TEXT_EQ"),
991        ];
992        for (condition, expected_type) in cases {
993            let built = condition.into_boolean_condition();
994            assert_eq!(built.condition_type, expected_type);
995            assert_eq!(built.values[0].user_entered_value, Some("x".to_string()));
996        }
997    }
998
999    #[test]
1000    fn date_conditions_build_an_absolute_value() {
1001        let cases = [
1002            (
1003                Condition::DateAfter(DateValue::Absolute("2024-01-01".to_string())),
1004                "DATE_AFTER",
1005            ),
1006            (
1007                Condition::DateBefore(DateValue::Absolute("2024-01-01".to_string())),
1008                "DATE_BEFORE",
1009            ),
1010            (
1011                Condition::DateOn(DateValue::Absolute("2024-01-01".to_string())),
1012                "DATE_EQ",
1013            ),
1014        ];
1015        for (condition, expected_type) in cases {
1016            let built = condition.into_boolean_condition();
1017            assert_eq!(built.condition_type, expected_type);
1018            assert_eq!(
1019                built.values[0].user_entered_value,
1020                Some("2024-01-01".to_string())
1021            );
1022            assert_eq!(built.values[0].relative_date, None);
1023        }
1024    }
1025
1026    // `date_value_parse_is_case_and_separator_insensitive` and
1027    // `relative_date_all_covers_every_variant` moved to
1028    // `date_value.rs`'s own test module (issue #1793) — `RelativeDate`/
1029    // `DateValue` are defined there now.
1030
1031    #[test]
1032    fn date_between_builds_two_absolute_values() {
1033        let condition = Condition::DateBetween("2024-01-01".to_string(), "2024-12-31".to_string());
1034        let built = condition.into_boolean_condition();
1035        assert_eq!(built.condition_type, "DATE_BETWEEN");
1036        assert_eq!(built.values.len(), 2);
1037    }
1038
1039    #[test]
1040    fn blank_and_not_blank_have_no_values() {
1041        assert_eq!(
1042            Condition::Blank.into_boolean_condition().condition_type,
1043            "BLANK"
1044        );
1045        assert!(Condition::Blank.into_boolean_condition().values.is_empty());
1046        assert_eq!(
1047            Condition::NotBlank.into_boolean_condition().condition_type,
1048            "NOT_BLANK"
1049        );
1050        assert!(Condition::NotBlank
1051            .into_boolean_condition()
1052            .values
1053            .is_empty());
1054    }
1055
1056    #[test]
1057    fn checkbox_has_no_values() {
1058        let built = Condition::Checkbox.into_boolean_condition();
1059        assert_eq!(built.condition_type, "BOOLEAN");
1060        assert!(built.values.is_empty());
1061    }
1062
1063    #[test]
1064    fn custom_formula_builds_a_single_value() {
1065        let condition = Condition::CustomFormula("=A1>0".to_string());
1066        let built = condition.into_boolean_condition();
1067        assert_eq!(built.condition_type, "CUSTOM_FORMULA");
1068        assert_eq!(built.values.len(), 1);
1069        assert_eq!(
1070            built.values[0].user_entered_value,
1071            Some("=A1>0".to_string())
1072        );
1073    }
1074
1075    #[test]
1076    fn condition_type_str_covers_every_condition() {
1077        let types = [
1078            Condition::OneOfList(vec!["a".to_string()]).condition_type_str(),
1079            Condition::OneOfRange("A1:A10".to_string()).condition_type_str(),
1080            Condition::NumberBetween(1.0, 2.0).condition_type_str(),
1081            Condition::NumberNotBetween(1.0, 2.0).condition_type_str(),
1082            Condition::NumberGreater(1.0).condition_type_str(),
1083            Condition::NumberGreaterEq(1.0).condition_type_str(),
1084            Condition::NumberLess(1.0).condition_type_str(),
1085            Condition::NumberLessEq(1.0).condition_type_str(),
1086            Condition::NumberEq(1.0).condition_type_str(),
1087            Condition::NumberNotEq(1.0).condition_type_str(),
1088            Condition::TextContains("x".to_string()).condition_type_str(),
1089            Condition::TextNotContains("x".to_string()).condition_type_str(),
1090            Condition::TextStartsWith("x".to_string()).condition_type_str(),
1091            Condition::TextEndsWith("x".to_string()).condition_type_str(),
1092            Condition::TextEq("x".to_string()).condition_type_str(),
1093            Condition::DateAfter(DateValue::Absolute("d".to_string())).condition_type_str(),
1094            Condition::DateBefore(DateValue::Absolute("d".to_string())).condition_type_str(),
1095            Condition::DateOn(DateValue::Absolute("d".to_string())).condition_type_str(),
1096            Condition::DateBetween("a".to_string(), "b".to_string()).condition_type_str(),
1097            Condition::Blank.condition_type_str(),
1098            Condition::NotBlank.condition_type_str(),
1099            Condition::Checkbox.condition_type_str(),
1100            Condition::CustomFormula("=TRUE".to_string()).condition_type_str(),
1101        ];
1102        let unique: HashSet<&&str> = types.iter().collect();
1103        assert_eq!(unique.len(), types.len());
1104    }
1105
1106    fn set_verb(condition: Condition, show_warning: bool) -> ValidationVerb {
1107        ValidationVerb::SetDataValidation {
1108            sheet: Some("Q1".to_string()),
1109            range: Some("A1:A10".to_string()),
1110            condition,
1111            input_message: None,
1112            show_warning,
1113        }
1114    }
1115
1116    fn clear_verb() -> ValidationVerb {
1117        ValidationVerb::ClearDataValidation {
1118            sheet: Some("Q1".to_string()),
1119            range: Some("A1:A10".to_string()),
1120        }
1121    }
1122
1123    #[test]
1124    fn log_operation_and_label_cover_every_verb() {
1125        let set = set_verb(Condition::Checkbox, false);
1126        let clear = clear_verb();
1127        assert_eq!(set.log_operation(), "sheets-set-data-validation");
1128        assert_eq!(clear.log_operation(), "sheets-clear-data-validation");
1129        assert_eq!(set.label(), "set-data-validation");
1130        assert_eq!(clear.label(), "clear-data-validation");
1131    }
1132
1133    #[test]
1134    fn describe_effect_covers_every_condition_and_strictness() {
1135        let reject = describe_effect(&set_verb(
1136            Condition::OneOfList(vec!["a".to_string()]),
1137            false,
1138        ));
1139        assert!(reject.contains("reject invalid entries"), "{reject}");
1140        assert!(reject.contains("one of list"), "{reject}");
1141
1142        let warn = describe_effect(&set_verb(Condition::NumberBetween(1.0, 10.0), true));
1143        assert!(warn.contains("warn only"), "{warn}");
1144        assert!(warn.contains("number between"), "{warn}");
1145
1146        let checkbox = describe_effect(&set_verb(Condition::Checkbox, false));
1147        assert!(checkbox.contains("boolean"), "{checkbox}");
1148
1149        let formula = describe_effect(&set_verb(
1150            Condition::CustomFormula("=A1>0".to_string()),
1151            false,
1152        ));
1153        assert!(formula.contains("custom formula"), "{formula}");
1154
1155        assert_eq!(describe_effect(&clear_verb()), "clear data validation");
1156    }
1157
1158    #[test]
1159    fn validate_condition_accepts_a_valid_number_between() {
1160        validate_condition(&Condition::NumberBetween(1.0, 10.0)).unwrap();
1161    }
1162
1163    #[test]
1164    fn validate_condition_accepts_a_non_empty_custom_formula() {
1165        validate_condition(&Condition::CustomFormula("=A1>0".to_string())).unwrap();
1166    }
1167
1168    #[test]
1169    fn validate_condition_rejects_an_empty_one_of_list() {
1170        let err = validate_condition(&Condition::OneOfList(Vec::new())).unwrap_err();
1171        assert!(err.contains("at least one value"), "{err}");
1172    }
1173
1174    #[test]
1175    fn validate_condition_rejects_a_reversed_number_between() {
1176        let err = validate_condition(&Condition::NumberBetween(10.0, 1.0)).unwrap_err();
1177        assert!(err.contains("must not exceed"), "{err}");
1178    }
1179
1180    #[test]
1181    fn validate_condition_rejects_a_nan_bound() {
1182        // `NaN > x` and `x > NaN` are both `false`, so a plain `min > max`
1183        // guard lets this through uncaught.
1184        let err = validate_condition(&Condition::NumberBetween(f64::NAN, 5.0)).unwrap_err();
1185        assert!(err.contains("must not exceed"), "{err}");
1186        let err = validate_condition(&Condition::NumberBetween(1.0, f64::NAN)).unwrap_err();
1187        assert!(err.contains("must not exceed"), "{err}");
1188    }
1189
1190    #[test]
1191    fn validate_condition_rejects_a_blank_custom_formula() {
1192        let err = validate_condition(&Condition::CustomFormula("   ".to_string())).unwrap_err();
1193        assert!(err.contains("must not be empty"), "{err}");
1194    }
1195
1196    #[test]
1197    fn validate_condition_rejects_a_custom_formula_missing_its_leading_equals() {
1198        // `--custom-formula 'F2>0'` (no leading `=`) reached the API and got
1199        // a raw 400 with no local hint (#1941).
1200        let err = validate_condition(&Condition::CustomFormula("F2>0".to_string())).unwrap_err();
1201        assert!(err.contains("should start with '='"), "{err}");
1202    }
1203
1204    #[test]
1205    fn validate_condition_accepts_a_valid_one_of_range() {
1206        validate_condition(&Condition::OneOfRange("Sheet2!A1:A10".to_string())).unwrap();
1207    }
1208
1209    #[test]
1210    fn validate_condition_rejects_a_blank_one_of_range() {
1211        let err = validate_condition(&Condition::OneOfRange(String::new())).unwrap_err();
1212        // Not the generic `a1::compose` "pass --range, --sheet, or both"
1213        // message — `--one-of-range` has no `--sheet` escape hatch.
1214        assert!(err.contains("must not be empty"), "{err}");
1215        assert!(!err.contains("--sheet"), "{err}");
1216    }
1217
1218    #[test]
1219    fn validate_condition_accepts_a_valid_number_not_between() {
1220        validate_condition(&Condition::NumberNotBetween(1.0, 10.0)).unwrap();
1221    }
1222
1223    #[test]
1224    fn validate_condition_rejects_a_reversed_number_not_between() {
1225        let err = validate_condition(&Condition::NumberNotBetween(10.0, 1.0)).unwrap_err();
1226        assert!(err.contains("must not exceed"), "{err}");
1227    }
1228
1229    #[test]
1230    fn validate_condition_rejects_a_nan_comparator() {
1231        let err = validate_condition(&Condition::NumberGreater(f64::NAN)).unwrap_err();
1232        assert!(err.contains("must not be NaN"), "{err}");
1233    }
1234
1235    #[test]
1236    fn validate_condition_accepts_a_numeric_comparator() {
1237        validate_condition(&Condition::NumberEq(1.0)).unwrap();
1238    }
1239
1240    #[test]
1241    fn validate_condition_rejects_empty_text() {
1242        let err = validate_condition(&Condition::TextContains(String::new())).unwrap_err();
1243        assert!(err.contains("must not be empty"), "{err}");
1244    }
1245
1246    #[test]
1247    fn validate_condition_accepts_non_blank_text() {
1248        validate_condition(&Condition::TextEq("x".to_string())).unwrap();
1249    }
1250
1251    #[test]
1252    fn validate_condition_accepts_whitespace_only_text() {
1253        // A whitespace-only value is a legitimate thing to search for, unlike
1254        // the flags that use `reject_blank` (e.g. `--custom-formula`).
1255        validate_condition(&Condition::TextContains(" ".to_string())).unwrap();
1256    }
1257
1258    #[test]
1259    fn validate_condition_rejects_a_blank_absolute_date() {
1260        let err = validate_condition(&Condition::DateAfter(DateValue::Absolute("  ".to_string())))
1261            .unwrap_err();
1262        assert!(err.contains("must not be empty"), "{err}");
1263    }
1264
1265    #[test]
1266    fn validate_condition_rejects_a_relative_date() {
1267        // issue #1934's exact repro: Sheets rejects relativeDate values in
1268        // data validation, unlike conditional formatting, which accepts
1269        // them for the identically-shaped condition.
1270        for (condition, flag) in [
1271            (
1272                Condition::DateAfter(DateValue::parse("today".to_string())),
1273                "--date-after",
1274            ),
1275            (
1276                Condition::DateBefore(DateValue::parse("past-week".to_string())),
1277                "--date-before",
1278            ),
1279            (
1280                Condition::DateOn(DateValue::parse("yesterday".to_string())),
1281                "--date-on",
1282            ),
1283        ] {
1284            let err = validate_condition(&condition).unwrap_err();
1285            assert!(err.contains(flag), "{err}");
1286            assert!(err.contains("relative"), "{err}");
1287        }
1288    }
1289
1290    #[test]
1291    fn validate_condition_accepts_an_absolute_date() {
1292        validate_condition(&Condition::DateAfter(DateValue::parse(
1293            "2024-01-01".to_string(),
1294        )))
1295        .unwrap();
1296    }
1297
1298    #[test]
1299    fn validate_condition_accepts_a_valid_date_between() {
1300        validate_condition(&Condition::DateBetween(
1301            "2024-01-01".to_string(),
1302            "2024-12-31".to_string(),
1303        ))
1304        .unwrap();
1305    }
1306
1307    #[test]
1308    fn validate_condition_rejects_a_blank_date_between_bound() {
1309        let err = validate_condition(&Condition::DateBetween(
1310            "2024-01-01".to_string(),
1311            "  ".to_string(),
1312        ))
1313        .unwrap_err();
1314        assert!(err.contains("must not be empty"), "{err}");
1315    }
1316
1317    #[test]
1318    fn validate_condition_rejects_a_relative_keyword_in_date_between() {
1319        let err = validate_condition(&Condition::DateBetween(
1320            "today".to_string(),
1321            "2024-12-31".to_string(),
1322        ))
1323        .unwrap_err();
1324        assert!(err.contains("absolute dates"), "{err}");
1325        // Issue #1934: the single-value date flags refuse relative keywords
1326        // here too, so the refusal must not redirect the user to them.
1327        assert!(!err.contains("--date-after"), "{err}");
1328
1329        let err = validate_condition(&Condition::DateBetween(
1330            "2024-01-01".to_string(),
1331            "tomorrow".to_string(),
1332        ))
1333        .unwrap_err();
1334        assert!(err.contains("absolute dates"), "{err}");
1335    }
1336
1337    #[test]
1338    fn validate_condition_rejects_a_reversed_date_between() {
1339        let err = validate_condition(&Condition::DateBetween(
1340            "2024-12-31".to_string(),
1341            "2024-01-01".to_string(),
1342        ))
1343        .unwrap_err();
1344        assert!(err.contains("must not be after"), "{err}");
1345    }
1346
1347    #[test]
1348    fn validate_condition_trusts_a_non_iso_date_between_through_untouched() {
1349        // Neither bound parses as `YYYY-MM-DD`, so the ordering check is
1350        // skipped and the value reaches the API as-is — the same
1351        // trust-the-caller stance as every other free-form date string here.
1352        validate_condition(&Condition::DateBetween(
1353            "12/31/2024".to_string(),
1354            "01/01/2024".to_string(),
1355        ))
1356        .unwrap();
1357    }
1358
1359    #[test]
1360    fn validate_condition_accepts_blank_and_not_blank() {
1361        validate_condition(&Condition::Blank).unwrap();
1362        validate_condition(&Condition::NotBlank).unwrap();
1363    }
1364
1365    #[test]
1366    fn clear_sets_no_rule() {
1367        let grid = GridRange {
1368            sheet_id: 1,
1369            ..Default::default()
1370        };
1371        let request = build_request(
1372            &ValidationVerb::ClearDataValidation {
1373                sheet: None,
1374                range: Some("A1".to_string()),
1375            },
1376            grid,
1377        );
1378        match request {
1379            BatchUpdateRequestItem::SetDataValidation(req) => assert!(req.rule.is_none()),
1380            other => panic!("expected SetDataValidation, got {other:?}"),
1381        }
1382    }
1383
1384    fn test_credentials() -> DriveCredentials {
1385        DriveCredentials {
1386            client_id: "client-1".to_string(),
1387            client_secret: Secret::new("secret-1"),
1388            refresh_token: Secret::new("refresh-1"),
1389            scope: DriveGrantedScopes::READONLY,
1390        }
1391    }
1392
1393    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1394        wiremock::Mock::given(wiremock::matchers::method("POST"))
1395            .and(wiremock::matchers::path("/token"))
1396            .respond_with(
1397                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1398                    "access_token": "test-token", "expires_in": 3600,
1399                })),
1400            )
1401            .mount(server)
1402            .await;
1403        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1404        crate::drive::client::test_support::replace_session(
1405            &mut drive,
1406            &test_credentials(),
1407            &format!("{}/token", server.uri()),
1408        );
1409        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1410        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1411        (drive, sheets)
1412    }
1413
1414    /// `version: "1"` throughout — matches [`leased_opts_for`]'s default
1415    /// seeded lease, so any test reaching the mutating call has a live,
1416    /// non-stale lease by construction (ADR-0080 §9).
1417    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1418        let parents: Vec<&str> = parents.to_vec();
1419        wiremock::Mock::given(wiremock::matchers::method("GET"))
1420            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1421            .respond_with(
1422                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1423                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1424                    "version": "1",
1425                })),
1426            )
1427    }
1428
1429    fn mount_folder(id: &str) -> wiremock::Mock {
1430        mount_file(id, "application/vnd.google-apps.folder", &[])
1431    }
1432
1433    fn allow_rule(folder: &str) -> FolderPermissionRule {
1434        FolderPermissionRule {
1435            folder_id: Some(folder.to_string()),
1436            file_id: None,
1437            recursive: true,
1438            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1439            deny: HashSet::default(),
1440            require_lease: true,
1441        }
1442    }
1443
1444    /// A fresh, isolated ledger path holding a live lease for `"sheet-1"`
1445    /// at version `"1"` (matching [`mount_file`]'s default).
1446    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1447        let ledger_path = tempfile::tempdir()
1448            .unwrap()
1449            .keep()
1450            .join("lease-ledger.jsonl");
1451        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1452        (Some(token), ledger_path)
1453    }
1454
1455    fn mount_workbook() -> wiremock::Mock {
1456        wiremock::Mock::given(wiremock::matchers::method("GET"))
1457            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1458            .respond_with(
1459                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1460                    "spreadsheetId": "sheet-1",
1461                    "properties": {"title": "Budget"},
1462                    "sheets": [
1463                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1464                    ],
1465                })),
1466            )
1467    }
1468
1469    #[tokio::test]
1470    async fn a_denied_gate_blocks_before_any_batch_update_call() {
1471        let server = wiremock::MockServer::start().await;
1472        let (drive, sheets) = clients(&server).await;
1473        mount_file(
1474            "sheet-1",
1475            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1476            &["folder-1"],
1477        )
1478        .mount(&server)
1479        .await;
1480        mount_folder("folder-1").mount(&server).await;
1481        let rules: Vec<FolderPermissionRule> = Vec::new();
1482        let opts = ValidationOptions {
1483            spreadsheet_id: "sheet-1".to_string(),
1484            verb: ValidationVerb::SetDataValidation {
1485                sheet: Some("Q1".to_string()),
1486                range: Some("A1:A10".to_string()),
1487                condition: Condition::Checkbox,
1488                input_message: None,
1489                show_warning: false,
1490            },
1491            dry_run: false,
1492            lease_token: None,
1493            ledger_path: std::path::PathBuf::new(),
1494        };
1495        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1496        assert!(matches!(outcome.result, ValidationResult::Blocked { .. }));
1497    }
1498
1499    #[tokio::test]
1500    async fn set_data_validation_sends_a_boolean_condition() {
1501        let server = wiremock::MockServer::start().await;
1502        let (drive, sheets) = clients(&server).await;
1503        mount_file(
1504            "sheet-1",
1505            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1506            &["folder-1"],
1507        )
1508        .mount(&server)
1509        .await;
1510        mount_folder("folder-1").mount(&server).await;
1511        mount_workbook().mount(&server).await;
1512        wiremock::Mock::given(wiremock::matchers::method("POST"))
1513            .and(wiremock::matchers::path(
1514                "/v4/spreadsheets/sheet-1:batchUpdate",
1515            ))
1516            .respond_with(
1517                wiremock::ResponseTemplate::new(200)
1518                    .set_body_json(serde_json::json!({"replies": [{}]})),
1519            )
1520            .mount(&server)
1521            .await;
1522        let rules = vec![allow_rule("folder-1")];
1523        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1524        let opts = ValidationOptions {
1525            spreadsheet_id: "sheet-1".to_string(),
1526            verb: ValidationVerb::SetDataValidation {
1527                sheet: Some("Q1".to_string()),
1528                range: Some("A1:A10".to_string()),
1529                condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
1530                input_message: Some("pick one".to_string()),
1531                show_warning: false,
1532            },
1533            dry_run: false,
1534            lease_token,
1535            ledger_path,
1536        };
1537        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1538        assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1539
1540        let requests = server.received_requests().await.unwrap();
1541        let batch = requests
1542            .iter()
1543            .find(|r| r.url.path().ends_with(":batchUpdate"))
1544            .unwrap();
1545        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1546        let rule = &body["requests"][0]["setDataValidation"]["rule"];
1547        assert_eq!(rule["condition"]["type"], "ONE_OF_LIST");
1548        assert_eq!(rule["strict"], true);
1549    }
1550
1551    #[tokio::test]
1552    async fn set_data_validation_sends_a_tranche_2_comparator_and_relative_date() {
1553        let server = wiremock::MockServer::start().await;
1554        let (drive, sheets) = clients(&server).await;
1555        mount_file(
1556            "sheet-1",
1557            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1558            &["folder-1"],
1559        )
1560        .mount(&server)
1561        .await;
1562        mount_folder("folder-1").mount(&server).await;
1563        mount_workbook().mount(&server).await;
1564        wiremock::Mock::given(wiremock::matchers::method("POST"))
1565            .and(wiremock::matchers::path(
1566                "/v4/spreadsheets/sheet-1:batchUpdate",
1567            ))
1568            .respond_with(
1569                wiremock::ResponseTemplate::new(200)
1570                    .set_body_json(serde_json::json!({"replies": [{}]})),
1571            )
1572            .mount(&server)
1573            .await;
1574        let rules = vec![allow_rule("folder-1")];
1575        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1576        let opts = ValidationOptions {
1577            spreadsheet_id: "sheet-1".to_string(),
1578            verb: ValidationVerb::SetDataValidation {
1579                sheet: Some("Q1".to_string()),
1580                range: Some("A1:A10".to_string()),
1581                condition: Condition::NumberGreater(0.0),
1582                input_message: None,
1583                show_warning: false,
1584            },
1585            dry_run: false,
1586            lease_token,
1587            ledger_path,
1588        };
1589        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1590        assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1591
1592        let requests = server.received_requests().await.unwrap();
1593        let batch = requests
1594            .iter()
1595            .find(|r| r.url.path().ends_with(":batchUpdate"))
1596            .unwrap();
1597        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1598        let rule = &body["requests"][0]["setDataValidation"]["rule"];
1599        assert_eq!(rule["condition"]["type"], "NUMBER_GREATER");
1600        assert_eq!(rule["condition"]["values"][0]["userEnteredValue"], "0");
1601        assert!(rule["condition"]["values"][0]["relativeDate"].is_null());
1602    }
1603
1604    #[tokio::test]
1605    async fn set_data_validation_refuses_a_relative_date_condition() {
1606        // issue #1934's exact repro: `--date-after today` used to reach the
1607        // API and fail with HTTP 400 ("relativeDate values are not
1608        // supported in data validation"). It must now be refused locally,
1609        // before the workbook is even fetched — no mocks besides the
1610        // client are set up, so reaching the API would fail this test.
1611        let server = wiremock::MockServer::start().await;
1612        let (drive, sheets) = clients(&server).await;
1613        let rules = vec![allow_rule("folder-1")];
1614        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1615        let opts = ValidationOptions {
1616            spreadsheet_id: "sheet-1".to_string(),
1617            verb: ValidationVerb::SetDataValidation {
1618                sheet: Some("Q1".to_string()),
1619                range: Some("A1:A10".to_string()),
1620                condition: Condition::DateAfter(DateValue::parse("today".to_string())),
1621                input_message: None,
1622                show_warning: false,
1623            },
1624            dry_run: false,
1625            lease_token,
1626            ledger_path,
1627        };
1628        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1629        match outcome.result {
1630            ValidationResult::RefusedInvalidRange { detail } => {
1631                assert!(detail.contains("--date-after"), "{detail}");
1632                assert!(detail.contains("relative"), "{detail}");
1633            }
1634            other => panic!("expected RefusedInvalidRange, got {other:?}"),
1635        }
1636    }
1637
1638    #[tokio::test]
1639    async fn clear_data_validation_full_apply_flow() {
1640        let server = wiremock::MockServer::start().await;
1641        let (drive, sheets) = clients(&server).await;
1642        mount_file(
1643            "sheet-1",
1644            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1645            &["folder-1"],
1646        )
1647        .mount(&server)
1648        .await;
1649        mount_folder("folder-1").mount(&server).await;
1650        mount_workbook().mount(&server).await;
1651        wiremock::Mock::given(wiremock::matchers::method("POST"))
1652            .and(wiremock::matchers::path(
1653                "/v4/spreadsheets/sheet-1:batchUpdate",
1654            ))
1655            .respond_with(
1656                wiremock::ResponseTemplate::new(200)
1657                    .set_body_json(serde_json::json!({"replies": [{}]})),
1658            )
1659            .mount(&server)
1660            .await;
1661        let rules = vec![allow_rule("folder-1")];
1662        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1663        let opts = ValidationOptions {
1664            spreadsheet_id: "sheet-1".to_string(),
1665            verb: clear_verb(),
1666            dry_run: false,
1667            lease_token,
1668            ledger_path,
1669        };
1670        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1671        assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
1672
1673        let requests = server.received_requests().await.unwrap();
1674        let batch = requests
1675            .iter()
1676            .find(|r| r.url.path().ends_with(":batchUpdate"))
1677            .unwrap();
1678        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1679        assert!(body["requests"][0]["setDataValidation"]["rule"].is_null());
1680    }
1681
1682    #[tokio::test]
1683    async fn dry_run_reports_would_change_without_calling_batch_update() {
1684        let server = wiremock::MockServer::start().await;
1685        let (drive, sheets) = clients(&server).await;
1686        mount_file(
1687            "sheet-1",
1688            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1689            &["folder-1"],
1690        )
1691        .mount(&server)
1692        .await;
1693        mount_folder("folder-1").mount(&server).await;
1694        mount_workbook().mount(&server).await;
1695        let rules = vec![allow_rule("folder-1")];
1696        let opts = ValidationOptions {
1697            spreadsheet_id: "sheet-1".to_string(),
1698            verb: set_verb(Condition::Checkbox, false),
1699            dry_run: true,
1700            lease_token: None,
1701            ledger_path: std::path::PathBuf::new(),
1702        };
1703        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1704        assert!(matches!(
1705            outcome.result,
1706            ValidationResult::WouldChange { .. }
1707        ));
1708        let requests = server.received_requests().await.unwrap();
1709        assert!(!requests
1710            .iter()
1711            .any(|r| r.url.path().ends_with(":batchUpdate")));
1712    }
1713
1714    /// Runs a `set-data-validation --range <range>` dry run against a
1715    /// 1000 x 26 `Q1`.
1716    async fn dry_run_on_a_sized_grid(range: &str) -> ValidationOutcome {
1717        let server = wiremock::MockServer::start().await;
1718        let (drive, sheets) = clients(&server).await;
1719        mount_file(
1720            "sheet-1",
1721            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1722            &["folder-1"],
1723        )
1724        .mount(&server)
1725        .await;
1726        mount_folder("folder-1").mount(&server).await;
1727        wiremock::Mock::given(wiremock::matchers::method("GET"))
1728            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1729            .respond_with(
1730                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1731                    "spreadsheetId": "sheet-1",
1732                    "properties": {"title": "Budget"},
1733                    "sheets": [{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1734                        "gridProperties": {"rowCount": 1000, "columnCount": 26}}}],
1735                })),
1736            )
1737            .mount(&server)
1738            .await;
1739        let opts = ValidationOptions {
1740            spreadsheet_id: "sheet-1".to_string(),
1741            verb: ValidationVerb::SetDataValidation {
1742                sheet: Some("Q1".to_string()),
1743                range: Some(range.to_string()),
1744                condition: Condition::Checkbox,
1745                input_message: None,
1746                show_warning: false,
1747            },
1748            dry_run: true,
1749            lease_token: None,
1750            ledger_path: std::path::PathBuf::new(),
1751        };
1752        validation(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await
1753    }
1754
1755    /// A range partly past the grid is not refused, but the preview names
1756    /// the clamped range Sheets actually applies it to (#1941: live,
1757    /// `Z1:AA2000` on a 1000 x 26 sheet was clamped silently).
1758    #[tokio::test]
1759    async fn a_range_past_the_grid_names_the_clamped_range() {
1760        let outcome = dry_run_on_a_sized_grid("Z1:AA2000").await;
1761        assert_eq!(
1762            outcome.result,
1763            ValidationResult::WouldChange {
1764                summary: describe_effect(&set_verb(Condition::Checkbox, false)),
1765                clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
1766            }
1767        );
1768        let text = describe(&outcome);
1769        assert!(text.contains("would apply to 'Q1'!Z1:Z1000 only"), "{text}");
1770
1771        let inside = dry_run_on_a_sized_grid("A1:B10").await;
1772        assert!(
1773            matches!(
1774                inside.result,
1775                ValidationResult::WouldChange {
1776                    clamped_to: None,
1777                    ..
1778                }
1779            ),
1780            "{:?}",
1781            inside.result
1782        );
1783    }
1784
1785    #[tokio::test]
1786    async fn a_range_wholly_past_the_grid_is_refused() {
1787        // Bounded, and open-ended with its start past the last row — the
1788        // second has no end for `clamp_to_sheet` to clip.
1789        for range in ["AB1:AC5", "A2000:A"] {
1790            let outcome = dry_run_on_a_sized_grid(range).await;
1791            let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1792                panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1793                // omni-dev: coverage ignore-line reason="guards this test's assumption; only reached when the refusal regresses"
1794            };
1795            assert!(
1796                detail.contains("wholly past the sheet's current grid"),
1797                "{detail}"
1798            );
1799        }
1800        // An open-ended range starting inside the grid is not.
1801        let inside = dry_run_on_a_sized_grid("A5:A").await;
1802        assert!(
1803            matches!(inside.result, ValidationResult::WouldChange { .. }),
1804            "{:?}",
1805            inside.result
1806        );
1807    }
1808
1809    #[tokio::test]
1810    async fn compose_error_returns_refused_invalid_range() {
1811        let server = wiremock::MockServer::start().await;
1812        let (drive, sheets) = clients(&server).await;
1813        let opts = ValidationOptions {
1814            spreadsheet_id: "sheet-1".to_string(),
1815            verb: ValidationVerb::SetDataValidation {
1816                sheet: None,
1817                range: None,
1818                condition: Condition::Checkbox,
1819                input_message: None,
1820                show_warning: false,
1821            },
1822            dry_run: false,
1823            lease_token: None,
1824            ledger_path: std::path::PathBuf::new(),
1825        };
1826        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1827        let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1828            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1829        };
1830        assert!(detail.contains("a range is required"), "{detail}");
1831        assert!(outcome.file_name.is_none());
1832    }
1833
1834    #[tokio::test]
1835    async fn invalid_condition_is_refused_before_any_network_call() {
1836        let server = wiremock::MockServer::start().await;
1837        let (drive, sheets) = clients(&server).await;
1838        let opts = ValidationOptions {
1839            spreadsheet_id: "sheet-1".to_string(),
1840            verb: set_verb(Condition::NumberBetween(10.0, 1.0), false),
1841            dry_run: false,
1842            lease_token: None,
1843            ledger_path: std::path::PathBuf::new(),
1844        };
1845        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1846        let ValidationResult::RefusedInvalidRange { detail } = &outcome.result else {
1847            panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
1848        };
1849        assert!(detail.contains("must not exceed"), "{detail}");
1850        let requests = server.received_requests().await.unwrap();
1851        assert!(!requests
1852            .iter()
1853            .any(|r| r.url.path().starts_with("/drive/v3/files")));
1854    }
1855
1856    #[tokio::test]
1857    async fn metadata_fetch_failure_is_reported_as_failed() {
1858        let server = wiremock::MockServer::start().await;
1859        let (drive, sheets) = clients(&server).await;
1860        wiremock::Mock::given(wiremock::matchers::method("GET"))
1861            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1862            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1863            .mount(&server)
1864            .await;
1865        let opts = ValidationOptions {
1866            spreadsheet_id: "sheet-1".to_string(),
1867            verb: set_verb(Condition::Checkbox, false),
1868            dry_run: false,
1869            lease_token: None,
1870            ledger_path: std::path::PathBuf::new(),
1871        };
1872        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1873        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1874        assert!(outcome.file_name.is_none());
1875    }
1876
1877    #[tokio::test]
1878    async fn shortcut_is_refused() {
1879        let server = wiremock::MockServer::start().await;
1880        let (drive, sheets) = clients(&server).await;
1881        mount_file(
1882            "sheet-1",
1883            "application/vnd.google-apps.shortcut",
1884            &["folder-1"],
1885        )
1886        .mount(&server)
1887        .await;
1888        let opts = ValidationOptions {
1889            spreadsheet_id: "sheet-1".to_string(),
1890            verb: set_verb(Condition::Checkbox, false),
1891            dry_run: false,
1892            lease_token: None,
1893            ledger_path: std::path::PathBuf::new(),
1894        };
1895        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1896        assert!(matches!(outcome.result, ValidationResult::RefusedShortcut));
1897        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
1898    }
1899
1900    #[tokio::test]
1901    async fn not_a_spreadsheet_is_refused() {
1902        let server = wiremock::MockServer::start().await;
1903        let (drive, sheets) = clients(&server).await;
1904        mount_file("sheet-1", "application/pdf", &["folder-1"])
1905            .mount(&server)
1906            .await;
1907        let opts = ValidationOptions {
1908            spreadsheet_id: "sheet-1".to_string(),
1909            verb: set_verb(Condition::Checkbox, false),
1910            dry_run: false,
1911            lease_token: None,
1912            ledger_path: std::path::PathBuf::new(),
1913        };
1914        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1915        assert!(matches!(
1916            outcome.result,
1917            ValidationResult::RefusedNotASpreadsheet { ref mime_type } if mime_type == "application/pdf"
1918        ));
1919    }
1920
1921    #[tokio::test]
1922    async fn no_visible_parents_is_refused() {
1923        let server = wiremock::MockServer::start().await;
1924        let (drive, sheets) = clients(&server).await;
1925        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1926            .mount(&server)
1927            .await;
1928        let opts = ValidationOptions {
1929            spreadsheet_id: "sheet-1".to_string(),
1930            verb: set_verb(Condition::Checkbox, false),
1931            dry_run: false,
1932            lease_token: None,
1933            ledger_path: std::path::PathBuf::new(),
1934        };
1935        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1936        assert!(matches!(
1937            outcome.result,
1938            ValidationResult::RefusedNoVisibleParents
1939        ));
1940    }
1941
1942    #[tokio::test]
1943    async fn gate_fetch_failure_is_reported_as_failed() {
1944        let server = wiremock::MockServer::start().await;
1945        let (drive, sheets) = clients(&server).await;
1946        mount_file(
1947            "sheet-1",
1948            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1949            &["folder-1"],
1950        )
1951        .mount(&server)
1952        .await;
1953        wiremock::Mock::given(wiremock::matchers::method("GET"))
1954            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1955            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1956            .mount(&server)
1957            .await;
1958        let opts = ValidationOptions {
1959            spreadsheet_id: "sheet-1".to_string(),
1960            verb: set_verb(Condition::Checkbox, false),
1961            dry_run: false,
1962            lease_token: None,
1963            ledger_path: std::path::PathBuf::new(),
1964        };
1965        let outcome = validation(&drive, &sheets, &opts, &[]).await;
1966        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1967        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
1968    }
1969
1970    #[tokio::test]
1971    async fn workbook_fetch_failure_is_reported_as_failed() {
1972        let server = wiremock::MockServer::start().await;
1973        let (drive, sheets) = clients(&server).await;
1974        mount_file(
1975            "sheet-1",
1976            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1977            &["folder-1"],
1978        )
1979        .mount(&server)
1980        .await;
1981        mount_folder("folder-1").mount(&server).await;
1982        wiremock::Mock::given(wiremock::matchers::method("GET"))
1983            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1984            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1985            .mount(&server)
1986            .await;
1987        let rules = vec![allow_rule("folder-1")];
1988        let opts = ValidationOptions {
1989            spreadsheet_id: "sheet-1".to_string(),
1990            verb: set_verb(Condition::Checkbox, false),
1991            dry_run: false,
1992            lease_token: None,
1993            ledger_path: std::path::PathBuf::new(),
1994        };
1995        let outcome = validation(&drive, &sheets, &opts, &rules).await;
1996        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
1997    }
1998
1999    #[tokio::test]
2000    async fn sheet_not_found_is_refused() {
2001        let server = wiremock::MockServer::start().await;
2002        let (drive, sheets) = clients(&server).await;
2003        mount_file(
2004            "sheet-1",
2005            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2006            &["folder-1"],
2007        )
2008        .mount(&server)
2009        .await;
2010        mount_folder("folder-1").mount(&server).await;
2011        mount_workbook().mount(&server).await;
2012        let rules = vec![allow_rule("folder-1")];
2013        let opts = ValidationOptions {
2014            spreadsheet_id: "sheet-1".to_string(),
2015            verb: ValidationVerb::SetDataValidation {
2016                sheet: Some("Nope".to_string()),
2017                range: Some("A1:A10".to_string()),
2018                condition: Condition::Checkbox,
2019                input_message: None,
2020                show_warning: false,
2021            },
2022            dry_run: false,
2023            lease_token: None,
2024            ledger_path: std::path::PathBuf::new(),
2025        };
2026        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2027        let ValidationResult::RefusedSheetNotFound { title, available } = &outcome.result else {
2028            panic!("expected RefusedSheetNotFound, got {:?}", outcome.result);
2029        };
2030        assert_eq!(title, "Nope");
2031        assert_eq!(available, &["Q1".to_string()]);
2032    }
2033
2034    #[tokio::test]
2035    async fn invalid_grid_range_is_refused() {
2036        let server = wiremock::MockServer::start().await;
2037        let (drive, sheets) = clients(&server).await;
2038        mount_file(
2039            "sheet-1",
2040            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2041            &["folder-1"],
2042        )
2043        .mount(&server)
2044        .await;
2045        mount_folder("folder-1").mount(&server).await;
2046        mount_workbook().mount(&server).await;
2047        let rules = vec![allow_rule("folder-1")];
2048        let opts = ValidationOptions {
2049            spreadsheet_id: "sheet-1".to_string(),
2050            verb: ValidationVerb::SetDataValidation {
2051                sheet: Some("Q1".to_string()),
2052                range: Some("??".to_string()),
2053                condition: Condition::Checkbox,
2054                input_message: None,
2055                show_warning: false,
2056            },
2057            dry_run: false,
2058            lease_token: None,
2059            ledger_path: std::path::PathBuf::new(),
2060        };
2061        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2062        assert!(matches!(
2063            outcome.result,
2064            ValidationResult::RefusedInvalidRange { .. }
2065        ));
2066    }
2067
2068    #[tokio::test]
2069    async fn batch_update_failure_is_reported_as_failed_not_changed() {
2070        let server = wiremock::MockServer::start().await;
2071        let (drive, sheets) = clients(&server).await;
2072        mount_file(
2073            "sheet-1",
2074            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2075            &["folder-1"],
2076        )
2077        .mount(&server)
2078        .await;
2079        mount_folder("folder-1").mount(&server).await;
2080        mount_workbook().mount(&server).await;
2081        wiremock::Mock::given(wiremock::matchers::method("POST"))
2082            .and(wiremock::matchers::path(
2083                "/v4/spreadsheets/sheet-1:batchUpdate",
2084            ))
2085            .respond_with(
2086                wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
2087                    "error": {
2088                        "code": 403,
2089                        "message": "The caller does not have permission",
2090                        "status": "PERMISSION_DENIED",
2091                    }
2092                })),
2093            )
2094            .mount(&server)
2095            .await;
2096        let rules = vec![allow_rule("folder-1")];
2097        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2098        let opts = ValidationOptions {
2099            spreadsheet_id: "sheet-1".to_string(),
2100            verb: set_verb(Condition::Checkbox, false),
2101            dry_run: false,
2102            lease_token,
2103            ledger_path,
2104        };
2105        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2106        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2107    }
2108
2109    #[tokio::test]
2110    async fn set_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2111        let server = wiremock::MockServer::start().await;
2112        let (drive, sheets) = clients(&server).await;
2113        mount_file(
2114            "sheet-1",
2115            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2116            &["folder-1"],
2117        )
2118        .mount(&server)
2119        .await;
2120        mount_folder("folder-1").mount(&server).await;
2121        mount_workbook().mount(&server).await;
2122        // 2xx, so the rule WAS set — but the body is not the reply shape.
2123        wiremock::Mock::given(wiremock::matchers::method("POST"))
2124            .and(wiremock::matchers::path(
2125                "/v4/spreadsheets/sheet-1:batchUpdate",
2126            ))
2127            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2128            .expect(1)
2129            .mount(&server)
2130            .await;
2131        let rules = vec![allow_rule("folder-1")];
2132        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2133        let opts = ValidationOptions {
2134            spreadsheet_id: "sheet-1".to_string(),
2135            verb: set_verb(Condition::Checkbox, false),
2136            dry_run: false,
2137            lease_token,
2138            ledger_path,
2139        };
2140        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2141        assert_eq!(
2142            outcome.result.log_status(),
2143            "applied-reply-unreadable",
2144            "{:?}",
2145            outcome.result
2146        );
2147        let text = describe(&outcome);
2148        assert!(
2149            text.starts_with("Applied, but the reply could not be read"),
2150            "{text}"
2151        );
2152    }
2153
2154    #[test]
2155    fn describe_lines_renders_applied_reply_unreadable() {
2156        let outcome = ValidationOutcome {
2157            spreadsheet_id: "sheet-1".to_string(),
2158            file_name: Some("Budget".to_string()),
2159            resolved_folder_id: None,
2160            verb: clear_verb(),
2161            result: ValidationResult::AppliedReplyUnreadable {
2162                summary: "clear data validation".to_string(),
2163                detail: "bad reply".to_string(),
2164                clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2165            },
2166        };
2167        // The plan-time clamp note survives, worded exactly as `Changed`
2168        // words it.
2169        assert_eq!(
2170            describe_lines(&outcome),
2171            vec![
2172                "Applied, but the reply could not be read: clear data validation in 'Budget' — \
2173                 check the spreadsheet to confirm (bad reply)"
2174                    .to_string(),
2175                "  the range ran past the sheet's current grid; Sheets clamped it, so this \
2176                 applied to 'Q1'!Z1:Z1000 only"
2177                    .to_string(),
2178            ]
2179        );
2180    }
2181
2182    #[test]
2183    fn the_unreadable_reply_logs_everything_changed_logs() {
2184        let outcome = ValidationOutcome {
2185            spreadsheet_id: "sheet-1".to_string(),
2186            file_name: Some("Budget".to_string()),
2187            resolved_folder_id: None,
2188            verb: set_verb(Condition::Checkbox, false),
2189            result: ValidationResult::AppliedReplyUnreadable {
2190                summary: "set data validation".to_string(),
2191                detail: "bad reply".to_string(),
2192                clamped_to: None,
2193            },
2194        };
2195        let opts = ValidationOptions {
2196            spreadsheet_id: "sheet-1".to_string(),
2197            verb: set_verb(Condition::Checkbox, false),
2198            dry_run: false,
2199            lease_token: None,
2200            ledger_path: std::path::PathBuf::new(),
2201        };
2202        let logged = mutation_outcome(&outcome, &opts, Duration::ZERO);
2203        assert_eq!(logged.status, "applied-reply-unreadable");
2204        assert_eq!(
2205            logged.validation_type.as_deref(),
2206            Some(Condition::Checkbox.condition_type_str())
2207        );
2208        assert_eq!(
2209            logged.fields_changed.as_deref(),
2210            Some("set data validation")
2211        );
2212        assert_eq!(logged.error.as_deref(), Some("bad reply"));
2213    }
2214
2215    fn every_validation_result() -> Vec<ValidationResult> {
2216        let all = vec![
2217            ValidationResult::WouldChange {
2218                summary: "set data validation".to_string(),
2219                clamped_to: None,
2220            },
2221            ValidationResult::WouldChange {
2222                summary: "set data validation".to_string(),
2223                clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2224            },
2225            ValidationResult::RefusedNotASpreadsheet {
2226                mime_type: "application/pdf".to_string(),
2227            },
2228            ValidationResult::RefusedShortcut,
2229            ValidationResult::RefusedNoVisibleParents,
2230            ValidationResult::RefusedSheetNotFound {
2231                title: "Nope".to_string(),
2232                available: vec!["Q1".to_string(), "Q2".to_string()],
2233            },
2234            ValidationResult::RefusedSheetNotFound {
2235                title: "Nope".to_string(),
2236                available: Vec::new(),
2237            },
2238            ValidationResult::RefusedInvalidRange {
2239                detail: "bad range".to_string(),
2240            },
2241            ValidationResult::Blocked { decided_by: None },
2242            ValidationResult::Blocked {
2243                decided_by: Some(DecidingRule::Folder {
2244                    folder_id: "folder-1".to_string(),
2245                    depth: 2,
2246                }),
2247            },
2248            ValidationResult::RefusedNoLease,
2249            ValidationResult::RefusedLeaseExpired,
2250            ValidationResult::RefusedLeaseWrongFile,
2251            ValidationResult::RefusedLeaseStale,
2252            ValidationResult::Changed {
2253                summary: "set data validation".to_string(),
2254                clamped_to: None,
2255            },
2256            ValidationResult::Changed {
2257                summary: "set data validation".to_string(),
2258                clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2259            },
2260            ValidationResult::AppliedReplyUnreadable {
2261                summary: "set data validation".to_string(),
2262                detail: "bad reply".to_string(),
2263                clamped_to: None,
2264            },
2265            ValidationResult::AppliedReplyUnreadable {
2266                summary: "set data validation".to_string(),
2267                detail: "bad reply".to_string(),
2268                clamped_to: Some("'Q1'!Z1:Z1000".to_string()),
2269            },
2270            ValidationResult::Failed {
2271                detail: "boom".to_string(),
2272            },
2273        ];
2274        // Compile-time exhaustiveness: a new variant fails to match here.
2275        for result in &all {
2276            match result {
2277                ValidationResult::WouldChange { .. }
2278                | ValidationResult::RefusedNotASpreadsheet { .. }
2279                | ValidationResult::RefusedShortcut
2280                | ValidationResult::RefusedNoVisibleParents
2281                | ValidationResult::RefusedSheetNotFound { .. }
2282                | ValidationResult::RefusedInvalidRange { .. }
2283                | ValidationResult::Blocked { .. }
2284                | ValidationResult::RefusedNoLease
2285                | ValidationResult::RefusedLeaseExpired
2286                | ValidationResult::RefusedLeaseWrongFile
2287                | ValidationResult::RefusedLeaseStale
2288                | ValidationResult::Changed { .. }
2289                | ValidationResult::AppliedReplyUnreadable { .. }
2290                | ValidationResult::Failed { .. } => {}
2291            }
2292        }
2293        all
2294    }
2295
2296    #[test]
2297    fn describe_lines_covers_every_result_for_every_verb() {
2298        for verb in [clear_verb(), set_verb(Condition::Checkbox, false)] {
2299            for result in every_validation_result() {
2300                let outcome = ValidationOutcome {
2301                    spreadsheet_id: "sheet-1".to_string(),
2302                    file_name: Some("Budget".to_string()),
2303                    resolved_folder_id: None,
2304                    verb: verb.clone(),
2305                    result,
2306                };
2307                let lines = describe_lines(&outcome);
2308                // Only a range clamped to the grid earns a second line.
2309                let clamped = matches!(
2310                    &outcome.result,
2311                    ValidationResult::WouldChange {
2312                        clamped_to: Some(_),
2313                        ..
2314                    } | ValidationResult::Changed {
2315                        clamped_to: Some(_),
2316                        ..
2317                    } | ValidationResult::AppliedReplyUnreadable {
2318                        clamped_to: Some(_),
2319                        ..
2320                    }
2321                );
2322                assert_eq!(
2323                    lines.len(),
2324                    1 + usize::from(clamped),
2325                    "{:?}",
2326                    outcome.result
2327                );
2328                assert_eq!(describe(&outcome), lines.join("\n"));
2329                for rendered in &lines {
2330                    assert!(!rendered.chars().any(char::is_control), "{rendered:?}");
2331                }
2332            }
2333        }
2334    }
2335
2336    #[test]
2337    fn log_status_covers_every_variant() {
2338        let statuses: HashSet<&'static str> = every_validation_result()
2339            .iter()
2340            .map(ValidationResult::log_status)
2341            .collect();
2342        // 14 `ValidationResult` variants; `every_validation_result` lists 19
2343        // entries so it can also exercise the two shapes each of
2344        // `WouldChange`, `RefusedSheetNotFound`, `Blocked`, `Changed` and
2345        // `AppliedReplyUnreadable`,
2346        // which share a `log_status`.
2347        assert_eq!(statuses.len(), 14);
2348        assert!(statuses.iter().all(|s| !s.is_empty()));
2349    }
2350
2351    #[test]
2352    fn write_jsonl_emits_one_line_of_json() {
2353        let outcome = ValidationOutcome {
2354            spreadsheet_id: "sheet-1".to_string(),
2355            file_name: Some("Budget".to_string()),
2356            resolved_folder_id: None,
2357            verb: clear_verb(),
2358            result: ValidationResult::Changed {
2359                summary: "clear data validation".to_string(),
2360                clamped_to: None,
2361            },
2362        };
2363        let mut buf = Vec::new();
2364        outcome.write_jsonl(&mut buf).unwrap();
2365        let text = String::from_utf8(buf).unwrap();
2366        assert_eq!(text.matches('\n').count(), 1);
2367        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2368        assert_eq!(parsed["result"]["status"], "changed");
2369    }
2370
2371    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
2372
2373    #[tokio::test]
2374    async fn refuses_without_a_lease_when_the_rule_requires_one() {
2375        let server = wiremock::MockServer::start().await;
2376        let (drive, sheets) = clients(&server).await;
2377        mount_file(
2378            "sheet-1",
2379            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2380            &["folder-1"],
2381        )
2382        .mount(&server)
2383        .await;
2384        mount_folder("folder-1").mount(&server).await;
2385        mount_workbook().mount(&server).await;
2386        // No batchUpdate mock mounted — a refusal must make zero mutating
2387        // calls.
2388        let rules = vec![allow_rule("folder-1")];
2389
2390        let opts = ValidationOptions {
2391            spreadsheet_id: "sheet-1".to_string(),
2392            verb: set_verb(Condition::Checkbox, false),
2393            dry_run: false,
2394            lease_token: None,
2395            ledger_path: std::path::PathBuf::new(),
2396        };
2397        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2398        assert!(matches!(outcome.result, ValidationResult::RefusedNoLease));
2399        assert_eq!(outcome.result.log_status(), "refused-no-lease");
2400    }
2401
2402    #[tokio::test]
2403    async fn reports_a_lock_acquisition_failure_as_failed() {
2404        // A pre-existing lock file simulates another `drive lease`
2405        // operation genuinely in progress — reported as an operational
2406        // failure, not folded into `RefusedLeaseExpired`.
2407        let server = wiremock::MockServer::start().await;
2408        let (drive, sheets) = clients(&server).await;
2409        mount_file(
2410            "sheet-1",
2411            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2412            &["folder-1"],
2413        )
2414        .mount(&server)
2415        .await;
2416        mount_folder("folder-1").mount(&server).await;
2417        mount_workbook().mount(&server).await;
2418        let rules = vec![allow_rule("folder-1")];
2419        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2420        // Under `flock` (issue #1687), a busy lock now waits rather than
2421        // hard-failing (`check_and_lock_lease` -> `acquire_waiting`), so a
2422        // held `LedgerLock` no longer reproduces an immediate failure here.
2423        // A directory at the lock path does: opening it for write fails
2424        // outright with an I/O error, which is never retried.
2425        let mut lock_path = ledger_path.clone().into_os_string();
2426        lock_path.push(".lock");
2427        std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
2428
2429        let opts = ValidationOptions {
2430            spreadsheet_id: "sheet-1".to_string(),
2431            verb: set_verb(Condition::Checkbox, false),
2432            dry_run: false,
2433            lease_token,
2434            ledger_path,
2435        };
2436        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2437        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2438    }
2439
2440    #[tokio::test]
2441    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
2442        // The gate's own resolve step succeeds off the first `files.get`,
2443        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
2444        // fails — the change must report `Failed` and never reach
2445        // `batchUpdate`.
2446        let server = wiremock::MockServer::start().await;
2447        let (drive, sheets) = clients(&server).await;
2448        mount_file(
2449            "sheet-1",
2450            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2451            &["folder-1"],
2452        )
2453        .up_to_n_times(1)
2454        .with_priority(1)
2455        .mount(&server)
2456        .await;
2457        wiremock::Mock::given(wiremock::matchers::method("GET"))
2458            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2459            .respond_with(wiremock::ResponseTemplate::new(500))
2460            .with_priority(2)
2461            .mount(&server)
2462            .await;
2463        mount_folder("folder-1").mount(&server).await;
2464        mount_workbook().mount(&server).await;
2465        wiremock::Mock::given(wiremock::matchers::method("POST"))
2466            .and(wiremock::matchers::path(
2467                "/v4/spreadsheets/sheet-1:batchUpdate",
2468            ))
2469            .respond_with(wiremock::ResponseTemplate::new(200))
2470            .expect(0)
2471            .mount(&server)
2472            .await;
2473        let rules = vec![allow_rule("folder-1")];
2474        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2475
2476        let opts = ValidationOptions {
2477            spreadsheet_id: "sheet-1".to_string(),
2478            verb: set_verb(Condition::Checkbox, false),
2479            dry_run: false,
2480            lease_token,
2481            ledger_path,
2482        };
2483        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2484        assert!(matches!(outcome.result, ValidationResult::Failed { .. }));
2485    }
2486
2487    #[tokio::test]
2488    async fn refuses_an_unknown_lease_token() {
2489        let server = wiremock::MockServer::start().await;
2490        let (drive, sheets) = clients(&server).await;
2491        mount_file(
2492            "sheet-1",
2493            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2494            &["folder-1"],
2495        )
2496        .mount(&server)
2497        .await;
2498        mount_folder("folder-1").mount(&server).await;
2499        mount_workbook().mount(&server).await;
2500        let rules = vec![allow_rule("folder-1")];
2501        let ledger_path = tempfile::tempdir()
2502            .unwrap()
2503            .keep()
2504            .join("lease-ledger.jsonl");
2505        // Never seeded — the ledger exists nowhere near this token.
2506
2507        let opts = ValidationOptions {
2508            spreadsheet_id: "sheet-1".to_string(),
2509            verb: set_verb(Condition::Checkbox, false),
2510            dry_run: false,
2511            lease_token: Some("bogus-token".to_string()),
2512            ledger_path,
2513        };
2514        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2515        assert!(matches!(
2516            outcome.result,
2517            ValidationResult::RefusedLeaseExpired
2518        ));
2519    }
2520
2521    #[tokio::test]
2522    async fn refuses_a_lease_bound_to_a_different_file() {
2523        let server = wiremock::MockServer::start().await;
2524        let (drive, sheets) = clients(&server).await;
2525        mount_file(
2526            "sheet-1",
2527            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2528            &["folder-1"],
2529        )
2530        .mount(&server)
2531        .await;
2532        mount_folder("folder-1").mount(&server).await;
2533        mount_workbook().mount(&server).await;
2534        let rules = vec![allow_rule("folder-1")];
2535        let ledger_path = tempfile::tempdir()
2536            .unwrap()
2537            .keep()
2538            .join("lease-ledger.jsonl");
2539        // Seeded for a *different* spreadsheet id.
2540        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2541
2542        let opts = ValidationOptions {
2543            spreadsheet_id: "sheet-1".to_string(),
2544            verb: set_verb(Condition::Checkbox, false),
2545            dry_run: false,
2546            lease_token: Some(token),
2547            ledger_path,
2548        };
2549        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2550        assert!(matches!(
2551            outcome.result,
2552            ValidationResult::RefusedLeaseWrongFile
2553        ));
2554    }
2555
2556    #[tokio::test]
2557    async fn refuses_a_stale_lease_when_the_file_has_moved() {
2558        let server = wiremock::MockServer::start().await;
2559        let (drive, sheets) = clients(&server).await;
2560        // `mount_file` always returns version "1"; the lease below was
2561        // acquired against version "0" — a foreign edit landed since.
2562        mount_file(
2563            "sheet-1",
2564            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2565            &["folder-1"],
2566        )
2567        .mount(&server)
2568        .await;
2569        mount_folder("folder-1").mount(&server).await;
2570        mount_workbook().mount(&server).await;
2571        let rules = vec![allow_rule("folder-1")];
2572        let ledger_path = tempfile::tempdir()
2573            .unwrap()
2574            .keep()
2575            .join("lease-ledger.jsonl");
2576        let token = seed_lease(&ledger_path, "sheet-1", "0");
2577
2578        let opts = ValidationOptions {
2579            spreadsheet_id: "sheet-1".to_string(),
2580            verb: set_verb(Condition::Checkbox, false),
2581            dry_run: false,
2582            lease_token: Some(token),
2583            ledger_path,
2584        };
2585        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2586        assert!(matches!(
2587            outcome.result,
2588            ValidationResult::RefusedLeaseStale
2589        ));
2590    }
2591
2592    #[tokio::test]
2593    async fn require_lease_false_writes_without_a_lease() {
2594        let server = wiremock::MockServer::start().await;
2595        let (drive, sheets) = clients(&server).await;
2596        mount_file(
2597            "sheet-1",
2598            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2599            &["folder-1"],
2600        )
2601        .mount(&server)
2602        .await;
2603        mount_folder("folder-1").mount(&server).await;
2604        mount_workbook().mount(&server).await;
2605        wiremock::Mock::given(wiremock::matchers::method("POST"))
2606            .and(wiremock::matchers::path(
2607                "/v4/spreadsheets/sheet-1:batchUpdate",
2608            ))
2609            .respond_with(
2610                wiremock::ResponseTemplate::new(200)
2611                    .set_body_json(serde_json::json!({"replies": [{}]})),
2612            )
2613            .mount(&server)
2614            .await;
2615        let rule = FolderPermissionRule {
2616            folder_id: Some("folder-1".to_string()),
2617            file_id: None,
2618            recursive: true,
2619            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2620            deny: HashSet::default(),
2621            require_lease: false,
2622        };
2623
2624        // No lease token presented at all, and no ledger exists.
2625        let opts = ValidationOptions {
2626            spreadsheet_id: "sheet-1".to_string(),
2627            verb: set_verb(Condition::Checkbox, false),
2628            dry_run: false,
2629            lease_token: None,
2630            ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
2631        };
2632        let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
2633        assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
2634    }
2635
2636    #[tokio::test]
2637    async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
2638        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
2639        // not the *meaning* — a token volunteered anyway is checked exactly
2640        // like a required one, including staleness.
2641        let server = wiremock::MockServer::start().await;
2642        let (drive, sheets) = clients(&server).await;
2643        // `mount_file` always returns version "1"; the lease below was
2644        // acquired against version "0" — a foreign edit landed since.
2645        mount_file(
2646            "sheet-1",
2647            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2648            &["folder-1"],
2649        )
2650        .mount(&server)
2651        .await;
2652        mount_folder("folder-1").mount(&server).await;
2653        mount_workbook().mount(&server).await;
2654        // No batchUpdate mock mounted — a refusal must make zero mutating
2655        // calls.
2656        let rule = FolderPermissionRule {
2657            folder_id: Some("folder-1".to_string()),
2658            file_id: None,
2659            recursive: true,
2660            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
2661            deny: HashSet::default(),
2662            require_lease: false,
2663        };
2664        let ledger_path = tempfile::tempdir()
2665            .unwrap()
2666            .keep()
2667            .join("lease-ledger.jsonl");
2668        let token = seed_lease(&ledger_path, "sheet-1", "0");
2669
2670        let opts = ValidationOptions {
2671            spreadsheet_id: "sheet-1".to_string(),
2672            verb: set_verb(Condition::Checkbox, false),
2673            dry_run: false,
2674            lease_token: Some(token),
2675            ledger_path,
2676        };
2677        let outcome = validation(&drive, &sheets, &opts, &[rule]).await;
2678        assert!(matches!(
2679            outcome.result,
2680            ValidationResult::RefusedLeaseStale
2681        ));
2682    }
2683
2684    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
2685
2686    #[tokio::test]
2687    async fn a_leased_validation_change_concludes_its_audit_pair_with_allowed() {
2688        let server = wiremock::MockServer::start().await;
2689        let (drive, sheets) = clients(&server).await;
2690        mount_file(
2691            "sheet-1",
2692            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2693            &["folder-1"],
2694        )
2695        .mount(&server)
2696        .await;
2697        mount_folder("folder-1").mount(&server).await;
2698        mount_workbook().mount(&server).await;
2699        wiremock::Mock::given(wiremock::matchers::method("POST"))
2700            .and(wiremock::matchers::path(
2701                "/v4/spreadsheets/sheet-1:batchUpdate",
2702            ))
2703            .respond_with(
2704                wiremock::ResponseTemplate::new(200)
2705                    .set_body_json(serde_json::json!({"replies": [{}]})),
2706            )
2707            .mount(&server)
2708            .await;
2709        let dir = tempfile::tempdir().unwrap();
2710        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
2711        let rules = vec![allow_rule("folder-1")];
2712        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2713        let opts = ValidationOptions {
2714            spreadsheet_id: "sheet-1".to_string(),
2715            verb: ValidationVerb::SetDataValidation {
2716                sheet: Some("Q1".to_string()),
2717                range: Some("A1:A10".to_string()),
2718                condition: Condition::OneOfList(vec!["yes".to_string(), "no".to_string()]),
2719                input_message: None,
2720                show_warning: false,
2721            },
2722            dry_run: false,
2723            lease_token,
2724            ledger_path,
2725        };
2726
2727        let outcome = validation(&drive, &sheets, &opts, &rules).await;
2728        assert!(matches!(outcome.result, ValidationResult::Changed { .. }));
2729
2730        let records = audit.records();
2731        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
2732        // The verb, not the engine — the same `["drive", <log_operation>]`
2733        // this write's `drivemutation` record carries, so an auditor can
2734        // see which verb ran without joining back to `log.jsonl`.
2735        assert_eq!(records[0].command, ["drive", "sheets-set-data-validation"]);
2736    }
2737}