Skip to main content

omni_dev/drive/sheets/
filter.rs

1//! The basic filter and filter views via `spreadsheets.batchUpdate` (issue
2//! #1794, [ADR-0081](../../../docs/adrs/adr-0081.md)).
3//!
4//! Gated by [`DriveOperation::SheetsStructure`] — every mutating verb here
5//! reaches that operation, unlike `structure.rs`'s split between
6//! `SheetsStructure` and `SheetsDelete`. ADR-0081 §1 settles this: a filter
7//! hides rows, which is view state, not data.
8//!
9//! **One exception: `set-basic-filter --sort-by`** (issue #1940). A basic
10//! filter's `sortSpecs` are not view state — the server applies them by
11//! physically reordering the rows of the filtered range, `sortRange`-style,
12//! and the reorder survives `clear-basic-filter`. That is the same
13//! permutation `randomize-range` performs, which live verification found
14//! carries each row's formatting, notes and data-validation rules with it
15//! (ADR-0083 §5), so it takes the same union of [`DriveOperation::SheetsWrite`]
16//! and [`DriveOperation::SheetsStructure`] — see
17//! `FilterVerb::gate_operations`. Its preview and report carry
18//! `sort-range`'s record-integrity caveats. A filter view's sort
19//! (`add-filter-view`/`update-filter-view --sort-by`) is per-view and never
20//! touches the grid, so it stays on `SheetsStructure` alone.
21//!
22//! Two shapes, each with its own upsert/CRUD story:
23//!
24//! - **The basic filter** has at most one per sheet, so `set-basic-filter`
25//!   is a plain upsert — no existing state to merge with — and
26//!   `clear-basic-filter` needs no identifier beyond the sheet
27//!   (`ClearBasicFilterRequest` carries only a `sheetId`).
28//! - **Filter views** are many, named and directly id-addressed. Unlike
29//!   `protection.rs`'s protected ranges (which have no user-facing handle
30//!   and so are resolved by exact range match), a filter view's
31//!   `filterViewId` is the stable handle `list-filter-views` discovers, so
32//!   `update-filter-view`/`delete-filter-view` take it directly via
33//!   `--filter-view-id` — there is no ambiguous-match case to handle.
34//!
35//! `list-filter-views` is read-only and ungated, like `list-protections`
36//! (ADR-0081).
37//!
38//! **Two documented cuts, not silent gaps** — matching `validation.rs`'s own
39//! framing of its curated condition surface:
40//!
41//! - **No `duplicate-filter-view`.** The issue's own background section
42//!   names `duplicateFilterView` as part of the API surface, but its
43//!   "Proposed verbs" list omits it; this module ships exactly that list.
44//! - **`FilterCriteria` supports only `hiddenValues`** — the literal
45//!   "uncheck a value in the dropdown" filter, the most common real use.
46//!   Condition-based filter criteria (the same `BooleanCondition`
47//!   vocabulary `validation.rs` curates for data validation) is not
48//!   exposed.
49//!
50//! Shape mirrors `protection.rs`: compose a target, resolve it against a
51//! freshly-fetched workbook, gate, dry-run, mutate, log.
52
53use std::collections::BTreeMap;
54use std::path::PathBuf;
55use std::time::{Duration, Instant};
56
57use serde::Serialize;
58
59use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
60use crate::drive::client::DriveClient;
61use crate::drive::files_api::FilesApi;
62use crate::drive::lease::check::{
63    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
64    LeasedWrite,
65};
66use crate::drive::sheets::a1;
67use crate::drive::sheets::api::{
68    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
69};
70use crate::drive::sheets::client::SheetsClient;
71use crate::drive::sheets::grid_range;
72use crate::drive::sheets::target_gate;
73use crate::drive::sheets::types::{
74    AddFilterViewRequest, BasicFilter, BatchUpdateRequestItem, ClearBasicFilterRequest,
75    DeleteFilterViewRequest, FilterCriteria, FilterView, FilterViewUpdate, GridRange,
76    SetBasicFilterRequest, SortOrder, SortSpec, Spreadsheet, UpdateFilterViewRequest,
77};
78use crate::drive::types::SheetTargetRefusal;
79use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
80use crate::request_log::{self, DriveMutationOutcome};
81
82/// Appended to `update-filter-view`'s summary when the change is sent as a
83/// re-creation ([`FilterViewWrite::Replace`]).
84const REPLACE_NOTE: &str = " — re-created under the same id, since Sheets cannot remove a \
85     sort column in place";
86
87/// The gate for every verb that only changes view state.
88const GATE_STRUCTURE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsStructure];
89/// The gate for `set-basic-filter --sort-by`, which physically reorders
90/// rows (module docs). `SheetsWrite` first:
91/// `target_gate::TargetGateUnionOutcome::Gated::denied` names the first of
92/// these that denied, and under the `sheets-structure`-only grant every
93/// other verb here needs, `sheets-write` is the operation actually missing.
94const GATE_WRITE_AND_STRUCTURE: &[DriveOperation] =
95    &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
96
97/// Which mutation to perform.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum FilterVerb {
100    /// Upsert the basic filter on a sheet.
101    SetBasicFilter {
102        /// The sheet to filter.
103        sheet: String,
104        /// The filtered A1 range, optionally carrying its own `Sheet!`
105        /// prefix.
106        range: String,
107        /// `<COL>:<asc|desc>` sort specs, in priority order.
108        sort_by: Vec<String>,
109        /// `<COL>:<v1,v2,...>` hidden-value criteria, one per column.
110        hide_values: Vec<String>,
111    },
112    /// Remove a sheet's basic filter.
113    ClearBasicFilter {
114        /// The sheet to clear the basic filter from.
115        sheet: String,
116    },
117    /// Add a named filter view.
118    AddFilterView {
119        /// The sheet to filter.
120        sheet: String,
121        /// The filtered A1 range, optionally carrying its own `Sheet!`
122        /// prefix.
123        range: String,
124        /// A human-readable name for the view.
125        title: Option<String>,
126        /// `<COL>:<asc|desc>` sort specs, in priority order.
127        sort_by: Vec<String>,
128        /// `<COL>:<v1,v2,...>` hidden-value criteria, one per column.
129        hide_values: Vec<String>,
130    },
131    /// Change an existing filter view's title, range, sort order, or hidden
132    /// values.
133    UpdateFilterView {
134        /// Which filter view to change, discovered via `list-filter-views`.
135        filter_view_id: i64,
136        /// A sheet title, supplying a prefix for a bare `range`, when
137        /// changing the filtered range.
138        sheet: Option<String>,
139        /// The new filtered A1 range, when changing it.
140        range: Option<String>,
141        /// The new title, when changing it.
142        title: Option<String>,
143        /// Sort specs to merge in, replacing any existing entry sharing a
144        /// column, else appending.
145        sort_by: Vec<String>,
146        /// Hidden-value criteria to merge in, replacing any existing entry
147        /// for the named column.
148        hide_values: Vec<String>,
149        /// Reset the sort order to empty before applying `sort_by`.
150        clear_sort: bool,
151        /// Reset the criteria to empty before applying `hide_values`.
152        clear_criteria: bool,
153    },
154    /// Remove a filter view.
155    DeleteFilterView {
156        /// Which filter view to remove, discovered via `list-filter-views`.
157        filter_view_id: i64,
158    },
159}
160
161impl FilterVerb {
162    const fn log_operation(&self) -> &'static str {
163        match self {
164            Self::SetBasicFilter { .. } => "sheets-set-basic-filter",
165            Self::ClearBasicFilter { .. } => "sheets-clear-basic-filter",
166            Self::AddFilterView { .. } => "sheets-add-filter-view",
167            Self::UpdateFilterView { .. } => "sheets-update-filter-view",
168            Self::DeleteFilterView { .. } => "sheets-delete-filter-view",
169        }
170    }
171
172    const fn label(&self) -> &'static str {
173        match self {
174            Self::SetBasicFilter { .. } => "set-basic-filter",
175            Self::ClearBasicFilter { .. } => "clear-basic-filter",
176            Self::AddFilterView { .. } => "add-filter-view",
177            Self::UpdateFilterView { .. } => "update-filter-view",
178            Self::DeleteFilterView { .. } => "delete-filter-view",
179        }
180    }
181
182    /// Whether this verb physically reorders the grid's rows: only
183    /// `set-basic-filter` with at least one `--sort-by` (issue #1940). A
184    /// filter view's sort is per-view and reorders nothing.
185    fn reorders_rows(&self) -> bool {
186        matches!(self, Self::SetBasicFilter { sort_by, .. } if !sort_by.is_empty())
187    }
188
189    /// The operations this verb's gate is the union of, in the order a
190    /// refusal reports them — `SheetsStructure` alone, except for a row
191    /// reorder (see [`Self::reorders_rows`] and the module docs).
192    fn gate_operations(&self) -> &'static [DriveOperation] {
193        if self.reorders_rows() {
194            GATE_WRITE_AND_STRUCTURE
195        } else {
196            GATE_STRUCTURE_ONLY
197        }
198    }
199}
200
201/// Per-call options.
202#[derive(Debug, Clone)]
203pub struct FilterOptions {
204    /// Spreadsheet id.
205    pub spreadsheet_id: String,
206    /// Which mutation to perform.
207    pub verb: FilterVerb,
208    /// Classify and describe only; never call `batchUpdate`.
209    pub dry_run: bool,
210    /// The lease token presented via `--lease`. Checked only when the
211    /// deciding rule requires one
212    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
213    /// `None` is only ever valid when it does not.
214    pub lease_token: Option<String>,
215    /// Path to the lease ledger the token is checked against. Production
216    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
217    /// result; tests pass a path under a `tempdir`.
218    pub ledger_path: PathBuf,
219}
220
221/// What happened (or, under `--dry-run`, would happen).
222#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
223#[serde(tag = "status", rename_all = "kebab-case")]
224pub enum FilterResult {
225    /// `--dry-run`, and the gate would allow it.
226    WouldChange {
227        /// A human-readable summary of the effect.
228        summary: String,
229        /// For a row reorder (`set-basic-filter --sort-by`) whose range
230        /// is narrower than its sheet, the record-integrity caveat
231        /// `sort-range` leads with (ADR-0083 §6). Omitted otherwise.
232        #[serde(skip_serializing_if = "Option::is_none")]
233        width_warning: Option<String>,
234    },
235    /// The target is not a Google Sheet.
236    RefusedNotASpreadsheet {
237        /// The target's actual MIME type.
238        mime_type: String,
239    },
240    /// The target is a shortcut, which we do not follow.
241    RefusedShortcut,
242    /// The target has no parents this account can see.
243    RefusedNoVisibleParents,
244    /// The named sheet does not exist in this workbook.
245    RefusedSheetNotFound {
246        /// The title that was not found.
247        title: String,
248        /// The titles that do exist.
249        available: Vec<String>,
250    },
251    /// The verb's own arguments were invalid — a bad `--sort-by`/
252    /// `--hide-values` format, a malformed `--sheet`/`--range` pair, or
253    /// (for `update-filter-view`) nothing to change.
254    RefusedInvalidRange {
255        /// What was wrong and why.
256        detail: String,
257    },
258    /// `update-filter-view`/`delete-filter-view` named a `--filter-view-id`
259    /// that does not exist in this workbook.
260    RefusedFilterViewNotFound {
261        /// The id that was not found.
262        filter_view_id: i64,
263    },
264    /// The folder write-permission gate refused it.
265    Blocked {
266        /// Which of `FilterVerb::gate_operations` denied first — only
267        /// ever not `sheets-structure` for `set-basic-filter --sort-by`'s
268        /// union gate, where which of the two denied is the actionable
269        /// part of the message.
270        operation: DriveOperation,
271        /// The rule that decided the refusal, if any.
272        decided_by: Option<DecidingRule>,
273    },
274    /// No `--lease` was presented, and the deciding rule requires one
275    /// (ADR-0080 §9).
276    RefusedNoLease,
277    /// The presented lease has expired, or was never a token this ledger
278    /// knows about.
279    RefusedLeaseExpired,
280    /// The presented lease is bound to a different file id.
281    RefusedLeaseWrongFile,
282    /// The file has moved since the lease's recorded `version` — the
283    /// staleness check (ADR-0080 §6).
284    RefusedLeaseStale,
285    /// The workbook is already in the requested state
286    /// (`clear-basic-filter` on a sheet with no basic filter), so no
287    /// request is sent. Reported identically by a dry run and a real run.
288    Unchanged {
289        /// Why nothing needed to change.
290        detail: String,
291    },
292    /// The mutation succeeded.
293    Changed {
294        /// Same summary as [`Self::WouldChange`].
295        summary: String,
296        /// The filter view's stable id — server-assigned for
297        /// `add-filter-view`, otherwise the one resolved against. `None`
298        /// for `set-basic-filter`/`clear-basic-filter`.
299        filter_view_id: Option<i64>,
300        /// Same as [`Self::WouldChange`]'s.
301        #[serde(skip_serializing_if = "Option::is_none")]
302        width_warning: Option<String>,
303    },
304    /// The API answered 2xx — the mutation **was applied** — but its reply
305    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: a
306    /// retry here could duplicate a filter view. The server-assigned id of an
307    /// added filter view was in that reply, so it is unknown.
308    AppliedReplyUnreadable {
309        /// Same summary as [`Self::WouldChange`].
310        summary: String,
311        /// The filter view id resolved before the call — `Some` for
312        /// `update-filter-view`/`delete-filter-view`, `None` otherwise. Never
313        /// the server-assigned id of an added view (that was in the reply).
314        filter_view_id: Option<i64>,
315        /// Same as [`Self::Changed`]'s.
316        #[serde(skip_serializing_if = "Option::is_none")]
317        width_warning: Option<String>,
318        /// Why the reply could not be read.
319        detail: String,
320    },
321    /// An API or validation error.
322    Failed {
323        /// A human-readable summary of what failed.
324        detail: String,
325    },
326}
327
328impl FromLeaseRefusal for FilterResult {
329    fn from_no_lease() -> Self {
330        Self::RefusedNoLease
331    }
332    fn from_lease_expired() -> Self {
333        Self::RefusedLeaseExpired
334    }
335    fn from_lease_wrong_file() -> Self {
336        Self::RefusedLeaseWrongFile
337    }
338    fn from_lease_stale() -> Self {
339        Self::RefusedLeaseStale
340    }
341    fn from_lease_failed(detail: String) -> Self {
342        Self::Failed { detail }
343    }
344}
345
346impl FilterResult {
347    fn log_status(&self) -> &'static str {
348        match self {
349            Self::WouldChange { .. } => "would-change",
350            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
351            Self::RefusedShortcut => "refused-shortcut",
352            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
353            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
354            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
355            Self::RefusedFilterViewNotFound { .. } => "refused-filter-view-not-found",
356            Self::Blocked { .. } => "blocked",
357            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
358            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
359            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
360            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
361            Self::Unchanged { .. } => "unchanged",
362            Self::Changed { .. } => "changed",
363            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
364            Self::Failed { .. } => "failed",
365        }
366    }
367}
368
369/// The full outcome of one attempt.
370#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
371pub struct FilterOutcome {
372    /// The spreadsheet acted on.
373    pub spreadsheet_id: String,
374    /// Its Drive file name, when the metadata fetch got that far.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub file_name: Option<String>,
377    /// The folder the gate evaluated against.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub resolved_folder_id: Option<String>,
380    /// The sheet a `set-basic-filter`/`clear-basic-filter` resolved
381    /// against, once known — the only identifier that can distinguish
382    /// which sheet in a multi-sheet workbook was affected, since a basic
383    /// filter (unlike a filter view) is scoped to a sheet rather than
384    /// id-addressed (issue #1794, `docs/log.md`). `None` before the
385    /// workbook resolves it, and for every other verb.
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub sheet_id: Option<i64>,
388    /// Which mutation was attempted. Not serialised.
389    #[serde(skip)]
390    pub verb: FilterVerb,
391    /// What happened.
392    pub result: FilterResult,
393}
394
395impl JsonlSerialize for FilterOutcome {
396    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
397        write_scalar_jsonl(self, out)
398    }
399}
400
401/// Runs one filter mutation, logging every attempt that isn't a dry run.
402pub async fn filter(
403    drive: &DriveClient,
404    sheets: &SheetsClient,
405    opts: &FilterOptions,
406    rules: &[FolderPermissionRule],
407) -> FilterOutcome {
408    let started = Instant::now();
409    let outcome = filter_inner(drive, sheets, opts, rules).await;
410    if !opts.dry_run {
411        record_attempt(&outcome, opts, started.elapsed());
412    }
413    outcome
414}
415
416async fn filter_inner(
417    drive: &DriveClient,
418    sheets: &SheetsClient,
419    opts: &FilterOptions,
420    rules: &[FolderPermissionRule],
421) -> FilterOutcome {
422    let bare = |result| FilterOutcome {
423        spreadsheet_id: opts.spreadsheet_id.clone(),
424        file_name: None,
425        resolved_folder_id: None,
426        sheet_id: None,
427        verb: opts.verb.clone(),
428        result,
429    };
430
431    let sort_specs = match parse_sort_specs(sort_by_flags(&opts.verb)) {
432        Ok(specs) => specs,
433        Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
434    };
435    let criteria = match parse_hidden_values(hide_values_flags(&opts.verb)) {
436        Ok(criteria) => criteria,
437        Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
438    };
439    let composed_range = match compose_target(&opts.verb) {
440        Ok(composed) => composed,
441        Err(detail) => return bare(FilterResult::RefusedInvalidRange { detail }),
442    };
443    if let Err(detail) = validate_verb(&opts.verb) {
444        return bare(FilterResult::RefusedInvalidRange { detail });
445    }
446
447    let operations = opts.verb.gate_operations();
448
449    let (target, verdict, denied, resolved_folder_id, requires_lease) =
450        match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
451            target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
452                return bare(FilterResult::Failed { detail })
453            }
454            target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
455                let result = match refusal {
456                    SheetTargetRefusal::Shortcut => FilterResult::RefusedShortcut,
457                    SheetTargetRefusal::NotASpreadsheet { mime_type } => {
458                        FilterResult::RefusedNotASpreadsheet { mime_type }
459                    }
460                    SheetTargetRefusal::NoVisibleParents => FilterResult::RefusedNoVisibleParents,
461                };
462                return FilterOutcome {
463                    spreadsheet_id: opts.spreadsheet_id.clone(),
464                    file_name: Some(target.name),
465                    resolved_folder_id: None,
466                    sheet_id: None,
467                    verb: opts.verb.clone(),
468                    result,
469                };
470            }
471            target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
472                return FilterOutcome {
473                    spreadsheet_id: opts.spreadsheet_id.clone(),
474                    file_name: Some(target.name),
475                    resolved_folder_id: None,
476                    sheet_id: None,
477                    verb: opts.verb.clone(),
478                    result: FilterResult::Failed { detail },
479                };
480            }
481            target_gate::TargetGateUnionOutcome::Gated {
482                target,
483                verdict,
484                denied,
485                resolved_folder_id,
486                requires_lease,
487            } => (target, verdict, denied, resolved_folder_id, requires_lease),
488        };
489
490    // Returns before the sheet/range resolution just below always have
491    // `sheet_id: None` — there is nothing to resolve it against yet.
492    // Distinct from `gated` below (never a shadow of it) so a future early
493    // return accidentally added between the two is a compile error
494    // ("cannot find value `gated`"), not a silent bind to the wrong
495    // closure.
496    let pre_gated = |result| FilterOutcome {
497        spreadsheet_id: opts.spreadsheet_id.clone(),
498        file_name: Some(target.name.clone()),
499        resolved_folder_id: resolved_folder_id.clone(),
500        sheet_id: None,
501        verb: opts.verb.clone(),
502        result,
503    };
504
505    if verdict == write_gate::Verdict::Deny {
506        // `denied` is `Some` whenever the verdict is `Deny`; the fallback
507        // only keeps this total.
508        let (operation, decided_by) = denied.unwrap_or((operations[0], None));
509        return pre_gated(FilterResult::Blocked {
510            operation,
511            decided_by,
512        });
513    }
514
515    let api = SheetsApi::new(sheets);
516    let workbook = match api
517        .get_spreadsheet_with_filter_views(&opts.spreadsheet_id)
518        .await
519    {
520        Ok(workbook) => workbook,
521        Err(err) => {
522            return pre_gated(FilterResult::Failed {
523                detail: format!("{err:#}"),
524            })
525        }
526    };
527
528    // `update-filter-view --range` with no `--sheet` means the view's own
529    // sheet.
530    let composed_range = match (&opts.verb, composed_range) {
531        (
532            FilterVerb::UpdateFilterView {
533                filter_view_id,
534                sheet: None,
535                ..
536            },
537            Some(composed),
538        ) => {
539            // An unknown id is reported as such here, before the bare range
540            // could be refused for naming no sheet.
541            let current_sheet = match find_existing_filter_view(&workbook, *filter_view_id) {
542                Ok(view) => view.range.as_ref().map(|range| range.sheet_id),
543                Err(result) => return pre_gated(result),
544            };
545            Some(grid_range::default_sheet_prefix(
546                &workbook,
547                composed,
548                current_sheet,
549            ))
550        }
551        (_, composed) => composed,
552    };
553    let resolved_target =
554        match resolve_sheet_target(&workbook, &opts.verb, composed_range.as_deref()) {
555            Ok(resolved) => resolved,
556            Err(result) => return pre_gated(result),
557        };
558    let sheet_id = resolved_target.map(|grid| grid.sheet_id);
559
560    // `set-basic-filter`/`clear-basic-filter` have no id-addressed handle
561    // the way a filter view does, so the sheet id resolved above is the
562    // only thing that can tell an audit record which sheet in a
563    // multi-sheet workbook was affected (docs/log.md).
564    let gated = |result| FilterOutcome {
565        spreadsheet_id: opts.spreadsheet_id.clone(),
566        file_name: Some(target.name.clone()),
567        resolved_folder_id: resolved_folder_id.clone(),
568        sheet_id: match &opts.verb {
569            FilterVerb::SetBasicFilter { .. } | FilterVerb::ClearBasicFilter { .. } => sheet_id,
570            _ => None,
571        },
572        verb: opts.verb.clone(),
573        result,
574    };
575
576    let existing = match &opts.verb {
577        FilterVerb::UpdateFilterView { filter_view_id, .. }
578        | FilterVerb::DeleteFilterView { filter_view_id } => {
579            match find_existing_filter_view(&workbook, *filter_view_id) {
580                Ok(existing) => Some(existing),
581                Err(result) => return gated(result),
582            }
583        }
584        FilterVerb::SetBasicFilter { .. }
585        | FilterVerb::ClearBasicFilter { .. }
586        | FilterVerb::AddFilterView { .. } => None,
587    };
588
589    // Before the dry-run branch, so a dry run reports the no-op the real
590    // run would. The workbook fetch above already carries each sheet's
591    // basic filter.
592    if let FilterVerb::ClearBasicFilter { sheet } = &opts.verb {
593        let has_filter = sheet_id
594            .and_then(|id| grid_range::find_sheet_by_id(&workbook, id))
595            .is_some_and(|found| found.basic_filter.is_some());
596        if !has_filter {
597            return gated(FilterResult::Unchanged {
598                detail: format!("sheet '{sheet}' has no basic filter to clear"),
599            });
600        }
601    }
602
603    // A sort column past the target range's width is silently accepted
604    // client-side, but the API 500s live rather than rejecting cleanly
605    // (`set-basic-filter --sort-by 9:asc` on a 5-column range) — validate
606    // it locally, matching `add-pivot-table`'s column-offset check.
607    // `update-filter-view` with no new range falls back to the existing
608    // view's range, since that is what the sort would apply to. A range
609    // whose column bounds are unknown (an open-ended row span) is left
610    // unchecked, as elsewhere.
611    let effective_grid = resolved_target.or_else(|| existing.and_then(|view| view.range));
612    if let Some(grid) = effective_grid {
613        if let (Some(start), Some(end)) = (grid.start_column_index, grid.end_column_index) {
614            if let Some(spec) = sort_specs
615                .iter()
616                .find(|spec| spec.dimension_index < start || spec.dimension_index >= end)
617            {
618                return gated(FilterResult::RefusedInvalidRange {
619                    detail: format!(
620                        "sort column {} is outside the selected range's columns {start}..{end} \
621                         (zero-based, end exclusive)",
622                        spec.dimension_index
623                    ),
624                });
625            }
626        }
627    }
628
629    // `update-filter-view`'s write is decided here, before the dry-run
630    // return, because it can refuse (a re-creation with no range to
631    // re-create over) and because a re-creation is worth saying in the
632    // preview. It only merges onto the already-fetched view, so it costs
633    // no call.
634    let update_write = match &opts.verb {
635        FilterVerb::UpdateFilterView {
636            filter_view_id,
637            title,
638            clear_sort,
639            clear_criteria,
640            ..
641        } => {
642            let Some(existing) = existing else {
643                // omni-dev: coverage ignore-line reason="find_existing_filter_view returns Some for UpdateFilterView or has already returned RefusedFilterViewNotFound; this else-arm exists only to unwrap the shared Option"
644                unreachable!("existing is resolved for UpdateFilterView above")
645            };
646            match build_update(
647                existing,
648                *filter_view_id,
649                title,
650                resolved_target,
651                sort_specs.clone(),
652                criteria.clone(),
653                *clear_sort,
654                *clear_criteria,
655            ) {
656                Ok(write) => Some(write),
657                Err(detail) => return gated(FilterResult::RefusedInvalidRange { detail }),
658            }
659        }
660        _ => None,
661    };
662
663    let mut summary = describe_effect(&opts.verb);
664    if matches!(update_write, Some(FilterViewWrite::Replace(_))) {
665        summary.push_str(REPLACE_NOTE);
666    }
667    // A delete is addressed by a bare id, so name what the id resolved to
668    // — the view was already fetched to confirm it exists.
669    if let (FilterVerb::DeleteFilterView { .. }, Some(view)) = (&opts.verb, existing) {
670        summary.push_str(&describe_existing_view(&workbook, view));
671    }
672    let width_warning = if opts.verb.reorders_rows() {
673        resolved_target
674            .as_ref()
675            .and_then(|grid| reorder_width_warning(&workbook, grid))
676    } else {
677        None
678    };
679
680    if opts.dry_run {
681        return gated(FilterResult::WouldChange {
682            summary,
683            width_warning,
684        });
685    }
686
687    // Every branch here reuses `resolved_target` rather than re-parsing
688    // `composed_range` a second time; `resolve_sheet_target` already did
689    // that work once, above.
690    let (requests, existing_id) = match &opts.verb {
691        FilterVerb::SetBasicFilter { .. } => {
692            let Some(grid) = resolved_target else {
693                // omni-dev: coverage ignore-line reason="resolve_sheet_target returns Some for SetBasicFilter or has already returned its refusal; this else-arm exists only to unwrap the shared Option"
694                unreachable!("resolved_target is resolved for SetBasicFilter above")
695            };
696            (
697                vec![BatchUpdateRequestItem::SetBasicFilter(
698                    SetBasicFilterRequest {
699                        filter: BasicFilter {
700                            range: Some(grid),
701                            sort_specs,
702                            criteria,
703                        },
704                    },
705                )],
706                None,
707            )
708        }
709        FilterVerb::ClearBasicFilter { .. } => {
710            let Some(sheet_id) = sheet_id else {
711                // omni-dev: coverage ignore-line reason="resolve_sheet_target returns Some for ClearBasicFilter or has already returned its refusal; this else-arm exists only to unwrap the shared Option"
712                unreachable!("sheet_id is resolved for ClearBasicFilter above")
713            };
714            (
715                vec![BatchUpdateRequestItem::ClearBasicFilter(
716                    ClearBasicFilterRequest { sheet_id },
717                )],
718                None,
719            )
720        }
721        FilterVerb::AddFilterView { title, .. } => {
722            let Some(grid) = resolved_target else {
723                // omni-dev: coverage ignore-line reason="resolve_sheet_target returns Some for AddFilterView or has already returned its refusal; this else-arm exists only to unwrap the shared Option"
724                unreachable!("resolved_target is resolved for AddFilterView above")
725            };
726            (
727                vec![BatchUpdateRequestItem::AddFilterView(
728                    AddFilterViewRequest {
729                        filter: FilterView {
730                            filter_view_id: None,
731                            title: title.clone(),
732                            range: Some(grid),
733                            named_range_id: None,
734                            table_id: None,
735                            sort_specs,
736                            criteria,
737                        },
738                    },
739                )],
740                None,
741            )
742        }
743        FilterVerb::UpdateFilterView { filter_view_id, .. } => {
744            let Some(write) = update_write else {
745                // omni-dev: coverage ignore-line reason="update_write is built for UpdateFilterView above or has already returned its refusal; this else-arm exists only to unwrap the shared Option"
746                unreachable!("update_write is built for UpdateFilterView above")
747            };
748            (write.into_requests(*filter_view_id), Some(*filter_view_id))
749        }
750        FilterVerb::DeleteFilterView { filter_view_id } => (
751            vec![BatchUpdateRequestItem::DeleteFilterView(
752                DeleteFilterViewRequest {
753                    filter_id: *filter_view_id,
754                },
755            )],
756            Some(*filter_view_id),
757        ),
758    };
759
760    // The lease check (ADR-0080 §9) sits here: after the permission gate
761    // and the `--dry-run` branch, before the mutating call — see
762    // `protection.rs::protection_inner`'s doc comment for the full
763    // reasoning, shared verbatim by every leased engine.
764    let files_api = FilesApi::new(drive);
765    let leased = LeasedWrite {
766        log_prefix: "drive sheets filter",
767        operation: opts.verb.log_operation(),
768        ledger_path: &opts.ledger_path,
769        file_id: &opts.spreadsheet_id,
770    };
771    let lease_grant = match gate_optional_leased_write(
772        leased,
773        &files_api,
774        requires_lease,
775        opts.lease_token.as_deref(),
776    )
777    .await
778    {
779        Ok(grant) => grant,
780        Err(err) => return gated(err.into_result()),
781    };
782
783    let result = match conclude_native_leased_write(
784        leased,
785        &lease_grant,
786        &files_api,
787        api.batch_update(&opts.spreadsheet_id, requests).await,
788        |err| format!("{err:#}"),
789    )
790    .await
791    {
792        Ok(BatchUpdateOutcome::Applied(response)) => {
793            let filter_view_id = added_filter_view_id(&response).or(existing_id);
794            FilterResult::Changed {
795                summary,
796                filter_view_id,
797                width_warning,
798            }
799        }
800        // NotIdempotent: a second `add-filter-view` would add a second view, and a
801        // second `sort-range` would re-sort already-moved rows.
802        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
803            FilterResult::AppliedReplyUnreadable {
804                summary,
805                filter_view_id: existing_id,
806                width_warning,
807                detail,
808            }
809        }
810        Err(err) => FilterResult::Failed {
811            detail: format!("{err:#}"),
812        },
813    };
814    drop(lease_grant);
815    gated(result)
816}
817
818/// `sort-range`'s range-width-vs-sheet-width caveat for a row reorder over
819/// `grid` (ADR-0083 §6): a reorder moves only the cells inside the range,
820/// so any data in the same rows but outside the selected columns is left
821/// behind. An open-ended column span reaches the sheet's last column.
822fn reorder_width_warning(workbook: &Spreadsheet, grid: &GridRange) -> Option<String> {
823    let allocated_columns = grid_range::find_sheet_by_id(workbook, grid.sheet_id)
824        .and_then(|sheet| sheet.properties.as_ref())
825        .and_then(|props| props.grid_properties.as_ref())
826        .and_then(|props| props.column_count);
827    let start_column = grid.start_column_index.unwrap_or(0);
828    match grid.end_column_index.or(allocated_columns) {
829        Some(end_column) => {
830            grid_range::width_warning("sorting", start_column, end_column, allocated_columns)
831        }
832        // Open-ended to the right on a sheet of unknown width: only a
833        // span that also starts past column A can leave columns behind.
834        None if start_column == 0 => None,
835        None => grid_range::width_warning("sorting", start_column, start_column, None),
836    }
837}
838
839/// `--sort-by` flags for whichever verb carries them.
840fn sort_by_flags(verb: &FilterVerb) -> &[String] {
841    match verb {
842        FilterVerb::SetBasicFilter { sort_by, .. }
843        | FilterVerb::AddFilterView { sort_by, .. }
844        | FilterVerb::UpdateFilterView { sort_by, .. } => sort_by,
845        FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => &[],
846    }
847}
848
849/// `--hide-values` flags for whichever verb carries them.
850fn hide_values_flags(verb: &FilterVerb) -> &[String] {
851    match verb {
852        FilterVerb::SetBasicFilter { hide_values, .. }
853        | FilterVerb::AddFilterView { hide_values, .. }
854        | FilterVerb::UpdateFilterView { hide_values, .. } => hide_values,
855        FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => &[],
856    }
857}
858
859/// Parses `<COL>:<asc|desc>` flags into [`SortSpec`]s, in the order given.
860fn parse_sort_specs(flags: &[String]) -> Result<Vec<SortSpec>, String> {
861    flags
862        .iter()
863        .map(|flag| {
864            let (col, order) = flag.split_once(':').ok_or_else(|| {
865                format!("'{flag}' is not COLUMN:asc|desc — expected e.g. '0:asc'")
866            })?;
867            let dimension_index: i64 = col
868                .trim()
869                .parse()
870                .map_err(|_| format!("'{col}' in '{flag}' is not a column index"))?;
871            let sort_order = match order.trim().to_ascii_lowercase().as_str() {
872                "asc" | "ascending" => SortOrder::Ascending,
873                "desc" | "descending" => SortOrder::Descending,
874                other => return Err(format!("'{other}' in '{flag}' is not 'asc' or 'desc'")),
875            };
876            Ok(SortSpec {
877                dimension_index,
878                sort_order,
879            })
880        })
881        .collect()
882}
883
884/// Parses `<COL>:<v1,v2,...>` flags into a `criteria` map.
885fn parse_hidden_values(flags: &[String]) -> Result<BTreeMap<String, FilterCriteria>, String> {
886    let mut criteria = BTreeMap::new();
887    for flag in flags {
888        let (col, values) = flag.split_once(':').ok_or_else(|| {
889            format!("'{flag}' is not COLUMN:VALUE[,VALUE...] — expected e.g. '1:Foo,Bar'")
890        })?;
891        let dimension_index: i64 = col
892            .trim()
893            .parse()
894            .map_err(|_| format!("'{col}' in '{flag}' is not a column index"))?;
895        if values.is_empty() {
896            return Err(format!("'{flag}' names no values to hide"));
897        }
898        let hidden_values: Vec<String> = values.split(',').map(str::to_string).collect();
899        let key = dimension_index.to_string();
900        // A `BTreeMap::insert` on a repeated column would silently drop the
901        // earlier `--hide-values` and keep only the last one, with the
902        // dry-run preview (which walks the raw flags, not this map) echoing
903        // both as if they combined (#1941).
904        if criteria.contains_key(&key) {
905            return Err(format!(
906                "--hide-values for column {dimension_index} is given more than once"
907            ));
908        }
909        criteria.insert(key, FilterCriteria::hiding(hidden_values));
910    }
911    Ok(criteria)
912}
913
914/// Composes the `--sheet`/`--range` pair a verb carries into one string,
915/// exactly like `protection.rs`/`format.rs`. Verbs with no range flags at
916/// all (`ClearBasicFilter`, `DeleteFilterView`) never call this.
917fn compose_target(verb: &FilterVerb) -> Result<Option<String>, String> {
918    match verb {
919        FilterVerb::SetBasicFilter { sheet, range, .. }
920        | FilterVerb::AddFilterView { sheet, range, .. } => a1::compose(Some(sheet), Some(range))
921            .map(Some)
922            .map_err(|err| err.to_string()),
923        FilterVerb::UpdateFilterView { sheet, range, .. } => {
924            match (sheet.as_deref(), range.as_deref()) {
925                (None, None) => Ok(None),
926                (sheet, range) => a1::compose(sheet, range)
927                    .map(Some)
928                    .map_err(|err| err.to_string()),
929            }
930        }
931        FilterVerb::ClearBasicFilter { .. } | FilterVerb::DeleteFilterView { .. } => Ok(None),
932    }
933}
934
935/// Rejects a verb whose own arguments are internally inconsistent, cheaply,
936/// before ever fetching the workbook.
937fn validate_verb(verb: &FilterVerb) -> Result<(), String> {
938    if let FilterVerb::UpdateFilterView {
939        sheet,
940        range,
941        title,
942        sort_by,
943        hide_values,
944        clear_sort,
945        clear_criteria,
946        ..
947    } = verb
948    {
949        let nothing_to_change = sheet.is_none()
950            && range.is_none()
951            && title.is_none()
952            && sort_by.is_empty()
953            && hide_values.is_empty()
954            && !clear_sort
955            && !clear_criteria;
956        if nothing_to_change {
957            return Err(
958                "nothing to change: pass --title, --sheet/--range, --sort-by, --hide-values, \
959                 --clear-sort, or --clear-criteria"
960                    .to_string(),
961            );
962        }
963    }
964    Ok(())
965}
966
967/// Resolves the sheet (and, for every verb but `ClearBasicFilter`, the full
968/// [`GridRange`]) a verb targets — once, so callers never need to re-parse
969/// the same `--sheet`/`--range` composition a second time to get the range
970/// they already resolved a sheet id from. `ClearBasicFilter`'s grid carries
971/// only `sheet_id`, with every other bound `Default`, since it names a
972/// sheet directly rather than a range within it — the same "whole sheet"
973/// shape `protection.rs::resolve_grid`'s `--whole-sheet` case uses.
974/// `UpdateFilterView` may target no sheet at all (leaving the existing
975/// view's range untouched), hence the `Option`; `DeleteFilterView` never
976/// does.
977fn resolve_sheet_target(
978    workbook: &Spreadsheet,
979    verb: &FilterVerb,
980    composed: Option<&str>,
981) -> Result<Option<GridRange>, FilterResult> {
982    match verb {
983        FilterVerb::SetBasicFilter { .. } | FilterVerb::AddFilterView { .. } => {
984            let composed = composed.unwrap_or_default();
985            let (_, grid) = grid_range::resolve_grid_range(
986                workbook,
987                composed,
988                |detail| FilterResult::RefusedInvalidRange { detail },
989                |title, available| FilterResult::RefusedSheetNotFound { title, available },
990            )?;
991            Ok(Some(grid))
992        }
993        FilterVerb::ClearBasicFilter { sheet } => {
994            let sheet_id = find_sheet_id(workbook, sheet)?;
995            Ok(Some(GridRange {
996                sheet_id,
997                ..Default::default()
998            }))
999        }
1000        FilterVerb::UpdateFilterView { .. } => match composed {
1001            Some(composed) => {
1002                let (_, grid) = grid_range::resolve_grid_range(
1003                    workbook,
1004                    composed,
1005                    |detail| FilterResult::RefusedInvalidRange { detail },
1006                    |title, available| FilterResult::RefusedSheetNotFound { title, available },
1007                )?;
1008                Ok(Some(grid))
1009            }
1010            None => Ok(None),
1011        },
1012        FilterVerb::DeleteFilterView { .. } => Ok(None),
1013    }
1014}
1015
1016fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, FilterResult> {
1017    grid_range::find_sheet_id(workbook, title, |title, available| {
1018        FilterResult::RefusedSheetNotFound { title, available }
1019    })
1020}
1021
1022/// Finds the one filter view whose id exactly matches `filter_view_id`.
1023/// Filter views are directly id-addressed (the id comes from
1024/// `list-filter-views`), so unlike `protection.rs::find_existing_protection`
1025/// there is no ambiguous-match case — ids are unique by construction.
1026fn find_existing_filter_view(
1027    workbook: &Spreadsheet,
1028    filter_view_id: i64,
1029) -> Result<&FilterView, FilterResult> {
1030    workbook
1031        .sheets
1032        .iter()
1033        .flat_map(|sheet| sheet.filter_views.iter())
1034        .find(|view| view.filter_view_id == Some(filter_view_id))
1035        .ok_or(FilterResult::RefusedFilterViewNotFound { filter_view_id })
1036}
1037
1038/// How `update-filter-view` reaches the server (issue #1931).
1039///
1040/// `updateFilterView` can only *merge* (see [`FilterViewUpdate`]): it has
1041/// no way to remove a sort column. So an update that drops one is sent
1042/// instead as a delete of the view plus an `addFilterView` carrying the
1043/// view's own id and the complete resulting state, in one atomic
1044/// `batchUpdate`. Sheets honours the requested id, so the view keeps its
1045/// `filterViewId` (live-verified).
1046#[derive(Debug, Clone, PartialEq, Eq)]
1047enum FilterViewWrite {
1048    /// A plain `updateFilterView`.
1049    Update(UpdateFilterViewRequest),
1050    /// Delete and re-add the view under its own id, with this full state.
1051    Replace(FilterView),
1052}
1053
1054impl FilterViewWrite {
1055    fn into_requests(self, filter_view_id: i64) -> Vec<BatchUpdateRequestItem> {
1056        match self {
1057            Self::Update(update) => vec![BatchUpdateRequestItem::UpdateFilterView(update)],
1058            Self::Replace(filter) => vec![
1059                BatchUpdateRequestItem::DeleteFilterView(DeleteFilterViewRequest {
1060                    filter_id: filter_view_id,
1061                }),
1062                BatchUpdateRequestItem::AddFilterView(AddFilterViewRequest { filter }),
1063            ],
1064        }
1065    }
1066}
1067
1068/// Builds the write for `update-filter-view` from the existing view's
1069/// current state.
1070///
1071/// The resulting sort order is computed client-side: `--clear-sort`
1072/// starts from empty, else from the existing order; each parsed `SortSpec`
1073/// then replaces any entry sharing its `dimension_index` (else appends).
1074/// When that result still names every column the view already sorts by,
1075/// it is sent as-is in an `updateFilterView` — Sheets puts the sent list
1076/// first and appends the existing columns it doesn't name, so there are
1077/// none left over to append. When it drops one (only `--clear-sort` can),
1078/// the view is re-created instead ([`FilterViewWrite::Replace`]), since no
1079/// `updateFilterView` body removes a sort column.
1080///
1081/// Criteria merge per column on the server, so an update sends only the
1082/// columns that change: each `--hide-values` column, plus — under
1083/// `--clear-criteria` — a `{}` reset for every other column the view
1084/// currently filters on. A column neither names is left alone rather than
1085/// re-sent, so criteria this crate doesn't model survive.
1086///
1087/// Errors when a re-creation is needed but there is no range to re-create
1088/// the view over: the existing view read back with none (it is not bound
1089/// to a grid range this crate models) and no `--range` was given.
1090#[allow(clippy::too_many_arguments)]
1091fn build_update(
1092    existing: &FilterView,
1093    filter_view_id: i64,
1094    title: &Option<String>,
1095    range: Option<GridRange>,
1096    sort_by: Vec<SortSpec>,
1097    hide_values: BTreeMap<String, FilterCriteria>,
1098    clear_sort: bool,
1099    clear_criteria: bool,
1100) -> Result<FilterViewWrite, String> {
1101    let sort_specs = (clear_sort || !sort_by.is_empty()).then(|| {
1102        let mut resulting = if clear_sort {
1103            Vec::new()
1104        } else {
1105            existing.sort_specs.clone()
1106        };
1107        for spec in sort_by {
1108            if let Some(slot) = resulting
1109                .iter_mut()
1110                .find(|s| s.dimension_index == spec.dimension_index)
1111            {
1112                *slot = spec;
1113            } else {
1114                resulting.push(spec);
1115            }
1116        }
1117        resulting
1118    });
1119    let removes_sort_column = sort_specs.as_ref().is_some_and(|resulting| {
1120        existing.sort_specs.iter().any(|old| {
1121            !resulting
1122                .iter()
1123                .any(|new| new.dimension_index == old.dimension_index)
1124        })
1125    });
1126
1127    if removes_sort_column {
1128        // A view bound to a table or named range is re-bound, not pinned
1129        // to its current range: Sheets rejects `addFilterView` with both a
1130        // binding and a `range` (live-verified), and a binding follows its
1131        // table/named range as it grows.
1132        let binding = existing
1133            .table_id
1134            .as_ref()
1135            .map(|id| format!("table {id}"))
1136            .or_else(|| {
1137                existing
1138                    .named_range_id
1139                    .as_ref()
1140                    .map(|id| format!("named range {id}"))
1141            });
1142        let range = match (binding, range) {
1143            (Some(binding), Some(_)) => {
1144                return Err(format!(
1145                    "removing a sort column re-creates filter view {filter_view_id}, which is \
1146                     bound to {binding}; re-creating it with --sheet/--range would unbind it, so \
1147                     drop --sheet/--range, or change the range in a separate update first"
1148                ))
1149            }
1150            (Some(_), None) => None,
1151            (None, range) => Some(range.or(existing.range).ok_or_else(|| {
1152                format!(
1153                    "removing a sort column re-creates filter view {filter_view_id}, but it has \
1154                     no grid range to re-create it over; pass --sheet/--range"
1155                )
1156            })?),
1157        };
1158        let mut criteria: BTreeMap<String, FilterCriteria> = if clear_criteria {
1159            BTreeMap::new()
1160        } else {
1161            existing
1162                .criteria
1163                .iter()
1164                .filter(|(_, criteria)| !criteria.is_empty())
1165                .map(|(col, criteria)| (col.clone(), criteria.clone()))
1166                .collect()
1167        };
1168        criteria.extend(hide_values);
1169        return Ok(FilterViewWrite::Replace(FilterView {
1170            filter_view_id: Some(filter_view_id),
1171            title: title.clone().or_else(|| existing.title.clone()),
1172            range,
1173            named_range_id: existing.named_range_id.clone(),
1174            table_id: existing.table_id.clone(),
1175            sort_specs: sort_specs.unwrap_or_default(),
1176            criteria,
1177        }));
1178    }
1179
1180    let criteria = (clear_criteria || !hide_values.is_empty()).then(|| {
1181        let mut changes: BTreeMap<String, FilterCriteria> = if clear_criteria {
1182            existing
1183                .criteria
1184                .iter()
1185                .filter(|(_, criteria)| !criteria.is_empty())
1186                .map(|(col, _)| (col.clone(), FilterCriteria::default()))
1187                .collect()
1188        } else {
1189            BTreeMap::new()
1190        };
1191        changes.extend(hide_values);
1192        changes
1193    });
1194
1195    let update = FilterViewUpdate {
1196        filter_view_id,
1197        title: title.clone(),
1198        range,
1199        sort_specs,
1200        criteria,
1201    };
1202    let fields = [
1203        ("title", update.title.is_some()),
1204        ("range", update.range.is_some()),
1205        ("sortSpecs", update.sort_specs.is_some()),
1206        ("criteria", update.criteria.is_some()),
1207    ]
1208    .into_iter()
1209    .filter_map(|(name, present)| present.then_some(name))
1210    .collect::<Vec<_>>()
1211    .join(",");
1212    Ok(FilterViewWrite::Update(UpdateFilterViewRequest {
1213        filter: update,
1214        fields,
1215    }))
1216}
1217
1218fn added_filter_view_id(
1219    response: &crate::drive::sheets::types::BatchUpdateResponse,
1220) -> Option<i64> {
1221    response
1222        .replies
1223        .iter()
1224        .find_map(|reply| reply.add_filter_view.as_ref())
1225        .and_then(|added| added.filter.as_ref())
1226        .and_then(|filter| filter.filter_view_id)
1227}
1228
1229fn describe_effect(verb: &FilterVerb) -> String {
1230    match verb {
1231        FilterVerb::SetBasicFilter {
1232            sort_by,
1233            hide_values,
1234            ..
1235        } => describe_filter_effect("set basic filter", sort_by, hide_values),
1236        FilterVerb::ClearBasicFilter { .. } => "clear basic filter".to_string(),
1237        FilterVerb::AddFilterView {
1238            title,
1239            sort_by,
1240            hide_values,
1241            ..
1242        } => {
1243            let named = title
1244                .as_deref()
1245                .map_or_else(String::new, |t| format!(" '{t}'"));
1246            describe_filter_effect(&format!("add filter view{named}"), sort_by, hide_values)
1247        }
1248        FilterVerb::UpdateFilterView {
1249            title,
1250            sort_by,
1251            hide_values,
1252            clear_sort,
1253            clear_criteria,
1254            ..
1255        } => {
1256            let mut parts = Vec::new();
1257            if let Some(title) = title {
1258                parts.push(format!("title='{title}'"));
1259            }
1260            if *clear_sort {
1261                parts.push("clear sort".to_string());
1262            }
1263            if !sort_by.is_empty() {
1264                parts.push(format!("sort {}", sort_by.join(",")));
1265            }
1266            if *clear_criteria {
1267                parts.push("clear criteria".to_string());
1268            }
1269            if !hide_values.is_empty() {
1270                parts.push(format!("hide {}", hide_values.join(",")));
1271            }
1272            if parts.is_empty() {
1273                "update filter view".to_string()
1274            } else {
1275                format!("update filter view ({})", parts.join(" "))
1276            }
1277        }
1278        FilterVerb::DeleteFilterView { .. } => "delete filter view".to_string(),
1279    }
1280}
1281
1282/// Identifies an existing filter view for a summary: its title and range,
1283/// e.g. ` 'Open items' over 'Q1'!A1:D10`. A range that is not fully bounded
1284/// falls back to [`grid_range::render_grid_range`]; a view with neither
1285/// renders nothing.
1286fn describe_existing_view(workbook: &Spreadsheet, view: &FilterView) -> String {
1287    let mut text = String::new();
1288    if let Some(title) = view.title.as_deref().filter(|t| !t.is_empty()) {
1289        text.push_str(&format!(" '{title}'"));
1290    }
1291    if let Some(grid) = &view.range {
1292        let a1 = grid_range::sheet_title_by_id(workbook, grid.sheet_id)
1293            .and_then(|sheet| grid_range::bounded_range_to_a1(&sheet, grid))
1294            .unwrap_or_else(|| grid_range::render_grid_range(grid));
1295        text.push_str(&format!(" over {a1}"));
1296    }
1297    text
1298}
1299
1300fn describe_filter_effect(prefix: &str, sort_by: &[String], hide_values: &[String]) -> String {
1301    let mut parts = Vec::new();
1302    if !sort_by.is_empty() {
1303        parts.push(format!("sort {}", sort_by.join(",")));
1304    }
1305    if !hide_values.is_empty() {
1306        parts.push(format!("hide {}", hide_values.join(",")));
1307    }
1308    if parts.is_empty() {
1309        prefix.to_string()
1310    } else {
1311        format!("{prefix} ({})", parts.join(" "))
1312    }
1313}
1314
1315fn record_attempt(outcome: &FilterOutcome, opts: &FilterOptions, duration: Duration) {
1316    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1317}
1318
1319/// The request-log record for one attempt. Split from [`record_attempt`] so
1320/// the logged fields are unit-testable.
1321fn mutation_record(
1322    outcome: &FilterOutcome,
1323    opts: &FilterOptions,
1324    duration: Duration,
1325) -> DriveMutationOutcome {
1326    let error = match &outcome.result {
1327        FilterResult::Failed { detail } | FilterResult::AppliedReplyUnreadable { detail, .. } => {
1328            Some(detail.clone())
1329        }
1330        _ => None,
1331    };
1332    let decided_by = match &outcome.result {
1333        FilterResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1334        _ => None,
1335    };
1336    let decided_by = write_gate::decided_by_log_fields(decided_by);
1337    let filter_view_id = match &outcome.result {
1338        FilterResult::Changed { filter_view_id, .. }
1339        | FilterResult::AppliedReplyUnreadable { filter_view_id, .. } => *filter_view_id,
1340        _ => None,
1341    };
1342    let fields_changed = match &outcome.result {
1343        // An applied change always records what it was, whatever the verb.
1344        FilterResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1345        FilterResult::Changed { summary, .. }
1346            if matches!(opts.verb, FilterVerb::UpdateFilterView { .. }) =>
1347        {
1348            Some(summary.clone())
1349        }
1350        _ => None,
1351    };
1352
1353    DriveMutationOutcome {
1354        operation: opts.verb.log_operation(),
1355        file_id: outcome.spreadsheet_id.clone(),
1356        file_name: outcome.file_name.clone().unwrap_or_default(),
1357        status: outcome.result.log_status().to_string(),
1358        resolved_folder_id: outcome.resolved_folder_id.clone(),
1359        decided_by_folder_id: decided_by.folder_id,
1360        decided_by_depth: decided_by.depth,
1361        decided_by_file_id: decided_by.file_id,
1362        sheet_id: outcome.sheet_id,
1363        filter_view_id,
1364        fields_changed,
1365        error,
1366        duration,
1367        ..Default::default()
1368    }
1369}
1370
1371/// Renders an outcome as human-readable text.
1372#[must_use]
1373pub fn describe(outcome: &FilterOutcome) -> String {
1374    describe_lines(outcome).join("\n")
1375}
1376
1377/// Renders an outcome as its individual lines, none of which contains a
1378/// newline.
1379#[must_use]
1380pub fn describe_lines(outcome: &FilterOutcome) -> Vec<String> {
1381    let verb = &outcome.verb;
1382    let book = outcome.file_name.as_deref().map_or_else(
1383        || format!("'{}'", outcome.spreadsheet_id),
1384        |n| format!("'{n}'"),
1385    );
1386    match &outcome.result {
1387        FilterResult::WouldChange {
1388            summary,
1389            width_warning,
1390        } => change_lines(
1391            verb,
1392            format!("Would {summary} in {book}"),
1393            width_warning.as_deref(),
1394            false,
1395        ),
1396        FilterResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1397            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1398             `drive sheets {}` only works on spreadsheets",
1399            verb.label()
1400        )],
1401        FilterResult::RefusedShortcut => vec![format!(
1402            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1403            verb.label()
1404        )],
1405        FilterResult::RefusedNoVisibleParents => {
1406            let ops = verb
1407                .gate_operations()
1408                .iter()
1409                .map(|op| format!("\"{op}\""))
1410                .collect::<Vec<_>>()
1411                .join(", ");
1412            vec![format!(
1413                "Refused: {book} has no parent folder visible to this account, so no folder \
1414                 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1415                 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1416            )]
1417        }
1418        FilterResult::RefusedSheetNotFound { title, available } => {
1419            vec![grid_range::no_sheet_refusal(&book, title, available)]
1420        }
1421        FilterResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1422        FilterResult::RefusedFilterViewNotFound { filter_view_id } => vec![format!(
1423            "Refused: {book} has no filter view with id {filter_view_id}; run \
1424             `drive sheets list-filter-views` to see what exists"
1425        )],
1426        FilterResult::Blocked {
1427            operation,
1428            decided_by,
1429        } => vec![match decided_by {
1430            Some(rule) => format!(
1431                "Blocked: {} on {book} refused by rule on {} {}{}",
1432                verb.label(),
1433                rule.kind_label(),
1434                rule.id(),
1435                rule.depth_suffix()
1436            ),
1437            None => format!(
1438                "Blocked: {} on {book} refused by default policy (no matching rule for \
1439                 {operation})",
1440                verb.label()
1441            ),
1442        }],
1443        FilterResult::RefusedNoLease => LeaseGateRefusal::NoLease
1444            .describe_line(&outcome.spreadsheet_id, &book)
1445            .into_iter()
1446            .collect(),
1447        FilterResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1448            .describe_line(&outcome.spreadsheet_id, &book)
1449            .into_iter()
1450            .collect(),
1451        FilterResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1452            .describe_line(&outcome.spreadsheet_id, &book)
1453            .into_iter()
1454            .collect(),
1455        FilterResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1456            .describe_line(&outcome.spreadsheet_id, &book)
1457            .into_iter()
1458            .collect(),
1459        FilterResult::Changed {
1460            summary,
1461            filter_view_id,
1462            width_warning,
1463        } => {
1464            let id = filter_view_id.map_or_else(String::new, |id| format!(" (id {id})"));
1465            change_lines(
1466                verb,
1467                format!("Applied: {summary}{id} in {book}"),
1468                width_warning.as_deref(),
1469                true,
1470            )
1471        }
1472        FilterResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
1473        FilterResult::AppliedReplyUnreadable {
1474            summary,
1475            filter_view_id,
1476            width_warning,
1477            detail,
1478        } => {
1479            let id = filter_view_id.map_or_else(String::new, |id| format!(" (id {id})"));
1480            let mut lines = vec![applied_reply_unreadable_line(
1481                &format!("{summary}{id}"),
1482                &book,
1483                RetryHint::NotIdempotent,
1484                detail,
1485            )];
1486            if matches!(verb, FilterVerb::AddFilterView { .. }) {
1487                lines.push(
1488                    "  the new filter view's id was in the unreadable reply; run \
1489                     `list-filter-views` to find it"
1490                        .to_string(),
1491                );
1492            }
1493            // The plan-time caveats `Changed` prints around its summary: the
1494            // summary is already in the line above, so keep only the rest.
1495            lines.extend(
1496                change_lines(verb, String::new(), width_warning.as_deref(), true)
1497                    .into_iter()
1498                    .filter(|line| !line.is_empty()),
1499            );
1500            lines
1501        }
1502        FilterResult::Failed { detail } => vec![format!("Failed: {detail}")],
1503    }
1504}
1505
1506/// A would-change/changed report's lines. A row reorder
1507/// (`set-basic-filter --sort-by`) gets `sort-range`'s caveats around the
1508/// summary — the width warning first, when there is one, then the
1509/// out-of-range-references line (ADR-0083 §6); every other verb is the
1510/// summary alone.
1511fn change_lines(
1512    verb: &FilterVerb,
1513    summary: String,
1514    width_warning: Option<&str>,
1515    changed: bool,
1516) -> Vec<String> {
1517    if !verb.reorders_rows() {
1518        return vec![summary];
1519    }
1520    grid_range::reorder_caveat_lines("sorting", summary, width_warning, changed, None)
1521}
1522
1523#[cfg(test)]
1524#[allow(clippy::unwrap_used, clippy::expect_used)]
1525mod tests {
1526    use super::*;
1527    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1528    use crate::drive::sheets::client::SHEETS_API_URL;
1529    use crate::drive::sheets::types::Sheet;
1530    use crate::drive::test_support::seed_lease;
1531    use crate::test_support::env::MapEnv;
1532    use crate::utils::secret::Secret;
1533    use std::collections::HashSet;
1534
1535    // ── parse_sort_specs / parse_hidden_values ──────────────────────────
1536
1537    #[test]
1538    fn parse_sort_specs_accepts_asc_and_desc() {
1539        let specs = parse_sort_specs(&["0:asc".to_string(), "2:desc".to_string()]).unwrap();
1540        assert_eq!(
1541            specs,
1542            vec![
1543                SortSpec {
1544                    dimension_index: 0,
1545                    sort_order: SortOrder::Ascending
1546                },
1547                SortSpec {
1548                    dimension_index: 2,
1549                    sort_order: SortOrder::Descending
1550                },
1551            ]
1552        );
1553    }
1554
1555    #[test]
1556    fn parse_sort_specs_rejects_missing_colon() {
1557        let err = parse_sort_specs(&["0asc".to_string()]).unwrap_err();
1558        assert!(err.contains("COLUMN:asc|desc"), "{err}");
1559    }
1560
1561    #[test]
1562    fn parse_sort_specs_rejects_non_numeric_column() {
1563        let err = parse_sort_specs(&["x:asc".to_string()]).unwrap_err();
1564        assert!(err.contains("not a column index"), "{err}");
1565    }
1566
1567    #[test]
1568    fn parse_sort_specs_rejects_unknown_direction() {
1569        let err = parse_sort_specs(&["0:sideways".to_string()]).unwrap_err();
1570        assert!(err.contains("not 'asc' or 'desc'"), "{err}");
1571    }
1572
1573    #[test]
1574    fn parse_hidden_values_builds_a_criteria_map() {
1575        let criteria = parse_hidden_values(&["1:Foo,Bar".to_string()]).unwrap();
1576        assert_eq!(
1577            criteria.get("1").unwrap().hidden_values,
1578            vec!["Foo".to_string(), "Bar".to_string()]
1579        );
1580    }
1581
1582    #[test]
1583    fn parse_hidden_values_rejects_missing_colon() {
1584        let err = parse_hidden_values(&["1Foo".to_string()]).unwrap_err();
1585        assert!(err.contains("COLUMN:VALUE"), "{err}");
1586    }
1587
1588    #[test]
1589    fn parse_hidden_values_rejects_empty_value_list() {
1590        let err = parse_hidden_values(&["1:".to_string()]).unwrap_err();
1591        assert!(err.contains("names no values"), "{err}");
1592    }
1593
1594    #[test]
1595    fn parse_hidden_values_rejects_a_repeated_column() {
1596        // `--hide-values 1:Cherry --hide-values 1:Apple` silently kept only
1597        // the last one via `BTreeMap::insert`, while the dry-run preview
1598        // (which walks the raw flags) echoed both (#1941).
1599        let err =
1600            parse_hidden_values(&["1:Cherry".to_string(), "1:Apple".to_string()]).unwrap_err();
1601        assert!(err.contains("column 1 is given more than once"), "{err}");
1602    }
1603
1604    // ── log_operation / label ────────────────────────────────────────────
1605
1606    #[test]
1607    fn every_verb_has_a_distinct_log_operation_and_label() {
1608        let verbs = [
1609            FilterVerb::SetBasicFilter {
1610                sheet: String::new(),
1611                range: String::new(),
1612                sort_by: Vec::new(),
1613                hide_values: Vec::new(),
1614            },
1615            FilterVerb::ClearBasicFilter {
1616                sheet: String::new(),
1617            },
1618            FilterVerb::AddFilterView {
1619                sheet: String::new(),
1620                range: String::new(),
1621                title: None,
1622                sort_by: Vec::new(),
1623                hide_values: Vec::new(),
1624            },
1625            FilterVerb::UpdateFilterView {
1626                filter_view_id: 1,
1627                sheet: None,
1628                range: None,
1629                title: None,
1630                sort_by: Vec::new(),
1631                hide_values: Vec::new(),
1632                clear_sort: false,
1633                clear_criteria: false,
1634            },
1635            FilterVerb::DeleteFilterView { filter_view_id: 1 },
1636        ];
1637        let ops: HashSet<&str> = verbs.iter().map(FilterVerb::log_operation).collect();
1638        let labels: HashSet<&str> = verbs.iter().map(FilterVerb::label).collect();
1639        assert_eq!(ops.len(), verbs.len());
1640        assert_eq!(labels.len(), verbs.len());
1641    }
1642
1643    // ── validate_verb ────────────────────────────────────────────────────
1644
1645    #[test]
1646    fn validate_verb_rejects_update_filter_view_with_nothing_to_change() {
1647        let verb = FilterVerb::UpdateFilterView {
1648            filter_view_id: 1,
1649            sheet: None,
1650            range: None,
1651            title: None,
1652            sort_by: Vec::new(),
1653            hide_values: Vec::new(),
1654            clear_sort: false,
1655            clear_criteria: false,
1656        };
1657        let err = validate_verb(&verb).unwrap_err();
1658        assert!(err.contains("nothing to change"), "{err}");
1659    }
1660
1661    #[test]
1662    fn validate_verb_accepts_update_filter_view_with_only_clear_sort() {
1663        let verb = FilterVerb::UpdateFilterView {
1664            filter_view_id: 1,
1665            sheet: None,
1666            range: None,
1667            title: None,
1668            sort_by: Vec::new(),
1669            hide_values: Vec::new(),
1670            clear_sort: true,
1671            clear_criteria: false,
1672        };
1673        assert!(validate_verb(&verb).is_ok());
1674    }
1675
1676    // ── build_update ─────────────────────────────────────────────────────
1677
1678    fn filter_view(id: i64) -> FilterView {
1679        FilterView {
1680            filter_view_id: Some(id),
1681            ..Default::default()
1682        }
1683    }
1684
1685    fn spec(dimension_index: i64, sort_order: SortOrder) -> SortSpec {
1686        SortSpec {
1687            dimension_index,
1688            sort_order,
1689        }
1690    }
1691
1692    fn hiding(col: &str, values: &[&str]) -> BTreeMap<String, FilterCriteria> {
1693        let mut criteria = BTreeMap::new();
1694        criteria.insert(
1695            col.to_string(),
1696            FilterCriteria::hiding(values.iter().map(|v| (*v).to_string()).collect()),
1697        );
1698        criteria
1699    }
1700
1701    /// A view shaped like the one the #1931 live probe started from:
1702    /// sorted by columns 2 then 3, hiding `b` in column 1.
1703    fn sorted_filtered_view(id: i64) -> FilterView {
1704        FilterView {
1705            filter_view_id: Some(id),
1706            title: Some("Old".to_string()),
1707            range: Some(GridRange {
1708                sheet_id: 0,
1709                start_row_index: Some(0),
1710                end_row_index: Some(6),
1711                start_column_index: Some(0),
1712                end_column_index: Some(4),
1713            }),
1714            named_range_id: None,
1715            table_id: None,
1716            sort_specs: vec![
1717                spec(2, SortOrder::Descending),
1718                spec(3, SortOrder::Descending),
1719            ],
1720            criteria: hiding("1", &["b"]),
1721        }
1722    }
1723
1724    /// The `batchUpdate` `requests` array the write turns into, as JSON —
1725    /// what actually reaches the wire, which is where #1931 hid.
1726    fn wire(write: FilterViewWrite, filter_view_id: i64) -> serde_json::Value {
1727        serde_json::to_value(write.into_requests(filter_view_id)).unwrap()
1728    }
1729
1730    #[test]
1731    fn build_update_sends_only_the_changed_criteria_columns() {
1732        let mut existing = filter_view(3);
1733        existing.criteria = hiding("0", &["Keep"]);
1734        let write = build_update(
1735            &existing,
1736            3,
1737            &None,
1738            None,
1739            Vec::new(),
1740            hiding("1", &["New"]),
1741            false,
1742            false,
1743        )
1744        .unwrap();
1745        // Sheets merges criteria per column, so column 0 is left out of
1746        // the body rather than re-sent — which is also what keeps any
1747        // criteria this crate doesn't model on it intact.
1748        assert_eq!(
1749            wire(write, 3),
1750            serde_json::json!([{"updateFilterView": {
1751                "fields": "criteria",
1752                "filter": {"filterViewId": 3, "criteria": {"1": {"hiddenValues": ["New"]}}},
1753            }}])
1754        );
1755    }
1756
1757    #[test]
1758    fn build_update_clear_criteria_resets_each_filtered_column_with_an_empty_object() {
1759        let mut existing = sorted_filtered_view(3);
1760        existing.criteria.extend(hiding("3", &["x"]));
1761        // A column an earlier reset left behind as `{}` needs no second
1762        // reset.
1763        existing
1764            .criteria
1765            .insert("0".to_string(), FilterCriteria::default());
1766        let write = build_update(
1767            &existing,
1768            3,
1769            &None,
1770            None,
1771            Vec::new(),
1772            hiding("3", &["y"]),
1773            false,
1774            true,
1775        )
1776        .unwrap();
1777        assert_eq!(
1778            wire(write, 3),
1779            serde_json::json!([{"updateFilterView": {
1780                "fields": "criteria",
1781                "filter": {"filterViewId": 3, "criteria": {
1782                    "1": {},
1783                    "3": {"hiddenValues": ["y"]},
1784                }},
1785            }}])
1786        );
1787    }
1788
1789    #[test]
1790    fn build_update_clear_criteria_on_an_unfiltered_view_still_names_the_field() {
1791        let write = build_update(
1792            &filter_view(3),
1793            3,
1794            &None,
1795            None,
1796            Vec::new(),
1797            BTreeMap::new(),
1798            false,
1799            true,
1800        )
1801        .unwrap();
1802        assert_eq!(
1803            wire(write, 3),
1804            serde_json::json!([{"updateFilterView": {
1805                "fields": "criteria",
1806                "filter": {"filterViewId": 3, "criteria": {}},
1807            }}])
1808        );
1809    }
1810
1811    #[test]
1812    fn build_update_sort_spec_replaces_matching_column_and_appends_others() {
1813        let mut existing = filter_view(3);
1814        existing.sort_specs = vec![spec(0, SortOrder::Ascending), spec(1, SortOrder::Ascending)];
1815        let write = build_update(
1816            &existing,
1817            3,
1818            &None,
1819            None,
1820            vec![
1821                spec(0, SortOrder::Descending),
1822                spec(2, SortOrder::Ascending),
1823            ],
1824            BTreeMap::new(),
1825            false,
1826            false,
1827        )
1828        .unwrap();
1829        assert_eq!(
1830            wire(write, 3),
1831            serde_json::json!([{"updateFilterView": {
1832                "fields": "sortSpecs",
1833                "filter": {"filterViewId": 3, "sortSpecs": [
1834                    {"dimensionIndex": 0, "sortOrder": "DESCENDING"},
1835                    {"dimensionIndex": 1, "sortOrder": "ASCENDING"},
1836                    {"dimensionIndex": 2, "sortOrder": "ASCENDING"},
1837                ]},
1838            }}])
1839        );
1840    }
1841
1842    #[test]
1843    fn build_update_sort_by_a_new_column_sends_the_whole_list() {
1844        // The issue's non-clear case: `--sort-by 1:asc` on `[2 desc]`.
1845        let mut existing = filter_view(3);
1846        existing.sort_specs = vec![spec(2, SortOrder::Descending)];
1847        let write = build_update(
1848            &existing,
1849            3,
1850            &None,
1851            None,
1852            vec![spec(1, SortOrder::Ascending)],
1853            BTreeMap::new(),
1854            false,
1855            false,
1856        )
1857        .unwrap();
1858        assert_eq!(
1859            wire(write, 3),
1860            serde_json::json!([{"updateFilterView": {
1861                "fields": "sortSpecs",
1862                "filter": {"filterViewId": 3, "sortSpecs": [
1863                    {"dimensionIndex": 2, "sortOrder": "DESCENDING"},
1864                    {"dimensionIndex": 1, "sortOrder": "ASCENDING"},
1865                ]},
1866            }}])
1867        );
1868    }
1869
1870    #[test]
1871    fn build_update_title_only_sends_no_sort_or_criteria() {
1872        let write = build_update(
1873            &sorted_filtered_view(3),
1874            3,
1875            &Some("New title".to_string()),
1876            None,
1877            Vec::new(),
1878            BTreeMap::new(),
1879            false,
1880            false,
1881        )
1882        .unwrap();
1883        assert_eq!(
1884            wire(write, 3),
1885            serde_json::json!([{"updateFilterView": {
1886                "fields": "title",
1887                "filter": {"filterViewId": 3, "title": "New title"},
1888            }}])
1889        );
1890    }
1891
1892    #[test]
1893    fn build_update_clear_sort_then_sort_by_recreates_the_view_with_exactly_that_order() {
1894        // The issue's second symptom: `--clear-sort --sort-by 0:asc` on
1895        // `[2 desc, 3 desc]` came back `[0 asc, 2 desc, 3 desc]`, because
1896        // `updateFilterView` merges. Re-creating is the only way to drop 2
1897        // and 3.
1898        let write = build_update(
1899            &sorted_filtered_view(3),
1900            3,
1901            &None,
1902            None,
1903            vec![spec(0, SortOrder::Ascending)],
1904            BTreeMap::new(),
1905            true,
1906            false,
1907        )
1908        .unwrap();
1909        assert_eq!(
1910            wire(write, 3),
1911            serde_json::json!([
1912                {"deleteFilterView": {"filterId": 3}},
1913                {"addFilterView": {"filter": {
1914                    "filterViewId": 3,
1915                    "title": "Old",
1916                    "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 6,
1917                              "startColumnIndex": 0, "endColumnIndex": 4},
1918                    "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
1919                    "criteria": {"1": {"hiddenValues": ["b"]}},
1920                }}},
1921            ])
1922        );
1923    }
1924
1925    #[test]
1926    fn build_update_clear_sort_alone_recreates_the_view_with_no_sort() {
1927        let write = build_update(
1928            &sorted_filtered_view(3),
1929            3,
1930            &None,
1931            None,
1932            Vec::new(),
1933            BTreeMap::new(),
1934            true,
1935            false,
1936        )
1937        .unwrap();
1938        let FilterViewWrite::Replace(view) = write else {
1939            panic!("expected a re-creation, got {write:?}");
1940        };
1941        assert!(view.sort_specs.is_empty());
1942        assert_eq!(view.criteria, hiding("1", &["b"]));
1943        assert_eq!(view.filter_view_id, Some(3));
1944    }
1945
1946    #[test]
1947    fn build_update_clear_sort_on_an_unsorted_view_is_a_plain_update() {
1948        let mut existing = sorted_filtered_view(3);
1949        existing.sort_specs.clear();
1950        let write = build_update(
1951            &existing,
1952            3,
1953            &None,
1954            None,
1955            Vec::new(),
1956            BTreeMap::new(),
1957            true,
1958            false,
1959        )
1960        .unwrap();
1961        assert_eq!(
1962            wire(write, 3),
1963            serde_json::json!([{"updateFilterView": {
1964                "fields": "sortSpecs",
1965                "filter": {"filterViewId": 3, "sortSpecs": []},
1966            }}])
1967        );
1968    }
1969
1970    #[test]
1971    fn build_update_clear_sort_keeping_every_column_reorders_in_place() {
1972        // Every existing column is still named, so the server's merge
1973        // (sent entries first) yields exactly `[3 desc, 2 asc]` — live-
1974        // verified — with no re-creation.
1975        let write = build_update(
1976            &sorted_filtered_view(3),
1977            3,
1978            &None,
1979            None,
1980            vec![
1981                spec(3, SortOrder::Descending),
1982                spec(2, SortOrder::Ascending),
1983            ],
1984            BTreeMap::new(),
1985            true,
1986            false,
1987        )
1988        .unwrap();
1989        assert_eq!(
1990            wire(write, 3),
1991            serde_json::json!([{"updateFilterView": {
1992                "fields": "sortSpecs",
1993                "filter": {"filterViewId": 3, "sortSpecs": [
1994                    {"dimensionIndex": 3, "sortOrder": "DESCENDING"},
1995                    {"dimensionIndex": 2, "sortOrder": "ASCENDING"},
1996                ]},
1997            }}])
1998        );
1999    }
2000
2001    #[test]
2002    fn build_update_recreation_applies_every_other_change_too() {
2003        let mut existing = sorted_filtered_view(3);
2004        // A reset column and an unmodelled condition, both as a read-back
2005        // would carry them.
2006        existing
2007            .criteria
2008            .insert("0".to_string(), FilterCriteria::default());
2009        let condition: FilterCriteria = serde_json::from_value(serde_json::json!({
2010            "condition": {"type": "NUMBER_GREATER", "values": [{"userEnteredValue": "2"}]}
2011        }))
2012        .unwrap();
2013        existing.criteria.insert("2".to_string(), condition.clone());
2014        let range = GridRange {
2015            sheet_id: 0,
2016            start_row_index: Some(0),
2017            end_row_index: Some(20),
2018            start_column_index: Some(0),
2019            end_column_index: Some(4),
2020        };
2021        let write = build_update(
2022            &existing,
2023            3,
2024            &Some("Renamed".to_string()),
2025            Some(range),
2026            Vec::new(),
2027            hiding("3", &["x"]),
2028            true,
2029            false,
2030        )
2031        .unwrap();
2032        let mut criteria = hiding("1", &["b"]);
2033        criteria.insert("2".to_string(), condition);
2034        criteria.extend(hiding("3", &["x"]));
2035        assert_eq!(
2036            write,
2037            FilterViewWrite::Replace(FilterView {
2038                filter_view_id: Some(3),
2039                title: Some("Renamed".to_string()),
2040                range: Some(range),
2041                named_range_id: None,
2042                table_id: None,
2043                sort_specs: Vec::new(),
2044                criteria,
2045            })
2046        );
2047    }
2048
2049    #[test]
2050    fn build_update_recreation_with_clear_criteria_keeps_only_the_new_columns() {
2051        let write = build_update(
2052            &sorted_filtered_view(3),
2053            3,
2054            &None,
2055            None,
2056            Vec::new(),
2057            hiding("3", &["x"]),
2058            true,
2059            true,
2060        )
2061        .unwrap();
2062        let FilterViewWrite::Replace(view) = write else {
2063            panic!("expected a re-creation, got {write:?}");
2064        };
2065        assert_eq!(view.criteria, hiding("3", &["x"]));
2066    }
2067
2068    #[test]
2069    fn build_update_recreation_without_a_range_is_refused() {
2070        let mut existing = sorted_filtered_view(3);
2071        existing.range = None;
2072        let detail = build_update(
2073            &existing,
2074            3,
2075            &None,
2076            None,
2077            Vec::new(),
2078            BTreeMap::new(),
2079            true,
2080            false,
2081        )
2082        .unwrap_err();
2083        assert!(detail.contains("--sheet/--range"), "{detail}");
2084    }
2085
2086    #[test]
2087    fn build_update_recreation_rebinds_a_table_bound_view_without_its_range() {
2088        // Sheets reports a table-bound view's resolved range too, but
2089        // rejects an `addFilterView` carrying both ("Only one of tableId and
2090        // range may be set") — live-verified — so only the binding goes.
2091        let mut existing = sorted_filtered_view(3);
2092        existing.table_id = Some("454645209".to_string());
2093        let write = build_update(
2094            &existing,
2095            3,
2096            &None,
2097            None,
2098            vec![spec(0, SortOrder::Ascending)],
2099            BTreeMap::new(),
2100            true,
2101            false,
2102        )
2103        .unwrap();
2104        assert_eq!(
2105            wire(write, 3),
2106            serde_json::json!([
2107                {"deleteFilterView": {"filterId": 3}},
2108                {"addFilterView": {"filter": {
2109                    "filterViewId": 3,
2110                    "title": "Old",
2111                    "tableId": "454645209",
2112                    "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
2113                    "criteria": {"1": {"hiddenValues": ["b"]}},
2114                }}},
2115            ])
2116        );
2117    }
2118
2119    #[test]
2120    fn build_update_recreation_rebinds_a_named_range_bound_view_without_its_range() {
2121        let mut existing = sorted_filtered_view(3);
2122        existing.named_range_id = Some("2014020481".to_string());
2123        let write = build_update(
2124            &existing,
2125            3,
2126            &None,
2127            None,
2128            Vec::new(),
2129            BTreeMap::new(),
2130            true,
2131            false,
2132        )
2133        .unwrap();
2134        let FilterViewWrite::Replace(view) = write else {
2135            panic!("expected a re-creation, got {write:?}");
2136        };
2137        assert_eq!(view.named_range_id.as_deref(), Some("2014020481"));
2138        assert_eq!(view.table_id, None);
2139        assert_eq!(view.range, None);
2140    }
2141
2142    #[test]
2143    fn build_update_recreation_of_a_bound_view_with_a_new_range_is_refused() {
2144        let mut existing = sorted_filtered_view(3);
2145        existing.table_id = Some("454645209".to_string());
2146        let detail = build_update(
2147            &existing,
2148            3,
2149            &None,
2150            sorted_filtered_view(3).range,
2151            Vec::new(),
2152            BTreeMap::new(),
2153            true,
2154            false,
2155        )
2156        .unwrap_err();
2157        assert!(detail.contains("bound to table 454645209"), "{detail}");
2158        assert!(detail.contains("would unbind it"), "{detail}");
2159    }
2160
2161    #[test]
2162    fn build_update_plain_update_of_a_bound_view_sends_no_binding() {
2163        // Only the re-creation re-sends the binding; an in-place update
2164        // leaves it alone.
2165        let mut existing = sorted_filtered_view(3);
2166        existing.table_id = Some("454645209".to_string());
2167        let write = build_update(
2168            &existing,
2169            3,
2170            &None,
2171            None,
2172            vec![spec(1, SortOrder::Ascending)],
2173            BTreeMap::new(),
2174            false,
2175            false,
2176        )
2177        .unwrap();
2178        let FilterViewWrite::Update(update) = write else {
2179            panic!("expected an in-place update, got {write:?}");
2180        };
2181        assert_eq!(update.fields, "sortSpecs");
2182    }
2183
2184    #[test]
2185    fn filter_criteria_keeps_unmodelled_fields_across_a_round_trip() {
2186        let json = serde_json::json!({
2187            "hiddenValues": ["a"],
2188            "condition": {"type": "TEXT_CONTAINS", "values": [{"userEnteredValue": "x"}]},
2189        });
2190        let criteria: FilterCriteria = serde_json::from_value(json.clone()).unwrap();
2191        assert!(!criteria.is_empty());
2192        assert_eq!(serde_json::to_value(&criteria).unwrap(), json);
2193        assert!(FilterCriteria::default().is_empty());
2194        assert_eq!(
2195            serde_json::to_value(FilterCriteria::default()).unwrap(),
2196            serde_json::json!({})
2197        );
2198    }
2199
2200    #[test]
2201    fn add_filter_view_wire_shape_is_unchanged() {
2202        let request = AddFilterViewRequest {
2203            filter: FilterView {
2204                filter_view_id: None,
2205                title: Some("T".to_string()),
2206                range: sorted_filtered_view(3).range,
2207                named_range_id: None,
2208                table_id: None,
2209                sort_specs: vec![spec(2, SortOrder::Descending)],
2210                criteria: hiding("1", &["b"]),
2211            },
2212        };
2213        assert_eq!(
2214            serde_json::to_string(&request).unwrap(),
2215            r#"{"filter":{"title":"T","range":{"sheetId":0,"startRowIndex":0,"endRowIndex":6,"startColumnIndex":0,"endColumnIndex":4},"sortSpecs":[{"dimensionIndex":2,"sortOrder":"DESCENDING"}],"criteria":{"1":{"hiddenValues":["b"]}}}}"#
2216        );
2217    }
2218
2219    // ── find_existing_filter_view ───────────────────────────────────────
2220
2221    fn workbook_with_filter_views(views: Vec<FilterView>) -> Spreadsheet {
2222        Spreadsheet {
2223            sheets: vec![Sheet {
2224                properties: None,
2225                filter_views: views,
2226                ..Default::default()
2227            }],
2228            ..Default::default()
2229        }
2230    }
2231
2232    /// Clearing a basic filter that isn't there sends nothing and says so,
2233    /// in a dry run exactly as in a real run.
2234    #[tokio::test]
2235    async fn clearing_an_absent_basic_filter_is_unchanged_in_dry_run_and_real_run() {
2236        for dry_run in [true, false] {
2237            let server = wiremock::MockServer::start().await;
2238            let (drive, sheets) = clients(&server).await;
2239            mount_file(
2240                "sheet-1",
2241                crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2242                &["folder-1"],
2243            )
2244            .mount(&server)
2245            .await;
2246            mount_folder("folder-1").mount(&server).await;
2247            mount_workbook(serde_json::json!([
2248                {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2249            ]))
2250            .mount(&server)
2251            .await;
2252            let mut opts = dry_run_opts(FilterVerb::ClearBasicFilter {
2253                sheet: "Q1".to_string(),
2254            });
2255            opts.dry_run = dry_run;
2256            let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
2257            assert_eq!(
2258                outcome.result,
2259                FilterResult::Unchanged {
2260                    detail: "sheet 'Q1' has no basic filter to clear".to_string()
2261                },
2262                "dry_run={dry_run}"
2263            );
2264            assert!(describe(&outcome).starts_with("Unchanged: sheet 'Q1' has no basic filter"));
2265            let requests = server.received_requests().await.unwrap();
2266            assert!(!requests
2267                .iter()
2268                .any(|r| r.url.path().ends_with(":batchUpdate")));
2269        }
2270    }
2271
2272    #[test]
2273    fn describe_existing_view_names_the_title_and_the_range() {
2274        let workbook = Spreadsheet {
2275            sheets: vec![Sheet {
2276                properties: Some(crate::drive::sheets::types::SheetProperties {
2277                    sheet_id: Some(0),
2278                    title: "Q1".to_string(),
2279                    ..Default::default()
2280                }),
2281                ..Default::default()
2282            }],
2283            ..Default::default()
2284        };
2285        let view = FilterView {
2286            filter_view_id: Some(7),
2287            title: Some("Open items".to_string()),
2288            range: Some(GridRange {
2289                sheet_id: 0,
2290                start_row_index: Some(0),
2291                end_row_index: Some(10),
2292                start_column_index: Some(0),
2293                end_column_index: Some(4),
2294            }),
2295            ..Default::default()
2296        };
2297        assert_eq!(
2298            describe_existing_view(&workbook, &view),
2299            " 'Open items' over 'Q1'!A1:D10"
2300        );
2301        let open_ended = FilterView {
2302            title: None,
2303            range: Some(GridRange {
2304                sheet_id: 0,
2305                start_column_index: Some(0),
2306                end_column_index: Some(1),
2307                ..Default::default()
2308            }),
2309            ..view
2310        };
2311        assert_eq!(
2312            describe_existing_view(&workbook, &open_ended),
2313            " over sheetId 0, cols 1-1"
2314        );
2315        assert_eq!(describe_existing_view(&workbook, &filter_view(7)), "");
2316    }
2317
2318    #[test]
2319    fn find_existing_filter_view_matches_by_id() {
2320        let workbook = workbook_with_filter_views(vec![filter_view(3), filter_view(5)]);
2321        let found = find_existing_filter_view(&workbook, 5).unwrap();
2322        assert_eq!(found.filter_view_id, Some(5));
2323    }
2324
2325    #[test]
2326    fn find_existing_filter_view_refuses_when_absent() {
2327        let workbook = workbook_with_filter_views(vec![filter_view(3)]);
2328        let err = find_existing_filter_view(&workbook, 99).unwrap_err();
2329        assert!(matches!(
2330            err,
2331            FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
2332        ));
2333    }
2334
2335    // ── write_jsonl / log_status ─────────────────────────────────────────
2336
2337    #[test]
2338    fn write_jsonl_emits_one_line_of_json() {
2339        let outcome = FilterOutcome {
2340            spreadsheet_id: "sheet-1".to_string(),
2341            file_name: Some("Budget".to_string()),
2342            resolved_folder_id: None,
2343            sheet_id: None,
2344            verb: FilterVerb::DeleteFilterView { filter_view_id: 3 },
2345            result: FilterResult::Changed {
2346                summary: "delete filter view".to_string(),
2347                filter_view_id: Some(3),
2348                width_warning: None,
2349            },
2350        };
2351        let mut buf = Vec::new();
2352        outcome.write_jsonl(&mut buf).unwrap();
2353        let text = String::from_utf8(buf).unwrap();
2354        assert_eq!(text.matches('\n').count(), 1);
2355        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2356        assert_eq!(parsed["result"]["status"], "changed");
2357    }
2358
2359    #[test]
2360    fn filter_result_log_status_names_every_variant() {
2361        assert_eq!(
2362            FilterResult::WouldChange {
2363                summary: String::new(),
2364                width_warning: None,
2365            }
2366            .log_status(),
2367            "would-change"
2368        );
2369        assert_eq!(
2370            FilterResult::RefusedFilterViewNotFound { filter_view_id: 1 }.log_status(),
2371            "refused-filter-view-not-found"
2372        );
2373        assert_eq!(
2374            FilterResult::AppliedReplyUnreadable {
2375                summary: String::new(),
2376                filter_view_id: None,
2377                width_warning: None,
2378                detail: String::new(),
2379            }
2380            .log_status(),
2381            "applied-reply-unreadable"
2382        );
2383        assert_eq!(
2384            FilterResult::Failed {
2385                detail: String::new()
2386            }
2387            .log_status(),
2388            "failed"
2389        );
2390    }
2391
2392    // ── end-to-end via wiremock ──────────────────────────────────────────
2393
2394    fn test_credentials() -> DriveCredentials {
2395        DriveCredentials {
2396            client_id: "client-1".to_string(),
2397            client_secret: Secret::new("secret-1"),
2398            refresh_token: Secret::new("refresh-1"),
2399            scope: DriveGrantedScopes::READONLY,
2400        }
2401    }
2402
2403    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2404        wiremock::Mock::given(wiremock::matchers::method("POST"))
2405            .and(wiremock::matchers::path("/token"))
2406            .respond_with(
2407                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2408                    "access_token": "test-token", "expires_in": 3600,
2409                })),
2410            )
2411            .mount(server)
2412            .await;
2413        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2414        crate::drive::client::test_support::replace_session(
2415            &mut drive,
2416            &test_credentials(),
2417            &format!("{}/token", server.uri()),
2418        );
2419        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2420        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2421        (drive, sheets)
2422    }
2423
2424    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2425        let parents: Vec<&str> = parents.to_vec();
2426        wiremock::Mock::given(wiremock::matchers::method("GET"))
2427            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2428            .respond_with(
2429                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2430                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2431                    "version": "1",
2432                })),
2433            )
2434    }
2435
2436    fn mount_folder(id: &str) -> wiremock::Mock {
2437        mount_file(id, "application/vnd.google-apps.folder", &[])
2438    }
2439
2440    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2441        let ledger_path = tempfile::tempdir()
2442            .unwrap()
2443            .keep()
2444            .join("lease-ledger.jsonl");
2445        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2446        (Some(token), ledger_path)
2447    }
2448
2449    fn allow_rule(folder: &str) -> FolderPermissionRule {
2450        allow_rule_for(folder, &[DriveOperation::SheetsStructure])
2451    }
2452
2453    fn allow_rule_for(folder: &str, operations: &[DriveOperation]) -> FolderPermissionRule {
2454        FolderPermissionRule {
2455            folder_id: Some(folder.to_string()),
2456            file_id: None,
2457            recursive: true,
2458            allow: operations.iter().copied().collect(),
2459            deny: HashSet::default(),
2460            require_lease: true,
2461        }
2462    }
2463
2464    fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
2465        wiremock::Mock::given(wiremock::matchers::method("GET"))
2466            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2467            .respond_with(
2468                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2469                    "spreadsheetId": "sheet-1",
2470                    "properties": {"title": "Budget"},
2471                    "sheets": sheets,
2472                })),
2473            )
2474    }
2475
2476    #[tokio::test]
2477    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
2478        let server = wiremock::MockServer::start().await;
2479        let (drive, sheets) = clients(&server).await;
2480        mount_file(
2481            "sheet-1",
2482            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2483            &["folder-1"],
2484        )
2485        .mount(&server)
2486        .await;
2487        mount_folder("folder-1").mount(&server).await;
2488        let rules: Vec<FolderPermissionRule> = Vec::new();
2489        let opts = FilterOptions {
2490            spreadsheet_id: "sheet-1".to_string(),
2491            verb: FilterVerb::SetBasicFilter {
2492                sheet: "Q1".to_string(),
2493                range: "A1:D10".to_string(),
2494                sort_by: Vec::new(),
2495                hide_values: Vec::new(),
2496            },
2497            dry_run: false,
2498            lease_token: None,
2499            ledger_path: std::path::PathBuf::new(),
2500        };
2501        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2502        assert!(matches!(outcome.result, FilterResult::Blocked { .. }));
2503    }
2504
2505    // ── set-basic-filter --sort-by's union gate (issue #1940) ────────────
2506
2507    #[test]
2508    fn gate_operations_are_the_union_only_for_a_sorting_basic_filter() {
2509        let sort = || vec!["2:desc".to_string()];
2510        assert_eq!(
2511            set_verb(sort(), Vec::new()).gate_operations(),
2512            GATE_WRITE_AND_STRUCTURE
2513        );
2514        assert_eq!(
2515            set_verb(Vec::new(), vec!["1:Foo".to_string()]).gate_operations(),
2516            GATE_STRUCTURE_ONLY
2517        );
2518        // A filter view's sort is per-view — it reorders nothing.
2519        for verb in [
2520            FilterVerb::ClearBasicFilter {
2521                sheet: "Q1".to_string(),
2522            },
2523            FilterVerb::AddFilterView {
2524                sheet: "Q1".to_string(),
2525                range: "A1:D10".to_string(),
2526                title: None,
2527                sort_by: sort(),
2528                hide_values: Vec::new(),
2529            },
2530            FilterVerb::UpdateFilterView {
2531                filter_view_id: 7,
2532                sheet: None,
2533                range: None,
2534                title: None,
2535                sort_by: sort(),
2536                hide_values: Vec::new(),
2537                clear_sort: false,
2538                clear_criteria: false,
2539            },
2540            FilterVerb::DeleteFilterView { filter_view_id: 7 },
2541        ] {
2542            assert_eq!(verb.gate_operations(), GATE_STRUCTURE_ONLY, "{verb:?}");
2543        }
2544    }
2545
2546    async fn gated_server() -> (wiremock::MockServer, DriveClient, SheetsClient) {
2547        let server = wiremock::MockServer::start().await;
2548        let (drive, sheets) = clients(&server).await;
2549        mount_file(
2550            "sheet-1",
2551            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2552            &["folder-1"],
2553        )
2554        .mount(&server)
2555        .await;
2556        mount_folder("folder-1").mount(&server).await;
2557        (server, drive, sheets)
2558    }
2559
2560    fn dry_run_opts(verb: FilterVerb) -> FilterOptions {
2561        FilterOptions {
2562            spreadsheet_id: "sheet-1".to_string(),
2563            verb,
2564            dry_run: true,
2565            lease_token: None,
2566            ledger_path: std::path::PathBuf::new(),
2567        }
2568    }
2569
2570    #[tokio::test]
2571    async fn a_sorting_basic_filter_under_a_structure_only_grant_is_blocked_on_sheets_write() {
2572        // No workbook or batchUpdate mock: the gate must refuse before
2573        // either call.
2574        let (_server, drive, sheets) = gated_server().await;
2575        let rules = vec![allow_rule("folder-1")];
2576        let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2577        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2578        assert_eq!(
2579            outcome.result,
2580            FilterResult::Blocked {
2581                operation: DriveOperation::SheetsWrite,
2582                decided_by: None,
2583            }
2584        );
2585    }
2586
2587    #[tokio::test]
2588    async fn a_sorting_basic_filter_under_a_write_only_grant_is_blocked_on_sheets_structure() {
2589        let (_server, drive, sheets) = gated_server().await;
2590        let rules = vec![allow_rule_for("folder-1", &[DriveOperation::SheetsWrite])];
2591        let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2592        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2593        assert_eq!(
2594            outcome.result,
2595            FilterResult::Blocked {
2596                operation: DriveOperation::SheetsStructure,
2597                decided_by: None,
2598            }
2599        );
2600    }
2601
2602    #[tokio::test]
2603    async fn a_non_sorting_basic_filter_and_a_sorting_filter_view_still_pass_a_structure_only_grant(
2604    ) {
2605        let (server, drive, sheets) = gated_server().await;
2606        mount_workbook(serde_json::json!([
2607            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2608        ]))
2609        .mount(&server)
2610        .await;
2611        let rules = vec![allow_rule("folder-1")];
2612        for verb in [
2613            set_verb(Vec::new(), vec!["1:Foo".to_string()]),
2614            FilterVerb::AddFilterView {
2615                sheet: "Q1".to_string(),
2616                range: "A1:D10".to_string(),
2617                title: None,
2618                sort_by: vec!["2:desc".to_string()],
2619                hide_values: Vec::new(),
2620            },
2621        ] {
2622            let outcome = filter(&drive, &sheets, &dry_run_opts(verb), &rules).await;
2623            assert!(
2624                matches!(
2625                    outcome.result,
2626                    FilterResult::WouldChange {
2627                        width_warning: None,
2628                        ..
2629                    }
2630                ),
2631                "{:?}",
2632                outcome.result
2633            );
2634            let text = describe(&outcome);
2635            assert!(!text.contains("different row"), "{text}");
2636        }
2637    }
2638
2639    #[tokio::test]
2640    async fn a_sorting_basic_filter_narrower_than_its_sheet_previews_the_reorder_caveats() {
2641        let (server, drive, sheets) = gated_server().await;
2642        mount_workbook(serde_json::json!([
2643            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2644                            "gridProperties": {"rowCount": 100, "columnCount": 10}}},
2645        ]))
2646        .mount(&server)
2647        .await;
2648        let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2649        let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2650        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2651        assert_eq!(
2652            outcome.result,
2653            FilterResult::WouldChange {
2654                summary: "set basic filter (sort 2:desc)".to_string(),
2655                width_warning: grid_range::width_warning("sorting", 0, 4, Some(10)),
2656            }
2657        );
2658        let lines = describe_lines(&outcome);
2659        assert_eq!(lines.len(), 3, "{lines:?}");
2660        assert!(
2661            lines[0].starts_with("Warning: selected columns 0..4"),
2662            "{lines:?}"
2663        );
2664        assert_eq!(
2665            lines[1],
2666            "Would set basic filter (sort 2:desc) in 'sheet-1'"
2667        );
2668        assert!(lines[2].ends_with("after sorting"), "{lines:?}");
2669    }
2670
2671    #[tokio::test]
2672    async fn a_sorting_basic_filter_spanning_every_column_has_no_width_warning() {
2673        let (server, drive, sheets) = gated_server().await;
2674        mount_workbook(serde_json::json!([
2675            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2676                            "gridProperties": {"rowCount": 100, "columnCount": 4}}},
2677        ]))
2678        .mount(&server)
2679        .await;
2680        let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2681        let opts = dry_run_opts(set_verb(vec!["2:desc".to_string()], Vec::new()));
2682        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2683        assert!(matches!(
2684            outcome.result,
2685            FilterResult::WouldChange {
2686                width_warning: None,
2687                ..
2688            }
2689        ));
2690        let lines = describe_lines(&outcome);
2691        assert_eq!(lines.len(), 2, "{lines:?}");
2692    }
2693
2694    #[test]
2695    fn reorder_width_warning_handles_open_ended_column_spans() {
2696        let workbook = |column_count: Option<i64>| -> Spreadsheet {
2697            serde_json::from_value(serde_json::json!({
2698                "spreadsheetId": "sheet-1",
2699                "sheets": [{"properties": {
2700                    "sheetId": 0, "title": "Q1", "index": 0,
2701                    "gridProperties": {"columnCount": column_count},
2702                }}],
2703            }))
2704            .unwrap()
2705        };
2706        let grid = |start: Option<i64>, end: Option<i64>| GridRange {
2707            sheet_id: 0,
2708            start_column_index: start,
2709            end_column_index: end,
2710            ..Default::default()
2711        };
2712        // Open-ended to the right on a known-width sheet reaches its edge.
2713        assert_eq!(
2714            reorder_width_warning(&workbook(Some(5)), &grid(None, None)),
2715            None
2716        );
2717        assert!(reorder_width_warning(&workbook(Some(5)), &grid(Some(1), None)).is_some());
2718        // Unknown width: only a span starting past column A can warn.
2719        assert_eq!(
2720            reorder_width_warning(&workbook(None), &grid(None, None)),
2721            None
2722        );
2723        assert!(reorder_width_warning(&workbook(None), &grid(Some(1), None)).is_some());
2724        assert!(reorder_width_warning(&workbook(None), &grid(None, Some(3))).is_some());
2725    }
2726
2727    #[test]
2728    fn describe_lines_adds_the_reorder_caveats_only_for_a_sorting_basic_filter() {
2729        let changed = outcome_with(
2730            set_verb(vec!["2:desc".to_string()], Vec::new()),
2731            Some("Budget"),
2732            FilterResult::Changed {
2733                summary: "set basic filter (sort 2:desc)".to_string(),
2734                filter_view_id: None,
2735                width_warning: Some("narrow".to_string()),
2736            },
2737        );
2738        assert_eq!(
2739            describe_lines(&changed),
2740            vec![
2741                "Warning: narrow".to_string(),
2742                "Applied: set basic filter (sort 2:desc) in 'Budget'".to_string(),
2743                "  references outside the range may now observe values from a different row"
2744                    .to_string(),
2745            ]
2746        );
2747
2748        let would = outcome_with(
2749            set_verb(vec!["2:desc".to_string()], Vec::new()),
2750            Some("Budget"),
2751            FilterResult::WouldChange {
2752                summary: "set basic filter (sort 2:desc)".to_string(),
2753                width_warning: None,
2754            },
2755        );
2756        assert_eq!(
2757            describe_lines(&would),
2758            vec![
2759                "Would set basic filter (sort 2:desc) in 'Budget'".to_string(),
2760                "  references outside the range may observe values from a different row after sorting"
2761                    .to_string(),
2762            ]
2763        );
2764    }
2765
2766    #[test]
2767    fn describe_lines_names_the_denied_operation_and_the_union_grant_hint() {
2768        let blocked = outcome_with(
2769            set_verb(vec!["2:desc".to_string()], Vec::new()),
2770            Some("Budget"),
2771            FilterResult::Blocked {
2772                operation: DriveOperation::SheetsWrite,
2773                decided_by: None,
2774            },
2775        );
2776        let text = describe(&blocked);
2777        assert!(text.contains("no matching rule for sheets-write"), "{text}");
2778
2779        let orphan = outcome_with(
2780            set_verb(vec!["2:desc".to_string()], Vec::new()),
2781            Some("Budget"),
2782            FilterResult::RefusedNoVisibleParents,
2783        );
2784        let text = describe(&orphan);
2785        assert!(
2786            text.contains(r#""allow": ["sheets-write", "sheets-structure"]"#),
2787            "{text}"
2788        );
2789        let orphan = outcome_with(
2790            set_verb(Vec::new(), Vec::new()),
2791            Some("Budget"),
2792            FilterResult::RefusedNoVisibleParents,
2793        );
2794        let text = describe(&orphan);
2795        assert!(text.contains(r#""allow": ["sheets-structure"]"#), "{text}");
2796    }
2797
2798    #[tokio::test]
2799    async fn set_basic_filter_sends_a_set_basic_filter_request() {
2800        let server = wiremock::MockServer::start().await;
2801        let (drive, sheets) = clients(&server).await;
2802        mount_file(
2803            "sheet-1",
2804            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2805            &["folder-1"],
2806        )
2807        .mount(&server)
2808        .await;
2809        mount_folder("folder-1").mount(&server).await;
2810        mount_workbook(serde_json::json!([
2811            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2812        ]))
2813        .mount(&server)
2814        .await;
2815        wiremock::Mock::given(wiremock::matchers::method("POST"))
2816            .and(wiremock::matchers::path(
2817                "/v4/spreadsheets/sheet-1:batchUpdate",
2818            ))
2819            .respond_with(
2820                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2821                    "replies": [{}]
2822                })),
2823            )
2824            .mount(&server)
2825            .await;
2826        // `--sort-by` physically reorders rows, so it needs the union
2827        // grant (issue #1940).
2828        let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2829        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2830        let opts = FilterOptions {
2831            spreadsheet_id: "sheet-1".to_string(),
2832            verb: FilterVerb::SetBasicFilter {
2833                sheet: "Q1".to_string(),
2834                range: "A1:D10".to_string(),
2835                sort_by: vec!["0:asc".to_string()],
2836                hide_values: vec!["1:Foo,Bar".to_string()],
2837            },
2838            dry_run: false,
2839            lease_token,
2840            ledger_path,
2841        };
2842        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2843        assert!(matches!(outcome.result, FilterResult::Changed { .. }));
2844        // A basic filter has no id of its own to log (unlike a filter
2845        // view's `filter_view_id`), so the resolved sheet id is the only
2846        // thing that can tell an audit record which sheet was affected —
2847        // see `record_attempt`/`docs/log.md`.
2848        assert_eq!(outcome.sheet_id, Some(0));
2849    }
2850
2851    #[tokio::test]
2852    async fn set_basic_filter_refuses_a_sort_column_outside_the_range() {
2853        // `--sort-by 9:asc` on a 5-column `A1:E10` range passed dry-run and
2854        // then 500'd live (`HTTP 500: Internal error encountered`) — refuse
2855        // it locally, matching `add-pivot-table`'s column-offset check
2856        // (#1941).
2857        let server = wiremock::MockServer::start().await;
2858        let (drive, sheets) = clients(&server).await;
2859        mount_file(
2860            "sheet-1",
2861            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2862            &["folder-1"],
2863        )
2864        .mount(&server)
2865        .await;
2866        mount_folder("folder-1").mount(&server).await;
2867        mount_workbook(serde_json::json!([
2868            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2869        ]))
2870        .mount(&server)
2871        .await;
2872        // `--sort-by` needs the union grant (issue #1940).
2873        let rules = vec![allow_rule_for("folder-1", GATE_WRITE_AND_STRUCTURE)];
2874        let opts = unleased_opts(FilterVerb::SetBasicFilter {
2875            sheet: "Q1".to_string(),
2876            range: "A1:E10".to_string(),
2877            sort_by: vec!["9:asc".to_string()],
2878            hide_values: Vec::new(),
2879        });
2880        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2881        match outcome.result {
2882            FilterResult::RefusedInvalidRange { detail } => {
2883                assert!(
2884                    detail.contains("sort column 9 is outside the selected range's columns 0..5"),
2885                    "{detail}"
2886                );
2887            }
2888            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2889        }
2890    }
2891
2892    #[tokio::test]
2893    async fn clear_basic_filter_reports_the_resolved_sheet_id() {
2894        let server = wiremock::MockServer::start().await;
2895        let (drive, sheets) = clients(&server).await;
2896        mount_file(
2897            "sheet-1",
2898            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2899            &["folder-1"],
2900        )
2901        .mount(&server)
2902        .await;
2903        mount_folder("folder-1").mount(&server).await;
2904        mount_workbook(serde_json::json!([
2905            {"properties": {"sheetId": 7, "title": "Q1", "index": 0},
2906             "basicFilter": {"range": {"sheetId": 7}}},
2907        ]))
2908        .mount(&server)
2909        .await;
2910        wiremock::Mock::given(wiremock::matchers::method("POST"))
2911            .and(wiremock::matchers::path(
2912                "/v4/spreadsheets/sheet-1:batchUpdate",
2913            ))
2914            .respond_with(
2915                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2916                    "replies": [{}]
2917                })),
2918            )
2919            .mount(&server)
2920            .await;
2921        let rules = vec![allow_rule("folder-1")];
2922        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2923        let opts = FilterOptions {
2924            spreadsheet_id: "sheet-1".to_string(),
2925            verb: FilterVerb::ClearBasicFilter {
2926                sheet: "Q1".to_string(),
2927            },
2928            dry_run: false,
2929            lease_token,
2930            ledger_path,
2931        };
2932        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2933        assert!(matches!(outcome.result, FilterResult::Changed { .. }));
2934        assert_eq!(outcome.sheet_id, Some(7));
2935    }
2936
2937    #[tokio::test]
2938    async fn add_filter_view_reports_the_server_assigned_id() {
2939        let server = wiremock::MockServer::start().await;
2940        let (drive, sheets) = clients(&server).await;
2941        mount_file(
2942            "sheet-1",
2943            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2944            &["folder-1"],
2945        )
2946        .mount(&server)
2947        .await;
2948        mount_folder("folder-1").mount(&server).await;
2949        mount_workbook(serde_json::json!([
2950            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
2951        ]))
2952        .mount(&server)
2953        .await;
2954        wiremock::Mock::given(wiremock::matchers::method("POST"))
2955            .and(wiremock::matchers::path(
2956                "/v4/spreadsheets/sheet-1:batchUpdate",
2957            ))
2958            .respond_with(
2959                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2960                    "replies": [{"addFilterView": {"filter": {"filterViewId": 42}}}]
2961                })),
2962            )
2963            .mount(&server)
2964            .await;
2965        let rules = vec![allow_rule("folder-1")];
2966        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2967        let opts = FilterOptions {
2968            spreadsheet_id: "sheet-1".to_string(),
2969            verb: FilterVerb::AddFilterView {
2970                sheet: "Q1".to_string(),
2971                range: "A1:D10".to_string(),
2972                title: Some("Open only".to_string()),
2973                sort_by: Vec::new(),
2974                hide_values: Vec::new(),
2975            },
2976            dry_run: false,
2977            lease_token,
2978            ledger_path,
2979        };
2980        let outcome = filter(&drive, &sheets, &opts, &rules).await;
2981        match outcome.result {
2982            FilterResult::Changed { filter_view_id, .. } => {
2983                assert_eq!(filter_view_id, Some(42));
2984            }
2985            other => panic!("expected Changed, got {other:?}"),
2986        }
2987        // Unlike the basic filter, a filter view is already identified by
2988        // its own `filter_view_id`, so `sheet_id` stays unset here — see
2989        // `clear_basic_filter_reports_the_resolved_sheet_id` for the case
2990        // that needs it.
2991        assert_eq!(outcome.sheet_id, None);
2992    }
2993
2994    #[tokio::test]
2995    async fn update_filter_view_refuses_an_unknown_id() {
2996        let server = wiremock::MockServer::start().await;
2997        let (drive, sheets) = clients(&server).await;
2998        mount_file(
2999            "sheet-1",
3000            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3001            &["folder-1"],
3002        )
3003        .mount(&server)
3004        .await;
3005        mount_folder("folder-1").mount(&server).await;
3006        mount_workbook(serde_json::json!([
3007            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}, "filterViews": []},
3008        ]))
3009        .mount(&server)
3010        .await;
3011        let rules = vec![allow_rule("folder-1")];
3012        let opts = FilterOptions {
3013            spreadsheet_id: "sheet-1".to_string(),
3014            verb: FilterVerb::UpdateFilterView {
3015                filter_view_id: 99,
3016                sheet: None,
3017                range: None,
3018                title: Some("New".to_string()),
3019                sort_by: Vec::new(),
3020                hide_values: Vec::new(),
3021                clear_sort: false,
3022                clear_criteria: false,
3023            },
3024            dry_run: false,
3025            lease_token: None,
3026            ledger_path: std::path::PathBuf::new(),
3027        };
3028        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3029        assert!(matches!(
3030            outcome.result,
3031            FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
3032        ));
3033    }
3034
3035    /// `update-filter-view --range` with no `--sheet` resolves against the
3036    /// view's own sheet rather than being refused for naming none (#1941).
3037    #[tokio::test]
3038    async fn update_filter_view_range_without_sheet_defaults_to_the_views_sheet() {
3039        let server = wiremock::MockServer::start().await;
3040        let (drive, sheets) = clients(&server).await;
3041        mount_file(
3042            "sheet-1",
3043            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3044            &["folder-1"],
3045        )
3046        .mount(&server)
3047        .await;
3048        mount_folder("folder-1").mount(&server).await;
3049        mount_workbook(serde_json::json!([
3050            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
3051            {"properties": {"sheetId": 5, "title": "Q2", "index": 1},
3052             "filterViews": [{"filterViewId": 7, "range": {"sheetId": 5}}]},
3053        ]))
3054        .mount(&server)
3055        .await;
3056        let opts = dry_run_opts(FilterVerb::UpdateFilterView {
3057            filter_view_id: 7,
3058            sheet: None,
3059            range: Some("A1:C9".to_string()),
3060            title: None,
3061            sort_by: Vec::new(),
3062            hide_values: Vec::new(),
3063            clear_sort: false,
3064            clear_criteria: false,
3065        });
3066        let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
3067        assert!(
3068            matches!(outcome.result, FilterResult::WouldChange { .. }),
3069            "{:?}",
3070            outcome.result
3071        );
3072    }
3073
3074    /// An unknown id is reported as not found, not as a bare `--range`
3075    /// naming no sheet — the sheet defaulting must not mask it.
3076    #[tokio::test]
3077    async fn update_filter_view_range_without_sheet_reports_an_unknown_id_as_not_found() {
3078        let server = wiremock::MockServer::start().await;
3079        let (drive, sheets) = clients(&server).await;
3080        mount_file(
3081            "sheet-1",
3082            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3083            &["folder-1"],
3084        )
3085        .mount(&server)
3086        .await;
3087        mount_folder("folder-1").mount(&server).await;
3088        mount_workbook(serde_json::json!([
3089            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
3090            {"properties": {"sheetId": 5, "title": "Q2", "index": 1},
3091             "filterViews": [{"filterViewId": 7, "range": {"sheetId": 5}}]},
3092        ]))
3093        .mount(&server)
3094        .await;
3095        let opts = dry_run_opts(FilterVerb::UpdateFilterView {
3096            filter_view_id: 99,
3097            sheet: None,
3098            range: Some("A1:C9".to_string()),
3099            title: None,
3100            sort_by: Vec::new(),
3101            hide_values: Vec::new(),
3102            clear_sort: false,
3103            clear_criteria: false,
3104        });
3105        let outcome = filter(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
3106        assert_eq!(
3107            outcome.result,
3108            FilterResult::RefusedFilterViewNotFound { filter_view_id: 99 }
3109        );
3110    }
3111
3112    #[tokio::test]
3113    async fn delete_filter_view_sends_a_delete_filter_view_request() {
3114        let server = wiremock::MockServer::start().await;
3115        let (drive, sheets) = clients(&server).await;
3116        mount_file(
3117            "sheet-1",
3118            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3119            &["folder-1"],
3120        )
3121        .mount(&server)
3122        .await;
3123        mount_folder("folder-1").mount(&server).await;
3124        mount_workbook(serde_json::json!([
3125            {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3126             "filterViews": [{"filterViewId": 7}]},
3127        ]))
3128        .mount(&server)
3129        .await;
3130        wiremock::Mock::given(wiremock::matchers::method("POST"))
3131            .and(wiremock::matchers::path(
3132                "/v4/spreadsheets/sheet-1:batchUpdate",
3133            ))
3134            .respond_with(
3135                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3136                    "replies": [{}]
3137                })),
3138            )
3139            .mount(&server)
3140            .await;
3141        let rules = vec![allow_rule("folder-1")];
3142        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3143        let opts = FilterOptions {
3144            spreadsheet_id: "sheet-1".to_string(),
3145            verb: FilterVerb::DeleteFilterView { filter_view_id: 7 },
3146            dry_run: false,
3147            lease_token,
3148            ledger_path,
3149        };
3150        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3151        match outcome.result {
3152            FilterResult::Changed { filter_view_id, .. } => {
3153                assert_eq!(filter_view_id, Some(7));
3154            }
3155            other => panic!("expected Changed, got {other:?}"),
3156        }
3157    }
3158
3159    #[tokio::test]
3160    async fn dry_run_makes_no_batch_update_call() {
3161        let server = wiremock::MockServer::start().await;
3162        let (drive, sheets) = clients(&server).await;
3163        mount_file(
3164            "sheet-1",
3165            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3166            &["folder-1"],
3167        )
3168        .mount(&server)
3169        .await;
3170        mount_folder("folder-1").mount(&server).await;
3171        mount_workbook(serde_json::json!([
3172            {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3173             "basicFilter": {"range": {"sheetId": 0}}},
3174        ]))
3175        .mount(&server)
3176        .await;
3177        // Deliberately no mock for `POST .../batchUpdate`: a call there
3178        // would panic the mock server on an unexpected request, proving
3179        // dry-run never issues one.
3180        let rules = vec![allow_rule("folder-1")];
3181        let opts = FilterOptions {
3182            spreadsheet_id: "sheet-1".to_string(),
3183            verb: FilterVerb::ClearBasicFilter {
3184                sheet: "Q1".to_string(),
3185            },
3186            dry_run: true,
3187            lease_token: None,
3188            ledger_path: std::path::PathBuf::new(),
3189        };
3190        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3191        assert!(matches!(outcome.result, FilterResult::WouldChange { .. }));
3192    }
3193
3194    // ── refusals before any call ─────────────────────────────────────────
3195
3196    fn set_verb(sort_by: Vec<String>, hide_values: Vec<String>) -> FilterVerb {
3197        FilterVerb::SetBasicFilter {
3198            sheet: "Q1".to_string(),
3199            range: "A1:D10".to_string(),
3200            sort_by,
3201            hide_values,
3202        }
3203    }
3204
3205    fn update_verb() -> FilterVerb {
3206        FilterVerb::UpdateFilterView {
3207            filter_view_id: 7,
3208            sheet: None,
3209            range: None,
3210            title: Some("Renamed".to_string()),
3211            sort_by: Vec::new(),
3212            hide_values: Vec::new(),
3213            clear_sort: false,
3214            clear_criteria: false,
3215        }
3216    }
3217
3218    fn unleased_opts(verb: FilterVerb) -> FilterOptions {
3219        FilterOptions {
3220            spreadsheet_id: "sheet-1".to_string(),
3221            verb,
3222            dry_run: false,
3223            lease_token: None,
3224            ledger_path: std::path::PathBuf::new(),
3225        }
3226    }
3227
3228    /// A refusal decided from the flags alone must make no HTTP call at
3229    /// all: the mock server has nothing mounted, so any request would fail
3230    /// the outcome as `Failed` rather than the refusal asserted here.
3231    async fn refused_from_flags_alone(verb: FilterVerb) -> FilterOutcome {
3232        let server = wiremock::MockServer::start().await;
3233        let (drive, sheets) = clients(&server).await;
3234        let rules = vec![allow_rule("folder-1")];
3235        let outcome = filter(&drive, &sheets, &unleased_opts(verb), &rules).await;
3236        assert_eq!(outcome.file_name, None);
3237        outcome
3238    }
3239
3240    #[tokio::test]
3241    async fn an_invalid_sort_spec_is_refused_before_any_call() {
3242        let outcome =
3243            refused_from_flags_alone(set_verb(vec!["0:sideways".to_string()], Vec::new())).await;
3244        match outcome.result {
3245            FilterResult::RefusedInvalidRange { detail } => {
3246                assert!(detail.contains("not 'asc' or 'desc'"), "{detail}");
3247            }
3248            other => panic!("expected RefusedInvalidRange, got {other:?}"),
3249        }
3250    }
3251
3252    #[tokio::test]
3253    async fn an_invalid_hidden_values_flag_is_refused_before_any_call() {
3254        let outcome = refused_from_flags_alone(set_verb(Vec::new(), vec!["1:".to_string()])).await;
3255        match outcome.result {
3256            FilterResult::RefusedInvalidRange { detail } => {
3257                assert!(detail.contains("names no values"), "{detail}");
3258            }
3259            other => panic!("expected RefusedInvalidRange, got {other:?}"),
3260        }
3261    }
3262
3263    #[tokio::test]
3264    async fn a_sheet_beside_a_prefixed_range_is_refused_before_any_call() {
3265        let verb = FilterVerb::AddFilterView {
3266            sheet: "Q1".to_string(),
3267            range: "Q2!A1:D10".to_string(),
3268            title: None,
3269            sort_by: Vec::new(),
3270            hide_values: Vec::new(),
3271        };
3272        let outcome = refused_from_flags_alone(verb).await;
3273        match outcome.result {
3274            FilterResult::RefusedInvalidRange { detail } => {
3275                assert!(detail.contains("already names a sheet"), "{detail}");
3276            }
3277            other => panic!("expected RefusedInvalidRange, got {other:?}"),
3278        }
3279    }
3280
3281    #[tokio::test]
3282    async fn update_filter_view_with_nothing_to_change_is_refused_before_any_call() {
3283        let verb = FilterVerb::UpdateFilterView {
3284            filter_view_id: 7,
3285            sheet: None,
3286            range: None,
3287            title: None,
3288            sort_by: Vec::new(),
3289            hide_values: Vec::new(),
3290            clear_sort: false,
3291            clear_criteria: false,
3292        };
3293        let outcome = refused_from_flags_alone(verb).await;
3294        match outcome.result {
3295            FilterResult::RefusedInvalidRange { detail } => {
3296                assert!(detail.contains("nothing to change"), "{detail}");
3297            }
3298            other => panic!("expected RefusedInvalidRange, got {other:?}"),
3299        }
3300    }
3301
3302    // ── the target gate ──────────────────────────────────────────────────
3303
3304    #[tokio::test]
3305    async fn a_metadata_fetch_failure_surfaces_as_failed() {
3306        let server = wiremock::MockServer::start().await;
3307        let (drive, sheets) = clients(&server).await;
3308        wiremock::Mock::given(wiremock::matchers::method("GET"))
3309            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3310            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
3311            .mount(&server)
3312            .await;
3313        let rules: Vec<FolderPermissionRule> = Vec::new();
3314        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3315        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3316        assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3317        assert_eq!(outcome.file_name, None);
3318    }
3319
3320    #[tokio::test]
3321    async fn a_shortcut_target_is_refused() {
3322        let server = wiremock::MockServer::start().await;
3323        let (drive, sheets) = clients(&server).await;
3324        mount_file(
3325            "sheet-1",
3326            "application/vnd.google-apps.shortcut",
3327            &["folder-1"],
3328        )
3329        .mount(&server)
3330        .await;
3331        let rules = vec![allow_rule("folder-1")];
3332        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3333        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3334        assert!(matches!(outcome.result, FilterResult::RefusedShortcut));
3335        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
3336    }
3337
3338    #[tokio::test]
3339    async fn a_non_spreadsheet_target_is_refused() {
3340        let server = wiremock::MockServer::start().await;
3341        let (drive, sheets) = clients(&server).await;
3342        mount_file(
3343            "sheet-1",
3344            "application/vnd.google-apps.document",
3345            &["folder-1"],
3346        )
3347        .mount(&server)
3348        .await;
3349        let rules = vec![allow_rule("folder-1")];
3350        let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
3351        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3352        match outcome.result {
3353            FilterResult::RefusedNotASpreadsheet { mime_type } => {
3354                assert_eq!(mime_type, "application/vnd.google-apps.document");
3355            }
3356            other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
3357        }
3358    }
3359
3360    #[tokio::test]
3361    async fn a_target_with_no_visible_parents_is_refused() {
3362        let server = wiremock::MockServer::start().await;
3363        let (drive, sheets) = clients(&server).await;
3364        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
3365            .mount(&server)
3366            .await;
3367        let rules: Vec<FolderPermissionRule> = Vec::new();
3368        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3369        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3370        assert!(matches!(
3371            outcome.result,
3372            FilterResult::RefusedNoVisibleParents
3373        ));
3374    }
3375
3376    #[tokio::test]
3377    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
3378        let server = wiremock::MockServer::start().await;
3379        let (drive, sheets) = clients(&server).await;
3380        mount_file(
3381            "sheet-1",
3382            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3383            &["folder-1"],
3384        )
3385        .mount(&server)
3386        .await;
3387        wiremock::Mock::given(wiremock::matchers::method("GET"))
3388            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
3389            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3390            .mount(&server)
3391            .await;
3392        let rules = vec![allow_rule("folder-1")];
3393        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3394        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3395        assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3396        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
3397    }
3398
3399    #[tokio::test]
3400    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
3401        let server = wiremock::MockServer::start().await;
3402        let (drive, sheets) = clients(&server).await;
3403        mount_file(
3404            "sheet-1",
3405            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3406            &["folder-1"],
3407        )
3408        .mount(&server)
3409        .await;
3410        mount_folder("folder-1").mount(&server).await;
3411        wiremock::Mock::given(wiremock::matchers::method("GET"))
3412            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
3413            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3414            .mount(&server)
3415            .await;
3416        let rules = vec![allow_rule("folder-1")];
3417        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3418        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3419        assert!(matches!(outcome.result, FilterResult::Failed { .. }));
3420        assert_eq!(outcome.sheet_id, None);
3421    }
3422
3423    #[tokio::test]
3424    async fn set_basic_filter_refuses_an_unknown_sheet() {
3425        let server = wiremock::MockServer::start().await;
3426        let (drive, sheets) = clients(&server).await;
3427        mount_file(
3428            "sheet-1",
3429            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3430            &["folder-1"],
3431        )
3432        .mount(&server)
3433        .await;
3434        mount_folder("folder-1").mount(&server).await;
3435        mount_workbook(serde_json::json!([
3436            {"properties": {"sheetId": 0, "title": "Q2", "index": 0}},
3437        ]))
3438        .mount(&server)
3439        .await;
3440        let rules = vec![allow_rule("folder-1")];
3441        let opts = unleased_opts(set_verb(Vec::new(), Vec::new()));
3442        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3443        match outcome.result {
3444            FilterResult::RefusedSheetNotFound { title, available } => {
3445                assert_eq!(title, "Q1");
3446                assert_eq!(available, vec!["Q2".to_string()]);
3447            }
3448            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
3449        }
3450    }
3451
3452    // ── update-filter-view end to end ────────────────────────────────────
3453
3454    fn mount_workbook_with_view_seven() -> wiremock::Mock {
3455        mount_workbook(serde_json::json!([
3456            {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3457             "filterViews": [{
3458                 "filterViewId": 7,
3459                 "title": "Old",
3460                 "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 10,
3461                           "startColumnIndex": 0, "endColumnIndex": 4},
3462                 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3463                 "criteria": {"0": {"hiddenValues": ["Old"]}},
3464             }]},
3465        ]))
3466    }
3467
3468    #[tokio::test]
3469    async fn update_filter_view_merges_onto_the_existing_view_and_reports_its_id() {
3470        let server = wiremock::MockServer::start().await;
3471        let (drive, sheets) = clients(&server).await;
3472        mount_file(
3473            "sheet-1",
3474            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3475            &["folder-1"],
3476        )
3477        .mount(&server)
3478        .await;
3479        mount_folder("folder-1").mount(&server).await;
3480        mount_workbook_with_view_seven().mount(&server).await;
3481        // The `fields` mask names every changed field; the merged
3482        // `sortSpecs` carry the existing entry alongside the new one, and
3483        // `criteria` carries only the changed column, since the server
3484        // merges criteria per column (issue #1931) — a partial body that
3485        // would 404 (and so fail the outcome) if either were wrong.
3486        wiremock::Mock::given(wiremock::matchers::method("POST"))
3487            .and(wiremock::matchers::path(
3488                "/v4/spreadsheets/sheet-1:batchUpdate",
3489            ))
3490            .and(wiremock::matchers::body_partial_json(serde_json::json!({
3491                "requests": [{"updateFilterView": {
3492                    "fields": "title,range,sortSpecs,criteria",
3493                    "filter": {
3494                        "filterViewId": 7,
3495                        "title": "Renamed",
3496                        "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 20,
3497                                  "startColumnIndex": 0, "endColumnIndex": 4},
3498                        "sortSpecs": [
3499                            {"dimensionIndex": 0, "sortOrder": "ASCENDING"},
3500                            {"dimensionIndex": 2, "sortOrder": "DESCENDING"},
3501                        ],
3502                        "criteria": {
3503                            "1": {"hiddenValues": ["Closed"]},
3504                        },
3505                    },
3506                }}]
3507            })))
3508            .respond_with(
3509                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3510                    "replies": [{}]
3511                })),
3512            )
3513            .mount(&server)
3514            .await;
3515        let rules = vec![allow_rule("folder-1")];
3516        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3517        let opts = FilterOptions {
3518            spreadsheet_id: "sheet-1".to_string(),
3519            verb: FilterVerb::UpdateFilterView {
3520                filter_view_id: 7,
3521                sheet: Some("Q1".to_string()),
3522                range: Some("A1:D20".to_string()),
3523                title: Some("Renamed".to_string()),
3524                sort_by: vec!["2:desc".to_string()],
3525                hide_values: vec!["1:Closed".to_string()],
3526                clear_sort: false,
3527                clear_criteria: false,
3528            },
3529            dry_run: false,
3530            lease_token,
3531            ledger_path,
3532        };
3533        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3534        match outcome.result {
3535            FilterResult::Changed {
3536                filter_view_id,
3537                summary,
3538                ..
3539            } => {
3540                assert_eq!(filter_view_id, Some(7));
3541                assert_eq!(
3542                    summary,
3543                    "update filter view (title='Renamed' sort 2:desc hide 1:Closed)"
3544                );
3545            }
3546            other => panic!("expected Changed, got {other:?}"),
3547        }
3548        // A filter view is identified by its own id, so `sheet_id` stays
3549        // unset even though a range was resolved to build the request.
3550        assert_eq!(outcome.sheet_id, None);
3551    }
3552
3553    /// Runs `update-filter-view` against view seven with a mocked
3554    /// `batchUpdate` answering `reply`, returning the outcome and the one
3555    /// `batchUpdate` body the server received.
3556    async fn run_update_on_view_seven(
3557        verb: FilterVerb,
3558        reply: serde_json::Value,
3559    ) -> (FilterOutcome, serde_json::Value) {
3560        let server = wiremock::MockServer::start().await;
3561        let (drive, sheets) = clients(&server).await;
3562        mount_granted_gate_with_view_seven(&server).await;
3563        wiremock::Mock::given(wiremock::matchers::method("POST"))
3564            .and(wiremock::matchers::path(
3565                "/v4/spreadsheets/sheet-1:batchUpdate",
3566            ))
3567            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(reply))
3568            .expect(1)
3569            .mount(&server)
3570            .await;
3571        let rules = vec![allow_rule("folder-1")];
3572        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3573        let opts = FilterOptions {
3574            spreadsheet_id: "sheet-1".to_string(),
3575            verb,
3576            dry_run: false,
3577            lease_token,
3578            ledger_path,
3579        };
3580        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3581        let body = server
3582            .received_requests()
3583            .await
3584            .unwrap()
3585            .into_iter()
3586            .find(|request| request.url.path().ends_with(":batchUpdate"))
3587            .map(|request| serde_json::from_slice(&request.body).unwrap())
3588            .unwrap();
3589        (outcome, body)
3590    }
3591
3592    fn update_view_seven(
3593        sort_by: &[&str],
3594        hide_values: &[&str],
3595        clear_sort: bool,
3596        clear_criteria: bool,
3597    ) -> FilterVerb {
3598        FilterVerb::UpdateFilterView {
3599            filter_view_id: 7,
3600            sheet: None,
3601            range: None,
3602            title: None,
3603            sort_by: sort_by.iter().map(|s| (*s).to_string()).collect(),
3604            hide_values: hide_values.iter().map(|s| (*s).to_string()).collect(),
3605            clear_sort,
3606            clear_criteria,
3607        }
3608    }
3609
3610    #[tokio::test]
3611    async fn update_filter_view_clear_criteria_sends_an_explicit_reset() {
3612        let (outcome, body) = run_update_on_view_seven(
3613            update_view_seven(&[], &[], false, true),
3614            serde_json::json!({"replies": [{}]}),
3615        )
3616        .await;
3617        assert!(
3618            matches!(outcome.result, FilterResult::Changed { .. }),
3619            "{:?}",
3620            outcome.result
3621        );
3622        assert_eq!(
3623            body,
3624            serde_json::json!({"requests": [{"updateFilterView": {
3625                "fields": "criteria",
3626                "filter": {"filterViewId": 7, "criteria": {"0": {}}},
3627            }}]})
3628        );
3629    }
3630
3631    #[tokio::test]
3632    async fn update_filter_view_clear_sort_recreates_the_view_under_its_own_id() {
3633        let (outcome, body) = run_update_on_view_seven(
3634            update_view_seven(&["2:desc"], &[], true, false),
3635            serde_json::json!({"replies": [
3636                {},
3637                {"addFilterView": {"filter": {"filterViewId": 7}}},
3638            ]}),
3639        )
3640        .await;
3641        match outcome.result {
3642            FilterResult::Changed {
3643                filter_view_id,
3644                summary,
3645                ..
3646            } => {
3647                assert_eq!(filter_view_id, Some(7));
3648                assert_eq!(
3649                    summary,
3650                    format!("update filter view (clear sort sort 2:desc){REPLACE_NOTE}")
3651                );
3652            }
3653            other => panic!("expected Changed, got {other:?}"),
3654        }
3655        assert_eq!(
3656            body,
3657            serde_json::json!({"requests": [
3658                {"deleteFilterView": {"filterId": 7}},
3659                {"addFilterView": {"filter": {
3660                    "filterViewId": 7,
3661                    "title": "Old",
3662                    "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 10,
3663                              "startColumnIndex": 0, "endColumnIndex": 4},
3664                    "sortSpecs": [{"dimensionIndex": 2, "sortOrder": "DESCENDING"}],
3665                    "criteria": {"0": {"hiddenValues": ["Old"]}},
3666                }}},
3667            ]})
3668        );
3669    }
3670
3671    /// Mounts the gate and a workbook whose view 7 is table-bound and
3672    /// carries an unmodelled `condition` criterion, as a read-back would.
3673    async fn run_clear_sort_on_bound_view_with_condition() -> (FilterOutcome, serde_json::Value) {
3674        let server = wiremock::MockServer::start().await;
3675        let (drive, sheets) = clients(&server).await;
3676        mount_file(
3677            "sheet-1",
3678            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3679            &["folder-1"],
3680        )
3681        .mount(&server)
3682        .await;
3683        mount_folder("folder-1").mount(&server).await;
3684        mount_workbook(serde_json::json!([
3685            {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3686             "filterViews": [{
3687                 "filterViewId": 7,
3688                 "title": "Bound",
3689                 "range": {"sheetId": 0, "startRowIndex": 10, "endRowIndex": 16,
3690                           "startColumnIndex": 0, "endColumnIndex": 4},
3691                 "tableId": "454645209",
3692                 "sortSpecs": [{"dimensionIndex": 2, "sortOrder": "DESCENDING"}],
3693                 "criteria": {
3694                     "1": {"hiddenValues": ["b"]},
3695                     "2": {"condition": {"type": "NUMBER_GREATER",
3696                                         "values": [{"userEnteredValue": "2"}]}},
3697                 },
3698             }]},
3699        ]))
3700        .mount(&server)
3701        .await;
3702        wiremock::Mock::given(wiremock::matchers::method("POST"))
3703            .and(wiremock::matchers::path(
3704                "/v4/spreadsheets/sheet-1:batchUpdate",
3705            ))
3706            .respond_with(
3707                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3708                    "replies": [{}, {"addFilterView": {"filter": {"filterViewId": 7}}}]
3709                })),
3710            )
3711            .expect(1)
3712            .mount(&server)
3713            .await;
3714        let rules = vec![allow_rule("folder-1")];
3715        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3716        let opts = FilterOptions {
3717            spreadsheet_id: "sheet-1".to_string(),
3718            verb: update_view_seven(&["0:asc"], &[], true, false),
3719            dry_run: false,
3720            lease_token,
3721            ledger_path,
3722        };
3723        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3724        let body = server
3725            .received_requests()
3726            .await
3727            .unwrap()
3728            .into_iter()
3729            .find(|request| request.url.path().ends_with(":batchUpdate"))
3730            .map(|request| serde_json::from_slice(&request.body).unwrap())
3731            .unwrap();
3732        (outcome, body)
3733    }
3734
3735    #[tokio::test]
3736    async fn update_filter_view_recreation_carries_unmodelled_criteria_and_the_table_binding() {
3737        let (outcome, body) = run_clear_sort_on_bound_view_with_condition().await;
3738        assert!(
3739            matches!(
3740                outcome.result,
3741                FilterResult::Changed {
3742                    filter_view_id: Some(7),
3743                    ..
3744                }
3745            ),
3746            "{:?}",
3747            outcome.result
3748        );
3749        assert_eq!(
3750            body,
3751            serde_json::json!({"requests": [
3752                {"deleteFilterView": {"filterId": 7}},
3753                {"addFilterView": {"filter": {
3754                    "filterViewId": 7,
3755                    "title": "Bound",
3756                    "tableId": "454645209",
3757                    "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3758                    "criteria": {
3759                        "1": {"hiddenValues": ["b"]},
3760                        "2": {"condition": {"type": "NUMBER_GREATER",
3761                                            "values": [{"userEnteredValue": "2"}]}},
3762                    },
3763                }}},
3764            ]})
3765        );
3766    }
3767
3768    #[tokio::test]
3769    async fn update_filter_view_dry_run_says_when_it_would_recreate_the_view() {
3770        let server = wiremock::MockServer::start().await;
3771        let (drive, sheets) = clients(&server).await;
3772        mount_granted_gate_with_view_seven(&server).await;
3773        let rules = vec![allow_rule("folder-1")];
3774        let mut opts = unleased_opts(update_view_seven(&[], &[], true, false));
3775        opts.dry_run = true;
3776        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3777        match outcome.result {
3778            FilterResult::WouldChange { summary, .. } => {
3779                assert_eq!(
3780                    summary,
3781                    format!("update filter view (clear sort){REPLACE_NOTE}")
3782                );
3783            }
3784            other => panic!("expected WouldChange, got {other:?}"),
3785        }
3786    }
3787
3788    #[tokio::test]
3789    async fn update_filter_view_recreation_with_no_range_is_refused_before_any_write() {
3790        let server = wiremock::MockServer::start().await;
3791        let (drive, sheets) = clients(&server).await;
3792        mount_file(
3793            "sheet-1",
3794            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3795            &["folder-1"],
3796        )
3797        .mount(&server)
3798        .await;
3799        mount_folder("folder-1").mount(&server).await;
3800        mount_workbook(serde_json::json!([
3801            {"properties": {"sheetId": 0, "title": "Q1", "index": 0},
3802             "filterViews": [{
3803                 "filterViewId": 7,
3804                 "sortSpecs": [{"dimensionIndex": 0, "sortOrder": "ASCENDING"}],
3805             }]},
3806        ]))
3807        .mount(&server)
3808        .await;
3809        let rules = vec![allow_rule("folder-1")];
3810        let opts = unleased_opts(update_view_seven(&[], &[], true, false));
3811        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3812        match outcome.result {
3813            FilterResult::RefusedInvalidRange { detail } => {
3814                assert!(detail.contains("re-creates filter view 7"), "{detail}");
3815            }
3816            other => panic!("expected RefusedInvalidRange, got {other:?}"),
3817        }
3818    }
3819
3820    #[tokio::test]
3821    async fn update_filter_view_with_only_a_range_change_is_described_bare() {
3822        let server = wiremock::MockServer::start().await;
3823        let (drive, sheets) = clients(&server).await;
3824        mount_file(
3825            "sheet-1",
3826            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3827            &["folder-1"],
3828        )
3829        .mount(&server)
3830        .await;
3831        mount_folder("folder-1").mount(&server).await;
3832        mount_workbook_with_view_seven().mount(&server).await;
3833        let rules = vec![allow_rule("folder-1")];
3834        let opts = FilterOptions {
3835            spreadsheet_id: "sheet-1".to_string(),
3836            verb: FilterVerb::UpdateFilterView {
3837                filter_view_id: 7,
3838                sheet: None,
3839                range: Some("Q1!A1:D20".to_string()),
3840                title: None,
3841                sort_by: Vec::new(),
3842                hide_values: Vec::new(),
3843                clear_sort: false,
3844                clear_criteria: false,
3845            },
3846            dry_run: true,
3847            lease_token: None,
3848            ledger_path: std::path::PathBuf::new(),
3849        };
3850        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3851        match outcome.result {
3852            FilterResult::WouldChange { summary, .. } => {
3853                assert_eq!(summary, "update filter view");
3854            }
3855            other => panic!("expected WouldChange, got {other:?}"),
3856        }
3857    }
3858
3859    #[tokio::test]
3860    async fn a_batch_update_failure_surfaces_as_failed() {
3861        let server = wiremock::MockServer::start().await;
3862        let (drive, sheets) = clients(&server).await;
3863        mount_file(
3864            "sheet-1",
3865            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3866            &["folder-1"],
3867        )
3868        .mount(&server)
3869        .await;
3870        mount_folder("folder-1").mount(&server).await;
3871        mount_workbook_with_view_seven().mount(&server).await;
3872        wiremock::Mock::given(wiremock::matchers::method("POST"))
3873            .and(wiremock::matchers::path(
3874                "/v4/spreadsheets/sheet-1:batchUpdate",
3875            ))
3876            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3877            .mount(&server)
3878            .await;
3879        let rules = vec![allow_rule("folder-1")];
3880        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3881        let opts = FilterOptions {
3882            spreadsheet_id: "sheet-1".to_string(),
3883            verb: update_verb(),
3884            dry_run: false,
3885            lease_token,
3886            ledger_path,
3887        };
3888        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3889        match outcome.result {
3890            FilterResult::Failed { detail } => assert!(detail.contains("500"), "{detail}"),
3891            other => panic!("expected Failed, got {other:?}"),
3892        }
3893    }
3894
3895    #[tokio::test]
3896    async fn update_filter_view_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3897        let server = wiremock::MockServer::start().await;
3898        let (drive, sheets) = clients(&server).await;
3899        mount_file(
3900            "sheet-1",
3901            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3902            &["folder-1"],
3903        )
3904        .mount(&server)
3905        .await;
3906        mount_folder("folder-1").mount(&server).await;
3907        mount_workbook_with_view_seven().mount(&server).await;
3908        // 2xx, so the change WAS applied — but the body is not the reply shape.
3909        wiremock::Mock::given(wiremock::matchers::method("POST"))
3910            .and(wiremock::matchers::path(
3911                "/v4/spreadsheets/sheet-1:batchUpdate",
3912            ))
3913            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3914            .expect(1)
3915            .mount(&server)
3916            .await;
3917        let rules = vec![allow_rule("folder-1")];
3918        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3919        let opts = FilterOptions {
3920            spreadsheet_id: "sheet-1".to_string(),
3921            verb: update_verb(),
3922            dry_run: false,
3923            lease_token,
3924            ledger_path,
3925        };
3926        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3927        match &outcome.result {
3928            // The id resolved before the call is plan-time data, so it survives.
3929            FilterResult::AppliedReplyUnreadable { filter_view_id, .. } => {
3930                assert_eq!(*filter_view_id, Some(7));
3931            }
3932            other => panic!("expected AppliedReplyUnreadable, got {other:?}"), // omni-dev: coverage ignore-line reason="this arm only fires if the match failed to bind the expected variant; the assertion below it pins the same variant, so the branch never executes"
3933        }
3934        assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3935        assert!(
3936            describe(&outcome).starts_with("Applied, but the reply could not be read"),
3937            "{}",
3938            describe(&outcome)
3939        );
3940    }
3941
3942    // ── the Drive write lease (ADR-0080 §9) ──────────────────────────────
3943
3944    #[tokio::test]
3945    async fn refuses_without_a_lease_when_the_rule_requires_one() {
3946        let server = wiremock::MockServer::start().await;
3947        let (drive, sheets) = clients(&server).await;
3948        mount_file(
3949            "sheet-1",
3950            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3951            &["folder-1"],
3952        )
3953        .mount(&server)
3954        .await;
3955        mount_folder("folder-1").mount(&server).await;
3956        mount_workbook_with_view_seven().mount(&server).await;
3957        // No batchUpdate mock mounted — a refusal must make zero mutating
3958        // calls.
3959        let rules = vec![allow_rule("folder-1")];
3960        let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
3961        let outcome = filter(&drive, &sheets, &opts, &rules).await;
3962        assert!(matches!(outcome.result, FilterResult::RefusedNoLease));
3963        assert_eq!(outcome.result.log_status(), "refused-no-lease");
3964    }
3965
3966    // ── describe_effect ──────────────────────────────────────────────────
3967
3968    #[test]
3969    fn describe_effect_names_the_view_and_its_sort_and_hidden_values() {
3970        let verb = FilterVerb::AddFilterView {
3971            sheet: "Q1".to_string(),
3972            range: "A1:D10".to_string(),
3973            title: Some("Open only".to_string()),
3974            sort_by: vec!["0:asc".to_string()],
3975            hide_values: vec!["1:Closed".to_string()],
3976        };
3977        assert_eq!(
3978            describe_effect(&verb),
3979            "add filter view 'Open only' (sort 0:asc hide 1:Closed)"
3980        );
3981        assert_eq!(
3982            describe_effect(&set_verb(Vec::new(), Vec::new())),
3983            "set basic filter"
3984        );
3985    }
3986
3987    #[test]
3988    fn describe_effect_lists_every_update_filter_view_part_in_order() {
3989        let verb = FilterVerb::UpdateFilterView {
3990            filter_view_id: 7,
3991            sheet: None,
3992            range: None,
3993            title: Some("Renamed".to_string()),
3994            sort_by: vec!["0:asc".to_string()],
3995            hide_values: vec!["1:Closed".to_string()],
3996            clear_sort: true,
3997            clear_criteria: true,
3998        };
3999        assert_eq!(
4000            describe_effect(&verb),
4001            "update filter view (title='Renamed' clear sort sort 0:asc clear criteria hide 1:Closed)"
4002        );
4003    }
4004
4005    // ── describe / describe_lines ────────────────────────────────────────
4006
4007    fn outcome_with(
4008        verb: FilterVerb,
4009        file_name: Option<&str>,
4010        result: FilterResult,
4011    ) -> FilterOutcome {
4012        FilterOutcome {
4013            spreadsheet_id: "sheet-1".to_string(),
4014            file_name: file_name.map(str::to_string),
4015            resolved_folder_id: None,
4016            sheet_id: None,
4017            verb,
4018            result,
4019        }
4020    }
4021
4022    #[test]
4023    fn describe_lines_renders_would_change() {
4024        let out = outcome_with(
4025            set_verb(Vec::new(), Vec::new()),
4026            Some("Budget"),
4027            FilterResult::WouldChange {
4028                summary: "set basic filter".to_string(),
4029                width_warning: None,
4030            },
4031        );
4032        assert_eq!(describe(&out), "Would set basic filter in 'Budget'");
4033    }
4034
4035    #[test]
4036    fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
4037        let out = outcome_with(
4038            set_verb(Vec::new(), Vec::new()),
4039            None,
4040            FilterResult::RefusedNotASpreadsheet {
4041                mime_type: "text/plain".to_string(),
4042            },
4043        );
4044        let text = describe(&out);
4045        assert!(text.contains("'sheet-1'"), "{text}");
4046        assert!(text.contains("set-basic-filter"), "{text}");
4047        assert!(text.contains("text/plain"), "{text}");
4048    }
4049
4050    #[test]
4051    fn describe_lines_renders_shortcut_and_no_visible_parents() {
4052        let shortcut = outcome_with(
4053            FilterVerb::DeleteFilterView { filter_view_id: 7 },
4054            Some("Budget"),
4055            FilterResult::RefusedShortcut,
4056        );
4057        let text = describe(&shortcut);
4058        assert!(text.contains("shortcut"), "{text}");
4059        assert!(text.contains("delete-filter-view"), "{text}");
4060
4061        let orphan = outcome_with(
4062            set_verb(Vec::new(), Vec::new()),
4063            Some("Budget"),
4064            FilterResult::RefusedNoVisibleParents,
4065        );
4066        assert!(describe(&orphan).contains("sheets-structure"));
4067    }
4068
4069    #[test]
4070    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
4071        let none = outcome_with(
4072            set_verb(Vec::new(), Vec::new()),
4073            Some("Budget"),
4074            FilterResult::RefusedSheetNotFound {
4075                title: "Q1".to_string(),
4076                available: Vec::new(),
4077            },
4078        );
4079        assert!(describe(&none).contains("Available: none"));
4080
4081        let some = outcome_with(
4082            set_verb(Vec::new(), Vec::new()),
4083            Some("Budget"),
4084            FilterResult::RefusedSheetNotFound {
4085                title: "Q1".to_string(),
4086                available: vec!["Q2".to_string(), "Q3".to_string()],
4087            },
4088        );
4089        assert!(describe(&some).contains("Available: 'Q2', 'Q3'"));
4090    }
4091
4092    #[test]
4093    fn describe_lines_renders_invalid_range_and_filter_view_not_found() {
4094        let invalid = outcome_with(
4095            set_verb(Vec::new(), Vec::new()),
4096            Some("Budget"),
4097            FilterResult::RefusedInvalidRange {
4098                detail: "bad range".to_string(),
4099            },
4100        );
4101        assert_eq!(describe(&invalid), "Refused: bad range");
4102
4103        let missing = outcome_with(
4104            update_verb(),
4105            Some("Budget"),
4106            FilterResult::RefusedFilterViewNotFound { filter_view_id: 7 },
4107        );
4108        let text = describe(&missing);
4109        assert!(text.contains("no filter view with id 7"), "{text}");
4110        assert!(text.contains("list-filter-views"), "{text}");
4111    }
4112
4113    #[test]
4114    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
4115        let folder_rule = outcome_with(
4116            update_verb(),
4117            Some("Budget"),
4118            FilterResult::Blocked {
4119                operation: DriveOperation::SheetsStructure,
4120                decided_by: Some(DecidingRule::Folder {
4121                    folder_id: "folder-1".to_string(),
4122                    depth: 2,
4123                }),
4124            },
4125        );
4126        let text = describe(&folder_rule);
4127        assert!(text.contains("update-filter-view"), "{text}");
4128        assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
4129
4130        let default_policy = outcome_with(
4131            FilterVerb::ClearBasicFilter {
4132                sheet: "Q1".to_string(),
4133            },
4134            Some("Budget"),
4135            FilterResult::Blocked {
4136                operation: DriveOperation::SheetsStructure,
4137                decided_by: None,
4138            },
4139        );
4140        let text = describe(&default_policy);
4141        assert!(text.contains("default policy"), "{text}");
4142        assert!(text.contains("sheets-structure"), "{text}");
4143    }
4144
4145    #[test]
4146    fn describe_lines_renders_every_lease_refusal_with_the_lease_acquire_hint() {
4147        for (result, phrase) in [
4148            (FilterResult::RefusedNoLease, "requires a Drive write lease"),
4149            (
4150                FilterResult::RefusedLeaseExpired,
4151                "expired, released, or unknown",
4152            ),
4153            (
4154                FilterResult::RefusedLeaseWrongFile,
4155                "acquired for a different file",
4156            ),
4157            (
4158                FilterResult::RefusedLeaseStale,
4159                "changed since the lease was acquired",
4160            ),
4161        ] {
4162            let out = outcome_with(update_verb(), Some("Budget"), result);
4163            let text = describe(&out);
4164            assert!(text.contains(phrase), "{text}");
4165            assert!(text.contains("drive lease acquire sheet-1"), "{text}");
4166        }
4167    }
4168
4169    #[test]
4170    fn describe_lines_renders_changed_with_and_without_an_id() {
4171        let with_id = outcome_with(
4172            FilterVerb::DeleteFilterView { filter_view_id: 7 },
4173            Some("Budget"),
4174            FilterResult::Changed {
4175                summary: "delete filter view".to_string(),
4176                filter_view_id: Some(7),
4177                width_warning: None,
4178            },
4179        );
4180        assert_eq!(
4181            describe(&with_id),
4182            "Applied: delete filter view (id 7) in 'Budget'"
4183        );
4184
4185        let without_id = outcome_with(
4186            set_verb(Vec::new(), Vec::new()),
4187            Some("Budget"),
4188            FilterResult::Changed {
4189                summary: "set basic filter".to_string(),
4190                filter_view_id: None,
4191                width_warning: None,
4192            },
4193        );
4194        assert_eq!(
4195            describe(&without_id),
4196            "Applied: set basic filter in 'Budget'"
4197        );
4198    }
4199
4200    #[test]
4201    fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
4202        let added = outcome_with(
4203            FilterVerb::AddFilterView {
4204                sheet: "Q1".to_string(),
4205                range: "A1:D10".to_string(),
4206                title: None,
4207                sort_by: Vec::new(),
4208                hide_values: Vec::new(),
4209            },
4210            Some("Budget"),
4211            FilterResult::AppliedReplyUnreadable {
4212                summary: "add filter view".to_string(),
4213                filter_view_id: None,
4214                width_warning: None,
4215                detail: "bad reply".to_string(),
4216            },
4217        );
4218        assert_eq!(
4219            describe_lines(&added),
4220            vec![
4221                "Applied, but the reply could not be read: add filter view in 'Budget' — \
4222                 do not retry; check the spreadsheet first (bad reply)"
4223                    .to_string(),
4224                "  the new filter view's id was in the unreadable reply; run \
4225                 `list-filter-views` to find it"
4226                    .to_string(),
4227            ]
4228        );
4229
4230        let updated = outcome_with(
4231            update_verb(),
4232            Some("Budget"),
4233            FilterResult::AppliedReplyUnreadable {
4234                summary: "update filter view 7".to_string(),
4235                filter_view_id: Some(7),
4236                width_warning: None,
4237                detail: "bad reply".to_string(),
4238            },
4239        );
4240        // Names the resolved id exactly as `Changed` does, and adds no hint.
4241        assert_eq!(
4242            describe_lines(&updated),
4243            vec![
4244                "Applied, but the reply could not be read: update filter view 7 (id 7) in \
4245                 'Budget' — do not retry; check the spreadsheet first (bad reply)"
4246                    .to_string()
4247            ]
4248        );
4249    }
4250
4251    /// Everything `Changed` carries that does not come from the reply — the
4252    /// width warning and the reorder caveat — survives an unreadable reply.
4253    #[test]
4254    fn describe_lines_keeps_the_reorder_caveats_for_an_unreadable_reply() {
4255        let out = outcome_with(
4256            set_verb(vec!["0:asc".to_string()], Vec::new()),
4257            Some("Budget"),
4258            FilterResult::AppliedReplyUnreadable {
4259                summary: "set basic filter (sort 0:asc)".to_string(),
4260                filter_view_id: None,
4261                width_warning: Some("selected columns 0..4 of 9".to_string()),
4262                detail: "bad reply".to_string(),
4263            },
4264        );
4265        assert_eq!(
4266            describe_lines(&out),
4267            vec![
4268                "Applied, but the reply could not be read: set basic filter (sort 0:asc) in \
4269                 'Budget' — do not retry; check the spreadsheet first (bad reply)"
4270                    .to_string(),
4271                "Warning: selected columns 0..4 of 9".to_string(),
4272                "  references outside the range may now observe values from a different row"
4273                    .to_string(),
4274            ]
4275        );
4276    }
4277
4278    #[test]
4279    fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
4280        let opts = unleased_opts(FilterVerb::DeleteFilterView { filter_view_id: 7 });
4281        let record = |result| {
4282            let mut out = outcome_with(opts.verb.clone(), Some("Budget"), result);
4283            out.sheet_id = Some(3);
4284            mutation_record(&out, &opts, Duration::ZERO)
4285        };
4286        let changed = record(FilterResult::Changed {
4287            summary: "delete filter view".to_string(),
4288            filter_view_id: Some(7),
4289            width_warning: None,
4290        });
4291        let unreadable = record(FilterResult::AppliedReplyUnreadable {
4292            summary: "delete filter view".to_string(),
4293            filter_view_id: Some(7),
4294            width_warning: None,
4295            detail: "bad reply".to_string(),
4296        });
4297        assert_eq!(unreadable.sheet_id, Some(3));
4298        assert_eq!(unreadable.filter_view_id, changed.filter_view_id);
4299        assert_eq!(unreadable.status, "applied-reply-unreadable");
4300        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
4301        // Every verb records what was applied, not just update-filter-view.
4302        assert_eq!(
4303            unreadable.fields_changed.as_deref(),
4304            Some("delete filter view")
4305        );
4306    }
4307
4308    #[test]
4309    fn describe_lines_renders_failed() {
4310        let out = outcome_with(
4311            set_verb(Vec::new(), Vec::new()),
4312            Some("Budget"),
4313            FilterResult::Failed {
4314                detail: "boom".to_string(),
4315            },
4316        );
4317        assert_eq!(describe(&out), "Failed: boom");
4318    }
4319
4320    // ── the other lease refusals ─────────────────────────────────────────
4321
4322    /// Mounts a granted gate and a workbook holding filter view 7, so the
4323    /// only thing left to decide the outcome is the lease.
4324    async fn mount_granted_gate_with_view_seven(server: &wiremock::MockServer) {
4325        mount_file(
4326            "sheet-1",
4327            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4328            &["folder-1"],
4329        )
4330        .mount(server)
4331        .await;
4332        mount_folder("folder-1").mount(server).await;
4333        mount_workbook_with_view_seven().mount(server).await;
4334    }
4335
4336    fn fresh_ledger_path() -> std::path::PathBuf {
4337        tempfile::tempdir()
4338            .unwrap()
4339            .keep()
4340            .join("lease-ledger.jsonl")
4341    }
4342
4343    #[tokio::test]
4344    async fn refuses_an_unknown_lease_token() {
4345        let server = wiremock::MockServer::start().await;
4346        let (drive, sheets) = clients(&server).await;
4347        mount_granted_gate_with_view_seven(&server).await;
4348        let rules = vec![allow_rule("folder-1")];
4349        // Never seeded — the ledger knows nothing of this token.
4350        let opts = FilterOptions {
4351            spreadsheet_id: "sheet-1".to_string(),
4352            verb: update_verb(),
4353            dry_run: false,
4354            lease_token: Some("bogus-token".to_string()),
4355            ledger_path: fresh_ledger_path(),
4356        };
4357        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4358        assert!(matches!(outcome.result, FilterResult::RefusedLeaseExpired));
4359        assert_eq!(outcome.result.log_status(), "refused-lease-expired");
4360    }
4361
4362    #[tokio::test]
4363    async fn refuses_a_lease_bound_to_a_different_file() {
4364        let server = wiremock::MockServer::start().await;
4365        let (drive, sheets) = clients(&server).await;
4366        mount_granted_gate_with_view_seven(&server).await;
4367        let rules = vec![allow_rule("folder-1")];
4368        let ledger_path = fresh_ledger_path();
4369        // Seeded for a *different* spreadsheet id.
4370        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
4371        let opts = FilterOptions {
4372            spreadsheet_id: "sheet-1".to_string(),
4373            verb: update_verb(),
4374            dry_run: false,
4375            lease_token: Some(token),
4376            ledger_path,
4377        };
4378        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4379        assert!(matches!(
4380            outcome.result,
4381            FilterResult::RefusedLeaseWrongFile
4382        ));
4383        assert_eq!(outcome.result.log_status(), "refused-lease-wrong-file");
4384    }
4385
4386    #[tokio::test]
4387    async fn refuses_a_stale_lease_when_the_file_has_moved() {
4388        let server = wiremock::MockServer::start().await;
4389        let (drive, sheets) = clients(&server).await;
4390        // `mount_file` always returns version "1"; the lease below was
4391        // acquired against version "0" — a foreign edit landed since.
4392        mount_granted_gate_with_view_seven(&server).await;
4393        let rules = vec![allow_rule("folder-1")];
4394        let ledger_path = fresh_ledger_path();
4395        let token = seed_lease(&ledger_path, "sheet-1", "0");
4396        let opts = FilterOptions {
4397            spreadsheet_id: "sheet-1".to_string(),
4398            verb: update_verb(),
4399            dry_run: false,
4400            lease_token: Some(token),
4401            ledger_path,
4402        };
4403        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4404        assert!(matches!(outcome.result, FilterResult::RefusedLeaseStale));
4405        assert_eq!(outcome.result.log_status(), "refused-lease-stale");
4406    }
4407
4408    // ── sheet resolution per verb ────────────────────────────────────────
4409
4410    #[tokio::test]
4411    async fn clear_basic_filter_refuses_an_unknown_sheet() {
4412        let server = wiremock::MockServer::start().await;
4413        let (drive, sheets) = clients(&server).await;
4414        mount_granted_gate_with_view_seven(&server).await;
4415        let rules = vec![allow_rule("folder-1")];
4416        let opts = unleased_opts(FilterVerb::ClearBasicFilter {
4417            sheet: "Nope".to_string(),
4418        });
4419        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4420        match outcome.result {
4421            FilterResult::RefusedSheetNotFound { title, available } => {
4422                assert_eq!(title, "Nope");
4423                assert_eq!(available, vec!["Q1".to_string()]);
4424            }
4425            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
4426        }
4427    }
4428
4429    #[tokio::test]
4430    async fn update_filter_view_refuses_a_range_on_an_unknown_sheet() {
4431        let server = wiremock::MockServer::start().await;
4432        let (drive, sheets) = clients(&server).await;
4433        mount_granted_gate_with_view_seven(&server).await;
4434        let rules = vec![allow_rule("folder-1")];
4435        let opts = unleased_opts(FilterVerb::UpdateFilterView {
4436            filter_view_id: 7,
4437            sheet: Some("Nope".to_string()),
4438            range: Some("A1:D20".to_string()),
4439            title: None,
4440            sort_by: Vec::new(),
4441            hide_values: Vec::new(),
4442            clear_sort: false,
4443            clear_criteria: false,
4444        });
4445        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4446        assert!(matches!(
4447            outcome.result,
4448            FilterResult::RefusedSheetNotFound { .. }
4449        ));
4450    }
4451
4452    #[tokio::test]
4453    async fn set_basic_filter_refuses_an_unparseable_range() {
4454        let server = wiremock::MockServer::start().await;
4455        let (drive, sheets) = clients(&server).await;
4456        mount_granted_gate_with_view_seven(&server).await;
4457        let rules = vec![allow_rule("folder-1")];
4458        // `A1:B` mixes a cell with a whole column on a *different* column,
4459        // which no supported A1 form spells — so it passes the flag-level
4460        // `a1::compose` check and only fails once the workbook's sheet is
4461        // known and the grid is parsed against it.
4462        let opts = unleased_opts(FilterVerb::SetBasicFilter {
4463            sheet: "Q1".to_string(),
4464            range: "A1:B".to_string(),
4465            sort_by: Vec::new(),
4466            hide_values: Vec::new(),
4467        });
4468        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4469        match outcome.result {
4470            FilterResult::RefusedInvalidRange { detail } => {
4471                assert!(detail.contains("not a recognised A1 range"), "{detail}");
4472            }
4473            other => panic!("expected RefusedInvalidRange, got {other:?}"),
4474        }
4475        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
4476    }
4477
4478    #[tokio::test]
4479    async fn update_filter_view_refuses_an_unparseable_range() {
4480        let server = wiremock::MockServer::start().await;
4481        let (drive, sheets) = clients(&server).await;
4482        mount_granted_gate_with_view_seven(&server).await;
4483        let rules = vec![allow_rule("folder-1")];
4484        let opts = unleased_opts(FilterVerb::UpdateFilterView {
4485            filter_view_id: 7,
4486            sheet: Some("Q1".to_string()),
4487            range: Some("A1:B".to_string()),
4488            title: None,
4489            sort_by: Vec::new(),
4490            hide_values: Vec::new(),
4491            clear_sort: false,
4492            clear_criteria: false,
4493        });
4494        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4495        assert!(matches!(
4496            outcome.result,
4497            FilterResult::RefusedInvalidRange { .. }
4498        ));
4499    }
4500
4501    #[tokio::test]
4502    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
4503        // The gate's own resolve step succeeds off the first `files.get`,
4504        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
4505        // fails — the change must report `Failed` and never reach
4506        // `batchUpdate`.
4507        let server = wiremock::MockServer::start().await;
4508        let (drive, sheets) = clients(&server).await;
4509        mount_file(
4510            "sheet-1",
4511            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4512            &["folder-1"],
4513        )
4514        .up_to_n_times(1)
4515        .with_priority(1)
4516        .mount(&server)
4517        .await;
4518        wiremock::Mock::given(wiremock::matchers::method("GET"))
4519            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
4520            .respond_with(wiremock::ResponseTemplate::new(500))
4521            .with_priority(2)
4522            .mount(&server)
4523            .await;
4524        mount_folder("folder-1").mount(&server).await;
4525        mount_workbook_with_view_seven().mount(&server).await;
4526        wiremock::Mock::given(wiremock::matchers::method("POST"))
4527            .and(wiremock::matchers::path(
4528                "/v4/spreadsheets/sheet-1:batchUpdate",
4529            ))
4530            .respond_with(wiremock::ResponseTemplate::new(200))
4531            .expect(0)
4532            .mount(&server)
4533            .await;
4534        let rules = vec![allow_rule("folder-1")];
4535        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
4536        let opts = FilterOptions {
4537            spreadsheet_id: "sheet-1".to_string(),
4538            verb: FilterVerb::DeleteFilterView { filter_view_id: 7 },
4539            dry_run: false,
4540            lease_token,
4541            ledger_path,
4542        };
4543        let outcome = filter(&drive, &sheets, &opts, &rules).await;
4544        assert!(matches!(outcome.result, FilterResult::Failed { .. }));
4545    }
4546}