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