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