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