Skip to main content

omni_dev/drive/sheets/
pivot.rs

1//! Pivot tables via `spreadsheets.batchUpdate`'s `updateCells` request
2//! (issue #1798, [ADR-0081](../../../docs/adrs/adr-0081.md) §5).
3//!
4//! **The one capability in the tranche with no dedicated `batchUpdate`
5//! request.** A pivot table is created by `updateCells` carrying a
6//! `pivotTable` in a single anchor cell's `CellData`, with a
7//! `fields: "pivotTable"` mask; the server renders the pivot's result
8//! outward from that anchor, overwriting whatever values are already
9//! there, with an extent the request itself never states. Deletion is the
10//! same request with an absent `pivotTable`, clearing the property.
11//!
12//! **`add-pivot-table` is gated by the union of `SheetsWrite` *and*
13//! `SheetsStructure`** — the first capability in the crate needing more
14//! than one [`DriveOperation`] to independently resolve `Allow`
15//! (ADR-0081 §5). `delete-pivot-table` needs `SheetsWrite` alone: it only
16//! ever clears the anchor's own value, no structural effect. Both verbs go
17//! through [`target_gate::resolve_all`] uniformly (`add` with two
18//! operations, `delete` with one), rather than branching between it and
19//! the single-operation [`target_gate::resolve`], so this module has one
20//! gate-handling code path instead of two.
21//!
22//! **`updateCells` stays value-incapable.** Issue #1643 decided
23//! `updateCells` would stay unused because writing through it could bypass
24//! `sheets-structure`'s "never a value" guarantee
25//! ([`crate::drive::sheets::types::RepeatCellData`]'s doc comment). This
26//! module is the first user of `updateCells`, but [`PivotCellData`] still
27//! has no `userEnteredValue` field — the guarantee survives even under the
28//! wider union gate, because there is nowhere on the wire type to put a
29//! literal value regardless of what a caller asks for.
30//!
31//! **`--dry-run` cannot enumerate the overwritten region** — the server
32//! computes it from the source data at creation time, the same shape of
33//! problem ADR-0075 §6 solved for dimension-shift previews, one size
34//! larger. The honest answer, and the one this module gives, is to name
35//! the anchor cell, the source range, the pivot configuration, and the
36//! anchor's own current content (which *is* exactly computable — the
37//! anchor is always overwritten), while stating plainly that the rendered
38//! extent beyond the anchor is unknown until the request is sent.
39//!
40//! **No `update-pivot-table` verb.** On the wire, replacing a pivot table
41//! is byte-identical to creating one — the field mask simply replaces
42//! whatever `pivotTable` the anchor already holds. Rather than add a verb
43//! indistinguishable from `add` on the wire, `add-pivot-table` refuses an
44//! anchor that already holds a pivot table
45//! ([`PivotResult::RefusedAnchorHasPivotTable`]) — delete it first.
46//!
47//! **`list-pivot-tables`** (proposed alongside the issue's two verbs, not
48//! named in it) exists for the same reason every other tranche member
49//! pairs its `delete-*` with a `list-*`: `delete-pivot-table` addresses a
50//! pivot by its anchor cell, and nothing else in the CLI can discover
51//! anchors. It is a plain, ungated read — its engine-facing pieces
52//! ([`describe_pivot_table`], [`Sheet::data`](crate::drive::sheets::types::Sheet::data))
53//! are `pub(crate)`, and the CLI leaf in
54//! `crate::cli::drive::sheets::pivot` renders them directly, mirroring
55//! `list-conditional-formats`.
56
57use std::path::PathBuf;
58use std::time::{Duration, Instant};
59
60use serde::Serialize;
61
62use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
63use crate::drive::client::DriveClient;
64use crate::drive::files_api::FilesApi;
65use crate::drive::lease::check::{
66    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
67    LeasedWrite,
68};
69use crate::drive::sheets::a1;
70use crate::drive::sheets::api::SheetsApi;
71use crate::drive::sheets::client::SheetsClient;
72use crate::drive::sheets::grid_range;
73use crate::drive::sheets::target_gate;
74use crate::drive::sheets::types::{
75    BatchUpdateRequestItem, CellSnapshot, GridCoordinate, PivotCellData, PivotFilterCriteria,
76    PivotFilterSpec, PivotGroup, PivotRowData, PivotTable, PivotValue, UpdateCellsRequest,
77};
78use crate::drive::types::SheetTargetRefusal;
79use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
80use crate::request_log::{self, DriveMutationOutcome};
81
82/// A `--row`/`--column` group's sort order — `ASCENDING`/`DESCENDING`.
83/// Engine-layer, deliberately free of any `clap` derive — the CLI keeps
84/// its own `ValueEnum` mirror where one is needed, the same split
85/// [`crate::drive::sheets::api::ValueRenderOption`]'s doc comment
86/// describes.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88enum SortOrder {
89    Ascending,
90    Descending,
91}
92
93impl SortOrder {
94    const fn as_sheets_str(self) -> &'static str {
95        match self {
96            Self::Ascending => "ASCENDING",
97            Self::Descending => "DESCENDING",
98        }
99    }
100
101    const fn describe(self) -> &'static str {
102        match self {
103            Self::Ascending => "asc",
104            Self::Descending => "desc",
105        }
106    }
107}
108
109/// A `--value COLUMN:FUNC` aggregation function — Sheets' `SUM`/`COUNTA`/
110/// `COUNT`/`COUNTUNIQUE`/`AVERAGE`/`MAX`/`MIN`/`MEDIAN`/`PRODUCT`/`STDEV`/
111/// `STDEVP`/`VAR`/`VARP`. `CUSTOM` (a value driven by a formula rather
112/// than a source column) is a documented cut — see the module doc's
113/// curated-surface stance.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum SummarizeFunction {
116    Sum,
117    Counta,
118    Count,
119    CountUnique,
120    Average,
121    Max,
122    Min,
123    Median,
124    Product,
125    Stdev,
126    Stdevp,
127    Var,
128    Varp,
129}
130
131impl SummarizeFunction {
132    const fn as_sheets_str(self) -> &'static str {
133        match self {
134            Self::Sum => "SUM",
135            Self::Counta => "COUNTA",
136            Self::Count => "COUNT",
137            Self::CountUnique => "COUNTUNIQUE",
138            Self::Average => "AVERAGE",
139            Self::Max => "MAX",
140            Self::Min => "MIN",
141            Self::Median => "MEDIAN",
142            Self::Product => "PRODUCT",
143            Self::Stdev => "STDEV",
144            Self::Stdevp => "STDEVP",
145            Self::Var => "VAR",
146            Self::Varp => "VARP",
147        }
148    }
149
150    fn parse(s: &str) -> Option<Self> {
151        Some(match s.trim().to_ascii_lowercase().as_str() {
152            "sum" => Self::Sum,
153            "counta" => Self::Counta,
154            "count" => Self::Count,
155            "countunique" => Self::CountUnique,
156            "average" | "avg" => Self::Average,
157            "max" => Self::Max,
158            "min" => Self::Min,
159            "median" => Self::Median,
160            "product" => Self::Product,
161            "stdev" => Self::Stdev,
162            "stdevp" => Self::Stdevp,
163            "var" => Self::Var,
164            "varp" => Self::Varp,
165            _ => return None,
166        })
167    }
168}
169
170/// `--value-layout`'s value set — `HORIZONTAL` (values as columns, the
171/// API's own default) or `VERTICAL` (values as rows).
172///
173/// Public: the CLI's `ValueLayoutArg` `clap::ValueEnum` mirror converts
174/// into this, the same split `GradientMidTypeArg`/`GradientPointType`
175/// use.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum ValueLayout {
178    /// Values laid out as columns.
179    Horizontal,
180    /// Values laid out as rows.
181    Vertical,
182}
183
184impl ValueLayout {
185    const fn as_sheets_str(self) -> &'static str {
186        match self {
187            Self::Horizontal => "HORIZONTAL",
188            Self::Vertical => "VERTICAL",
189        }
190    }
191}
192
193/// One parsed `--row`/`--column` group.
194#[derive(Debug, Clone, PartialEq, Eq)]
195struct PivotGroupSpec {
196    /// 0-based, relative to the source range's first column.
197    column: i64,
198    /// The order the caller gave, or `None` for a bare `COLUMN`. Kept
199    /// distinct from [`Self::effective_sort_order`] so the preview can say
200    /// the order was defaulted rather than chosen.
201    sort_order: Option<SortOrder>,
202}
203
204impl PivotGroupSpec {
205    /// The order actually sent: the caller's, else [`SortOrder::Ascending`].
206    ///
207    /// The Sheets API rejects a `PivotGroup` with no `sortOrder`
208    /// (`HTTP 400 ... No sort order specified.`, #1930), so a bare
209    /// `COLUMN` cannot be passed through as "absent" — it defaults here,
210    /// the one place both the wire request and the dry-run preview read
211    /// from.
212    fn effective_sort_order(&self) -> SortOrder {
213        self.sort_order.unwrap_or(SortOrder::Ascending)
214    }
215
216    fn to_wire(&self, show_totals: bool) -> PivotGroup {
217        PivotGroup {
218            source_column_offset: self.column,
219            show_totals,
220            sort_order: Some(self.effective_sort_order().as_sheets_str().to_string()),
221        }
222    }
223}
224
225/// One parsed `--value` aggregation.
226#[derive(Debug, Clone, PartialEq, Eq)]
227struct PivotValueSpec {
228    /// 0-based, relative to the source range's first column.
229    column: i64,
230    function: SummarizeFunction,
231}
232
233impl PivotValueSpec {
234    fn to_wire(&self) -> PivotValue {
235        PivotValue {
236            source_column_offset: self.column,
237            summarize_function: self.function.as_sheets_str().to_string(),
238            name: None,
239        }
240    }
241}
242
243/// One parsed `--filter` value allow-list.
244#[derive(Debug, Clone, PartialEq, Eq)]
245struct PivotFilterSpecArg {
246    /// 0-based, relative to the source range's first column.
247    column: i64,
248    visible_values: Vec<String>,
249}
250
251impl PivotFilterSpecArg {
252    fn to_wire(&self) -> PivotFilterSpec {
253        PivotFilterSpec {
254            column_offset_index: self.column,
255            filter_criteria: PivotFilterCriteria {
256                visible_values: self.visible_values.clone(),
257            },
258        }
259    }
260}
261
262/// Which pivot-table mutation to perform.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum PivotVerb {
265    /// Write a new pivot table at `anchor`, refused if one is already
266    /// there.
267    AddPivotTable {
268        /// The sheet `anchor` lives on.
269        sheet: String,
270        /// A single-cell A1 reference (no `Sheet!` prefix — `sheet`
271        /// supplies it).
272        anchor: String,
273        /// The pivot's source range — may carry its own `Sheet!` prefix
274        /// (a pivot commonly sources from a different tab than it's
275        /// anchored on); falls back to `sheet` when it doesn't.
276        source: String,
277        /// Raw `COLUMN[:asc|desc]` flags, parsed once the source range's
278        /// width is known.
279        rows: Vec<String>,
280        /// Raw `COLUMN[:asc|desc]` flags.
281        columns: Vec<String>,
282        /// Raw `COLUMN:FUNC` flags. At least one required.
283        values: Vec<String>,
284        /// Raw `COLUMN:VALUE[,VALUE...]` flags.
285        filters: Vec<String>,
286        /// `--value-layout`, if given.
287        value_layout: Option<ValueLayout>,
288        /// `false` when `--no-totals` was given; applies to every row and
289        /// column group alike (Sheets has no per-group flag on the CLI
290        /// surface here).
291        show_totals: bool,
292    },
293    /// Clear the pivot table at `anchor`, refused if there isn't one.
294    DeletePivotTable {
295        /// The sheet `anchor` lives on.
296        sheet: String,
297        /// A single-cell A1 reference (no `Sheet!` prefix — `sheet`
298        /// supplies it).
299        anchor: String,
300    },
301}
302
303impl PivotVerb {
304    /// The `operation` this verb records in the request log.
305    const fn log_operation(&self) -> &'static str {
306        match self {
307            Self::AddPivotTable { .. } => "sheets-add-pivot-table",
308            Self::DeletePivotTable { .. } => "sheets-delete-pivot-table",
309        }
310    }
311
312    /// Human-readable verb for CLI output.
313    const fn label(&self) -> &'static str {
314        match self {
315            Self::AddPivotTable { .. } => "add-pivot-table",
316            Self::DeletePivotTable { .. } => "delete-pivot-table",
317        }
318    }
319
320    fn sheet(&self) -> &str {
321        match self {
322            Self::AddPivotTable { sheet, .. } | Self::DeletePivotTable { sheet, .. } => sheet,
323        }
324    }
325
326    fn anchor(&self) -> &str {
327        match self {
328            Self::AddPivotTable { anchor, .. } | Self::DeletePivotTable { anchor, .. } => anchor,
329        }
330    }
331
332    /// Which operations must **all** resolve `Allow` (ADR-0081 §5). Order
333    /// matters: [`target_gate::TargetGateUnionOutcome::Gated::denied`]
334    /// names the first of these that denied, and `SheetsWrite` first is
335    /// the grant an operator holding only `sheets-structure` is missing —
336    /// the message that matters for `add-pivot-table`.
337    const fn gate_operations(&self) -> &'static [DriveOperation] {
338        match self {
339            Self::AddPivotTable { .. } => {
340                &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure]
341            }
342            Self::DeletePivotTable { .. } => &[DriveOperation::SheetsWrite],
343        }
344    }
345}
346
347/// Per-call options.
348#[derive(Debug, Clone)]
349pub struct PivotOptions {
350    /// Spreadsheet id.
351    pub spreadsheet_id: String,
352    /// Which mutation to perform.
353    pub verb: PivotVerb,
354    /// Classify and describe only; never call `batchUpdate`.
355    pub dry_run: bool,
356    /// The lease token presented via `--lease`. Checked only when the
357    /// deciding rule requires one.
358    pub lease_token: Option<String>,
359    /// Path to the lease ledger the token is checked against.
360    pub ledger_path: PathBuf,
361}
362
363/// The substance of a `WouldChange`/`Changed` outcome — what would be (or
364/// was) written, and what the anchor held beforehand.
365#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
366pub struct PivotChange {
367    /// The anchor cell, composed with its sheet.
368    pub anchor: String,
369    /// `add-pivot-table` only: the pivot's source range, composed with its
370    /// sheet.
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub source: Option<String>,
373    /// `add-pivot-table` only: a one-line summary of the rows/columns/
374    /// values/filters/layout/totals that would be configured.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub config: Option<String>,
377    /// What the anchor cell held before this mutation — a plain value,
378    /// empty, or an existing pivot table's own summary. Always known,
379    /// unlike the rendered extent — see the module doc's `--dry-run`
380    /// paragraph.
381    pub anchor_currently: String,
382}
383
384/// What happened (or, under `--dry-run`, would happen).
385#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
386#[serde(tag = "status", rename_all = "kebab-case")]
387pub enum PivotResult {
388    /// `--dry-run`, and the gate would allow it.
389    WouldChange(PivotChange),
390    /// The target is not a Google Sheet.
391    RefusedNotASpreadsheet {
392        /// The target's actual MIME type.
393        mime_type: String,
394    },
395    /// The target is a shortcut, which we do not follow.
396    RefusedShortcut,
397    /// The target has no parents this account can see.
398    RefusedNoVisibleParents,
399    /// The named sheet does not exist in this workbook.
400    RefusedSheetNotFound {
401        /// The title that was not found.
402        title: String,
403        /// The titles that do exist.
404        available: Vec<String>,
405    },
406    /// `--sheet`/`--anchor`/`--source` failed to compose into a range, or
407    /// named a sheet no range grammar could resolve.
408    RefusedInvalidRange {
409        /// What was wrong and why.
410        detail: String,
411    },
412    /// `--anchor` resolved to more than one cell.
413    RefusedInvalidAnchor {
414        /// What was wrong and why.
415        detail: String,
416    },
417    /// `--source` was unbounded, or a `--row`/`--column`/`--value`/
418    /// `--filter` column offset fell outside it, or no `--value` was
419    /// given.
420    RefusedInvalidSource {
421        /// What was wrong and why.
422        detail: String,
423    },
424    /// `add-pivot-table`'s anchor already holds a pivot table.
425    RefusedAnchorHasPivotTable {
426        /// The anchor, composed with its sheet.
427        anchor: String,
428        /// A summary of the pivot table already there.
429        existing_summary: String,
430    },
431    /// `delete-pivot-table`'s anchor holds no pivot table.
432    RefusedNoPivotTableAtAnchor {
433        /// The anchor, composed with its sheet.
434        anchor: String,
435    },
436    /// The folder write-permission gate refused it.
437    Blocked {
438        /// Which of [`PivotVerb::gate_operations`] denied first.
439        operation: DriveOperation,
440        /// The rule that decided the refusal, if any.
441        decided_by: Option<DecidingRule>,
442    },
443    /// No `--lease` was presented, and the deciding rule requires one.
444    RefusedNoLease,
445    /// The presented lease has expired, or was never a token this ledger
446    /// knows about.
447    RefusedLeaseExpired,
448    /// The presented lease is bound to a different file id.
449    RefusedLeaseWrongFile,
450    /// The file has moved since the lease's recorded `version`.
451    RefusedLeaseStale,
452    /// The mutation succeeded.
453    Changed(PivotChange),
454    /// An API or validation error.
455    Failed {
456        /// A human-readable summary of what failed.
457        detail: String,
458    },
459}
460
461impl FromLeaseRefusal for PivotResult {
462    fn from_no_lease() -> Self {
463        Self::RefusedNoLease
464    }
465    fn from_lease_expired() -> Self {
466        Self::RefusedLeaseExpired
467    }
468    fn from_lease_wrong_file() -> Self {
469        Self::RefusedLeaseWrongFile
470    }
471    fn from_lease_stale() -> Self {
472        Self::RefusedLeaseStale
473    }
474    fn from_lease_failed(detail: String) -> Self {
475        Self::Failed { detail }
476    }
477}
478
479impl PivotResult {
480    fn log_status(&self) -> &'static str {
481        match self {
482            Self::WouldChange(_) => "would-change",
483            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
484            Self::RefusedShortcut => "refused-shortcut",
485            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
486            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
487            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
488            Self::RefusedInvalidAnchor { .. } => "refused-invalid-anchor",
489            Self::RefusedInvalidSource { .. } => "refused-invalid-source",
490            Self::RefusedAnchorHasPivotTable { .. } => "refused-anchor-has-pivot-table",
491            Self::RefusedNoPivotTableAtAnchor { .. } => "refused-no-pivot-table-at-anchor",
492            Self::Blocked { .. } => "blocked",
493            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
494            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
495            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
496            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
497            Self::Changed(_) => "changed",
498            Self::Failed { .. } => "failed",
499        }
500    }
501}
502
503/// The full outcome of one attempt.
504#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
505pub struct PivotOutcome {
506    /// The spreadsheet acted on.
507    pub spreadsheet_id: String,
508    /// Its Drive file name, when the metadata fetch got that far.
509    #[serde(skip_serializing_if = "Option::is_none")]
510    pub file_name: Option<String>,
511    /// The folder the gate evaluated against.
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub resolved_folder_id: Option<String>,
514    /// Which mutation was attempted. Not serialised.
515    #[serde(skip)]
516    pub verb: PivotVerb,
517    /// What happened.
518    pub result: PivotResult,
519}
520
521impl JsonlSerialize for PivotOutcome {
522    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
523        write_scalar_jsonl(self, out)
524    }
525}
526
527/// Runs one pivot-table mutation, logging every attempt that isn't a dry
528/// run.
529pub async fn pivot(
530    drive: &DriveClient,
531    sheets: &SheetsClient,
532    opts: &PivotOptions,
533    rules: &[FolderPermissionRule],
534) -> PivotOutcome {
535    let started = Instant::now();
536    let outcome = pivot_inner(drive, sheets, opts, rules).await;
537    if !opts.dry_run {
538        record_attempt(&outcome, opts, started.elapsed());
539    }
540    outcome
541}
542
543async fn pivot_inner(
544    drive: &DriveClient,
545    sheets: &SheetsClient,
546    opts: &PivotOptions,
547    rules: &[FolderPermissionRule],
548) -> PivotOutcome {
549    let bare = |result| PivotOutcome {
550        spreadsheet_id: opts.spreadsheet_id.clone(),
551        file_name: None,
552        resolved_folder_id: None,
553        verb: opts.verb.clone(),
554        result,
555    };
556
557    let anchor_composed = match a1::compose(Some(opts.verb.sheet()), Some(opts.verb.anchor())) {
558        Ok(composed) => composed,
559        Err(err) => {
560            return bare(PivotResult::RefusedInvalidRange {
561                detail: err.to_string(),
562            })
563        }
564    };
565
566    let operations = opts.verb.gate_operations();
567
568    let (target, verdict, denied, resolved_folder_id, requires_lease) =
569        match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
570            target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
571                return bare(PivotResult::Failed { detail })
572            }
573            target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
574                let result = match refusal {
575                    SheetTargetRefusal::Shortcut => PivotResult::RefusedShortcut,
576                    SheetTargetRefusal::NotASpreadsheet { mime_type } => {
577                        PivotResult::RefusedNotASpreadsheet { mime_type }
578                    }
579                    SheetTargetRefusal::NoVisibleParents => PivotResult::RefusedNoVisibleParents,
580                };
581                return PivotOutcome {
582                    spreadsheet_id: opts.spreadsheet_id.clone(),
583                    file_name: Some(target.name),
584                    resolved_folder_id: None,
585                    verb: opts.verb.clone(),
586                    result,
587                };
588            }
589            target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
590                return PivotOutcome {
591                    spreadsheet_id: opts.spreadsheet_id.clone(),
592                    file_name: Some(target.name),
593                    resolved_folder_id: None,
594                    verb: opts.verb.clone(),
595                    result: PivotResult::Failed { detail },
596                };
597            }
598            target_gate::TargetGateUnionOutcome::Gated {
599                target,
600                verdict,
601                denied,
602                resolved_folder_id,
603                requires_lease,
604            } => (target, verdict, denied, resolved_folder_id, requires_lease),
605        };
606
607    let gated = |result| PivotOutcome {
608        spreadsheet_id: opts.spreadsheet_id.clone(),
609        file_name: Some(target.name.clone()),
610        resolved_folder_id: resolved_folder_id.clone(),
611        verb: opts.verb.clone(),
612        result,
613    };
614
615    if verdict == write_gate::Verdict::Deny {
616        let (operation, decided_by) = denied.unwrap_or((operations[0], None));
617        return gated(PivotResult::Blocked {
618            operation,
619            decided_by,
620        });
621    }
622
623    let api = SheetsApi::new(sheets);
624    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
625        Ok(workbook) => workbook,
626        Err(err) => {
627            return gated(PivotResult::Failed {
628                detail: format!("{err:#}"),
629            })
630        }
631    };
632
633    let (_, anchor_grid) = match grid_range::resolve_grid_range(
634        &workbook,
635        &anchor_composed,
636        |detail| PivotResult::RefusedInvalidRange { detail },
637        |title, available| PivotResult::RefusedSheetNotFound { title, available },
638    ) {
639        Ok(resolved) => resolved,
640        Err(result) => return gated(result),
641    };
642
643    if !grid_range::is_single_cell(&anchor_grid) {
644        return gated(PivotResult::RefusedInvalidAnchor {
645            detail: format!(
646                "--anchor '{}' must name a single cell, not a range",
647                opts.verb.anchor()
648            ),
649        });
650    }
651    let start = GridCoordinate {
652        sheet_id: anchor_grid.sheet_id,
653        row_index: anchor_grid.start_row_index.unwrap_or(0),
654        column_index: anchor_grid.start_column_index.unwrap_or(0),
655    };
656
657    // Read the anchor's current content once — it backs the
658    // occupied-anchor refusal, `delete`'s missing-pivot refusal, and every
659    // `anchor_currently` preview below, so one fetch serves all three.
660    let cell_workbook = match api
661        .get_cell_pivot(&opts.spreadsheet_id, &anchor_composed)
662        .await
663    {
664        Ok(workbook) => workbook,
665        Err(err) => {
666            return gated(PivotResult::Failed {
667                detail: format!("{err:#}"),
668            })
669        }
670    };
671    let existing_cell = cell_snapshot_at(&cell_workbook, anchor_grid.sheet_id);
672    let existing_pivot = existing_cell.and_then(|c| c.pivot_table.as_ref());
673
674    let (config, source_a1, request) = match &opts.verb {
675        PivotVerb::AddPivotTable {
676            source,
677            rows,
678            columns,
679            values,
680            filters,
681            value_layout,
682            show_totals,
683            ..
684        } => {
685            if let Some(existing) = existing_pivot {
686                return gated(PivotResult::RefusedAnchorHasPivotTable {
687                    anchor: anchor_composed.clone(),
688                    existing_summary: describe_pivot_table(existing),
689                });
690            }
691
692            let source_composed = match compose_source(opts.verb.sheet(), source) {
693                Ok(composed) => composed,
694                Err(detail) => return gated(PivotResult::RefusedInvalidRange { detail }),
695            };
696            let (_, source_grid) = match grid_range::resolve_grid_range(
697                &workbook,
698                &source_composed,
699                |detail| PivotResult::RefusedInvalidRange { detail },
700                |title, available| PivotResult::RefusedSheetNotFound { title, available },
701            ) {
702                Ok(resolved) => resolved,
703                Err(result) => return gated(result),
704            };
705            if !grid_range::is_bounded(&source_grid) {
706                return gated(PivotResult::RefusedInvalidSource {
707                    detail: format!(
708                        "--source '{source}' must be a bounded range, not an open-ended \
709                         column/row span"
710                    ),
711                });
712            }
713            #[allow(clippy::unwrap_used)] // `is_bounded` just confirmed all four are `Some`.
714            let width =
715                source_grid.end_column_index.unwrap() - source_grid.start_column_index.unwrap();
716
717            let row_specs = match parse_group_specs(rows) {
718                Ok(specs) => specs,
719                Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
720            };
721            let column_specs = match parse_group_specs(columns) {
722                Ok(specs) => specs,
723                Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
724            };
725            let value_specs = match parse_value_specs(values) {
726                Ok(specs) => specs,
727                Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
728            };
729            let filter_specs = match parse_filter_specs(filters) {
730                Ok(specs) => specs,
731                Err(detail) => return gated(PivotResult::RefusedInvalidSource { detail }),
732            };
733
734            if value_specs.is_empty() {
735                return gated(PivotResult::RefusedInvalidSource {
736                    detail: "add-pivot-table needs at least one --value".to_string(),
737                });
738            }
739
740            let out_of_bounds = row_specs
741                .iter()
742                .map(|s| s.column)
743                .chain(column_specs.iter().map(|s| s.column))
744                .chain(value_specs.iter().map(|s| s.column))
745                .chain(filter_specs.iter().map(|s| s.column))
746                .find(|&col| col < 0 || col >= width);
747            if let Some(col) = out_of_bounds {
748                return gated(PivotResult::RefusedInvalidSource {
749                    detail: format!(
750                        "column offset {col} is out of bounds for source '{source_composed}' \
751                         ({width} column(s), valid range 0-{})",
752                        width - 1
753                    ),
754                });
755            }
756
757            let pivot_table = PivotTable {
758                source: source_grid,
759                rows: row_specs.iter().map(|s| s.to_wire(*show_totals)).collect(),
760                columns: column_specs
761                    .iter()
762                    .map(|s| s.to_wire(*show_totals))
763                    .collect(),
764                values: value_specs.iter().map(PivotValueSpec::to_wire).collect(),
765                filter_specs: filter_specs
766                    .iter()
767                    .map(PivotFilterSpecArg::to_wire)
768                    .collect(),
769                value_layout: value_layout.map(|l| l.as_sheets_str().to_string()),
770            };
771
772            let config = describe_pivot_config(
773                &row_specs,
774                &column_specs,
775                &value_specs,
776                &filter_specs,
777                *value_layout,
778                *show_totals,
779            );
780
781            let request = BatchUpdateRequestItem::UpdateCells(UpdateCellsRequest {
782                start,
783                rows: vec![PivotRowData {
784                    values: vec![PivotCellData {
785                        pivot_table: Some(pivot_table),
786                    }],
787                }],
788                fields: "pivotTable".to_string(),
789            });
790
791            (Some(config), Some(source_composed), request)
792        }
793        PivotVerb::DeletePivotTable { .. } => {
794            if existing_pivot.is_none() {
795                return gated(PivotResult::RefusedNoPivotTableAtAnchor {
796                    anchor: anchor_composed.clone(),
797                });
798            }
799            let request = BatchUpdateRequestItem::UpdateCells(UpdateCellsRequest {
800                start,
801                rows: vec![PivotRowData {
802                    values: vec![PivotCellData { pivot_table: None }],
803                }],
804                fields: "pivotTable".to_string(),
805            });
806            (None, None, request)
807        }
808    };
809
810    let change = PivotChange {
811        anchor: anchor_composed.clone(),
812        source: source_a1,
813        config,
814        anchor_currently: describe_anchor_currently(existing_cell),
815    };
816
817    if opts.dry_run {
818        return gated(PivotResult::WouldChange(change));
819    }
820
821    // The lease check sits here: after the permission gate and the
822    // `--dry-run` branch, before the mutating call — see
823    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
824    // shared verbatim by every leased engine.
825    let files_api = FilesApi::new(drive);
826    let leased = LeasedWrite {
827        log_prefix: "drive sheets pivot",
828        operation: opts.verb.log_operation(),
829        ledger_path: &opts.ledger_path,
830        file_id: &opts.spreadsheet_id,
831    };
832    let lease_grant = match gate_optional_leased_write(
833        leased,
834        &files_api,
835        requires_lease,
836        opts.lease_token.as_deref(),
837    )
838    .await
839    {
840        Ok(grant) => grant,
841        Err(err) => return gated(err.into_result()),
842    };
843
844    let result = match conclude_native_leased_write(
845        leased,
846        &lease_grant,
847        &files_api,
848        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
849        |err| format!("{err:#}"),
850    )
851    .await
852    {
853        Ok(_response) => PivotResult::Changed(change),
854        Err(err) => PivotResult::Failed {
855            detail: format!("{err:#}"),
856        },
857    };
858    drop(lease_grant);
859    gated(result)
860}
861
862/// Composes `--source` with `--sheet`: `source` carrying its own `Sheet!`
863/// prefix is used as-is (a pivot commonly sources from a different tab
864/// than it's anchored on), otherwise `sheet` supplies the prefix.
865fn compose_source(sheet: &str, source: &str) -> Result<String, String> {
866    if a1::split_sheet_prefix(source).is_some() {
867        a1::validate_range(source)
868            .map(|()| source.to_string())
869            .map_err(|err| err.to_string())
870    } else {
871        a1::compose(Some(sheet), Some(source)).map_err(|err| err.to_string())
872    }
873}
874
875/// Finds the anchor's own [`CellSnapshot`] in a `get_cell_pivot` response —
876/// the response's `sheets.data` is scoped by the `ranges` query parameter
877/// to just the anchor, so the first sheet whose id matches, first chunk,
878/// first row, first cell, is always the anchor itself when present at all.
879fn cell_snapshot_at(
880    workbook: &crate::drive::sheets::types::Spreadsheet,
881    sheet_id: i64,
882) -> Option<&CellSnapshot> {
883    workbook
884        .sheets
885        .iter()
886        .find(|s| s.sheet_id() == Some(sheet_id))
887        .and_then(|s| s.data.first())
888        .and_then(|gd| gd.row_data.first())
889        .and_then(|rd| rd.values.first())
890}
891
892/// Parses `COLUMN[:asc|desc]` flags into [`PivotGroupSpec`]s, in the order
893/// given.
894fn parse_group_specs(flags: &[String]) -> Result<Vec<PivotGroupSpec>, String> {
895    flags
896        .iter()
897        .map(|flag| {
898            let (col_str, order_str) = match flag.split_once(':') {
899                Some((col, order)) => (col, Some(order)),
900                None => (flag.as_str(), None),
901            };
902            let column: i64 = col_str
903                .trim()
904                .parse()
905                .map_err(|_| format!("'{col_str}' in '{flag}' is not a column offset"))?;
906            let sort_order = match order_str {
907                None => None,
908                Some(order) => Some(match order.trim().to_ascii_lowercase().as_str() {
909                    "asc" | "ascending" => SortOrder::Ascending,
910                    "desc" | "descending" => SortOrder::Descending,
911                    other => return Err(format!("'{other}' in '{flag}' is not 'asc' or 'desc'")),
912                }),
913            };
914            Ok(PivotGroupSpec { column, sort_order })
915        })
916        .collect()
917}
918
919/// Parses `COLUMN:FUNC` flags into [`PivotValueSpec`]s, in the order
920/// given.
921fn parse_value_specs(flags: &[String]) -> Result<Vec<PivotValueSpec>, String> {
922    flags
923        .iter()
924        .map(|flag| {
925            let (col, func) = flag
926                .split_once(':')
927                .ok_or_else(|| format!("'{flag}' is not COLUMN:FUNC — expected e.g. '3:sum'"))?;
928            let column: i64 = col
929                .trim()
930                .parse()
931                .map_err(|_| format!("'{col}' in '{flag}' is not a column offset"))?;
932            let function = SummarizeFunction::parse(func).ok_or_else(|| {
933                format!(
934                    "'{func}' in '{flag}' is not a recognized summarize function (sum, counta, \
935                     count, countunique, average, max, min, median, product, stdev, stdevp, \
936                     var, varp)"
937                )
938            })?;
939            Ok(PivotValueSpec { column, function })
940        })
941        .collect()
942}
943
944/// Parses `COLUMN:VALUE[,VALUE...]` flags into [`PivotFilterSpecArg`]s, in
945/// the order given.
946fn parse_filter_specs(flags: &[String]) -> Result<Vec<PivotFilterSpecArg>, String> {
947    flags
948        .iter()
949        .map(|flag| {
950            let (col, values) = flag.split_once(':').ok_or_else(|| {
951                format!("'{flag}' is not COLUMN:VALUE[,VALUE...] — expected e.g. '1:Foo,Bar'")
952            })?;
953            let column: i64 = col
954                .trim()
955                .parse()
956                .map_err(|_| format!("'{col}' in '{flag}' is not a column offset"))?;
957            if values.is_empty() {
958                return Err(format!("'{flag}' names no values to filter"));
959            }
960            let visible_values = values.split(',').map(str::to_string).collect();
961            Ok(PivotFilterSpecArg {
962                column,
963                visible_values,
964            })
965        })
966        .collect()
967}
968
969/// A one-line summary of a pivot configuration, for a dry-run/changed
970/// preview.
971fn describe_pivot_config(
972    rows: &[PivotGroupSpec],
973    columns: &[PivotGroupSpec],
974    values: &[PivotValueSpec],
975    filters: &[PivotFilterSpecArg],
976    value_layout: Option<ValueLayout>,
977    show_totals: bool,
978) -> String {
979    let describe_group = |g: &PivotGroupSpec| {
980        let order = g.effective_sort_order().describe();
981        if g.sort_order.is_some() {
982            format!("col {} ({order})", g.column)
983        } else {
984            format!("col {} ({order}, default)", g.column)
985        }
986    };
987    let rows_desc = if rows.is_empty() {
988        "none".to_string()
989    } else {
990        rows.iter()
991            .map(describe_group)
992            .collect::<Vec<_>>()
993            .join(", ")
994    };
995    let columns_desc = if columns.is_empty() {
996        "none".to_string()
997    } else {
998        columns
999            .iter()
1000            .map(describe_group)
1001            .collect::<Vec<_>>()
1002            .join(", ")
1003    };
1004    let values_desc = values
1005        .iter()
1006        .map(|v| format!("{} of col {}", v.function.as_sheets_str(), v.column))
1007        .collect::<Vec<_>>()
1008        .join(", ");
1009    let layout_desc = value_layout.map_or("HORIZONTAL (default)", ValueLayout::as_sheets_str);
1010    let totals_desc = if show_totals { "on" } else { "off" };
1011    let mut desc = format!(
1012        "rows: {rows_desc}; columns: {columns_desc}; values: {values_desc}; \
1013         layout: {layout_desc}; totals: {totals_desc}"
1014    );
1015    if !filters.is_empty() {
1016        let filters_desc = filters
1017            .iter()
1018            .map(|f| format!("col {} in [{}]", f.column, f.visible_values.join(", ")))
1019            .collect::<Vec<_>>()
1020            .join(", ");
1021        desc.push_str(&format!("; filters: {filters_desc}"));
1022    }
1023    desc
1024}
1025
1026/// A short summary of an existing pivot table — used in refusal and
1027/// preview messages, and by [`list_entries`].
1028#[must_use]
1029pub(crate) fn describe_pivot_table(pt: &PivotTable) -> String {
1030    format!(
1031        "{} row group(s), {} column group(s), {} value(s)",
1032        pt.rows.len(),
1033        pt.columns.len(),
1034        pt.values.len()
1035    )
1036}
1037
1038/// One pivot table found by `list-pivot-tables`.
1039pub(crate) struct PivotListEntry {
1040    /// The sheet the pivot table is anchored on.
1041    pub sheet: String,
1042    /// The anchor cell, in bare A1 form (no `Sheet!` prefix — `sheet`
1043    /// carries that separately).
1044    pub anchor: String,
1045    /// [`describe_pivot_table`]'s summary.
1046    pub summary: String,
1047}
1048
1049/// Scans every sheet's grid data (populated by
1050/// [`crate::drive::sheets::api::SheetsApi::get_spreadsheet_with_pivot_tables`])
1051/// for a populated `pivotTable` property, computing each one's real A1
1052/// anchor from its chunk's `startRow`/`startColumn` offset — this lives in
1053/// the engine rather than the CLI layer because `grid_range` (which
1054/// `column_index_to_letters` comes from) is private to
1055/// `crate::drive::sheets`.
1056pub(crate) fn list_entries(
1057    workbook: &crate::drive::sheets::types::Spreadsheet,
1058) -> Vec<PivotListEntry> {
1059    let mut entries = Vec::new();
1060    for sheet in &workbook.sheets {
1061        for chunk in &sheet.data {
1062            let row0 = chunk.start_row.unwrap_or(0);
1063            let col0 = chunk.start_column.unwrap_or(0);
1064            for (row_idx, row) in chunk.row_data.iter().enumerate() {
1065                for (col_idx, cell) in row.values.iter().enumerate() {
1066                    let Some(pivot_table) = &cell.pivot_table else {
1067                        continue;
1068                    };
1069                    let anchor = format!(
1070                        "{}{}",
1071                        grid_range::column_index_to_letters(col0 + col_idx as i64),
1072                        row0 + row_idx as i64 + 1
1073                    );
1074                    entries.push(PivotListEntry {
1075                        sheet: sheet.title().to_string(),
1076                        anchor,
1077                        summary: describe_pivot_table(pivot_table),
1078                    });
1079                }
1080            }
1081        }
1082    }
1083    entries
1084}
1085
1086/// Describes what the anchor cell held before a mutation.
1087fn describe_anchor_currently(cell: Option<&CellSnapshot>) -> String {
1088    let Some(cell) = cell else {
1089        return "empty".to_string();
1090    };
1091    if let Some(pivot) = &cell.pivot_table {
1092        return format!("a pivot table ({})", describe_pivot_table(pivot));
1093    }
1094    match cell.formatted_value.as_deref() {
1095        Some(value) if !value.is_empty() => format!("value {value:?}"),
1096        _ => "empty".to_string(),
1097    }
1098}
1099
1100fn record_attempt(outcome: &PivotOutcome, opts: &PivotOptions, duration: Duration) {
1101    let error = match &outcome.result {
1102        PivotResult::Failed { detail } => Some(detail.clone()),
1103        _ => None,
1104    };
1105    let decided_by = match &outcome.result {
1106        PivotResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1107        _ => None,
1108    };
1109    let decided_by = write_gate::decided_by_log_fields(decided_by);
1110
1111    request_log::record_drive_mutation(DriveMutationOutcome {
1112        operation: opts.verb.log_operation(),
1113        file_id: outcome.spreadsheet_id.clone(),
1114        file_name: outcome.file_name.clone().unwrap_or_default(),
1115        status: outcome.result.log_status().to_string(),
1116        resolved_folder_id: outcome.resolved_folder_id.clone(),
1117        decided_by_folder_id: decided_by.folder_id,
1118        decided_by_depth: decided_by.depth,
1119        decided_by_file_id: decided_by.file_id,
1120        error,
1121        duration,
1122        ..Default::default()
1123    });
1124}
1125
1126/// Renders the change-specific lines of a `WouldChange`/`Changed` outcome
1127/// — everything but the leading "Would "/"Applied: " the caller prepends,
1128/// so both share this one builder.
1129fn pivot_change_lines(verb: &PivotVerb, change: &PivotChange, book: &str) -> Vec<String> {
1130    let action = match verb {
1131        PivotVerb::AddPivotTable { .. } => "add a pivot table",
1132        PivotVerb::DeletePivotTable { .. } => "delete the pivot table",
1133    };
1134    let mut lines = vec![format!("{action} anchored at {} in {book}", change.anchor)];
1135    if let Some(source) = &change.source {
1136        lines.push(format!("  source: {source}"));
1137    }
1138    if let Some(config) = &change.config {
1139        lines.push(format!("  {config}"));
1140    }
1141    lines.push(format!(
1142        "  anchor {} currently: {}",
1143        change.anchor, change.anchor_currently
1144    ));
1145    if change.source.is_some() {
1146        lines.push(
1147            "  NOTE: the rendered extent is computed by the server from the source data and \
1148             is not known until the request is sent; cells right of and below the anchor may \
1149             be overwritten."
1150                .to_string(),
1151        );
1152    }
1153    lines
1154}
1155
1156/// Renders an outcome as human-readable text.
1157#[must_use]
1158pub fn describe(outcome: &PivotOutcome) -> String {
1159    describe_lines(outcome).join("\n")
1160}
1161
1162/// Renders an outcome as its individual lines, none of which contains a
1163/// newline.
1164#[must_use]
1165pub fn describe_lines(outcome: &PivotOutcome) -> Vec<String> {
1166    let verb = &outcome.verb;
1167    let book = outcome.file_name.as_deref().map_or_else(
1168        || format!("'{}'", outcome.spreadsheet_id),
1169        |n| format!("'{n}'"),
1170    );
1171    match &outcome.result {
1172        PivotResult::WouldChange(change) => {
1173            let mut lines = pivot_change_lines(verb, change, &book);
1174            lines[0] = format!("Would {}", lines[0]);
1175            lines
1176        }
1177        PivotResult::Changed(change) => {
1178            let mut lines = pivot_change_lines(verb, change, &book);
1179            lines[0] = format!("Applied: {}", lines[0]);
1180            lines
1181        }
1182        PivotResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1183            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); `drive sheets {}` \
1184             only works on spreadsheets",
1185            verb.label()
1186        )],
1187        PivotResult::RefusedShortcut => vec![format!(
1188            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1189            verb.label()
1190        )],
1191        PivotResult::RefusedNoVisibleParents => {
1192            let ops = match verb {
1193                PivotVerb::AddPivotTable { .. } => "\"sheets-write\", \"sheets-structure\"",
1194                PivotVerb::DeletePivotTable { .. } => "\"sheets-write\"",
1195            };
1196            vec![format!(
1197                "Refused: {book} has no parent folder visible to this account, so no folder \
1198                 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1199                 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1200            )]
1201        }
1202        PivotResult::RefusedSheetNotFound { title, available } => {
1203            vec![grid_range::no_sheet_refusal(&book, title, available)]
1204        }
1205        PivotResult::RefusedInvalidRange { detail }
1206        | PivotResult::RefusedInvalidAnchor { detail }
1207        | PivotResult::RefusedInvalidSource { detail } => vec![format!("Refused: {detail}")],
1208        PivotResult::RefusedAnchorHasPivotTable {
1209            anchor,
1210            existing_summary,
1211        } => vec![format!(
1212            "Refused: {book}'s anchor {anchor} already holds a pivot table \
1213             ({existing_summary}); delete it first with `drive sheets delete-pivot-table`, or \
1214             choose a different anchor"
1215        )],
1216        PivotResult::RefusedNoPivotTableAtAnchor { anchor } => vec![format!(
1217            "Refused: {book} has no pivot table anchored at {anchor}. Run `drive sheets \
1218             list-pivot-tables` to see existing ones."
1219        )],
1220        PivotResult::Blocked {
1221            operation,
1222            decided_by,
1223        } => vec![match decided_by {
1224            Some(rule) => format!(
1225                "Blocked: {} on {book} refused by rule on {} {}{}",
1226                verb.label(),
1227                rule.kind_label(),
1228                rule.id(),
1229                rule.depth_suffix()
1230            ),
1231            None => format!(
1232                "Blocked: {} on {book} refused by default policy (no matching rule for \
1233                 {operation})",
1234                verb.label()
1235            ),
1236        }],
1237        PivotResult::RefusedNoLease => LeaseGateRefusal::NoLease
1238            .describe_line(&outcome.spreadsheet_id, &book)
1239            .into_iter()
1240            .collect(),
1241        PivotResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1242            .describe_line(&outcome.spreadsheet_id, &book)
1243            .into_iter()
1244            .collect(),
1245        PivotResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1246            .describe_line(&outcome.spreadsheet_id, &book)
1247            .into_iter()
1248            .collect(),
1249        PivotResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1250            .describe_line(&outcome.spreadsheet_id, &book)
1251            .into_iter()
1252            .collect(),
1253        PivotResult::Failed { detail } => vec![format!("Failed: {detail}")],
1254    }
1255}
1256
1257#[cfg(test)]
1258#[allow(clippy::unwrap_used, clippy::expect_used)]
1259mod tests {
1260    use super::*;
1261    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1262    use crate::drive::sheets::client::SHEETS_API_URL;
1263    use crate::drive::sheets::types::GridRange;
1264    use crate::drive::test_support::seed_lease;
1265    use crate::test_support::env::MapEnv;
1266    use crate::utils::secret::Secret;
1267    use std::collections::HashSet;
1268
1269    // ── parse_group_specs / parse_value_specs / parse_filter_specs ──────
1270
1271    #[test]
1272    fn parse_group_specs_accepts_a_bare_column_or_with_sort_order() {
1273        let specs = parse_group_specs(&["0".to_string(), "2:desc".to_string()]).unwrap();
1274        assert_eq!(
1275            specs,
1276            vec![
1277                PivotGroupSpec {
1278                    column: 0,
1279                    sort_order: None
1280                },
1281                PivotGroupSpec {
1282                    column: 2,
1283                    sort_order: Some(SortOrder::Descending)
1284                },
1285            ]
1286        );
1287    }
1288
1289    #[test]
1290    fn parse_group_specs_rejects_an_unknown_direction() {
1291        let err = parse_group_specs(&["0:sideways".to_string()]).unwrap_err();
1292        assert!(err.contains("'sideways'"), "{err}");
1293    }
1294
1295    #[test]
1296    fn parse_group_specs_rejects_a_non_numeric_column() {
1297        let err = parse_group_specs(&["x:asc".to_string()]).unwrap_err();
1298        assert!(err.contains("column offset"), "{err}");
1299    }
1300
1301    #[test]
1302    fn parse_value_specs_parses_column_and_function() {
1303        let specs = parse_value_specs(&["3:sum".to_string(), "1:AVERAGE".to_string()]).unwrap();
1304        assert_eq!(
1305            specs,
1306            vec![
1307                PivotValueSpec {
1308                    column: 3,
1309                    function: SummarizeFunction::Sum
1310                },
1311                PivotValueSpec {
1312                    column: 1,
1313                    function: SummarizeFunction::Average
1314                },
1315            ]
1316        );
1317    }
1318
1319    #[test]
1320    fn parse_value_specs_rejects_a_missing_colon() {
1321        let err = parse_value_specs(&["3sum".to_string()]).unwrap_err();
1322        assert!(err.contains("COLUMN:FUNC"), "{err}");
1323    }
1324
1325    #[test]
1326    fn parse_value_specs_rejects_an_unknown_function() {
1327        let err = parse_value_specs(&["3:bogus".to_string()]).unwrap_err();
1328        assert!(err.contains("'bogus'"), "{err}");
1329    }
1330
1331    #[test]
1332    fn parse_filter_specs_splits_values_on_commas() {
1333        let specs = parse_filter_specs(&["1:Foo,Bar".to_string()]).unwrap();
1334        assert_eq!(
1335            specs,
1336            vec![PivotFilterSpecArg {
1337                column: 1,
1338                visible_values: vec!["Foo".to_string(), "Bar".to_string()],
1339            }]
1340        );
1341    }
1342
1343    #[test]
1344    fn parse_filter_specs_rejects_an_empty_value_list() {
1345        let err = parse_filter_specs(&["1:".to_string()]).unwrap_err();
1346        assert!(err.contains("names no values"), "{err}");
1347    }
1348
1349    #[test]
1350    fn parse_filter_specs_rejects_a_missing_colon() {
1351        let err = parse_filter_specs(&["1".to_string()]).unwrap_err();
1352        assert!(err.contains("COLUMN:VALUE"), "{err}");
1353    }
1354
1355    // ── to_wire mappings ─────────────────────────────────────────────────
1356
1357    #[test]
1358    fn pivot_group_spec_to_wire_maps_sort_order_and_totals() {
1359        let asc = PivotGroupSpec {
1360            column: 0,
1361            sort_order: Some(SortOrder::Ascending),
1362        }
1363        .to_wire(true);
1364        assert_eq!(asc.sort_order.as_deref(), Some("ASCENDING"));
1365        assert!(asc.show_totals);
1366
1367        let desc = PivotGroupSpec {
1368            column: 1,
1369            sort_order: Some(SortOrder::Descending),
1370        }
1371        .to_wire(false);
1372        assert_eq!(desc.sort_order.as_deref(), Some("DESCENDING"));
1373        assert!(!desc.show_totals);
1374
1375        // A bare `COLUMN` defaults to ascending rather than omitting
1376        // `sortOrder`, which the live API rejects (#1930).
1377        let none = PivotGroupSpec {
1378            column: 2,
1379            sort_order: None,
1380        }
1381        .to_wire(true);
1382        assert_eq!(none.sort_order.as_deref(), Some("ASCENDING"));
1383        let json = serde_json::to_value(&none).unwrap();
1384        assert_eq!(json["sortOrder"], "ASCENDING", "{json}");
1385    }
1386
1387    #[test]
1388    fn summarize_function_as_sheets_str_covers_every_variant() {
1389        let cases = [
1390            (SummarizeFunction::Sum, "SUM"),
1391            (SummarizeFunction::Counta, "COUNTA"),
1392            (SummarizeFunction::Count, "COUNT"),
1393            (SummarizeFunction::CountUnique, "COUNTUNIQUE"),
1394            (SummarizeFunction::Average, "AVERAGE"),
1395            (SummarizeFunction::Max, "MAX"),
1396            (SummarizeFunction::Min, "MIN"),
1397            (SummarizeFunction::Median, "MEDIAN"),
1398            (SummarizeFunction::Product, "PRODUCT"),
1399            (SummarizeFunction::Stdev, "STDEV"),
1400            (SummarizeFunction::Stdevp, "STDEVP"),
1401            (SummarizeFunction::Var, "VAR"),
1402            (SummarizeFunction::Varp, "VARP"),
1403        ];
1404        for (func, expected) in cases {
1405            assert_eq!(func.as_sheets_str(), expected);
1406        }
1407    }
1408
1409    #[test]
1410    fn pivot_filter_spec_arg_to_wire_maps_fields() {
1411        let spec = PivotFilterSpecArg {
1412            column: 2,
1413            visible_values: vec!["Open".to_string(), "Closed".to_string()],
1414        };
1415        let wire = spec.to_wire();
1416        assert_eq!(wire.column_offset_index, 2);
1417        assert_eq!(
1418            wire.filter_criteria.visible_values,
1419            vec!["Open".to_string(), "Closed".to_string()]
1420        );
1421    }
1422
1423    // ── describe helpers ─────────────────────────────────────────────────
1424
1425    #[test]
1426    fn describe_pivot_config_names_every_axis() {
1427        let desc = describe_pivot_config(
1428            &[PivotGroupSpec {
1429                column: 0,
1430                sort_order: Some(SortOrder::Ascending),
1431            }],
1432            &[],
1433            &[PivotValueSpec {
1434                column: 3,
1435                function: SummarizeFunction::Sum,
1436            }],
1437            &[],
1438            None,
1439            true,
1440        );
1441        assert!(desc.contains("rows: col 0 (asc)"), "{desc}");
1442        assert!(desc.contains("columns: none"), "{desc}");
1443        assert!(desc.contains("values: SUM of col 3"), "{desc}");
1444        assert!(desc.contains("layout: HORIZONTAL (default)"), "{desc}");
1445        assert!(desc.contains("totals: on"), "{desc}");
1446    }
1447
1448    #[test]
1449    fn describe_pivot_config_appends_filters_only_when_present() {
1450        let without = describe_pivot_config(&[], &[], &[], &[], None, false);
1451        assert!(!without.contains("filters:"), "{without}");
1452
1453        let with = describe_pivot_config(
1454            &[],
1455            &[],
1456            &[],
1457            &[PivotFilterSpecArg {
1458                column: 1,
1459                visible_values: vec!["Foo".to_string()],
1460            }],
1461            Some(ValueLayout::Vertical),
1462            false,
1463        );
1464        assert!(with.contains("filters: col 1 in [Foo]"), "{with}");
1465        assert!(with.contains("layout: VERTICAL"), "{with}");
1466        assert!(with.contains("totals: off"), "{with}");
1467    }
1468
1469    #[test]
1470    fn describe_pivot_config_covers_every_sort_order_and_an_explicit_horizontal_layout() {
1471        let desc = describe_pivot_config(
1472            &[
1473                PivotGroupSpec {
1474                    column: 0,
1475                    sort_order: None,
1476                },
1477                PivotGroupSpec {
1478                    column: 1,
1479                    sort_order: Some(SortOrder::Descending),
1480                },
1481            ],
1482            &[PivotGroupSpec {
1483                column: 2,
1484                sort_order: None,
1485            }],
1486            &[PivotValueSpec {
1487                column: 3,
1488                function: SummarizeFunction::Sum,
1489            }],
1490            &[],
1491            Some(ValueLayout::Horizontal),
1492            true,
1493        );
1494        // A bare column previews the ascending order it will be sent with
1495        // (#1930), marked as defaulted.
1496        assert!(
1497            desc.contains("rows: col 0 (asc, default), col 1 (desc)"),
1498            "{desc}"
1499        );
1500        assert!(desc.contains("columns: col 2 (asc, default)"), "{desc}");
1501        // Explicit `Horizontal`, distinct from the `None` default's
1502        // "HORIZONTAL (default)" wording.
1503        assert!(desc.contains("layout: HORIZONTAL"), "{desc}");
1504        assert!(!desc.contains("HORIZONTAL (default)"), "{desc}");
1505    }
1506
1507    #[test]
1508    fn describe_anchor_currently_covers_empty_value_and_pivot() {
1509        assert_eq!(describe_anchor_currently(None), "empty");
1510
1511        let value_cell = CellSnapshot {
1512            pivot_table: None,
1513            formatted_value: Some("Q2 totals".to_string()),
1514            ..Default::default()
1515        };
1516        assert_eq!(
1517            describe_anchor_currently(Some(&value_cell)),
1518            "value \"Q2 totals\""
1519        );
1520
1521        let pivot_cell = CellSnapshot {
1522            pivot_table: Some(PivotTable {
1523                source: GridRange::default(),
1524                values: vec![PivotValue::default()],
1525                ..Default::default()
1526            }),
1527            formatted_value: None,
1528            ..Default::default()
1529        };
1530        let pivot_desc = describe_anchor_currently(Some(&pivot_cell));
1531        assert!(pivot_desc.contains("a pivot table"), "{pivot_desc}");
1532
1533        // A cell present in the response but carrying neither a pivot
1534        // table nor a (non-empty) formatted value — the catch-all arm.
1535        let blank_cell = CellSnapshot {
1536            pivot_table: None,
1537            formatted_value: None,
1538            ..Default::default()
1539        };
1540        assert_eq!(describe_anchor_currently(Some(&blank_cell)), "empty");
1541        let empty_string_cell = CellSnapshot {
1542            pivot_table: None,
1543            formatted_value: Some(String::new()),
1544            ..Default::default()
1545        };
1546        assert_eq!(describe_anchor_currently(Some(&empty_string_cell)), "empty");
1547    }
1548
1549    // ── verb/result structural sanity ────────────────────────────────────
1550
1551    #[test]
1552    fn pivot_verb_label_names_each_verb() {
1553        let add = PivotVerb::AddPivotTable {
1554            sheet: String::new(),
1555            anchor: String::new(),
1556            source: String::new(),
1557            rows: vec![],
1558            columns: vec![],
1559            values: vec![],
1560            filters: vec![],
1561            value_layout: None,
1562            show_totals: true,
1563        };
1564        assert_eq!(add.label(), "add-pivot-table");
1565        let delete = PivotVerb::DeletePivotTable {
1566            sheet: String::new(),
1567            anchor: String::new(),
1568        };
1569        assert_eq!(delete.label(), "delete-pivot-table");
1570    }
1571
1572    #[test]
1573    fn list_entries_computes_the_real_anchor_from_chunk_offsets() {
1574        let workbook: crate::drive::sheets::types::Spreadsheet =
1575            serde_json::from_value(serde_json::json!({
1576                "sheets": [{
1577                    "properties": {"sheetId": 0, "title": "Report"},
1578                    "data": [{
1579                        "startRow": 2, "startColumn": 1,
1580                        "rowData": [
1581                            {"values": [{}, {"pivotTable": existing_pivot_json()}]},
1582                        ],
1583                    }],
1584                }],
1585            }))
1586            .unwrap();
1587        let entries = list_entries(&workbook);
1588        assert_eq!(entries.len(), 1);
1589        assert_eq!(entries[0].sheet, "Report");
1590        // `startColumn` 1 + the pivot's own column index 1 = column C;
1591        // `startRow` 2 + row index 0, 1-based = row 3.
1592        assert_eq!(entries[0].anchor, "C3");
1593        let summary = &entries[0].summary;
1594        assert!(summary.contains("value(s)"), "{summary}");
1595    }
1596
1597    #[test]
1598    fn pivot_outcome_write_jsonl_emits_the_result_status() {
1599        let outcome = outcome_with(add_verb(), Some("Budget"), PivotResult::RefusedShortcut);
1600        let mut buf = Vec::new();
1601        outcome.write_jsonl(&mut buf).unwrap();
1602        let text = String::from_utf8(buf).unwrap();
1603        assert!(text.contains("refused-shortcut"), "{text}");
1604    }
1605
1606    #[test]
1607    fn log_operations_are_distinct_and_kebab_cased() {
1608        let ops: HashSet<&str> = [
1609            PivotVerb::AddPivotTable {
1610                sheet: String::new(),
1611                anchor: String::new(),
1612                source: String::new(),
1613                rows: vec![],
1614                columns: vec![],
1615                values: vec![],
1616                filters: vec![],
1617                value_layout: None,
1618                show_totals: true,
1619            }
1620            .log_operation(),
1621            PivotVerb::DeletePivotTable {
1622                sheet: String::new(),
1623                anchor: String::new(),
1624            }
1625            .log_operation(),
1626        ]
1627        .into_iter()
1628        .collect();
1629        assert_eq!(ops.len(), 2);
1630        for op in ops {
1631            assert!(op.starts_with("sheets-"), "{op}");
1632            assert!(!op.contains('_'), "{op}");
1633        }
1634    }
1635
1636    #[test]
1637    fn gate_operations_put_sheets_write_first_for_add() {
1638        let verb = PivotVerb::AddPivotTable {
1639            sheet: String::new(),
1640            anchor: String::new(),
1641            source: String::new(),
1642            rows: vec![],
1643            columns: vec![],
1644            values: vec![],
1645            filters: vec![],
1646            value_layout: None,
1647            show_totals: true,
1648        };
1649        assert_eq!(
1650            verb.gate_operations(),
1651            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure]
1652        );
1653        let delete = PivotVerb::DeletePivotTable {
1654            sheet: String::new(),
1655            anchor: String::new(),
1656        };
1657        assert_eq!(delete.gate_operations(), &[DriveOperation::SheetsWrite]);
1658    }
1659
1660    // ── end-to-end (wiremock) ────────────────────────────────────────────
1661
1662    fn test_credentials() -> DriveCredentials {
1663        DriveCredentials {
1664            client_id: "client-1".to_string(),
1665            client_secret: Secret::new("secret-1"),
1666            refresh_token: Secret::new("refresh-1"),
1667            scope: DriveGrantedScopes::READONLY,
1668        }
1669    }
1670
1671    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1672        wiremock::Mock::given(wiremock::matchers::method("POST"))
1673            .and(wiremock::matchers::path("/token"))
1674            .respond_with(
1675                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1676                    "access_token": "test-token", "expires_in": 3600,
1677                })),
1678            )
1679            .mount(server)
1680            .await;
1681        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1682        crate::drive::client::test_support::replace_session(
1683            &mut drive,
1684            &test_credentials(),
1685            &format!("{}/token", server.uri()),
1686        );
1687        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1688        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1689        (drive, sheets)
1690    }
1691
1692    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1693        let parents: Vec<&str> = parents.to_vec();
1694        wiremock::Mock::given(wiremock::matchers::method("GET"))
1695            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1696            .respond_with(
1697                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1698                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1699                    "version": "1",
1700                })),
1701            )
1702    }
1703
1704    fn mount_folder(id: &str) -> wiremock::Mock {
1705        mount_file(id, "application/vnd.google-apps.folder", &[])
1706    }
1707
1708    fn allow_rule(folder: &str, ops: &[DriveOperation]) -> FolderPermissionRule {
1709        FolderPermissionRule {
1710            folder_id: Some(folder.to_string()),
1711            file_id: None,
1712            recursive: true,
1713            allow: ops.iter().copied().collect(),
1714            deny: HashSet::default(),
1715            require_lease: true,
1716        }
1717    }
1718
1719    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1720        let ledger_path = tempfile::tempdir()
1721            .unwrap()
1722            .keep()
1723            .join("lease-ledger.jsonl");
1724        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1725        (Some(token), ledger_path)
1726    }
1727
1728    /// A workbook with an empty anchor sheet ("Report", sheetId 0) and a
1729    /// 100x4 source sheet ("Data", sheetId 1). `anchor_pivot`, if given,
1730    /// is embedded as an existing pivot table at "Report"!A1.
1731    fn mount_workbook(anchor_pivot: Option<serde_json::Value>) -> wiremock::Mock {
1732        let report_data = anchor_pivot.map(|pivot| {
1733            serde_json::json!([{
1734                "startRow": 0, "startColumn": 0,
1735                "rowData": [{"values": [{"pivotTable": pivot}]}],
1736            }])
1737        });
1738        let mut report_sheet = serde_json::json!({
1739            "properties": {"sheetId": 0, "title": "Report", "index": 0},
1740        });
1741        if let Some(data) = report_data {
1742            report_sheet["data"] = data;
1743        }
1744        wiremock::Mock::given(wiremock::matchers::method("GET"))
1745            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1746            .respond_with(
1747                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1748                    "spreadsheetId": "sheet-1",
1749                    "properties": {"title": "Budget"},
1750                    "sheets": [
1751                        report_sheet,
1752                        {"properties": {"sheetId": 1, "title": "Data", "index": 1}},
1753                    ],
1754                })),
1755            )
1756    }
1757
1758    fn add_verb() -> PivotVerb {
1759        PivotVerb::AddPivotTable {
1760            sheet: "Report".to_string(),
1761            anchor: "A1".to_string(),
1762            source: "Data!A1:D100".to_string(),
1763            rows: vec!["0:asc".to_string()],
1764            columns: vec![],
1765            values: vec!["3:sum".to_string()],
1766            filters: vec![],
1767            value_layout: None,
1768            show_totals: true,
1769        }
1770    }
1771
1772    fn delete_verb() -> PivotVerb {
1773        PivotVerb::DeletePivotTable {
1774            sheet: "Report".to_string(),
1775            anchor: "A1".to_string(),
1776        }
1777    }
1778
1779    fn existing_pivot_json() -> serde_json::Value {
1780        serde_json::json!({
1781            "source": {
1782                "sheetId": 1, "startRowIndex": 0, "endRowIndex": 100,
1783                "startColumnIndex": 0, "endColumnIndex": 4,
1784            },
1785            "values": [{"sourceColumnOffset": 2, "summarizeFunction": "COUNTA"}],
1786        })
1787    }
1788
1789    #[tokio::test]
1790    async fn add_denied_when_only_sheets_structure_is_granted_names_sheets_write() {
1791        let server = wiremock::MockServer::start().await;
1792        let (drive, sheets) = clients(&server).await;
1793        mount_file(
1794            "sheet-1",
1795            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1796            &["folder-1"],
1797        )
1798        .mount(&server)
1799        .await;
1800        mount_folder("folder-1").mount(&server).await;
1801        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
1802        let opts = PivotOptions {
1803            spreadsheet_id: "sheet-1".to_string(),
1804            verb: add_verb(),
1805            dry_run: false,
1806            lease_token: None,
1807            ledger_path: std::path::PathBuf::new(),
1808        };
1809        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1810        match outcome.result {
1811            PivotResult::Blocked { operation, .. } => {
1812                assert_eq!(operation, DriveOperation::SheetsWrite);
1813            }
1814            other => panic!("expected Blocked naming sheets-write, got {other:?}"),
1815        }
1816        // No workbook GET is mocked beyond the target/parent metadata —
1817        // if the gate had been bypassed, the missing mock would 404 and
1818        // the outcome would be `Failed` instead of `Blocked`.
1819    }
1820
1821    #[tokio::test]
1822    async fn delete_allowed_by_sheets_write_alone() {
1823        let server = wiremock::MockServer::start().await;
1824        let (drive, sheets) = clients(&server).await;
1825        mount_file(
1826            "sheet-1",
1827            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1828            &["folder-1"],
1829        )
1830        .mount(&server)
1831        .await;
1832        mount_folder("folder-1").mount(&server).await;
1833        mount_workbook(Some(existing_pivot_json()))
1834            .mount(&server)
1835            .await;
1836        wiremock::Mock::given(wiremock::matchers::method("POST"))
1837            .and(wiremock::matchers::path(
1838                "/v4/spreadsheets/sheet-1:batchUpdate",
1839            ))
1840            .respond_with(
1841                wiremock::ResponseTemplate::new(200)
1842                    .set_body_json(serde_json::json!({"replies": [{}]})),
1843            )
1844            .mount(&server)
1845            .await;
1846        // Only `sheets-write` is granted — `delete-pivot-table` must not
1847        // need `sheets-structure`.
1848        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1849        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1850        let opts = PivotOptions {
1851            spreadsheet_id: "sheet-1".to_string(),
1852            verb: delete_verb(),
1853            dry_run: false,
1854            lease_token,
1855            ledger_path,
1856        };
1857        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1858        assert!(
1859            matches!(outcome.result, PivotResult::Changed(_)),
1860            "{:?}",
1861            outcome.result
1862        );
1863    }
1864
1865    #[tokio::test]
1866    async fn add_dry_run_reports_the_extent_note_and_calls_no_batch_update() {
1867        let server = wiremock::MockServer::start().await;
1868        let (drive, sheets) = clients(&server).await;
1869        mount_file(
1870            "sheet-1",
1871            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1872            &["folder-1"],
1873        )
1874        .mount(&server)
1875        .await;
1876        mount_folder("folder-1").mount(&server).await;
1877        mount_workbook(None).mount(&server).await;
1878        let rules = vec![allow_rule(
1879            "folder-1",
1880            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1881        )];
1882        let opts = PivotOptions {
1883            spreadsheet_id: "sheet-1".to_string(),
1884            verb: add_verb(),
1885            dry_run: true,
1886            lease_token: None,
1887            ledger_path: std::path::PathBuf::new(),
1888        };
1889        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1890        let PivotResult::WouldChange(change) = &outcome.result else {
1891            panic!("expected WouldChange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="dry_run plus a fully-allowing gate always reaches WouldChange here; this branch is a safety net against an unexpected refusal, not a coverage gap"
1892        };
1893        assert_eq!(change.anchor_currently, "empty");
1894        let rendered = describe(&outcome);
1895        assert!(rendered.contains("computed by the server"), "{rendered}");
1896        assert!(rendered.contains("Data!A1:D100"), "{rendered}");
1897
1898        assert!(!server
1899            .received_requests()
1900            .await
1901            .unwrap()
1902            .iter()
1903            .any(|r| r.url.path().ends_with(":batchUpdate")));
1904    }
1905
1906    #[tokio::test]
1907    async fn add_sends_updatecells_with_no_uservalue_and_the_pivot_field_mask() {
1908        let server = wiremock::MockServer::start().await;
1909        let (drive, sheets) = clients(&server).await;
1910        mount_file(
1911            "sheet-1",
1912            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1913            &["folder-1"],
1914        )
1915        .mount(&server)
1916        .await;
1917        mount_folder("folder-1").mount(&server).await;
1918        mount_workbook(None).mount(&server).await;
1919        wiremock::Mock::given(wiremock::matchers::method("POST"))
1920            .and(wiremock::matchers::path(
1921                "/v4/spreadsheets/sheet-1:batchUpdate",
1922            ))
1923            .respond_with(
1924                wiremock::ResponseTemplate::new(200)
1925                    .set_body_json(serde_json::json!({"replies": [{}]})),
1926            )
1927            .mount(&server)
1928            .await;
1929        let rules = vec![allow_rule(
1930            "folder-1",
1931            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1932        )];
1933        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1934        let opts = PivotOptions {
1935            spreadsheet_id: "sheet-1".to_string(),
1936            verb: add_verb(),
1937            dry_run: false,
1938            lease_token,
1939            ledger_path,
1940        };
1941        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
1942        assert!(
1943            matches!(outcome.result, PivotResult::Changed(_)),
1944            "{:?}",
1945            outcome.result
1946        );
1947
1948        let requests = server.received_requests().await.unwrap();
1949        let batch = requests
1950            .iter()
1951            .find(|r| r.url.path().ends_with(":batchUpdate"))
1952            .unwrap();
1953        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1954        let update = &body["requests"][0]["updateCells"];
1955        assert_eq!(update["fields"], "pivotTable");
1956        assert_eq!(update["start"]["sheetId"], 0);
1957        assert_eq!(update["start"]["rowIndex"], 0);
1958        assert_eq!(update["start"]["columnIndex"], 0);
1959        let cell = &update["rows"][0]["values"][0];
1960        assert!(
1961            cell.get("userEnteredValue").is_none(),
1962            "updateCells must never carry a value: {cell}"
1963        );
1964        let pivot = &cell["pivotTable"];
1965        assert_eq!(pivot["source"]["sheetId"], 1);
1966        assert_eq!(pivot["rows"][0]["sourceColumnOffset"], 0);
1967        assert_eq!(pivot["rows"][0]["sortOrder"], "ASCENDING");
1968        assert_eq!(pivot["values"][0]["sourceColumnOffset"], 3);
1969        assert_eq!(pivot["values"][0]["summarizeFunction"], "SUM");
1970    }
1971
1972    #[tokio::test]
1973    async fn delete_sends_an_empty_cell_to_clear_the_pivot() {
1974        let server = wiremock::MockServer::start().await;
1975        let (drive, sheets) = clients(&server).await;
1976        mount_file(
1977            "sheet-1",
1978            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1979            &["folder-1"],
1980        )
1981        .mount(&server)
1982        .await;
1983        mount_folder("folder-1").mount(&server).await;
1984        mount_workbook(Some(existing_pivot_json()))
1985            .mount(&server)
1986            .await;
1987        wiremock::Mock::given(wiremock::matchers::method("POST"))
1988            .and(wiremock::matchers::path(
1989                "/v4/spreadsheets/sheet-1:batchUpdate",
1990            ))
1991            .respond_with(
1992                wiremock::ResponseTemplate::new(200)
1993                    .set_body_json(serde_json::json!({"replies": [{}]})),
1994            )
1995            .mount(&server)
1996            .await;
1997        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1998        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1999        let opts = PivotOptions {
2000            spreadsheet_id: "sheet-1".to_string(),
2001            verb: delete_verb(),
2002            dry_run: false,
2003            lease_token,
2004            ledger_path,
2005        };
2006        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2007        assert!(
2008            matches!(outcome.result, PivotResult::Changed(_)),
2009            "{:?}",
2010            outcome.result
2011        );
2012
2013        let requests = server.received_requests().await.unwrap();
2014        let batch = requests
2015            .iter()
2016            .find(|r| r.url.path().ends_with(":batchUpdate"))
2017            .unwrap();
2018        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
2019        let update = &body["requests"][0]["updateCells"];
2020        assert_eq!(update["fields"], "pivotTable");
2021        assert_eq!(update["rows"][0]["values"][0], serde_json::json!({}));
2022    }
2023
2024    #[tokio::test]
2025    async fn add_refuses_an_anchor_that_already_holds_a_pivot_table() {
2026        let server = wiremock::MockServer::start().await;
2027        let (drive, sheets) = clients(&server).await;
2028        mount_file(
2029            "sheet-1",
2030            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2031            &["folder-1"],
2032        )
2033        .mount(&server)
2034        .await;
2035        mount_folder("folder-1").mount(&server).await;
2036        mount_workbook(Some(existing_pivot_json()))
2037            .mount(&server)
2038            .await;
2039        let rules = vec![allow_rule(
2040            "folder-1",
2041            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2042        )];
2043        let opts = PivotOptions {
2044            spreadsheet_id: "sheet-1".to_string(),
2045            verb: add_verb(),
2046            dry_run: true,
2047            lease_token: None,
2048            ledger_path: std::path::PathBuf::new(),
2049        };
2050        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2051        assert!(
2052            matches!(
2053                outcome.result,
2054                PivotResult::RefusedAnchorHasPivotTable { .. }
2055            ),
2056            "{:?}",
2057            outcome.result
2058        );
2059    }
2060
2061    #[tokio::test]
2062    async fn delete_refuses_an_anchor_with_no_pivot_table() {
2063        let server = wiremock::MockServer::start().await;
2064        let (drive, sheets) = clients(&server).await;
2065        mount_file(
2066            "sheet-1",
2067            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2068            &["folder-1"],
2069        )
2070        .mount(&server)
2071        .await;
2072        mount_folder("folder-1").mount(&server).await;
2073        mount_workbook(None).mount(&server).await;
2074        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2075        let opts = PivotOptions {
2076            spreadsheet_id: "sheet-1".to_string(),
2077            verb: delete_verb(),
2078            dry_run: true,
2079            lease_token: None,
2080            ledger_path: std::path::PathBuf::new(),
2081        };
2082        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2083        assert!(
2084            matches!(
2085                outcome.result,
2086                PivotResult::RefusedNoPivotTableAtAnchor { .. }
2087            ),
2088            "{:?}",
2089            outcome.result
2090        );
2091    }
2092
2093    #[tokio::test]
2094    async fn add_refuses_a_column_offset_out_of_bounds_for_the_source() {
2095        let server = wiremock::MockServer::start().await;
2096        let (drive, sheets) = clients(&server).await;
2097        mount_file(
2098            "sheet-1",
2099            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2100            &["folder-1"],
2101        )
2102        .mount(&server)
2103        .await;
2104        mount_folder("folder-1").mount(&server).await;
2105        mount_workbook(None).mount(&server).await;
2106        let rules = vec![allow_rule(
2107            "folder-1",
2108            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2109        )];
2110        let verb = PivotVerb::AddPivotTable {
2111            sheet: "Report".to_string(),
2112            anchor: "A1".to_string(),
2113            source: "Data!A1:D100".to_string(),
2114            rows: vec![],
2115            columns: vec![],
2116            // Source is 4 columns wide (offsets 0-3); 9 is out of bounds.
2117            values: vec!["9:sum".to_string()],
2118            filters: vec![],
2119            value_layout: None,
2120            show_totals: true,
2121        };
2122        let opts = PivotOptions {
2123            spreadsheet_id: "sheet-1".to_string(),
2124            verb,
2125            dry_run: true,
2126            lease_token: None,
2127            ledger_path: std::path::PathBuf::new(),
2128        };
2129        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2130        match outcome.result {
2131            PivotResult::RefusedInvalidSource { detail } => {
2132                assert!(detail.contains("out of bounds"), "{detail}");
2133            }
2134            other => panic!("expected RefusedInvalidSource, got {other:?}"),
2135        }
2136    }
2137
2138    #[tokio::test]
2139    async fn add_refuses_with_no_values_given() {
2140        let server = wiremock::MockServer::start().await;
2141        let (drive, sheets) = clients(&server).await;
2142        mount_file(
2143            "sheet-1",
2144            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2145            &["folder-1"],
2146        )
2147        .mount(&server)
2148        .await;
2149        mount_folder("folder-1").mount(&server).await;
2150        mount_workbook(None).mount(&server).await;
2151        let rules = vec![allow_rule(
2152            "folder-1",
2153            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2154        )];
2155        let verb = PivotVerb::AddPivotTable {
2156            sheet: "Report".to_string(),
2157            anchor: "A1".to_string(),
2158            source: "Data!A1:D100".to_string(),
2159            rows: vec![],
2160            columns: vec![],
2161            values: vec![],
2162            filters: vec![],
2163            value_layout: None,
2164            show_totals: true,
2165        };
2166        let opts = PivotOptions {
2167            spreadsheet_id: "sheet-1".to_string(),
2168            verb,
2169            dry_run: true,
2170            lease_token: None,
2171            ledger_path: std::path::PathBuf::new(),
2172        };
2173        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2174        match outcome.result {
2175            PivotResult::RefusedInvalidSource { detail } => {
2176                assert!(detail.contains("at least one --value"), "{detail}");
2177            }
2178            other => panic!("expected RefusedInvalidSource, got {other:?}"),
2179        }
2180    }
2181
2182    #[tokio::test]
2183    async fn add_refuses_an_anchor_that_names_more_than_one_cell() {
2184        let server = wiremock::MockServer::start().await;
2185        let (drive, sheets) = clients(&server).await;
2186        mount_file(
2187            "sheet-1",
2188            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2189            &["folder-1"],
2190        )
2191        .mount(&server)
2192        .await;
2193        mount_folder("folder-1").mount(&server).await;
2194        mount_workbook(None).mount(&server).await;
2195        let rules = vec![allow_rule(
2196            "folder-1",
2197            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2198        )];
2199        let mut verb = add_verb();
2200        if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2201            *anchor = "A1:B2".to_string();
2202        }
2203        let opts = PivotOptions {
2204            spreadsheet_id: "sheet-1".to_string(),
2205            verb,
2206            dry_run: true,
2207            lease_token: None,
2208            ledger_path: std::path::PathBuf::new(),
2209        };
2210        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2211        assert!(
2212            matches!(outcome.result, PivotResult::RefusedInvalidAnchor { .. }),
2213            "{:?}",
2214            outcome.result
2215        );
2216    }
2217
2218    #[tokio::test]
2219    async fn add_refuses_when_sheet_and_anchor_conflict_on_the_sheet_name() {
2220        let server = wiremock::MockServer::start().await;
2221        let (drive, sheets) = clients(&server).await;
2222        // No mocks at all: the ambiguity is caught by `a1::compose` before
2223        // any HTTP request is ever sent.
2224        let mut verb = add_verb();
2225        if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2226            *anchor = "Other!A1".to_string();
2227        }
2228        let opts = PivotOptions {
2229            spreadsheet_id: "sheet-1".to_string(),
2230            verb,
2231            dry_run: true,
2232            lease_token: None,
2233            ledger_path: std::path::PathBuf::new(),
2234        };
2235        let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2236        assert!(
2237            matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2238            "{:?}",
2239            outcome.result
2240        );
2241        assert_eq!(outcome.file_name, None);
2242    }
2243
2244    #[tokio::test]
2245    async fn pivot_reports_failed_on_a_metadata_fetch_failure() {
2246        let server = wiremock::MockServer::start().await;
2247        let (drive, sheets) = clients(&server).await;
2248        wiremock::Mock::given(wiremock::matchers::method("GET"))
2249            .and(wiremock::matchers::path("/drive/v3/files/missing"))
2250            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2251            .mount(&server)
2252            .await;
2253        let opts = PivotOptions {
2254            spreadsheet_id: "missing".to_string(),
2255            verb: add_verb(),
2256            dry_run: true,
2257            lease_token: None,
2258            ledger_path: std::path::PathBuf::new(),
2259        };
2260        let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2261        assert!(
2262            matches!(outcome.result, PivotResult::Failed { .. }),
2263            "{:?}",
2264            outcome.result
2265        );
2266    }
2267
2268    #[tokio::test]
2269    async fn pivot_refuses_a_shortcut_target() {
2270        let server = wiremock::MockServer::start().await;
2271        let (drive, sheets) = clients(&server).await;
2272        mount_file(
2273            "sheet-1",
2274            "application/vnd.google-apps.shortcut",
2275            &["folder-1"],
2276        )
2277        .mount(&server)
2278        .await;
2279        // No mock for folder-1: proves the gate never runs.
2280        let rules = vec![allow_rule(
2281            "folder-1",
2282            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2283        )];
2284        let opts = PivotOptions {
2285            spreadsheet_id: "sheet-1".to_string(),
2286            verb: add_verb(),
2287            dry_run: true,
2288            lease_token: None,
2289            ledger_path: std::path::PathBuf::new(),
2290        };
2291        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2292        assert!(
2293            matches!(outcome.result, PivotResult::RefusedShortcut),
2294            "{:?}",
2295            outcome.result
2296        );
2297        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2298    }
2299
2300    #[tokio::test]
2301    async fn pivot_refuses_a_non_spreadsheet_target() {
2302        let server = wiremock::MockServer::start().await;
2303        let (drive, sheets) = clients(&server).await;
2304        mount_file("sheet-1", "text/plain", &["folder-1"])
2305            .mount(&server)
2306            .await;
2307        let rules = vec![allow_rule(
2308            "folder-1",
2309            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2310        )];
2311        let opts = PivotOptions {
2312            spreadsheet_id: "sheet-1".to_string(),
2313            verb: add_verb(),
2314            dry_run: true,
2315            lease_token: None,
2316            ledger_path: std::path::PathBuf::new(),
2317        };
2318        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2319        assert!(
2320            matches!(outcome.result, PivotResult::RefusedNotASpreadsheet { .. }),
2321            "{:?}",
2322            outcome.result
2323        );
2324    }
2325
2326    #[tokio::test]
2327    async fn pivot_refuses_a_target_with_no_visible_parents() {
2328        let server = wiremock::MockServer::start().await;
2329        let (drive, sheets) = clients(&server).await;
2330        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2331            .mount(&server)
2332            .await;
2333        let opts = PivotOptions {
2334            spreadsheet_id: "sheet-1".to_string(),
2335            verb: add_verb(),
2336            dry_run: true,
2337            lease_token: None,
2338            ledger_path: std::path::PathBuf::new(),
2339        };
2340        let outcome = pivot(&drive, &sheets, &opts, &[]).await;
2341        assert!(
2342            matches!(outcome.result, PivotResult::RefusedNoVisibleParents),
2343            "{:?}",
2344            outcome.result
2345        );
2346    }
2347
2348    #[tokio::test]
2349    async fn pivot_reports_failed_on_a_gate_ancestor_fetch_failure() {
2350        let server = wiremock::MockServer::start().await;
2351        let (drive, sheets) = clients(&server).await;
2352        mount_file(
2353            "sheet-1",
2354            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2355            &["folder-1"],
2356        )
2357        .mount(&server)
2358        .await;
2359        wiremock::Mock::given(wiremock::matchers::method("GET"))
2360            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2361            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2362            .mount(&server)
2363            .await;
2364        let rules = vec![allow_rule(
2365            "folder-1",
2366            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2367        )];
2368        let opts = PivotOptions {
2369            spreadsheet_id: "sheet-1".to_string(),
2370            verb: add_verb(),
2371            dry_run: true,
2372            lease_token: None,
2373            ledger_path: std::path::PathBuf::new(),
2374        };
2375        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2376        assert!(
2377            matches!(outcome.result, PivotResult::Failed { .. }),
2378            "{:?}",
2379            outcome.result
2380        );
2381    }
2382
2383    #[tokio::test]
2384    async fn add_reports_failed_when_the_workbook_fetch_fails() {
2385        let server = wiremock::MockServer::start().await;
2386        let (drive, sheets) = clients(&server).await;
2387        mount_file(
2388            "sheet-1",
2389            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2390            &["folder-1"],
2391        )
2392        .mount(&server)
2393        .await;
2394        mount_folder("folder-1").mount(&server).await;
2395        wiremock::Mock::given(wiremock::matchers::method("GET"))
2396            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2397            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2398            .mount(&server)
2399            .await;
2400        let rules = vec![allow_rule(
2401            "folder-1",
2402            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2403        )];
2404        let opts = PivotOptions {
2405            spreadsheet_id: "sheet-1".to_string(),
2406            verb: add_verb(),
2407            dry_run: true,
2408            lease_token: None,
2409            ledger_path: std::path::PathBuf::new(),
2410        };
2411        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2412        assert!(
2413            matches!(outcome.result, PivotResult::Failed { .. }),
2414            "{:?}",
2415            outcome.result
2416        );
2417    }
2418
2419    #[tokio::test]
2420    async fn pivot_reports_failed_when_the_cell_pivot_fetch_fails() {
2421        let server = wiremock::MockServer::start().await;
2422        let (drive, sheets) = clients(&server).await;
2423        mount_file(
2424            "sheet-1",
2425            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2426            &["folder-1"],
2427        )
2428        .mount(&server)
2429        .await;
2430        mount_folder("folder-1").mount(&server).await;
2431        mount_workbook(None).mount(&server).await;
2432        // A more specific mock, matched only by the `ranges`-scoped
2433        // `get_cell_pivot` call. wiremock checks equal-priority mocks in
2434        // mount order, so a higher priority (a *lower* number) is needed
2435        // for this one to be checked before the general workbook mock
2436        // above — which otherwise matches first and would serve `200` to
2437        // every `GET`, `ranges` or not. `get_spreadsheet` (which never
2438        // sends `ranges`) still falls through to that general mock.
2439        wiremock::Mock::given(wiremock::matchers::method("GET"))
2440            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2441            .and(wiremock::matchers::query_param("ranges", "'Report'!A1"))
2442            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2443            .with_priority(1)
2444            .mount(&server)
2445            .await;
2446        let rules = vec![allow_rule(
2447            "folder-1",
2448            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2449        )];
2450        let opts = PivotOptions {
2451            spreadsheet_id: "sheet-1".to_string(),
2452            verb: add_verb(),
2453            dry_run: true,
2454            lease_token: None,
2455            ledger_path: std::path::PathBuf::new(),
2456        };
2457        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2458        assert!(
2459            matches!(outcome.result, PivotResult::Failed { .. }),
2460            "{:?}",
2461            outcome.result
2462        );
2463    }
2464
2465    #[tokio::test]
2466    async fn add_refuses_an_anchor_sheet_that_does_not_exist() {
2467        let server = wiremock::MockServer::start().await;
2468        let (drive, sheets) = clients(&server).await;
2469        mount_file(
2470            "sheet-1",
2471            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2472            &["folder-1"],
2473        )
2474        .mount(&server)
2475        .await;
2476        mount_folder("folder-1").mount(&server).await;
2477        mount_workbook(None).mount(&server).await;
2478        let rules = vec![allow_rule(
2479            "folder-1",
2480            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2481        )];
2482        let mut verb = add_verb();
2483        if let PivotVerb::AddPivotTable { sheet, .. } = &mut verb {
2484            *sheet = "Nope".to_string();
2485        }
2486        let opts = PivotOptions {
2487            spreadsheet_id: "sheet-1".to_string(),
2488            verb,
2489            dry_run: true,
2490            lease_token: None,
2491            ledger_path: std::path::PathBuf::new(),
2492        };
2493        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2494        assert!(
2495            matches!(outcome.result, PivotResult::RefusedSheetNotFound { .. }),
2496            "{:?}",
2497            outcome.result
2498        );
2499    }
2500
2501    #[tokio::test]
2502    async fn add_refuses_an_anchor_with_invalid_range_syntax() {
2503        let server = wiremock::MockServer::start().await;
2504        let (drive, sheets) = clients(&server).await;
2505        mount_file(
2506            "sheet-1",
2507            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2508            &["folder-1"],
2509        )
2510        .mount(&server)
2511        .await;
2512        mount_folder("folder-1").mount(&server).await;
2513        mount_workbook(None).mount(&server).await;
2514        let rules = vec![allow_rule(
2515            "folder-1",
2516            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2517        )];
2518        let mut verb = add_verb();
2519        if let PivotVerb::AddPivotTable { anchor, .. } = &mut verb {
2520            // Row 0 does not exist (rows are 1-based) — a genuinely
2521            // malformed range, distinct from the multi-cell case above.
2522            *anchor = "A0".to_string();
2523        }
2524        let opts = PivotOptions {
2525            spreadsheet_id: "sheet-1".to_string(),
2526            verb,
2527            dry_run: true,
2528            lease_token: None,
2529            ledger_path: std::path::PathBuf::new(),
2530        };
2531        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2532        assert!(
2533            matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2534            "{:?}",
2535            outcome.result
2536        );
2537    }
2538
2539    #[tokio::test]
2540    async fn add_refuses_an_invalid_source_composed_from_sheet_and_bare_range() {
2541        let server = wiremock::MockServer::start().await;
2542        let (drive, sheets) = clients(&server).await;
2543        mount_file(
2544            "sheet-1",
2545            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2546            &["folder-1"],
2547        )
2548        .mount(&server)
2549        .await;
2550        mount_folder("folder-1").mount(&server).await;
2551        mount_workbook(None).mount(&server).await;
2552        let rules = vec![allow_rule(
2553            "folder-1",
2554            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2555        )];
2556        let mut verb = add_verb();
2557        if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2558            // No `Sheet!` prefix — falls back to composing with `--sheet`.
2559            // The newline sits *inside* the range (not at either edge, so
2560            // `compose`'s own `str::trim` can't remove it) and fails
2561            // `a1::validate_range` inside `compose`.
2562            *source = "A1\n:D100".to_string();
2563        }
2564        let opts = PivotOptions {
2565            spreadsheet_id: "sheet-1".to_string(),
2566            verb,
2567            dry_run: true,
2568            lease_token: None,
2569            ledger_path: std::path::PathBuf::new(),
2570        };
2571        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2572        assert!(
2573            matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2574            "{:?}",
2575            outcome.result
2576        );
2577    }
2578
2579    #[tokio::test]
2580    async fn add_refuses_a_source_sheet_that_does_not_exist() {
2581        let server = wiremock::MockServer::start().await;
2582        let (drive, sheets) = clients(&server).await;
2583        mount_file(
2584            "sheet-1",
2585            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2586            &["folder-1"],
2587        )
2588        .mount(&server)
2589        .await;
2590        mount_folder("folder-1").mount(&server).await;
2591        mount_workbook(None).mount(&server).await;
2592        let rules = vec![allow_rule(
2593            "folder-1",
2594            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2595        )];
2596        let mut verb = add_verb();
2597        if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2598            *source = "Nope!A1:D10".to_string();
2599        }
2600        let opts = PivotOptions {
2601            spreadsheet_id: "sheet-1".to_string(),
2602            verb,
2603            dry_run: true,
2604            lease_token: None,
2605            ledger_path: std::path::PathBuf::new(),
2606        };
2607        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2608        assert!(
2609            matches!(outcome.result, PivotResult::RefusedSheetNotFound { .. }),
2610            "{:?}",
2611            outcome.result
2612        );
2613    }
2614
2615    #[tokio::test]
2616    async fn add_refuses_a_source_with_invalid_range_syntax() {
2617        let server = wiremock::MockServer::start().await;
2618        let (drive, sheets) = clients(&server).await;
2619        mount_file(
2620            "sheet-1",
2621            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2622            &["folder-1"],
2623        )
2624        .mount(&server)
2625        .await;
2626        mount_folder("folder-1").mount(&server).await;
2627        mount_workbook(None).mount(&server).await;
2628        let rules = vec![allow_rule(
2629            "folder-1",
2630            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2631        )];
2632        let mut verb = add_verb();
2633        if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2634            *source = "Data!A0:D10".to_string();
2635        }
2636        let opts = PivotOptions {
2637            spreadsheet_id: "sheet-1".to_string(),
2638            verb,
2639            dry_run: true,
2640            lease_token: None,
2641            ledger_path: std::path::PathBuf::new(),
2642        };
2643        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2644        assert!(
2645            matches!(outcome.result, PivotResult::RefusedInvalidRange { .. }),
2646            "{:?}",
2647            outcome.result
2648        );
2649    }
2650
2651    #[tokio::test]
2652    async fn add_refuses_an_unbounded_source_range() {
2653        let server = wiremock::MockServer::start().await;
2654        let (drive, sheets) = clients(&server).await;
2655        mount_file(
2656            "sheet-1",
2657            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2658            &["folder-1"],
2659        )
2660        .mount(&server)
2661        .await;
2662        mount_folder("folder-1").mount(&server).await;
2663        mount_workbook(None).mount(&server).await;
2664        let rules = vec![allow_rule(
2665            "folder-1",
2666            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2667        )];
2668        let mut verb = add_verb();
2669        if let PivotVerb::AddPivotTable { source, .. } = &mut verb {
2670            // A whole-column span: bounded in columns but not in rows.
2671            *source = "Data!A:D".to_string();
2672        }
2673        let opts = PivotOptions {
2674            spreadsheet_id: "sheet-1".to_string(),
2675            verb,
2676            dry_run: true,
2677            lease_token: None,
2678            ledger_path: std::path::PathBuf::new(),
2679        };
2680        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2681        match outcome.result {
2682            PivotResult::RefusedInvalidSource { detail } => {
2683                assert!(detail.contains("bounded range"), "{detail}");
2684            }
2685            other => panic!("expected RefusedInvalidSource, got {other:?}"),
2686        }
2687    }
2688
2689    #[tokio::test]
2690    async fn add_refuses_an_unparseable_row_spec() {
2691        let server = wiremock::MockServer::start().await;
2692        let (drive, sheets) = clients(&server).await;
2693        mount_file(
2694            "sheet-1",
2695            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2696            &["folder-1"],
2697        )
2698        .mount(&server)
2699        .await;
2700        mount_folder("folder-1").mount(&server).await;
2701        mount_workbook(None).mount(&server).await;
2702        let rules = vec![allow_rule(
2703            "folder-1",
2704            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2705        )];
2706        let mut verb = add_verb();
2707        if let PivotVerb::AddPivotTable { rows, .. } = &mut verb {
2708            *rows = vec!["bad".to_string()];
2709        }
2710        let opts = PivotOptions {
2711            spreadsheet_id: "sheet-1".to_string(),
2712            verb,
2713            dry_run: true,
2714            lease_token: None,
2715            ledger_path: std::path::PathBuf::new(),
2716        };
2717        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2718        assert!(
2719            matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2720            "{:?}",
2721            outcome.result
2722        );
2723    }
2724
2725    #[tokio::test]
2726    async fn add_refuses_an_unparseable_column_spec() {
2727        let server = wiremock::MockServer::start().await;
2728        let (drive, sheets) = clients(&server).await;
2729        mount_file(
2730            "sheet-1",
2731            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2732            &["folder-1"],
2733        )
2734        .mount(&server)
2735        .await;
2736        mount_folder("folder-1").mount(&server).await;
2737        mount_workbook(None).mount(&server).await;
2738        let rules = vec![allow_rule(
2739            "folder-1",
2740            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2741        )];
2742        let mut verb = add_verb();
2743        if let PivotVerb::AddPivotTable { columns, .. } = &mut verb {
2744            *columns = vec!["bad".to_string()];
2745        }
2746        let opts = PivotOptions {
2747            spreadsheet_id: "sheet-1".to_string(),
2748            verb,
2749            dry_run: true,
2750            lease_token: None,
2751            ledger_path: std::path::PathBuf::new(),
2752        };
2753        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2754        assert!(
2755            matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2756            "{:?}",
2757            outcome.result
2758        );
2759    }
2760
2761    #[tokio::test]
2762    async fn add_refuses_an_unparseable_value_spec() {
2763        let server = wiremock::MockServer::start().await;
2764        let (drive, sheets) = clients(&server).await;
2765        mount_file(
2766            "sheet-1",
2767            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2768            &["folder-1"],
2769        )
2770        .mount(&server)
2771        .await;
2772        mount_folder("folder-1").mount(&server).await;
2773        mount_workbook(None).mount(&server).await;
2774        let rules = vec![allow_rule(
2775            "folder-1",
2776            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2777        )];
2778        let mut verb = add_verb();
2779        if let PivotVerb::AddPivotTable { values, .. } = &mut verb {
2780            *values = vec!["bad".to_string()];
2781        }
2782        let opts = PivotOptions {
2783            spreadsheet_id: "sheet-1".to_string(),
2784            verb,
2785            dry_run: true,
2786            lease_token: None,
2787            ledger_path: std::path::PathBuf::new(),
2788        };
2789        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2790        assert!(
2791            matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2792            "{:?}",
2793            outcome.result
2794        );
2795    }
2796
2797    #[tokio::test]
2798    async fn add_refuses_an_unparseable_filter_spec() {
2799        let server = wiremock::MockServer::start().await;
2800        let (drive, sheets) = clients(&server).await;
2801        mount_file(
2802            "sheet-1",
2803            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2804            &["folder-1"],
2805        )
2806        .mount(&server)
2807        .await;
2808        mount_folder("folder-1").mount(&server).await;
2809        mount_workbook(None).mount(&server).await;
2810        let rules = vec![allow_rule(
2811            "folder-1",
2812            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2813        )];
2814        let mut verb = add_verb();
2815        if let PivotVerb::AddPivotTable { filters, .. } = &mut verb {
2816            *filters = vec!["bad".to_string()];
2817        }
2818        let opts = PivotOptions {
2819            spreadsheet_id: "sheet-1".to_string(),
2820            verb,
2821            dry_run: true,
2822            lease_token: None,
2823            ledger_path: std::path::PathBuf::new(),
2824        };
2825        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2826        assert!(
2827            matches!(outcome.result, PivotResult::RefusedInvalidSource { .. }),
2828            "{:?}",
2829            outcome.result
2830        );
2831    }
2832
2833    #[tokio::test]
2834    async fn add_reports_failed_when_batch_update_fails() {
2835        let server = wiremock::MockServer::start().await;
2836        let (drive, sheets) = clients(&server).await;
2837        mount_file(
2838            "sheet-1",
2839            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2840            &["folder-1"],
2841        )
2842        .mount(&server)
2843        .await;
2844        mount_folder("folder-1").mount(&server).await;
2845        mount_workbook(None).mount(&server).await;
2846        wiremock::Mock::given(wiremock::matchers::method("POST"))
2847            .and(wiremock::matchers::path(
2848                "/v4/spreadsheets/sheet-1:batchUpdate",
2849            ))
2850            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2851            .mount(&server)
2852            .await;
2853        let rules = vec![allow_rule(
2854            "folder-1",
2855            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2856        )];
2857        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2858        let opts = PivotOptions {
2859            spreadsheet_id: "sheet-1".to_string(),
2860            verb: add_verb(),
2861            dry_run: false,
2862            lease_token,
2863            ledger_path,
2864        };
2865        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2866        assert!(
2867            matches!(outcome.result, PivotResult::Failed { .. }),
2868            "{:?}",
2869            outcome.result
2870        );
2871    }
2872
2873    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
2874
2875    #[tokio::test]
2876    async fn add_refuses_without_a_lease_when_the_rule_requires_one() {
2877        let server = wiremock::MockServer::start().await;
2878        let (drive, sheets) = clients(&server).await;
2879        mount_file(
2880            "sheet-1",
2881            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2882            &["folder-1"],
2883        )
2884        .mount(&server)
2885        .await;
2886        mount_folder("folder-1").mount(&server).await;
2887        mount_workbook(None).mount(&server).await;
2888        let rules = vec![allow_rule(
2889            "folder-1",
2890            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2891        )];
2892        let opts = PivotOptions {
2893            spreadsheet_id: "sheet-1".to_string(),
2894            verb: add_verb(),
2895            dry_run: false,
2896            lease_token: None,
2897            ledger_path: std::path::PathBuf::new(),
2898        };
2899        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2900        assert!(matches!(outcome.result, PivotResult::RefusedNoLease));
2901    }
2902
2903    #[tokio::test]
2904    async fn add_refuses_an_unknown_lease_token() {
2905        let server = wiremock::MockServer::start().await;
2906        let (drive, sheets) = clients(&server).await;
2907        mount_file(
2908            "sheet-1",
2909            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2910            &["folder-1"],
2911        )
2912        .mount(&server)
2913        .await;
2914        mount_folder("folder-1").mount(&server).await;
2915        mount_workbook(None).mount(&server).await;
2916        let rules = vec![allow_rule(
2917            "folder-1",
2918            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2919        )];
2920        let ledger_path = tempfile::tempdir()
2921            .unwrap()
2922            .keep()
2923            .join("lease-ledger.jsonl");
2924        let opts = PivotOptions {
2925            spreadsheet_id: "sheet-1".to_string(),
2926            verb: add_verb(),
2927            dry_run: false,
2928            lease_token: Some("bogus-token".to_string()),
2929            ledger_path,
2930        };
2931        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2932        assert!(matches!(outcome.result, PivotResult::RefusedLeaseExpired));
2933    }
2934
2935    #[tokio::test]
2936    async fn add_refuses_a_lease_bound_to_a_different_file() {
2937        let server = wiremock::MockServer::start().await;
2938        let (drive, sheets) = clients(&server).await;
2939        mount_file(
2940            "sheet-1",
2941            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2942            &["folder-1"],
2943        )
2944        .mount(&server)
2945        .await;
2946        mount_folder("folder-1").mount(&server).await;
2947        mount_workbook(None).mount(&server).await;
2948        let rules = vec![allow_rule(
2949            "folder-1",
2950            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2951        )];
2952        let ledger_path = tempfile::tempdir()
2953            .unwrap()
2954            .keep()
2955            .join("lease-ledger.jsonl");
2956        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2957        let opts = PivotOptions {
2958            spreadsheet_id: "sheet-1".to_string(),
2959            verb: add_verb(),
2960            dry_run: false,
2961            lease_token: Some(token),
2962            ledger_path,
2963        };
2964        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2965        assert!(matches!(outcome.result, PivotResult::RefusedLeaseWrongFile));
2966    }
2967
2968    #[tokio::test]
2969    async fn add_refuses_a_stale_lease_when_the_file_has_moved() {
2970        let server = wiremock::MockServer::start().await;
2971        let (drive, sheets) = clients(&server).await;
2972        mount_file(
2973            "sheet-1",
2974            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2975            &["folder-1"],
2976        )
2977        .mount(&server)
2978        .await;
2979        mount_folder("folder-1").mount(&server).await;
2980        mount_workbook(None).mount(&server).await;
2981        let rules = vec![allow_rule(
2982            "folder-1",
2983            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2984        )];
2985        let ledger_path = tempfile::tempdir()
2986            .unwrap()
2987            .keep()
2988            .join("lease-ledger.jsonl");
2989        let token = seed_lease(&ledger_path, "sheet-1", "0");
2990        let opts = PivotOptions {
2991            spreadsheet_id: "sheet-1".to_string(),
2992            verb: add_verb(),
2993            dry_run: false,
2994            lease_token: Some(token),
2995            ledger_path,
2996        };
2997        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
2998        assert!(matches!(outcome.result, PivotResult::RefusedLeaseStale));
2999    }
3000
3001    #[tokio::test]
3002    async fn add_reports_failed_when_the_lease_checks_live_version_refetch_fails() {
3003        let server = wiremock::MockServer::start().await;
3004        let (drive, sheets) = clients(&server).await;
3005        // The gate's own metadata fetch consumes this mock (it fetches the
3006        // target's metadata exactly once); the lease check's *separate*
3007        // live-version refetch (`gate_leased_write`) is a second
3008        // `files.get` call, served by the always-on 500 mock below.
3009        mount_file(
3010            "sheet-1",
3011            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3012            &["folder-1"],
3013        )
3014        .up_to_n_times(1)
3015        .mount(&server)
3016        .await;
3017        wiremock::Mock::given(wiremock::matchers::method("GET"))
3018            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3019            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3020            .mount(&server)
3021            .await;
3022        mount_folder("folder-1").mount(&server).await;
3023        mount_workbook(None).mount(&server).await;
3024        let rules = vec![allow_rule(
3025            "folder-1",
3026            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3027        )];
3028        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3029        let opts = PivotOptions {
3030            spreadsheet_id: "sheet-1".to_string(),
3031            verb: add_verb(),
3032            dry_run: false,
3033            lease_token,
3034            ledger_path,
3035        };
3036        let outcome = pivot(&drive, &sheets, &opts, &rules).await;
3037        assert!(
3038            matches!(outcome.result, PivotResult::Failed { .. }),
3039            "{:?}",
3040            outcome.result
3041        );
3042    }
3043
3044    // ── describe_lines / describe ────────────────────────────────────────
3045
3046    fn outcome_with(verb: PivotVerb, file_name: Option<&str>, result: PivotResult) -> PivotOutcome {
3047        PivotOutcome {
3048            spreadsheet_id: "sheet-1".to_string(),
3049            file_name: file_name.map(str::to_string),
3050            resolved_folder_id: None,
3051            verb,
3052            result,
3053        }
3054    }
3055
3056    #[test]
3057    fn describe_lines_renders_book_fallback_when_file_name_is_absent() {
3058        let out = outcome_with(add_verb(), None, PivotResult::RefusedShortcut);
3059        let text = describe(&out);
3060        assert!(text.contains("'sheet-1'"), "{text}");
3061    }
3062
3063    #[test]
3064    fn describe_lines_renders_not_a_spreadsheet_and_shortcut() {
3065        let not_a_sheet = outcome_with(
3066            add_verb(),
3067            Some("Budget"),
3068            PivotResult::RefusedNotASpreadsheet {
3069                mime_type: "text/plain".to_string(),
3070            },
3071        );
3072        let text = describe(&not_a_sheet);
3073        assert!(text.contains("not a Google Sheet"), "{text}");
3074        assert!(text.contains("add-pivot-table"), "{text}");
3075
3076        let shortcut = outcome_with(delete_verb(), Some("Budget"), PivotResult::RefusedShortcut);
3077        let text = describe(&shortcut);
3078        assert!(text.contains("shortcut"), "{text}");
3079        assert!(text.contains("delete-pivot-table"), "{text}");
3080    }
3081
3082    #[test]
3083    fn describe_lines_renders_no_visible_parents_naming_each_verbs_operations() {
3084        let add = outcome_with(
3085            add_verb(),
3086            Some("Budget"),
3087            PivotResult::RefusedNoVisibleParents,
3088        );
3089        let text = describe(&add);
3090        assert!(text.contains("\"sheets-write\""), "{text}");
3091        assert!(text.contains("\"sheets-structure\""), "{text}");
3092
3093        let delete = outcome_with(
3094            delete_verb(),
3095            Some("Budget"),
3096            PivotResult::RefusedNoVisibleParents,
3097        );
3098        let text = describe(&delete);
3099        assert!(text.contains("\"sheets-write\""), "{text}");
3100        assert!(!text.contains("sheets-structure"), "{text}");
3101    }
3102
3103    #[test]
3104    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
3105        let none_available = outcome_with(
3106            add_verb(),
3107            Some("Budget"),
3108            PivotResult::RefusedSheetNotFound {
3109                title: "Nope".to_string(),
3110                available: vec![],
3111            },
3112        );
3113        let text = describe(&none_available);
3114        assert!(text.contains("Available: none"), "{text}");
3115
3116        let some_available = outcome_with(
3117            add_verb(),
3118            Some("Budget"),
3119            PivotResult::RefusedSheetNotFound {
3120                title: "Nope".to_string(),
3121                available: vec!["Report".to_string(), "Data".to_string()],
3122            },
3123        );
3124        let text = describe(&some_available);
3125        assert!(text.contains("'Report', 'Data'"), "{text}");
3126    }
3127
3128    #[test]
3129    fn describe_lines_renders_invalid_range_anchor_and_source() {
3130        for result in [
3131            PivotResult::RefusedInvalidRange {
3132                detail: "bad range".to_string(),
3133            },
3134            PivotResult::RefusedInvalidAnchor {
3135                detail: "bad anchor".to_string(),
3136            },
3137            PivotResult::RefusedInvalidSource {
3138                detail: "bad source".to_string(),
3139            },
3140        ] {
3141            let out = outcome_with(add_verb(), Some("Budget"), result);
3142            let text = describe(&out);
3143            assert!(text.starts_with("Refused: bad "), "{text}");
3144        }
3145    }
3146
3147    #[test]
3148    fn describe_lines_renders_anchor_has_pivot_table_and_no_pivot_at_anchor() {
3149        let occupied = outcome_with(
3150            add_verb(),
3151            Some("Budget"),
3152            PivotResult::RefusedAnchorHasPivotTable {
3153                anchor: "'Report'!A1".to_string(),
3154                existing_summary: "1 row group(s), 0 column group(s), 1 value(s)".to_string(),
3155            },
3156        );
3157        let text = describe(&occupied);
3158        assert!(text.contains("already holds a pivot table"), "{text}");
3159        assert!(text.contains("delete-pivot-table"), "{text}");
3160
3161        let missing = outcome_with(
3162            delete_verb(),
3163            Some("Budget"),
3164            PivotResult::RefusedNoPivotTableAtAnchor {
3165                anchor: "'Report'!A1".to_string(),
3166            },
3167        );
3168        let text = describe(&missing);
3169        assert!(text.contains("no pivot table anchored"), "{text}");
3170        assert!(text.contains("list-pivot-tables"), "{text}");
3171    }
3172
3173    #[test]
3174    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
3175        let folder_rule = outcome_with(
3176            add_verb(),
3177            Some("Budget"),
3178            PivotResult::Blocked {
3179                operation: DriveOperation::SheetsWrite,
3180                decided_by: Some(DecidingRule::Folder {
3181                    folder_id: "folder-1".to_string(),
3182                    depth: 2,
3183                }),
3184            },
3185        );
3186        let text = describe(&folder_rule);
3187        assert!(text.contains("add-pivot-table"), "{text}");
3188        assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
3189
3190        let default_policy = outcome_with(
3191            delete_verb(),
3192            Some("Budget"),
3193            PivotResult::Blocked {
3194                operation: DriveOperation::SheetsWrite,
3195                decided_by: None,
3196            },
3197        );
3198        let text = describe(&default_policy);
3199        assert!(text.contains("default policy"), "{text}");
3200        assert!(text.contains("sheets-write"), "{text}");
3201    }
3202
3203    #[test]
3204    fn describe_lines_renders_every_lease_refusal() {
3205        for (result, needle) in [
3206            (PivotResult::RefusedNoLease, "requires a Drive write lease"),
3207            (
3208                PivotResult::RefusedLeaseExpired,
3209                "expired, released, or unknown",
3210            ),
3211            (
3212                PivotResult::RefusedLeaseWrongFile,
3213                "acquired for a different file",
3214            ),
3215            (
3216                PivotResult::RefusedLeaseStale,
3217                "changed since the lease was acquired",
3218            ),
3219        ] {
3220            let out = outcome_with(add_verb(), Some("Budget"), result);
3221            let text = describe(&out);
3222            assert!(text.contains(needle), "{text}");
3223            assert!(text.contains("drive lease acquire sheet-1"), "{text}");
3224        }
3225    }
3226
3227    #[test]
3228    fn describe_lines_renders_changed_for_delete_and_failed() {
3229        let change = PivotChange {
3230            anchor: "'Report'!A1".to_string(),
3231            source: None,
3232            config: None,
3233            anchor_currently: "a pivot table (1 row group(s), 0 column group(s), 1 value(s))"
3234                .to_string(),
3235        };
3236        let changed = outcome_with(delete_verb(), Some("Budget"), PivotResult::Changed(change));
3237        let text = describe(&changed);
3238        assert!(text.contains("Applied: delete the pivot table"), "{text}");
3239
3240        let failed = outcome_with(
3241            add_verb(),
3242            Some("Budget"),
3243            PivotResult::Failed {
3244                detail: "boom".to_string(),
3245            },
3246        );
3247        assert_eq!(describe(&failed), "Failed: boom");
3248    }
3249
3250    #[test]
3251    fn log_status_covers_every_variant() {
3252        let decided_by = None;
3253        let change = PivotChange {
3254            anchor: "'Report'!A1".to_string(),
3255            source: None,
3256            config: None,
3257            anchor_currently: "empty".to_string(),
3258        };
3259        let results = [
3260            PivotResult::WouldChange(change.clone()),
3261            PivotResult::RefusedNotASpreadsheet {
3262                mime_type: "text/plain".to_string(),
3263            },
3264            PivotResult::RefusedShortcut,
3265            PivotResult::RefusedNoVisibleParents,
3266            PivotResult::RefusedSheetNotFound {
3267                title: "X".to_string(),
3268                available: vec![],
3269            },
3270            PivotResult::RefusedInvalidRange {
3271                detail: String::new(),
3272            },
3273            PivotResult::RefusedInvalidAnchor {
3274                detail: String::new(),
3275            },
3276            PivotResult::RefusedInvalidSource {
3277                detail: String::new(),
3278            },
3279            PivotResult::RefusedAnchorHasPivotTable {
3280                anchor: String::new(),
3281                existing_summary: String::new(),
3282            },
3283            PivotResult::RefusedNoPivotTableAtAnchor {
3284                anchor: String::new(),
3285            },
3286            PivotResult::Blocked {
3287                operation: DriveOperation::SheetsWrite,
3288                decided_by,
3289            },
3290            PivotResult::RefusedNoLease,
3291            PivotResult::RefusedLeaseExpired,
3292            PivotResult::RefusedLeaseWrongFile,
3293            PivotResult::RefusedLeaseStale,
3294            PivotResult::Changed(change),
3295            PivotResult::Failed {
3296                detail: String::new(),
3297            },
3298        ];
3299        let statuses: HashSet<&str> = results.iter().map(PivotResult::log_status).collect();
3300        assert_eq!(
3301            statuses.len(),
3302            results.len(),
3303            "log_status must be unique per variant"
3304        );
3305        for status in statuses {
3306            assert!(!status.contains('_'), "{status}");
3307        }
3308    }
3309}