Skip to main content

omni_dev/drive/sheets/
paste.rs

1//! Clipboard-style range operations — `cutPaste`, `copyPaste` and
2//! `pasteData` — via `spreadsheets.batchUpdate` (issue #1839,
3//! [ADR-0083](../../../docs/adrs/adr-0083.md) §4).
4//!
5//! **Gate composition, per `PasteType`.** `cut-paste` always resolves
6//! **both** `SheetsWrite` and `SheetsStructure`, whatever `--paste-type`
7//! asks for: the source is always cleared in full — values, formats and
8//! merges — regardless of what is pasted, and clearing formats/merges is
9//! `SheetsStructure`'s territory exactly as clearing values is
10//! `SheetsWrite`'s. `copy-paste` and `paste-data` resolve their gate from
11//! `--paste-type` alone ([`PasteType::writes_values`]/
12//! [`PasteType::writes_presentation`]): a value-only type needs
13//! `SheetsWrite` alone, a presentation-only type `SheetsStructure` alone,
14//! and `PASTE_NORMAL` needs both, going through
15//! [`target_gate::resolve_all`] uniformly like `pivot.rs`'s union gate —
16//! this module has one gate-handling code path, not three.
17//!
18//! **Curated `PasteType`: four of the API's seven variants** —
19//! `PASTE_NORMAL`, `PASTE_VALUES`, `PASTE_FORMULA`, `PASTE_FORMAT`. See
20//! [`crate::drive::sheets::types::PasteType`]'s doc comment for the
21//! deferred three. `paste-data` is `delimiter`-form only; the API's `html`
22//! alternative is not modelled anywhere on the wire type, so it cannot be
23//! sent.
24//!
25//! **Defaults, fixed by ADR-0083 §4 so this module doesn't pick its own**:
26//! `--paste-type` defaults to `normal` on `cut-paste`/`copy-paste` (so a
27//! bare invocation under a `sheets-write`-only grant is refused, naming
28//! the missing `sheets-structure` operation rather than silently pasting
29//! less than the user copied); `paste-data` defaults to `values`, since its
30//! delimited-text input carries no formats, merges or validation for
31//! `normal` to add.
32//!
33//! **`--dry-run` reports counts and A1 locations, never cell values**
34//! (ADR-0083 §6 — the posture every verb but `merge-cells` holds to). The
35//! written extent is computed from the request plus the workbook's
36//! structure alone — [`copy_paste_extent`] for `copy-paste` (the larger of
37//! source and destination anchored at the destination's top-left, per the
38//! API's own spill/repeat rule), the source's own dimensions for
39//! `cut-paste` (a move, never a spill or repeat), and a locally-split
40//! upper bound for `paste-data` (ADR-0083 §6 names this an upper bound,
41//! not an exact prediction: the API's row-separator convention for
42//! `pasteData` is undocumented). A bounded `values.get` then reports the
43//! non-blank cells within that extent; `cut-paste` makes a second,
44//! independent `values.get` over the source to report what gets cleared,
45//! rather than one `values.batchGet` — simpler than matching two results
46//! by their echoed `range` field for a preview that already makes one
47//! `spreadsheets.get` call per attempt. A presentation-only
48//! `--paste-type format` reads no values at all, the same as
49//! `format-cells`' preview.
50//!
51//! **Grid-edge behaviour is verified live (#1937): the API grows the
52//! grid** when a written extent runs past the sheet's current
53//! `rowCount`/`columnCount`, for all three verbs — `copyPaste`/
54//! `pasteData` were measured first (1000x26 → 1001x27), and `cutPaste`
55//! confirmed identically (a 10-row sheet grew to 11 rows to fit a
56//! 3-row-tall block landing on rows 8-10). This module still never
57//! prepends a structural request to grow the grid first (that would
58//! smuggle `SheetsStructure` into a `SheetsWrite`-gated batch) — there is
59//! nothing to smuggle now that growth is the verified, API-driven outcome
60//! — and a preview whose extent exceeds the sheet's known dimensions
61//! carries a caveat naming that growth rather than an uncertainty. The
62//! *preview read* is a separate question from the written extent and is
63//! clipped to the sheet's current grid ([`grid_range::clamp_to_sheet`]):
64//! `values.get` refuses a range past the edge outright, so reading the
65//! unclipped extent would turn the very case the caveat exists to report
66//! into an opaque failure. (`cutPaste`'s destination *coordinate* is a
67//! separate matter from the block it anchors: measured live, the API
68//! refuses outright — a plain 400, surfaced here as an ordinary `Failed`,
69//! with no special-casing — when the coordinate itself lies past the last
70//! existing row/column; only a coordinate inside the grid whose block
71//! then spills past the edge triggers growth. This crate performs no
72//! extent check of its own on the destination coordinate, so that
73//! refusal is entirely the API's.)
74//!
75//! **Destination must be fully bounded** for every verb, the same
76//! restriction `merge-cells`/`insert-range` place on their own ranges: an
77//! open-ended destination (`A:A`) has no fixed size to anchor the extent
78//! computation against, and guessing would risk the "preview that lies"
79//! ADR-0083 §6 refuses to ship. `cut-paste`'s and `paste-data`'s
80//! destination must additionally be a single cell — the API's own
81//! `GridCoordinate`/`GridCoordinate` shape for those two requests, unlike
82//! `copy-paste`'s `GridRange`.
83
84use std::io::Read as _;
85use std::path::PathBuf;
86use std::time::{Duration, Instant};
87
88use serde::Serialize;
89
90use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
91use crate::drive::client::DriveClient;
92use crate::drive::files_api::FilesApi;
93use crate::drive::lease::check::{
94    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
95    LeasedWrite,
96};
97use crate::drive::sheets::a1;
98use crate::drive::sheets::api::{
99    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
100};
101use crate::drive::sheets::client::SheetsClient;
102use crate::drive::sheets::grid_range;
103use crate::drive::sheets::target_gate;
104use crate::drive::sheets::types::{
105    BatchUpdateRequestItem, CopyPasteRequest, CutPasteRequest, GridCoordinate, GridRange,
106    PasteDataRequest, PasteOrientation, PasteType,
107};
108use crate::drive::types::SheetTargetRefusal;
109use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
110use crate::request_log::{self, DriveMutationOutcome};
111
112const GATE_WRITE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsWrite];
113const GATE_STRUCTURE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsStructure];
114// `SheetsWrite` first: `target_gate::TargetGateUnionOutcome::Gated::denied`
115// names the first of these that denied, and for a bare `--paste-type
116// normal` under a `sheets-structure`-only grant, `sheets-write` is the
117// operation actually missing.
118const GATE_BOTH: &[DriveOperation] =
119    &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
120
121/// Which clipboard-style operation to perform.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum PasteVerb {
124    /// Move `source` to `destination` (a single cell), clearing `source`
125    /// entirely regardless of `paste_type`.
126    CutPaste {
127        /// Default sheet for `source`/`destination` when either lacks its
128        /// own `'Sheet'!` prefix.
129        sheet: Option<String>,
130        /// The range to move. May carry its own `'Sheet'!` prefix.
131        source: String,
132        /// The single-cell destination. May carry its own `'Sheet'!`
133        /// prefix.
134        destination: String,
135        /// What to carry over into the destination. The source is cleared
136        /// in full either way.
137        paste_type: PasteType,
138    },
139    /// Copy `source` to `destination`, spilling or repeating per the
140    /// API's own rule ([`copy_paste_extent`]).
141    CopyPaste {
142        /// Default sheet for `source`/`destination` when either lacks its
143        /// own `'Sheet'!` prefix.
144        sheet: Option<String>,
145        /// The range to copy from. May carry its own `'Sheet'!` prefix.
146        source: String,
147        /// The destination range (a single cell is a valid anchor). May
148        /// carry its own `'Sheet'!` prefix.
149        destination: String,
150        /// What to carry over.
151        paste_type: PasteType,
152        /// Whether rows/columns are swapped before pasting.
153        orientation: PasteOrientation,
154    },
155    /// Paste delimited text into a range anchored at `destination`.
156    PasteData {
157        /// Default sheet for `destination` when it lacks its own
158        /// `'Sheet'!` prefix.
159        sheet: Option<String>,
160        /// The single-cell destination. May carry its own `'Sheet'!`
161        /// prefix.
162        destination: String,
163        /// The delimited text to paste.
164        data: String,
165        /// The delimiter splitting `data` into columns.
166        delimiter: String,
167        /// What to carry over.
168        paste_type: PasteType,
169    },
170}
171
172impl PasteVerb {
173    /// The `operation` this verb records in the request log.
174    const fn log_operation(&self) -> &'static str {
175        match self {
176            Self::CutPaste { .. } => "sheets-cut-paste",
177            Self::CopyPaste { .. } => "sheets-copy-paste",
178            Self::PasteData { .. } => "sheets-paste-data",
179        }
180    }
181
182    /// Human-readable verb for CLI output and error messages.
183    const fn label(&self) -> &'static str {
184        match self {
185            Self::CutPaste { .. } => "cut-paste",
186            Self::CopyPaste { .. } => "copy-paste",
187            Self::PasteData { .. } => "paste-data",
188        }
189    }
190
191    fn sheet(&self) -> Option<&str> {
192        match self {
193            Self::CutPaste { sheet, .. }
194            | Self::CopyPaste { sheet, .. }
195            | Self::PasteData { sheet, .. } => sheet.as_deref(),
196        }
197    }
198
199    fn source(&self) -> Option<&str> {
200        match self {
201            Self::CutPaste { source, .. } | Self::CopyPaste { source, .. } => Some(source),
202            Self::PasteData { .. } => None,
203        }
204    }
205
206    fn destination(&self) -> &str {
207        match self {
208            Self::CutPaste { destination, .. }
209            | Self::CopyPaste { destination, .. }
210            | Self::PasteData { destination, .. } => destination,
211        }
212    }
213
214    const fn paste_type(&self) -> PasteType {
215        match self {
216            Self::CutPaste { paste_type, .. }
217            | Self::CopyPaste { paste_type, .. }
218            | Self::PasteData { paste_type, .. } => *paste_type,
219        }
220    }
221
222    /// Which operations must **all** resolve `Allow` (ADR-0083 §4).
223    fn gate_operations(&self) -> &'static [DriveOperation] {
224        match self {
225            // Always both, whatever `paste_type` names — the source is
226            // cleared in full regardless (module doc comment).
227            Self::CutPaste { .. } => GATE_BOTH,
228            Self::CopyPaste { paste_type, .. } | Self::PasteData { paste_type, .. } => {
229                match (paste_type.writes_values(), paste_type.writes_presentation()) {
230                    (false, true) => GATE_STRUCTURE_ONLY,
231                    (true, false) => GATE_WRITE_ONLY,
232                    // `(false, false)` is unreachable while every curated
233                    // `PasteType` writes at least one of the two, and shares
234                    // this arm rather than getting its own so that the
235                    // *strictest* gate, not the weakest, is what a future
236                    // variant this table has not been taught about falls
237                    // into. Merged with `(true, true)` because they must
238                    // stay the same answer: splitting them would invite
239                    // someone to "simplify" the unreachable one downwards.
240                    (true, true) | (false, false) => GATE_BOTH,
241                }
242            }
243        }
244    }
245}
246
247/// Per-call options.
248#[derive(Debug, Clone)]
249pub struct PasteOptions {
250    /// Spreadsheet id.
251    pub spreadsheet_id: String,
252    /// Which mutation to perform.
253    pub verb: PasteVerb,
254    /// Classify and describe only; never call `batchUpdate`.
255    pub dry_run: bool,
256    /// The lease token presented via `--lease`. Checked only when the
257    /// deciding rule requires one.
258    pub lease_token: Option<String>,
259    /// Path to the lease ledger the token is checked against.
260    pub ledger_path: PathBuf,
261}
262
263/// The substance of a `WouldChange`/`Changed` outcome.
264#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
265pub struct PasteChange {
266    /// The destination, composed with its sheet.
267    pub destination: String,
268    /// `cut-paste`/`copy-paste` only: the source, composed with its sheet.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub source: Option<String>,
271    /// The A1 extent that would be (or was) written into, computed from
272    /// the request plus the workbook's structure alone — always known
273    /// (ADR-0083 §6), unlike the values within it.
274    pub written_extent: String,
275    /// Non-blank cells within `written_extent` that would be (or were)
276    /// overwritten, as `"A1"` addresses — never their values. `None` when
277    /// `paste_type` writes no cell content, so no values were read.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub overwritten: Option<Vec<String>>,
280    /// `cut-paste` only: the non-blank cells within `source` that get
281    /// cleared, as `"A1"` addresses.
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub cleared: Option<Vec<String>>,
284    /// Set when `written_extent` runs past the sheet's currently allocated
285    /// rows/columns — verified live (#1937): the API grows the grid to
286    /// fit rather than erroring, for all three verbs. This names that
287    /// growth rather than an uncertainty.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub grid_edge_caveat: Option<String>,
290}
291
292/// What happened (or, under `--dry-run`, would happen).
293#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
294#[serde(tag = "status", rename_all = "kebab-case")]
295pub enum PasteResult {
296    /// `--dry-run`, and the gate would allow it.
297    WouldChange(PasteChange),
298    /// The target is not a Google Sheet.
299    RefusedNotASpreadsheet {
300        /// The target's actual MIME type.
301        mime_type: String,
302    },
303    /// The target is a shortcut, which we do not follow.
304    RefusedShortcut,
305    /// The target has no parents this account can see.
306    RefusedNoVisibleParents,
307    /// The named sheet does not exist in this workbook.
308    RefusedSheetNotFound {
309        /// The title that was not found.
310        title: String,
311        /// The titles that do exist.
312        available: Vec<String>,
313    },
314    /// `--sheet`/`--source`/`--destination` failed to compose into a
315    /// range, named a sheet no range grammar could resolve, was
316    /// open-ended where a bounded range is required, or (for `cut-paste`/
317    /// `paste-data`) named more than one cell where a single cell is
318    /// required.
319    RefusedInvalidRange {
320        /// What was wrong and why.
321        detail: String,
322    },
323    /// `paste-data`'s `--data`/`--data-file` resolved to an empty string,
324    /// so there is nothing to paste and no extent to preview.
325    RefusedEmptyData,
326    /// `paste-data`'s `--delimiter` was empty. The API rejects this
327    /// (`Must either set html=true or a delimiter`) only once the request
328    /// reaches it, so refuse locally instead, matching
329    /// `text-to-columns --custom-delimiter ''` (#1941).
330    RefusedInvalidDelimiter {
331        /// What was wrong and why.
332        detail: String,
333    },
334    /// The folder write-permission gate refused it.
335    Blocked {
336        /// Which of [`PasteVerb::gate_operations`] denied first.
337        operation: DriveOperation,
338        /// The rule that decided the refusal, if any.
339        decided_by: Option<DecidingRule>,
340    },
341    /// No `--lease` was presented, and the deciding rule requires one.
342    RefusedNoLease,
343    /// The presented lease has expired, or was never a token this ledger
344    /// knows about.
345    RefusedLeaseExpired,
346    /// The presented lease is bound to a different file id.
347    RefusedLeaseWrongFile,
348    /// The file has moved since the lease's recorded `version`.
349    RefusedLeaseStale,
350    /// The mutation succeeded.
351    Changed(PasteChange),
352    /// The API answered 2xx — the mutation **was applied** — but its reply
353    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: the
354    /// paste happened, and a retry would paste (or, for `cut-paste`, move)
355    /// a second time.
356    AppliedReplyUnreadable {
357        /// What was pasted where — the head of [`Self::Changed`]'s text,
358        /// without the workbook name.
359        summary: String,
360        /// Same as [`Self::Changed`] — all of it (extent, overwritten and
361        /// cleared cells, grid-edge caveat) is plan-time data, so none of it
362        /// is lost with the reply.
363        #[serde(flatten)]
364        change: PasteChange,
365        /// Why the reply could not be read.
366        detail: String,
367    },
368    /// An API or validation error.
369    Failed {
370        /// A human-readable summary of what failed.
371        detail: String,
372    },
373}
374
375impl FromLeaseRefusal for PasteResult {
376    fn from_no_lease() -> Self {
377        Self::RefusedNoLease
378    }
379    fn from_lease_expired() -> Self {
380        Self::RefusedLeaseExpired
381    }
382    fn from_lease_wrong_file() -> Self {
383        Self::RefusedLeaseWrongFile
384    }
385    fn from_lease_stale() -> Self {
386        Self::RefusedLeaseStale
387    }
388    fn from_lease_failed(detail: String) -> Self {
389        Self::Failed { detail }
390    }
391}
392
393impl PasteResult {
394    fn log_status(&self) -> &'static str {
395        match self {
396            Self::WouldChange(_) => "would-change",
397            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
398            Self::RefusedShortcut => "refused-shortcut",
399            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
400            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
401            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
402            Self::RefusedEmptyData => "refused-empty-data",
403            Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
404            Self::Blocked { .. } => "blocked",
405            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
406            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
407            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
408            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
409            Self::Changed(_) => "changed",
410            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
411            Self::Failed { .. } => "failed",
412        }
413    }
414}
415
416/// The full outcome of one attempt.
417///
418/// Serialised by hand (below) rather than derived, so the JSON can name
419/// the `paste_type` (and, for `copy-paste`, the `orientation`) the verb
420/// carries without serialising the whole verb — the text output names
421/// both, since they decide what lands and which grants were consumed.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct PasteOutcome {
424    /// The spreadsheet acted on.
425    pub spreadsheet_id: String,
426    /// Its Drive file name, when the metadata fetch got that far.
427    pub file_name: Option<String>,
428    /// The folder the gate evaluated against.
429    pub resolved_folder_id: Option<String>,
430    /// Which mutation was attempted. Only its `paste_type` and
431    /// `orientation` are serialised.
432    pub verb: PasteVerb,
433    /// What happened.
434    pub result: PasteResult,
435}
436
437impl Serialize for PasteOutcome {
438    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
439        #[derive(Serialize)]
440        struct Wire<'a> {
441            spreadsheet_id: &'a str,
442            #[serde(skip_serializing_if = "Option::is_none")]
443            file_name: Option<&'a str>,
444            #[serde(skip_serializing_if = "Option::is_none")]
445            resolved_folder_id: Option<&'a str>,
446            paste_type: PasteType,
447            #[serde(skip_serializing_if = "Option::is_none")]
448            orientation: Option<PasteOrientation>,
449            result: &'a PasteResult,
450        }
451        let (paste_type, orientation) = match &self.verb {
452            PasteVerb::CutPaste { paste_type, .. } | PasteVerb::PasteData { paste_type, .. } => {
453                (*paste_type, None)
454            }
455            PasteVerb::CopyPaste {
456                paste_type,
457                orientation,
458                ..
459            } => (*paste_type, Some(*orientation)),
460        };
461        Wire {
462            spreadsheet_id: &self.spreadsheet_id,
463            file_name: self.file_name.as_deref(),
464            resolved_folder_id: self.resolved_folder_id.as_deref(),
465            paste_type,
466            orientation,
467            result: &self.result,
468        }
469        .serialize(serializer)
470    }
471}
472
473impl JsonlSerialize for PasteOutcome {
474    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
475        write_scalar_jsonl(self, out)
476    }
477}
478
479/// Runs one clipboard-style mutation, logging every attempt that isn't a
480/// dry run.
481pub async fn paste(
482    drive: &DriveClient,
483    sheets: &SheetsClient,
484    opts: &PasteOptions,
485    rules: &[FolderPermissionRule],
486) -> PasteOutcome {
487    let started = Instant::now();
488    let outcome = paste_inner(drive, sheets, opts, rules).await;
489    if !opts.dry_run {
490        record_attempt(&outcome, opts, started.elapsed());
491    }
492    outcome
493}
494
495async fn paste_inner(
496    drive: &DriveClient,
497    sheets: &SheetsClient,
498    opts: &PasteOptions,
499    rules: &[FolderPermissionRule],
500) -> PasteOutcome {
501    let bare = |result| PasteOutcome {
502        spreadsheet_id: opts.spreadsheet_id.clone(),
503        file_name: None,
504        resolved_folder_id: None,
505        verb: opts.verb.clone(),
506        result,
507    };
508
509    if let PasteVerb::PasteData {
510        data, delimiter, ..
511    } = &opts.verb
512    {
513        if data.is_empty() {
514            return bare(PasteResult::RefusedEmptyData);
515        }
516        if delimiter.is_empty() {
517            return bare(PasteResult::RefusedInvalidDelimiter {
518                detail: "--delimiter must not be empty".to_string(),
519            });
520        }
521    }
522    if let Some(sheet) = unusable_sheet_default(&opts.verb) {
523        return bare(PasteResult::RefusedInvalidRange { detail: sheet });
524    }
525
526    let destination_composed = match compose_with_default_sheet(
527        opts.verb.sheet(),
528        opts.verb.destination(),
529        "--destination",
530    ) {
531        Ok(composed) => composed,
532        Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
533    };
534    let source_composed = match opts.verb.source() {
535        Some(source) => match compose_with_default_sheet(opts.verb.sheet(), source, "--source") {
536            Ok(composed) => Some(composed),
537            Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
538        },
539        None => None,
540    };
541
542    let operations = opts.verb.gate_operations();
543
544    let (target, verdict, denied, resolved_folder_id, requires_lease) =
545        match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
546            target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
547                return bare(PasteResult::Failed { detail })
548            }
549            target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
550                let result = match refusal {
551                    SheetTargetRefusal::Shortcut => PasteResult::RefusedShortcut,
552                    SheetTargetRefusal::NotASpreadsheet { mime_type } => {
553                        PasteResult::RefusedNotASpreadsheet { mime_type }
554                    }
555                    SheetTargetRefusal::NoVisibleParents => PasteResult::RefusedNoVisibleParents,
556                };
557                return PasteOutcome {
558                    spreadsheet_id: opts.spreadsheet_id.clone(),
559                    file_name: Some(target.name),
560                    resolved_folder_id: None,
561                    verb: opts.verb.clone(),
562                    result,
563                };
564            }
565            target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
566                return PasteOutcome {
567                    spreadsheet_id: opts.spreadsheet_id.clone(),
568                    file_name: Some(target.name),
569                    resolved_folder_id: None,
570                    verb: opts.verb.clone(),
571                    result: PasteResult::Failed { detail },
572                };
573            }
574            target_gate::TargetGateUnionOutcome::Gated {
575                target,
576                verdict,
577                denied,
578                resolved_folder_id,
579                requires_lease,
580            } => (target, verdict, denied, resolved_folder_id, requires_lease),
581        };
582
583    let gated = |result| PasteOutcome {
584        spreadsheet_id: opts.spreadsheet_id.clone(),
585        file_name: Some(target.name.clone()),
586        resolved_folder_id: resolved_folder_id.clone(),
587        verb: opts.verb.clone(),
588        result,
589    };
590
591    if verdict == write_gate::Verdict::Deny {
592        let (operation, decided_by) = denied.unwrap_or((operations[0], None));
593        return gated(PasteResult::Blocked {
594            operation,
595            decided_by,
596        });
597    }
598
599    let api = SheetsApi::new(sheets);
600    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
601        Ok(workbook) => workbook,
602        Err(err) => {
603            return gated(PasteResult::Failed {
604                detail: format!("{err:#}"),
605            })
606        }
607    };
608
609    let (dest_title, dest_grid) = match grid_range::resolve_grid_range(
610        &workbook,
611        &destination_composed,
612        |detail| PasteResult::RefusedInvalidRange { detail },
613        |title, available| PasteResult::RefusedSheetNotFound { title, available },
614    ) {
615        Ok(resolved) => resolved,
616        Err(result) => return gated(result),
617    };
618    if !grid_range::is_bounded(&dest_grid) {
619        return gated(PasteResult::RefusedInvalidRange {
620            detail: format!(
621                "--destination '{}' must be a bounded range, not an open-ended column/row span",
622                opts.verb.destination()
623            ),
624        });
625    }
626    let needs_single_cell_destination = matches!(
627        opts.verb,
628        PasteVerb::CutPaste { .. } | PasteVerb::PasteData { .. }
629    );
630    if needs_single_cell_destination && !grid_range::is_single_cell(&dest_grid) {
631        return gated(PasteResult::RefusedInvalidRange {
632            detail: format!(
633                "--destination '{}' must name a single cell, not a range",
634                opts.verb.destination()
635            ),
636        });
637    }
638
639    let source_resolved = match &source_composed {
640        Some(source_composed) => {
641            match grid_range::resolve_grid_range(
642                &workbook,
643                source_composed,
644                |detail| PasteResult::RefusedInvalidRange { detail },
645                |title, available| PasteResult::RefusedSheetNotFound { title, available },
646            ) {
647                Ok((source_title, grid)) => {
648                    if !grid_range::is_bounded(&grid) {
649                        return gated(PasteResult::RefusedInvalidRange {
650                            detail: format!(
651                                "--source '{}' must be a bounded range, not an open-ended \
652                                 column/row span",
653                                opts.verb.source().unwrap_or_default()
654                            ),
655                        });
656                    }
657                    Some((source_title, grid))
658                }
659                Err(result) => return gated(result),
660            }
661        }
662        None => None,
663    };
664    let source_grid = source_resolved.as_ref().map(|(_, grid)| *grid);
665
666    let extent = match &opts.verb {
667        PasteVerb::CutPaste { .. } => {
668            #[allow(clippy::unwrap_used)] // `source_grid` is always `Some` for `CutPaste`.
669            let source_grid = source_grid.unwrap();
670            let (rows, cols) = grid_dims(&source_grid);
671            anchored_extent(&dest_grid, rows, cols)
672        }
673        PasteVerb::CopyPaste { orientation, .. } => {
674            #[allow(clippy::unwrap_used)] // `source_grid` is always `Some` for `CopyPaste`.
675            let source_grid = source_grid.unwrap();
676            copy_paste_extent(&source_grid, &dest_grid, *orientation)
677        }
678        PasteVerb::PasteData {
679            data, delimiter, ..
680        } => {
681            let (rows, cols) = paste_data_upper_bound(data, delimiter);
682            anchored_extent(&dest_grid, rows, cols)
683        }
684    };
685
686    let grid_edge_caveat = sheet_exceeds_dimensions(&workbook, &extent).then(|| {
687        "written extent runs past the sheet's currently allocated rows/columns; Sheets grows \
688         the sheet to fit (verified live, #1937)"
689            .to_string()
690    });
691
692    // The extent is reported unclamped — it is what the *request* writes,
693    // and `grid_edge_caveat` above says so when that runs past the sheet.
694    // The read below is a different question, and is clamped: see
695    // `read_non_blank`.
696    let extent_a1 = grid_range::bounded_range_to_a1(&dest_title, &extent)
697        // Unreachable: every extent is anchored on a bounded destination and
698        // sized from a bounded source or a non-empty `--data`, so it is never
699        // empty. Falling back to the destination beats an `unwrap` on a value
700        // only invariants — not types — keep `Some`.
701        .unwrap_or_else(|| destination_composed.clone()); // omni-dev: coverage ignore-line reason="extents are bounded and non-empty by construction; the fallback exists so an invariant break degrades instead of panicking"
702
703    let overwritten = if opts.verb.paste_type().writes_values() {
704        match read_non_blank(&api, &opts.spreadsheet_id, &workbook, &dest_title, &extent).await {
705            Ok(locations) => Some(locations),
706            Err(detail) => return gated(PasteResult::Failed { detail }),
707        }
708    } else {
709        None
710    };
711
712    let cleared = if let (PasteVerb::CutPaste { .. }, Some((source_title, source_grid))) =
713        (&opts.verb, &source_resolved)
714    {
715        match read_non_blank(
716            &api,
717            &opts.spreadsheet_id,
718            &workbook,
719            source_title,
720            source_grid,
721        )
722        .await
723        {
724            Ok(locations) => Some(locations),
725            Err(detail) => return gated(PasteResult::Failed { detail }),
726        }
727    } else {
728        None
729    };
730
731    let change = PasteChange {
732        destination: destination_composed.clone(),
733        source: source_composed.clone(),
734        written_extent: extent_a1,
735        overwritten,
736        cleared,
737        grid_edge_caveat,
738    };
739
740    if opts.dry_run {
741        return gated(PasteResult::WouldChange(change));
742    }
743
744    let request = match &opts.verb {
745        PasteVerb::CutPaste { paste_type, .. } => {
746            #[allow(clippy::unwrap_used)] // `source_grid` is always `Some` for `CutPaste`.
747            let source_grid = source_grid.unwrap();
748            BatchUpdateRequestItem::CutPaste(CutPasteRequest {
749                source: source_grid,
750                destination: GridCoordinate {
751                    sheet_id: dest_grid.sheet_id,
752                    row_index: dest_grid.start_row_index.unwrap_or(0),
753                    column_index: dest_grid.start_column_index.unwrap_or(0),
754                },
755                paste_type: *paste_type,
756            })
757        }
758        PasteVerb::CopyPaste {
759            paste_type,
760            orientation,
761            ..
762        } => {
763            #[allow(clippy::unwrap_used)] // `source_grid` is always `Some` for `CopyPaste`.
764            let source_grid = source_grid.unwrap();
765            BatchUpdateRequestItem::CopyPaste(CopyPasteRequest {
766                source: source_grid,
767                destination: dest_grid,
768                paste_type: *paste_type,
769                paste_orientation: *orientation,
770            })
771        }
772        PasteVerb::PasteData {
773            data,
774            delimiter,
775            paste_type,
776            ..
777        } => BatchUpdateRequestItem::PasteData(PasteDataRequest {
778            coordinate: GridCoordinate {
779                sheet_id: dest_grid.sheet_id,
780                row_index: dest_grid.start_row_index.unwrap_or(0),
781                column_index: dest_grid.start_column_index.unwrap_or(0),
782            },
783            data: data.clone(),
784            delimiter: delimiter.clone(),
785            r#type: *paste_type,
786        }),
787    };
788
789    // The lease check sits here: after the permission gate and the
790    // `--dry-run` branch, before the mutating call — see
791    // `content_edit.rs::edit_inner`'s doc comment for the full reasoning,
792    // shared verbatim by every leased engine.
793    let files_api = FilesApi::new(drive);
794    let leased = LeasedWrite {
795        log_prefix: "drive sheets paste",
796        operation: opts.verb.log_operation(),
797        ledger_path: &opts.ledger_path,
798        file_id: &opts.spreadsheet_id,
799    };
800    let lease_grant = match gate_optional_leased_write(
801        leased,
802        &files_api,
803        requires_lease,
804        opts.lease_token.as_deref(),
805    )
806    .await
807    {
808        Ok(grant) => grant,
809        Err(err) => return gated(err.into_result()),
810    };
811
812    let result = match conclude_native_leased_write(
813        leased,
814        &lease_grant,
815        &files_api,
816        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
817        |err| format!("{err:#}"),
818    )
819    .await
820    {
821        Ok(BatchUpdateOutcome::Applied(_response)) => PasteResult::Changed(change),
822        // `RetryHint::NotIdempotent`: a `cut-paste` moves cells (the source is
823        // already cleared) and a `paste-data`/`copy-paste` re-lands on top of
824        // whatever the caller has edited since (#2021).
825        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
826            PasteResult::AppliedReplyUnreadable {
827                summary: unreadable_summary(&opts.verb, &change),
828                change,
829                detail,
830            }
831        }
832        Err(err) => PasteResult::Failed {
833            detail: format!("{err:#}"),
834        },
835    };
836    drop(lease_grant);
837    gated(result)
838}
839
840/// The `summary` of [`PasteResult::AppliedReplyUnreadable`]: the same
841/// verb, destination and written extent [`paste_change_lines`] leads with.
842fn unreadable_summary(verb: &PasteVerb, change: &PasteChange) -> String {
843    format!(
844        "{} into {} (writing {})",
845        verb.label(),
846        change.destination,
847        change.written_extent
848    )
849}
850
851/// Composes a `--source`/`--destination` A1 reference against an optional
852/// default `--sheet`: a reference already carrying its own `'Sheet'!`
853/// prefix is used as-is — source and destination may sit on different
854/// sheets, and the API allows it — otherwise `sheet` supplies the prefix.
855/// Mirrors `pivot.rs::compose_source`'s stance for the same shape of
856/// two-range command, but `sheet` is optional here since either field may
857/// self-prefix independently; `flag` names which one this call is for, so
858/// the "neither given" error names the flag the caller actually typed.
859fn compose_with_default_sheet(
860    sheet: Option<&str>,
861    range: &str,
862    flag: &str,
863) -> Result<String, String> {
864    if a1::split_sheet_prefix(range).is_some() {
865        a1::validate_range(range)
866            .map(|()| range.to_string())
867            .map_err(|err| err.to_string())
868    } else {
869        match sheet {
870            Some(sheet) => a1::compose(Some(sheet), Some(range)).map_err(|err| err.to_string()),
871            None => Err(format!(
872                "{flag} '{range}' does not name a sheet; pass --sheet, or give {flag} its own \
873                 'Sheet'! prefix"
874            )),
875        }
876    }
877}
878
879/// The `--sheet` a caller passed that no flag can use, as a refusal
880/// message — `None` when it is used, or was not passed at all.
881///
882/// `--sheet` is a *default* here, not the target's sheet: each of
883/// `--source`/`--destination` may carry its own `'Sheet'!` prefix and a
884/// paste may legitimately straddle two sheets, so a prefix on one flag
885/// beside a `--sheet` for the other is correct and stays allowed. What is
886/// refused is the case where every flag self-prefixes, leaving `--sheet`
887/// with nothing to apply to: silently ignoring it would hide a typo in
888/// whichever prefix the caller meant it to correct. The narrower cousin of
889/// `a1::compose`'s blanket "already names a sheet, so --sheet would be
890/// ambiguous" refusal, which this module cannot use as-is without losing
891/// the cross-sheet paste (issue #1839).
892fn unusable_sheet_default(verb: &PasteVerb) -> Option<String> {
893    let sheet = verb.sheet()?;
894    let prefixed = |range: &str| a1::split_sheet_prefix(range).is_some();
895    let every_range_self_prefixes =
896        prefixed(verb.destination()) && verb.source().is_none_or(prefixed);
897    every_range_self_prefixes.then(|| {
898        format!(
899            "--sheet '{sheet}' has nothing to apply to: every range already carries its own \
900             'Sheet'! prefix. Drop --sheet, or drop the prefix from the range it was meant for"
901        )
902    })
903}
904
905/// Every non-blank cell of `grid`, as A1 addresses, from one `values.get`.
906///
907/// The read range is **clipped to the sheet's current grid**: a paste's
908/// written extent is a property of the request, so it can name rows or
909/// columns the sheet does not have yet, and `values.get` refuses such a
910/// range outright ("exceeds grid limits") — which would turn the one case
911/// `grid_edge_caveat` exists to report into an opaque `Failed`. A range
912/// with nothing inside the grid at all reads as no cells, because that is
913/// the truth: cells that do not exist hold nothing to overwrite.
914async fn read_non_blank(
915    api: &SheetsApi<'_>,
916    spreadsheet_id: &str,
917    workbook: &crate::drive::sheets::types::Spreadsheet,
918    sheet_title: &str,
919    grid: &GridRange,
920) -> Result<Vec<String>, String> {
921    let Some(clamped) = grid_range::clamp_to_sheet(workbook, grid) else {
922        return Ok(Vec::new());
923    };
924    let Some(range) = grid_range::bounded_range_to_a1(sheet_title, &clamped) else {
925        return Ok(Vec::new());
926    };
927    let values = api
928        .values_get(spreadsheet_id, &range, ValueRenderOption::Formatted)
929        .await
930        .map_err(|err| format!("{err:#}"))?;
931    Ok(grid_range::non_blank_locations(
932        &values,
933        clamped.start_row_index.unwrap_or(0),
934        clamped.start_column_index.unwrap_or(0),
935    ))
936}
937
938/// `(rows, columns)` of a fully-bounded [`GridRange`]. Only ever called
939/// after `grid_range::is_bounded` has confirmed all four indices are
940/// `Some`.
941fn grid_dims(grid: &GridRange) -> (i64, i64) {
942    (
943        grid.end_row_index.unwrap_or(0) - grid.start_row_index.unwrap_or(0),
944        grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0),
945    )
946}
947
948/// A `rows` x `cols` region anchored at `dest`'s top-left corner, on
949/// `dest`'s sheet.
950fn anchored_extent(dest: &GridRange, rows: i64, cols: i64) -> GridRange {
951    let start_row = dest.start_row_index.unwrap_or(0);
952    let start_col = dest.start_column_index.unwrap_or(0);
953    GridRange {
954        sheet_id: dest.sheet_id,
955        start_row_index: Some(start_row),
956        end_row_index: Some(start_row + rows),
957        start_column_index: Some(start_col),
958        end_column_index: Some(start_col + cols),
959    }
960}
961
962/// One axis of [`copy_paste_extent`]: the Sheets API repeats a source that
963/// evenly divides a *larger* destination to fill it exactly; otherwise the
964/// source is copied at its own length, spilling past a smaller destination
965/// or leaving a larger, non-multiple one only partly filled — the
966/// documented behaviour ADR-0083 §6 verifies for #1839.
967fn extent_len(src_len: i64, dst_len: i64) -> i64 {
968    if src_len > 0 && dst_len > src_len && dst_len % src_len == 0 {
969        dst_len
970    } else {
971        src_len
972    }
973}
974
975/// The region a `copy-paste` writes: the larger of `source` and
976/// `destination` per axis, anchored at `destination`'s top-left corner,
977/// with `orientation` swapping `source`'s dimensions first (ADR-0083 §4,
978/// §6). Both `source` and `destination` must already be fully bounded —
979/// callers check `grid_range::is_bounded` before reaching here.
980pub(crate) fn copy_paste_extent(
981    source: &GridRange,
982    destination: &GridRange,
983    orientation: PasteOrientation,
984) -> GridRange {
985    let (mut src_rows, mut src_cols) = grid_dims(source);
986    if orientation == PasteOrientation::Transpose {
987        std::mem::swap(&mut src_rows, &mut src_cols);
988    }
989    let (dst_rows, dst_cols) = grid_dims(destination);
990    let rows = extent_len(src_rows, dst_rows);
991    let cols = extent_len(src_cols, dst_cols);
992    anchored_extent(destination, rows, cols)
993}
994
995/// A local, upper-bound estimate of the rows/columns a `paste-data` write
996/// reaches: `data` split on `\n` (tolerating a trailing `\r`) for rows,
997/// then each row split on `delimiter` for columns, taking the widest row.
998/// The API's own row-separator convention for `pasteData` is undocumented
999/// (ADR-0083 §6, a #1839 live-verification item) — this is a preview
1000/// input, never sent on the wire, so an imprecise split costs nothing but
1001/// preview accuracy.
1002///
1003/// One *terminating* newline is not a row separator: every POSIX text file
1004/// and every `printf` ends with one, so counting it would add a phantom
1005/// row to the extent on the commonest input there is, and the preview
1006/// would then name cells in that row as overwritten when nothing touches
1007/// them. A blank line in the middle, or a second trailing one, is still a
1008/// row — that is data, not a terminator.
1009pub(crate) fn paste_data_upper_bound(data: &str, delimiter: &str) -> (i64, i64) {
1010    if data.is_empty() {
1011        return (0, 0);
1012    }
1013    let body = data.strip_suffix('\n').unwrap_or(data);
1014    if body.is_empty() {
1015        // `data` was exactly one line terminator: one empty row, not zero.
1016        return (1, 1);
1017    }
1018    let rows: Vec<&str> = body
1019        .split('\n')
1020        .map(|line| line.trim_end_matches('\r'))
1021        .collect();
1022    let cols = rows
1023        .iter()
1024        .map(|row| {
1025            if delimiter.is_empty() {
1026                1
1027            } else {
1028                row.split(delimiter).count()
1029            }
1030        })
1031        .max()
1032        .unwrap_or(0);
1033    (rows.len() as i64, cols as i64)
1034}
1035
1036/// Whether `extent` runs past `workbook`'s currently allocated rows or
1037/// columns on `extent`'s sheet. `None` on either axis (the API reported no
1038/// grid dimensions at all) never counts as exceeding — there's nothing to
1039/// compare against.
1040fn sheet_exceeds_dimensions(
1041    workbook: &crate::drive::sheets::types::Spreadsheet,
1042    extent: &GridRange,
1043) -> bool {
1044    let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
1045        return false;
1046    };
1047    let grid = sheet
1048        .properties
1049        .as_ref()
1050        .and_then(|p| p.grid_properties.as_ref());
1051    let row_count = grid.and_then(|g| g.row_count);
1052    let column_count = grid.and_then(|g| g.column_count);
1053    let rows_exceed = matches!(
1054        (extent.end_row_index, row_count),
1055        (Some(end), Some(count)) if end > count
1056    );
1057    let cols_exceed = matches!(
1058        (extent.end_column_index, column_count),
1059        (Some(end), Some(count)) if end > count
1060    );
1061    rows_exceed || cols_exceed
1062}
1063
1064fn record_attempt(outcome: &PasteOutcome, opts: &PasteOptions, duration: Duration) {
1065    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1066}
1067
1068/// The request-log record for `outcome`, split from [`record_attempt`] so a
1069/// test can read the fields it carries.
1070fn mutation_record(
1071    outcome: &PasteOutcome,
1072    opts: &PasteOptions,
1073    duration: Duration,
1074) -> DriveMutationOutcome {
1075    let error = match &outcome.result {
1076        PasteResult::Failed { detail } | PasteResult::AppliedReplyUnreadable { detail, .. } => {
1077            Some(detail.clone())
1078        }
1079        _ => None,
1080    };
1081    let decided_by = match &outcome.result {
1082        PasteResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1083        _ => None,
1084    };
1085    let decided_by = write_gate::decided_by_log_fields(decided_by);
1086    // The write happened, so name what it was even though the reply is gone.
1087    let fields_changed = match &outcome.result {
1088        PasteResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1089        _ => None,
1090    };
1091
1092    DriveMutationOutcome {
1093        operation: opts.verb.log_operation(),
1094        file_id: outcome.spreadsheet_id.clone(),
1095        file_name: outcome.file_name.clone().unwrap_or_default(),
1096        status: outcome.result.log_status().to_string(),
1097        resolved_folder_id: outcome.resolved_folder_id.clone(),
1098        decided_by_folder_id: decided_by.folder_id,
1099        decided_by_depth: decided_by.depth,
1100        decided_by_file_id: decided_by.file_id,
1101        fields_changed,
1102        error,
1103        duration,
1104        ..Default::default()
1105    }
1106}
1107
1108/// Renders the change-specific lines of a `WouldChange`/`Changed` outcome
1109/// — everything but the leading "Would "/"Applied: " the caller prepends,
1110/// so both share this one builder. `applied` picks the tense: a real run
1111/// reports what it *did*, not what it would do.
1112fn paste_change_lines(
1113    verb: &PasteVerb,
1114    change: &PasteChange,
1115    book: &str,
1116    applied: bool,
1117) -> Vec<String> {
1118    let mut lines = vec![format!(
1119        "{} into {} in {book}, writing {}",
1120        verb.label(),
1121        change.destination,
1122        change.written_extent
1123    )];
1124    if let Some(source) = &change.source {
1125        lines.push(format!("  source: {source}"));
1126    }
1127    // Named on every line of output, not just in the flag that set it: the
1128    // paste type decides both what lands in the destination and which
1129    // operations the gate consumed, so an outcome that doesn't say which
1130    // one ran can't be read back afterwards.
1131    lines.push(format!("  paste type: {}", verb.paste_type().as_str()));
1132    if let PasteVerb::CopyPaste { orientation, .. } = verb {
1133        lines.push(format!("  orientation: {}", orientation.as_str()));
1134    }
1135    let overwritten_verb = if applied {
1136        "were overwritten"
1137    } else {
1138        "would be overwritten"
1139    };
1140    match &change.overwritten {
1141        Some(locations) if locations.is_empty() => {
1142            lines.push(format!(
1143                "  no non-blank cells in the destination {overwritten_verb}"
1144            ));
1145        }
1146        Some(locations) => {
1147            lines.push(format!(
1148                "  {} non-blank cell(s) in the destination {overwritten_verb}: {}",
1149                locations.len(),
1150                grid_range::render_locations(locations)
1151            ));
1152        }
1153        None => lines.push(
1154            "  destination overwrite: not previewed (this paste type writes no cell values)"
1155                .to_string(),
1156        ),
1157    }
1158    if let Some(locations) = &change.cleared {
1159        if locations.is_empty() {
1160            lines.push(format!(
1161                "  the source {} no non-blank cells to clear",
1162                if applied { "had" } else { "has" }
1163            ));
1164        } else {
1165            lines.push(format!(
1166                "  {} non-blank cell(s) in the source {}: {}",
1167                locations.len(),
1168                if applied {
1169                    "were cleared"
1170                } else {
1171                    "will be cleared"
1172                },
1173                grid_range::render_locations(locations)
1174            ));
1175        }
1176    }
1177    if let Some(caveat) = &change.grid_edge_caveat {
1178        lines.push(format!("  note: {caveat}"));
1179    }
1180    lines
1181}
1182
1183/// Renders one [`PasteOutcome`] as human-readable lines, for both
1184/// `--dry-run` and a real run.
1185pub fn describe_lines(outcome: &PasteOutcome) -> Vec<String> {
1186    let verb = &outcome.verb;
1187    let book = outcome.file_name.as_deref().map_or_else(
1188        || format!("'{}'", outcome.spreadsheet_id),
1189        |n| format!("'{n}'"),
1190    );
1191    match &outcome.result {
1192        PasteResult::WouldChange(change) => {
1193            let mut lines = paste_change_lines(verb, change, &book, false);
1194            lines[0] = format!("Would {}", lines[0]);
1195            lines
1196        }
1197        PasteResult::Changed(change) => {
1198            let mut lines = paste_change_lines(verb, change, &book, true);
1199            lines[0] = format!("Applied: {}", lines[0]);
1200            lines
1201        }
1202        PasteResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1203            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); `drive sheets {}` \
1204             only works on spreadsheets",
1205            verb.label()
1206        )],
1207        PasteResult::RefusedShortcut => vec![format!(
1208            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1209            verb.label()
1210        )],
1211        PasteResult::RefusedNoVisibleParents => {
1212            let ops = match verb.gate_operations() {
1213                [DriveOperation::SheetsWrite] => "\"sheets-write\"",
1214                [DriveOperation::SheetsStructure] => "\"sheets-structure\"",
1215                _ => "\"sheets-write\", \"sheets-structure\"",
1216            };
1217            vec![format!(
1218                "Refused: {book} has no parent folder visible to this account, so no folder \
1219                 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1220                 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1221            )]
1222        }
1223        PasteResult::RefusedSheetNotFound { title, available } => {
1224            vec![grid_range::no_sheet_refusal(&book, title, available)]
1225        }
1226        PasteResult::RefusedInvalidRange { detail }
1227        | PasteResult::RefusedInvalidDelimiter { detail } => {
1228            vec![format!("Refused: {detail}")]
1229        }
1230        PasteResult::RefusedEmptyData => vec![format!(
1231            "Refused: `drive sheets {}` was given no data to paste (--data/--data-file is empty)",
1232            verb.label()
1233        )],
1234        PasteResult::Blocked {
1235            operation,
1236            decided_by,
1237        } => vec![match decided_by {
1238            Some(rule) => format!(
1239                "Blocked: {} on {book} refused by rule on {} {}{}",
1240                verb.label(),
1241                rule.kind_label(),
1242                rule.id(),
1243                rule.depth_suffix()
1244            ),
1245            None => format!(
1246                "Blocked: {} on {book} refused by default policy (no matching rule for \
1247                 {operation})",
1248                verb.label()
1249            ),
1250        }],
1251        PasteResult::RefusedNoLease => LeaseGateRefusal::NoLease
1252            .describe_line(&outcome.spreadsheet_id, &book)
1253            .into_iter()
1254            .collect(),
1255        PasteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1256            .describe_line(&outcome.spreadsheet_id, &book)
1257            .into_iter()
1258            .collect(),
1259        PasteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1260            .describe_line(&outcome.spreadsheet_id, &book)
1261            .into_iter()
1262            .collect(),
1263        PasteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1264            .describe_line(&outcome.spreadsheet_id, &book)
1265            .into_iter()
1266            .collect(),
1267        PasteResult::AppliedReplyUnreadable {
1268            summary,
1269            change,
1270            detail,
1271        } => {
1272            // The head line is replaced by the shared one; every line after
1273            // it is exactly what `Changed` prints.
1274            let mut lines = paste_change_lines(verb, change, &book, true);
1275            lines[0] =
1276                applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail);
1277            lines
1278        }
1279        PasteResult::Failed { detail } => vec![format!("Failed: {detail}")],
1280    }
1281}
1282
1283/// Reads `--data-file`: a local file path, or `-` for stdin, capped and
1284/// UTF-8-validated exactly like `write.rs::read_values`'s stdin/file
1285/// branches, but returning the raw text untouched — `pasteData` sends the
1286/// caller's content verbatim, unlike `write`'s CSV/JSON cell parse.
1287///
1288/// Empty content is *not* an error here: `paste_inner` refuses it as
1289/// [`PasteResult::RefusedEmptyData`], so an empty file and a literal
1290/// `--data ''` are refused the same way and in the same shape as every
1291/// other refusal, rather than one of them being an `anyhow` error.
1292pub(crate) fn read_data_text(source: &str) -> anyhow::Result<String> {
1293    use anyhow::Context;
1294
1295    if source == "-" {
1296        let mut buf = Vec::new();
1297        std::io::stdin()
1298            .take(crate::drive::files_api::MAX_UPLOAD_BYTES + 1)
1299            .read_to_end(&mut buf)
1300            .context("Failed to read --data-file from stdin")?;
1301        anyhow::ensure!(
1302            buf.len() as u64 <= crate::drive::files_api::MAX_UPLOAD_BYTES,
1303            "--data-file from stdin is over the {} byte cap",
1304            crate::drive::files_api::MAX_UPLOAD_BYTES
1305        );
1306        String::from_utf8(buf).context("--data-file from stdin is not valid UTF-8")
1307    } else {
1308        let metadata = std::fs::metadata(source)
1309            .with_context(|| format!("Failed to stat --data-file {source}"))?;
1310        anyhow::ensure!(
1311            metadata.len() <= crate::drive::files_api::MAX_UPLOAD_BYTES,
1312            "--data-file {source} is {} bytes, over the {} byte cap",
1313            metadata.len(),
1314            crate::drive::files_api::MAX_UPLOAD_BYTES
1315        );
1316        std::fs::read_to_string(source)
1317            .with_context(|| format!("Failed to read --data-file {source}"))
1318    }
1319}
1320
1321#[cfg(test)]
1322#[allow(clippy::unwrap_used, clippy::expect_used)]
1323mod tests {
1324    use super::*;
1325
1326    // ── pure functions ──
1327
1328    #[test]
1329    fn extent_len_repeats_a_source_that_evenly_divides_a_larger_destination() {
1330        assert_eq!(extent_len(2, 6), 6);
1331        assert_eq!(extent_len(3, 9), 9);
1332    }
1333
1334    #[test]
1335    fn extent_len_spills_a_source_larger_than_its_destination() {
1336        assert_eq!(extent_len(5, 2), 5);
1337    }
1338
1339    #[test]
1340    fn extent_len_copies_once_when_the_destination_is_not_a_clean_multiple() {
1341        assert_eq!(extent_len(2, 5), 2);
1342    }
1343
1344    #[test]
1345    fn extent_len_treats_equal_lengths_as_a_single_copy() {
1346        assert_eq!(extent_len(3, 3), 3);
1347    }
1348
1349    fn range(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1350        GridRange {
1351            sheet_id,
1352            start_row_index: Some(r0),
1353            end_row_index: Some(r1),
1354            start_column_index: Some(c0),
1355            end_column_index: Some(c1),
1356        }
1357    }
1358
1359    #[test]
1360    fn copy_paste_extent_anchors_at_the_destinations_top_left() {
1361        let source = range(1, 0, 2, 0, 2); // 2x2
1362        let destination = range(1, 10, 11, 10, 11); // 1x1 anchor
1363        let extent = copy_paste_extent(&source, &destination, PasteOrientation::Normal);
1364        assert_eq!(extent, range(1, 10, 12, 10, 12));
1365    }
1366
1367    #[test]
1368    fn copy_paste_extent_repeats_to_fill_a_multiple_destination() {
1369        let source = range(1, 0, 1, 0, 1); // 1x1
1370        let destination = range(1, 0, 3, 0, 3); // 3x3
1371        let extent = copy_paste_extent(&source, &destination, PasteOrientation::Normal);
1372        assert_eq!(extent, range(1, 0, 3, 0, 3));
1373    }
1374
1375    #[test]
1376    fn copy_paste_extent_transposes_the_source_before_computing() {
1377        let source = range(1, 0, 1, 0, 3); // 1 row x 3 cols
1378        let destination = range(1, 5, 6, 5, 6); // 1x1 anchor
1379        let extent = copy_paste_extent(&source, &destination, PasteOrientation::Transpose);
1380        // Transposed source is 3 rows x 1 col.
1381        assert_eq!(extent, range(1, 5, 8, 5, 6));
1382    }
1383
1384    #[test]
1385    fn paste_data_upper_bound_counts_rows_and_the_widest_row() {
1386        assert_eq!(paste_data_upper_bound("a\tb\nc\td\te", "\t"), (2, 3));
1387    }
1388
1389    #[test]
1390    fn paste_data_upper_bound_tolerates_crlf_line_endings() {
1391        assert_eq!(paste_data_upper_bound("a,b\r\nc,d\r\n", ","), (2, 2));
1392    }
1393
1394    #[test]
1395    fn paste_data_upper_bound_does_not_count_a_terminating_newline_as_a_row() {
1396        // What `printf '1\t2\n3\t4\n'` produces, and what every text file
1397        // ends with: two rows, not three. Counting the terminator would
1398        // name a row of cells as overwritten that nothing writes to.
1399        assert_eq!(paste_data_upper_bound("1\t2\n3\t4\n", "\t"), (2, 2));
1400        assert_eq!(paste_data_upper_bound("1\t2\n3\t4", "\t"), (2, 2));
1401    }
1402
1403    #[test]
1404    fn paste_data_upper_bound_counts_a_second_trailing_newline_as_a_blank_row() {
1405        // Only the *terminator* is dropped: a blank line before it is data.
1406        assert_eq!(paste_data_upper_bound("a\nb\n\n", "\t"), (3, 1));
1407        assert_eq!(paste_data_upper_bound("a\n\nb\n", "\t"), (3, 1));
1408    }
1409
1410    #[test]
1411    fn paste_data_upper_bound_of_a_lone_newline_is_one_empty_row() {
1412        assert_eq!(paste_data_upper_bound("\n", "\t"), (1, 1));
1413    }
1414
1415    #[test]
1416    fn paste_data_upper_bound_of_empty_data_is_zero_by_zero() {
1417        assert_eq!(paste_data_upper_bound("", "\t"), (0, 0));
1418    }
1419
1420    #[test]
1421    fn compose_with_default_sheet_prefers_the_ranges_own_prefix() {
1422        let composed = compose_with_default_sheet(Some("Q1"), "'Q2'!A1:B2", "--source").unwrap();
1423        assert_eq!(composed, "'Q2'!A1:B2");
1424    }
1425
1426    #[test]
1427    fn compose_with_default_sheet_falls_back_to_sheet() {
1428        let composed = compose_with_default_sheet(Some("Q1"), "A1:B2", "--source").unwrap();
1429        assert_eq!(composed, "'Q1'!A1:B2");
1430    }
1431
1432    #[test]
1433    fn compose_with_default_sheet_refuses_neither() {
1434        let err = compose_with_default_sheet(None, "A1:B2", "--destination").unwrap_err();
1435        assert!(err.contains("--destination"), "{err}");
1436        assert!(err.contains("--sheet"), "{err}");
1437    }
1438
1439    #[test]
1440    fn unusable_sheet_default_is_none_when_sheet_is_the_only_source_of_a_prefix() {
1441        let verb = PasteVerb::CutPaste {
1442            sheet: Some("Q1".to_string()),
1443            source: "A1:B2".to_string(),
1444            destination: "D1".to_string(),
1445            paste_type: PasteType::Normal,
1446        };
1447        assert_eq!(unusable_sheet_default(&verb), None);
1448    }
1449
1450    #[test]
1451    fn unusable_sheet_default_is_none_for_a_cross_sheet_paste_that_still_uses_sheet() {
1452        // The case a blanket refusal would have cost: one end prefixed,
1453        // the other leaning on `--sheet`.
1454        let verb = PasteVerb::CutPaste {
1455            sheet: Some("Q1".to_string()),
1456            source: "A1:B2".to_string(),
1457            destination: "'Q2'!D1".to_string(),
1458            paste_type: PasteType::Normal,
1459        };
1460        assert_eq!(unusable_sheet_default(&verb), None);
1461    }
1462
1463    #[test]
1464    fn unusable_sheet_default_refuses_a_sheet_no_range_can_use() {
1465        let verb = PasteVerb::CutPaste {
1466            sheet: Some("Q1".to_string()),
1467            source: "'Q2'!A1:B2".to_string(),
1468            destination: "'Q3'!D1".to_string(),
1469            paste_type: PasteType::Normal,
1470        };
1471        let detail = unusable_sheet_default(&verb).expect("refusal");
1472        assert!(detail.contains("--sheet 'Q1'"), "{detail}");
1473        assert!(detail.contains("nothing to apply to"), "{detail}");
1474    }
1475
1476    #[test]
1477    fn unusable_sheet_default_refuses_a_sheet_beside_a_prefixed_paste_data_destination() {
1478        // `paste-data` has only one range, so a prefix on it leaves
1479        // `--sheet` with nothing at all.
1480        let verb = PasteVerb::PasteData {
1481            sheet: Some("Q1".to_string()),
1482            destination: "'Q2'!A1".to_string(),
1483            data: "1".to_string(),
1484            delimiter: "\t".to_string(),
1485            paste_type: PasteType::Values,
1486        };
1487        assert!(unusable_sheet_default(&verb).is_some());
1488    }
1489
1490    #[test]
1491    fn unusable_sheet_default_is_none_without_a_sheet_flag() {
1492        let verb = PasteVerb::PasteData {
1493            sheet: None,
1494            destination: "'Q2'!A1".to_string(),
1495            data: "1".to_string(),
1496            delimiter: "\t".to_string(),
1497            paste_type: PasteType::Values,
1498        };
1499        assert_eq!(unusable_sheet_default(&verb), None);
1500    }
1501
1502    #[test]
1503    fn gate_operations_for_cut_paste_is_always_both() {
1504        let verb = PasteVerb::CutPaste {
1505            sheet: None,
1506            source: "A1".to_string(),
1507            destination: "B1".to_string(),
1508            paste_type: PasteType::Values,
1509        };
1510        assert_eq!(verb.gate_operations(), GATE_BOTH);
1511    }
1512
1513    #[test]
1514    fn gate_operations_for_copy_paste_follows_the_paste_type_table() {
1515        let verb_of = |paste_type| PasteVerb::CopyPaste {
1516            sheet: None,
1517            source: "A1".to_string(),
1518            destination: "B1".to_string(),
1519            paste_type,
1520            orientation: PasteOrientation::Normal,
1521        };
1522        assert_eq!(verb_of(PasteType::Normal).gate_operations(), GATE_BOTH);
1523        assert_eq!(
1524            verb_of(PasteType::Values).gate_operations(),
1525            GATE_WRITE_ONLY
1526        );
1527        assert_eq!(
1528            verb_of(PasteType::Formula).gate_operations(),
1529            GATE_WRITE_ONLY
1530        );
1531        assert_eq!(
1532            verb_of(PasteType::Format).gate_operations(),
1533            GATE_STRUCTURE_ONLY
1534        );
1535    }
1536
1537    #[test]
1538    fn sheet_exceeds_dimensions_flags_an_extent_past_the_grid() {
1539        let workbook: crate::drive::sheets::types::Spreadsheet =
1540            serde_json::from_value(serde_json::json!({
1541                "sheets": [
1542                    {"properties": {"sheetId": 1, "title": "Q1",
1543                                     "gridProperties": {"rowCount": 10, "columnCount": 5}}},
1544                ],
1545            }))
1546            .unwrap();
1547        assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 11, 0, 3)));
1548        assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 3, 0, 6)));
1549        assert!(!sheet_exceeds_dimensions(&workbook, &range(1, 0, 10, 0, 5)));
1550    }
1551
1552    #[test]
1553    fn sheet_exceeds_dimensions_is_false_when_the_extents_sheet_is_unknown() {
1554        let workbook: crate::drive::sheets::types::Spreadsheet =
1555            serde_json::from_value(serde_json::json!({
1556                "sheets": [
1557                    {"properties": {"sheetId": 1, "title": "Q1",
1558                                     "gridProperties": {"rowCount": 10, "columnCount": 5}}},
1559                ],
1560            }))
1561            .unwrap();
1562        // `extent.sheet_id` names a sheet this workbook doesn't have —
1563        // nothing to compare against, so this is never "exceeds".
1564        assert!(!sheet_exceeds_dimensions(
1565            &workbook,
1566            &range(99, 0, 20, 0, 20)
1567        ));
1568    }
1569
1570    #[test]
1571    fn paste_data_upper_bound_with_an_empty_delimiter_counts_one_column() {
1572        assert_eq!(paste_data_upper_bound("ab\ncd", ""), (2, 1));
1573    }
1574
1575    #[test]
1576    fn log_operation_is_defined_for_every_verb() {
1577        for verb in every_paste_verb() {
1578            assert!(!verb.log_operation().is_empty());
1579        }
1580    }
1581
1582    #[test]
1583    fn log_status_is_defined_for_every_result_variant() {
1584        for result in every_paste_result() {
1585            assert!(!result.log_status().is_empty());
1586        }
1587    }
1588
1589    #[test]
1590    fn from_lease_refusal_maps_every_refusal_kind() {
1591        assert_eq!(PasteResult::from_no_lease(), PasteResult::RefusedNoLease);
1592        assert_eq!(
1593            PasteResult::from_lease_expired(),
1594            PasteResult::RefusedLeaseExpired
1595        );
1596        assert_eq!(
1597            PasteResult::from_lease_wrong_file(),
1598            PasteResult::RefusedLeaseWrongFile
1599        );
1600        assert_eq!(
1601            PasteResult::from_lease_stale(),
1602            PasteResult::RefusedLeaseStale
1603        );
1604        assert_eq!(
1605            PasteResult::from_lease_failed("boom".to_string()),
1606            PasteResult::Failed {
1607                detail: "boom".to_string()
1608            }
1609        );
1610    }
1611
1612    #[test]
1613    fn write_jsonl_serializes_the_outcome_as_one_json_line() {
1614        let outcome = PasteOutcome {
1615            spreadsheet_id: "sheet-1".to_string(),
1616            file_name: Some("Budget".to_string()),
1617            resolved_folder_id: None,
1618            verb: every_paste_verb().remove(0),
1619            result: PasteResult::RefusedShortcut,
1620        };
1621        let mut buf = Vec::new();
1622        outcome.write_jsonl(&mut buf).unwrap();
1623        let text = String::from_utf8(buf).unwrap();
1624        assert!(text.contains("refused-shortcut"), "{text}");
1625    }
1626
1627    /// The JSON names the paste type every verb used and, for
1628    /// `copy-paste` alone, its orientation — as the text output does
1629    /// (#1941). The verb's source/destination/data are not serialised.
1630    #[test]
1631    fn json_names_the_paste_type_and_copy_pastes_orientation() {
1632        let expected = [
1633            ("PASTE_NORMAL", None),
1634            ("PASTE_VALUES", Some("TRANSPOSE")),
1635            ("PASTE_VALUES", None),
1636        ];
1637        for (verb, (paste_type, orientation)) in every_paste_verb().into_iter().zip(expected) {
1638            let outcome = PasteOutcome {
1639                spreadsheet_id: "sheet-1".to_string(),
1640                file_name: None,
1641                resolved_folder_id: None,
1642                verb,
1643                result: PasteResult::RefusedShortcut,
1644            };
1645            let json = serde_json::to_value(&outcome).unwrap();
1646            assert_eq!(json["paste_type"], paste_type, "{json}");
1647            assert_eq!(
1648                json.get("orientation").and_then(|v| v.as_str()),
1649                orientation,
1650                "{json}"
1651            );
1652            assert!(json.get("file_name").is_none(), "{json}");
1653            assert!(
1654                json.get("data").is_none() && json.get("source").is_none(),
1655                "{json}"
1656            );
1657            assert_eq!(json["result"]["status"], "refused-shortcut", "{json}");
1658        }
1659    }
1660
1661    #[test]
1662    fn read_data_text_reads_a_local_file_verbatim() {
1663        let dir = tempfile::tempdir().unwrap();
1664        let path = dir.path().join("clip.tsv");
1665        std::fs::write(&path, "a\tb\nc\td").unwrap();
1666        let text = read_data_text(path.to_str().unwrap()).unwrap();
1667        assert_eq!(text, "a\tb\nc\td");
1668    }
1669
1670    #[test]
1671    fn read_data_text_reports_a_missing_file_clearly() {
1672        let err = read_data_text("/definitely/not/here.tsv").unwrap_err();
1673        assert!(err.to_string().contains("Failed to stat"), "{err}");
1674    }
1675
1676    #[test]
1677    fn read_data_text_refuses_a_file_over_the_upload_cap() {
1678        let dir = tempfile::tempdir().unwrap();
1679        let path = dir.path().join("huge.tsv");
1680        let oversize = vec![b'a'; (crate::drive::files_api::MAX_UPLOAD_BYTES + 1) as usize];
1681        std::fs::write(&path, oversize).unwrap();
1682        let err = read_data_text(path.to_str().unwrap()).unwrap_err();
1683        assert!(err.to_string().contains("over the"), "{err}");
1684    }
1685
1686    // ── async flow, against a wiremock Drive+Sheets backend ──
1687
1688    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1689    use crate::drive::sheets::client::SHEETS_API_URL;
1690    use crate::test_support::env::MapEnv;
1691    use crate::utils::secret::Secret;
1692
1693    fn test_credentials() -> DriveCredentials {
1694        DriveCredentials {
1695            client_id: "client-1".to_string(),
1696            client_secret: Secret::new("secret-1"),
1697            refresh_token: Secret::new("refresh-1"),
1698            scope: DriveGrantedScopes::READONLY,
1699        }
1700    }
1701
1702    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1703        wiremock::Mock::given(wiremock::matchers::method("POST"))
1704            .and(wiremock::matchers::path("/token"))
1705            .respond_with(
1706                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1707                    "access_token": "test-token", "expires_in": 3600,
1708                })),
1709            )
1710            .mount(server)
1711            .await;
1712        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1713        crate::drive::client::test_support::replace_session(
1714            &mut drive,
1715            &test_credentials(),
1716            &format!("{}/token", server.uri()),
1717        );
1718        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1719        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1720        (drive, sheets)
1721    }
1722
1723    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1724        let parents: Vec<&str> = parents.to_vec();
1725        wiremock::Mock::given(wiremock::matchers::method("GET"))
1726            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1727            .respond_with(
1728                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1729                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1730                    "version": "1",
1731                })),
1732            )
1733    }
1734
1735    fn mount_folder(id: &str) -> wiremock::Mock {
1736        mount_file(id, "application/vnd.google-apps.folder", &[])
1737    }
1738
1739    async fn mount_workbook(server: &wiremock::MockServer) {
1740        wiremock::Mock::given(wiremock::matchers::method("GET"))
1741            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1742            .respond_with(
1743                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1744                    "spreadsheetId": "sheet-1",
1745                    "properties": {"title": "Budget"},
1746                    "sheets": [
1747                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1748                                         "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1749                    ],
1750                })),
1751            )
1752            .mount(server)
1753            .await;
1754    }
1755
1756    /// Every sheets operation allowed on every folder — for the tests that
1757    /// must be refused *before* the gate is ever consulted, where the rule
1758    /// set is deliberately not the reason.
1759    fn rules_allowing_everything() -> Vec<FolderPermissionRule> {
1760        vec![allow_rule(
1761            "folder-1",
1762            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1763        )]
1764    }
1765
1766    /// `require_lease: false` by default — most tests here exercise the
1767    /// permission gate or the dry-run/real-run split, not the lease gate,
1768    /// which has its own dedicated test.
1769    fn allow_rule(folder_id: &str, ops: &[DriveOperation]) -> FolderPermissionRule {
1770        FolderPermissionRule::folder(folder_id)
1771            .allowing(ops.iter().copied())
1772            .requiring_lease(false)
1773    }
1774
1775    #[tokio::test]
1776    async fn copy_paste_default_normal_is_blocked_naming_sheets_structure_under_write_only() {
1777        let server = wiremock::MockServer::start().await;
1778        let (drive, sheets) = clients(&server).await;
1779        mount_file(
1780            "sheet-1",
1781            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1782            &["folder-1"],
1783        )
1784        .mount(&server)
1785        .await;
1786        mount_folder("folder-1").mount(&server).await;
1787        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1788
1789        let opts = PasteOptions {
1790            spreadsheet_id: "sheet-1".to_string(),
1791            verb: PasteVerb::CopyPaste {
1792                sheet: Some("Q1".to_string()),
1793                source: "A1:B2".to_string(),
1794                destination: "D1:E2".to_string(),
1795                paste_type: PasteType::Normal,
1796                orientation: PasteOrientation::Normal,
1797            },
1798            dry_run: true,
1799            lease_token: None,
1800            ledger_path: PathBuf::new(),
1801        };
1802        let outcome = paste(&drive, &sheets, &opts, &rules).await;
1803        match outcome.result {
1804            PasteResult::Blocked { operation, .. } => {
1805                assert_eq!(operation, DriveOperation::SheetsStructure);
1806            }
1807            other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
1808        }
1809    }
1810
1811    #[tokio::test]
1812    async fn copy_paste_values_only_proceeds_under_a_write_only_grant() {
1813        let server = wiremock::MockServer::start().await;
1814        let (drive, sheets) = clients(&server).await;
1815        mount_file(
1816            "sheet-1",
1817            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1818            &["folder-1"],
1819        )
1820        .mount(&server)
1821        .await;
1822        mount_folder("folder-1").mount(&server).await;
1823        mount_workbook(&server).await;
1824        wiremock::Mock::given(wiremock::matchers::method("GET"))
1825            .and(wiremock::matchers::path(
1826                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
1827            ))
1828            .respond_with(
1829                wiremock::ResponseTemplate::new(200)
1830                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
1831            )
1832            .mount(&server)
1833            .await;
1834        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1835
1836        let opts = PasteOptions {
1837            spreadsheet_id: "sheet-1".to_string(),
1838            verb: PasteVerb::CopyPaste {
1839                sheet: Some("Q1".to_string()),
1840                source: "A1:B2".to_string(),
1841                destination: "D1:E2".to_string(),
1842                paste_type: PasteType::Values,
1843                orientation: PasteOrientation::Normal,
1844            },
1845            dry_run: true,
1846            lease_token: None,
1847            ledger_path: PathBuf::new(),
1848        };
1849        let outcome = paste(&drive, &sheets, &opts, &rules).await;
1850        assert!(
1851            matches!(outcome.result, PasteResult::WouldChange(_)),
1852            "{:?}",
1853            outcome.result
1854        );
1855    }
1856
1857    #[tokio::test]
1858    async fn format_only_copy_paste_dry_run_reads_no_values() {
1859        let server = wiremock::MockServer::start().await;
1860        let (drive, sheets) = clients(&server).await;
1861        mount_file(
1862            "sheet-1",
1863            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1864            &["folder-1"],
1865        )
1866        .mount(&server)
1867        .await;
1868        mount_folder("folder-1").mount(&server).await;
1869        mount_workbook(&server).await;
1870        // No `values.get` mock at all — a `format`-only dry run must never
1871        // call it, or this test's request would 404 and the outcome would
1872        // be `Failed` instead of `WouldChange`.
1873        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
1874
1875        let opts = PasteOptions {
1876            spreadsheet_id: "sheet-1".to_string(),
1877            verb: PasteVerb::CopyPaste {
1878                sheet: Some("Q1".to_string()),
1879                source: "A1:B2".to_string(),
1880                destination: "D1:E2".to_string(),
1881                paste_type: PasteType::Format,
1882                orientation: PasteOrientation::Normal,
1883            },
1884            dry_run: true,
1885            lease_token: None,
1886            ledger_path: PathBuf::new(),
1887        };
1888        let outcome = paste(&drive, &sheets, &opts, &rules).await;
1889        match outcome.result {
1890            PasteResult::WouldChange(change) => assert_eq!(change.overwritten, None),
1891            other => panic!("expected WouldChange with no overwritten preview, got {other:?}"),
1892        }
1893    }
1894
1895    #[tokio::test]
1896    async fn dry_run_never_calls_batch_update() {
1897        let server = wiremock::MockServer::start().await;
1898        let (drive, sheets) = clients(&server).await;
1899        mount_file(
1900            "sheet-1",
1901            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1902            &["folder-1"],
1903        )
1904        .mount(&server)
1905        .await;
1906        mount_folder("folder-1").mount(&server).await;
1907        mount_workbook(&server).await;
1908        wiremock::Mock::given(wiremock::matchers::method("GET"))
1909            .and(wiremock::matchers::path(
1910                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
1911            ))
1912            .respond_with(
1913                wiremock::ResponseTemplate::new(200)
1914                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
1915            )
1916            .mount(&server)
1917            .await;
1918        // No `batchUpdate` mock — a dry run calling it would 404.
1919        let rules = vec![allow_rule(
1920            "folder-1",
1921            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1922        )];
1923
1924        let opts = PasteOptions {
1925            spreadsheet_id: "sheet-1".to_string(),
1926            verb: PasteVerb::CopyPaste {
1927                sheet: Some("Q1".to_string()),
1928                source: "A1:B2".to_string(),
1929                destination: "D1:E2".to_string(),
1930                paste_type: PasteType::Normal,
1931                orientation: PasteOrientation::Normal,
1932            },
1933            dry_run: true,
1934            lease_token: None,
1935            ledger_path: PathBuf::new(),
1936        };
1937        let outcome = paste(&drive, &sheets, &opts, &rules).await;
1938        assert!(matches!(outcome.result, PasteResult::WouldChange(_)));
1939    }
1940
1941    #[tokio::test]
1942    async fn cut_paste_always_needs_both_operations_even_for_values_only() {
1943        let server = wiremock::MockServer::start().await;
1944        let (drive, sheets) = clients(&server).await;
1945        mount_file(
1946            "sheet-1",
1947            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1948            &["folder-1"],
1949        )
1950        .mount(&server)
1951        .await;
1952        mount_folder("folder-1").mount(&server).await;
1953        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1954
1955        let opts = PasteOptions {
1956            spreadsheet_id: "sheet-1".to_string(),
1957            verb: PasteVerb::CutPaste {
1958                sheet: Some("Q1".to_string()),
1959                source: "A1:B2".to_string(),
1960                destination: "D1".to_string(),
1961                paste_type: PasteType::Values,
1962            },
1963            dry_run: true,
1964            lease_token: None,
1965            ledger_path: PathBuf::new(),
1966        };
1967        let outcome = paste(&drive, &sheets, &opts, &rules).await;
1968        match outcome.result {
1969            PasteResult::Blocked { operation, .. } => {
1970                assert_eq!(operation, DriveOperation::SheetsStructure);
1971            }
1972            other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
1973        }
1974    }
1975
1976    #[tokio::test]
1977    async fn cut_paste_refuses_a_multi_cell_destination() {
1978        let server = wiremock::MockServer::start().await;
1979        let (drive, sheets) = clients(&server).await;
1980        mount_file(
1981            "sheet-1",
1982            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1983            &["folder-1"],
1984        )
1985        .mount(&server)
1986        .await;
1987        mount_folder("folder-1").mount(&server).await;
1988        mount_workbook(&server).await;
1989        let rules = vec![allow_rule(
1990            "folder-1",
1991            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1992        )];
1993
1994        let opts = PasteOptions {
1995            spreadsheet_id: "sheet-1".to_string(),
1996            verb: PasteVerb::CutPaste {
1997                sheet: Some("Q1".to_string()),
1998                source: "A1:B2".to_string(),
1999                destination: "D1:E2".to_string(),
2000                paste_type: PasteType::Normal,
2001            },
2002            dry_run: true,
2003            lease_token: None,
2004            ledger_path: PathBuf::new(),
2005        };
2006        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2007        match outcome.result {
2008            PasteResult::RefusedInvalidRange { detail } => {
2009                assert!(detail.contains("single cell"), "{detail}");
2010            }
2011            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2012        }
2013    }
2014
2015    #[tokio::test]
2016    async fn cut_paste_reports_destination_overwrite_and_source_clear_separately() {
2017        let server = wiremock::MockServer::start().await;
2018        let (drive, sheets) = clients(&server).await;
2019        mount_file(
2020            "sheet-1",
2021            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2022            &["folder-1"],
2023        )
2024        .mount(&server)
2025        .await;
2026        mount_folder("folder-1").mount(&server).await;
2027        mount_workbook(&server).await;
2028        wiremock::Mock::given(wiremock::matchers::method("GET"))
2029            .and(wiremock::matchers::path(
2030                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2031            ))
2032            .respond_with(
2033                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2034                    "range": "'Q1'!D1:E2", "values": [["x", ""], ["", "y"]],
2035                })),
2036            )
2037            .mount(&server)
2038            .await;
2039        wiremock::Mock::given(wiremock::matchers::method("GET"))
2040            .and(wiremock::matchers::path(
2041                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2042            ))
2043            .respond_with(
2044                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2045                    "range": "'Q1'!A1:B2", "values": [["1", "2"], ["3", "4"]],
2046                })),
2047            )
2048            .mount(&server)
2049            .await;
2050        let rules = vec![allow_rule(
2051            "folder-1",
2052            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2053        )];
2054
2055        let opts = PasteOptions {
2056            spreadsheet_id: "sheet-1".to_string(),
2057            verb: PasteVerb::CutPaste {
2058                sheet: Some("Q1".to_string()),
2059                source: "A1:B2".to_string(),
2060                destination: "D1".to_string(),
2061                paste_type: PasteType::Normal,
2062            },
2063            dry_run: true,
2064            lease_token: None,
2065            ledger_path: PathBuf::new(),
2066        };
2067        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2068        match outcome.result {
2069            PasteResult::WouldChange(change) => {
2070                assert_eq!(
2071                    change.overwritten,
2072                    Some(vec!["D1".to_string(), "E2".to_string()])
2073                );
2074                assert_eq!(
2075                    change.cleared,
2076                    Some(vec![
2077                        "A1".to_string(),
2078                        "B1".to_string(),
2079                        "A2".to_string(),
2080                        "B2".to_string()
2081                    ])
2082                );
2083            }
2084            other => panic!("expected WouldChange, got {other:?}"),
2085        }
2086    }
2087
2088    #[tokio::test]
2089    async fn paste_data_defaults_and_real_run_succeeds() {
2090        let server = wiremock::MockServer::start().await;
2091        let (drive, sheets) = clients(&server).await;
2092        mount_file(
2093            "sheet-1",
2094            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2095            &["folder-1"],
2096        )
2097        .mount(&server)
2098        .await;
2099        mount_folder("folder-1").mount(&server).await;
2100        mount_workbook(&server).await;
2101        wiremock::Mock::given(wiremock::matchers::method("GET"))
2102            .and(wiremock::matchers::path(
2103                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
2104            ))
2105            .respond_with(
2106                wiremock::ResponseTemplate::new(200)
2107                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
2108            )
2109            .mount(&server)
2110            .await;
2111        wiremock::Mock::given(wiremock::matchers::method("POST"))
2112            .and(wiremock::matchers::path(
2113                "/v4/spreadsheets/sheet-1:batchUpdate",
2114            ))
2115            .respond_with(
2116                wiremock::ResponseTemplate::new(200)
2117                    .set_body_json(serde_json::json!({"replies": [{}]})),
2118            )
2119            .mount(&server)
2120            .await;
2121        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2122
2123        let opts = PasteOptions {
2124            spreadsheet_id: "sheet-1".to_string(),
2125            verb: PasteVerb::PasteData {
2126                sheet: Some("Q1".to_string()),
2127                destination: "A1".to_string(),
2128                data: "1\t2".to_string(),
2129                delimiter: "\t".to_string(),
2130                paste_type: PasteType::Values,
2131            },
2132            dry_run: false,
2133            lease_token: None,
2134            ledger_path: PathBuf::new(),
2135        };
2136        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2137        assert!(
2138            matches!(outcome.result, PasteResult::Changed(_)),
2139            "{:?}",
2140            outcome.result
2141        );
2142    }
2143
2144    // ── early refusals, before any gate/workbook call ──
2145
2146    #[tokio::test]
2147    async fn a_destination_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
2148        let server = wiremock::MockServer::start().await;
2149        let (drive, sheets) = clients(&server).await;
2150        // No file/folder/workbook mock is mounted at all — reaching any of
2151        // them would 404 and mask this as `Failed` rather than
2152        // `RefusedInvalidRange`.
2153        let opts = PasteOptions {
2154            spreadsheet_id: "sheet-1".to_string(),
2155            verb: PasteVerb::CopyPaste {
2156                sheet: None,
2157                source: "'Q1'!A1:B2".to_string(),
2158                destination: "D1:E2".to_string(),
2159                paste_type: PasteType::Values,
2160                orientation: PasteOrientation::Normal,
2161            },
2162            dry_run: true,
2163            lease_token: None,
2164            ledger_path: PathBuf::new(),
2165        };
2166        let outcome = paste(&drive, &sheets, &opts, &[]).await;
2167        match outcome.result {
2168            PasteResult::RefusedInvalidRange { detail } => {
2169                assert!(detail.contains("--destination"), "{detail}");
2170            }
2171            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2172        }
2173        assert_eq!(outcome.file_name, None);
2174    }
2175
2176    #[tokio::test]
2177    async fn a_source_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
2178        let server = wiremock::MockServer::start().await;
2179        let (drive, sheets) = clients(&server).await;
2180        let opts = PasteOptions {
2181            spreadsheet_id: "sheet-1".to_string(),
2182            verb: PasteVerb::CopyPaste {
2183                sheet: None,
2184                source: "A1:B2".to_string(),
2185                destination: "'Q1'!D1:E2".to_string(),
2186                paste_type: PasteType::Values,
2187                orientation: PasteOrientation::Normal,
2188            },
2189            dry_run: true,
2190            lease_token: None,
2191            ledger_path: PathBuf::new(),
2192        };
2193        let outcome = paste(&drive, &sheets, &opts, &[]).await;
2194        match outcome.result {
2195            PasteResult::RefusedInvalidRange { detail } => {
2196                assert!(detail.contains("--source"), "{detail}");
2197            }
2198            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2199        }
2200    }
2201
2202    // ── target-gate refusals/failures ──
2203
2204    #[tokio::test]
2205    async fn a_metadata_fetch_failure_surfaces_as_failed() {
2206        let server = wiremock::MockServer::start().await;
2207        let (drive, sheets) = clients(&server).await;
2208        wiremock::Mock::given(wiremock::matchers::method("GET"))
2209            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2210            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2211            .mount(&server)
2212            .await;
2213        let opts = PasteOptions {
2214            spreadsheet_id: "sheet-1".to_string(),
2215            verb: PasteVerb::CopyPaste {
2216                sheet: Some("Q1".to_string()),
2217                source: "A1:B2".to_string(),
2218                destination: "D1:E2".to_string(),
2219                paste_type: PasteType::Normal,
2220                orientation: PasteOrientation::Normal,
2221            },
2222            // `false` so this also drives `record_attempt`'s `Failed` arm.
2223            dry_run: false,
2224            lease_token: None,
2225            ledger_path: PathBuf::new(),
2226        };
2227        let outcome = paste(&drive, &sheets, &opts, &[]).await;
2228        assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2229    }
2230
2231    #[tokio::test]
2232    async fn a_shortcut_target_is_refused() {
2233        let server = wiremock::MockServer::start().await;
2234        let (drive, sheets) = clients(&server).await;
2235        mount_file(
2236            "sheet-1",
2237            "application/vnd.google-apps.shortcut",
2238            &["folder-1"],
2239        )
2240        .mount(&server)
2241        .await;
2242        let rules = vec![allow_rule(
2243            "folder-1",
2244            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2245        )];
2246        let opts = PasteOptions {
2247            spreadsheet_id: "sheet-1".to_string(),
2248            verb: PasteVerb::CopyPaste {
2249                sheet: Some("Q1".to_string()),
2250                source: "A1:B2".to_string(),
2251                destination: "D1:E2".to_string(),
2252                paste_type: PasteType::Normal,
2253                orientation: PasteOrientation::Normal,
2254            },
2255            dry_run: true,
2256            lease_token: None,
2257            ledger_path: PathBuf::new(),
2258        };
2259        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2260        assert!(matches!(outcome.result, PasteResult::RefusedShortcut));
2261    }
2262
2263    #[tokio::test]
2264    async fn a_non_spreadsheet_target_is_refused() {
2265        let server = wiremock::MockServer::start().await;
2266        let (drive, sheets) = clients(&server).await;
2267        mount_file(
2268            "sheet-1",
2269            "application/vnd.google-apps.document",
2270            &["folder-1"],
2271        )
2272        .mount(&server)
2273        .await;
2274        let rules = vec![allow_rule(
2275            "folder-1",
2276            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2277        )];
2278        let opts = PasteOptions {
2279            spreadsheet_id: "sheet-1".to_string(),
2280            verb: PasteVerb::CopyPaste {
2281                sheet: Some("Q1".to_string()),
2282                source: "A1:B2".to_string(),
2283                destination: "D1:E2".to_string(),
2284                paste_type: PasteType::Normal,
2285                orientation: PasteOrientation::Normal,
2286            },
2287            dry_run: true,
2288            lease_token: None,
2289            ledger_path: PathBuf::new(),
2290        };
2291        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2292        assert!(matches!(
2293            outcome.result,
2294            PasteResult::RefusedNotASpreadsheet { .. }
2295        ));
2296    }
2297
2298    #[tokio::test]
2299    async fn a_target_with_no_visible_parents_is_refused() {
2300        let server = wiremock::MockServer::start().await;
2301        let (drive, sheets) = clients(&server).await;
2302        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2303            .mount(&server)
2304            .await;
2305        let opts = PasteOptions {
2306            spreadsheet_id: "sheet-1".to_string(),
2307            verb: PasteVerb::CopyPaste {
2308                sheet: Some("Q1".to_string()),
2309                source: "A1:B2".to_string(),
2310                destination: "D1:E2".to_string(),
2311                paste_type: PasteType::Normal,
2312                orientation: PasteOrientation::Normal,
2313            },
2314            dry_run: true,
2315            lease_token: None,
2316            ledger_path: PathBuf::new(),
2317        };
2318        let outcome = paste(&drive, &sheets, &opts, &[]).await;
2319        assert!(matches!(
2320            outcome.result,
2321            PasteResult::RefusedNoVisibleParents
2322        ));
2323    }
2324
2325    #[tokio::test]
2326    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2327        let server = wiremock::MockServer::start().await;
2328        let (drive, sheets) = clients(&server).await;
2329        mount_file(
2330            "sheet-1",
2331            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2332            &["folder-1"],
2333        )
2334        .mount(&server)
2335        .await;
2336        wiremock::Mock::given(wiremock::matchers::method("GET"))
2337            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2338            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2339            .mount(&server)
2340            .await;
2341        let rules = vec![allow_rule(
2342            "folder-1",
2343            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2344        )];
2345        let opts = PasteOptions {
2346            spreadsheet_id: "sheet-1".to_string(),
2347            verb: PasteVerb::CopyPaste {
2348                sheet: Some("Q1".to_string()),
2349                source: "A1:B2".to_string(),
2350                destination: "D1:E2".to_string(),
2351                paste_type: PasteType::Normal,
2352                orientation: PasteOrientation::Normal,
2353            },
2354            dry_run: true,
2355            lease_token: None,
2356            ledger_path: PathBuf::new(),
2357        };
2358        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2359        assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2360    }
2361
2362    #[tokio::test]
2363    async fn a_workbook_fetch_failure_surfaces_as_failed() {
2364        let server = wiremock::MockServer::start().await;
2365        let (drive, sheets) = clients(&server).await;
2366        mount_file(
2367            "sheet-1",
2368            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2369            &["folder-1"],
2370        )
2371        .mount(&server)
2372        .await;
2373        mount_folder("folder-1").mount(&server).await;
2374        wiremock::Mock::given(wiremock::matchers::method("GET"))
2375            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2376            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2377            .mount(&server)
2378            .await;
2379        let rules = vec![allow_rule(
2380            "folder-1",
2381            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2382        )];
2383        let opts = PasteOptions {
2384            spreadsheet_id: "sheet-1".to_string(),
2385            verb: PasteVerb::CopyPaste {
2386                sheet: Some("Q1".to_string()),
2387                source: "A1:B2".to_string(),
2388                destination: "D1:E2".to_string(),
2389                paste_type: PasteType::Normal,
2390                orientation: PasteOrientation::Normal,
2391            },
2392            dry_run: true,
2393            lease_token: None,
2394            ledger_path: PathBuf::new(),
2395        };
2396        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2397        assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2398    }
2399
2400    // ── destination/source range resolution ──
2401
2402    #[tokio::test]
2403    async fn a_destination_naming_an_unknown_sheet_is_refused() {
2404        let server = wiremock::MockServer::start().await;
2405        let (drive, sheets) = clients(&server).await;
2406        mount_file(
2407            "sheet-1",
2408            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2409            &["folder-1"],
2410        )
2411        .mount(&server)
2412        .await;
2413        mount_folder("folder-1").mount(&server).await;
2414        mount_workbook(&server).await;
2415        let rules = vec![allow_rule(
2416            "folder-1",
2417            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2418        )];
2419        let opts = PasteOptions {
2420            spreadsheet_id: "sheet-1".to_string(),
2421            verb: PasteVerb::CopyPaste {
2422                sheet: None,
2423                source: "'Q1'!A1:B2".to_string(),
2424                destination: "'Ghost'!D1:E2".to_string(),
2425                paste_type: PasteType::Normal,
2426                orientation: PasteOrientation::Normal,
2427            },
2428            dry_run: true,
2429            lease_token: None,
2430            ledger_path: PathBuf::new(),
2431        };
2432        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2433        match outcome.result {
2434            PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
2435            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2436        }
2437    }
2438
2439    #[tokio::test]
2440    async fn a_destination_with_an_unparseable_range_is_refused() {
2441        let server = wiremock::MockServer::start().await;
2442        let (drive, sheets) = clients(&server).await;
2443        mount_file(
2444            "sheet-1",
2445            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2446            &["folder-1"],
2447        )
2448        .mount(&server)
2449        .await;
2450        mount_folder("folder-1").mount(&server).await;
2451        mount_workbook(&server).await;
2452        let rules = vec![allow_rule(
2453            "folder-1",
2454            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2455        )];
2456        let opts = PasteOptions {
2457            spreadsheet_id: "sheet-1".to_string(),
2458            verb: PasteVerb::CopyPaste {
2459                sheet: None,
2460                source: "'Q1'!A1:B2".to_string(),
2461                destination: "'Q1'!not a range".to_string(),
2462                paste_type: PasteType::Normal,
2463                orientation: PasteOrientation::Normal,
2464            },
2465            dry_run: true,
2466            lease_token: None,
2467            ledger_path: PathBuf::new(),
2468        };
2469        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2470        assert!(
2471            matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
2472            "{:?}",
2473            outcome.result
2474        );
2475    }
2476
2477    #[tokio::test]
2478    async fn an_unbounded_destination_is_refused() {
2479        let server = wiremock::MockServer::start().await;
2480        let (drive, sheets) = clients(&server).await;
2481        mount_file(
2482            "sheet-1",
2483            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2484            &["folder-1"],
2485        )
2486        .mount(&server)
2487        .await;
2488        mount_folder("folder-1").mount(&server).await;
2489        mount_workbook(&server).await;
2490        let rules = vec![allow_rule(
2491            "folder-1",
2492            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2493        )];
2494        let opts = PasteOptions {
2495            spreadsheet_id: "sheet-1".to_string(),
2496            verb: PasteVerb::CopyPaste {
2497                sheet: None,
2498                source: "'Q1'!A1:B2".to_string(),
2499                destination: "'Q1'!A:A".to_string(),
2500                paste_type: PasteType::Normal,
2501                orientation: PasteOrientation::Normal,
2502            },
2503            dry_run: true,
2504            lease_token: None,
2505            ledger_path: PathBuf::new(),
2506        };
2507        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2508        match outcome.result {
2509            PasteResult::RefusedInvalidRange { detail } => {
2510                assert!(detail.contains("bounded"), "{detail}");
2511            }
2512            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2513        }
2514    }
2515
2516    #[tokio::test]
2517    async fn a_source_naming_an_unknown_sheet_is_refused() {
2518        let server = wiremock::MockServer::start().await;
2519        let (drive, sheets) = clients(&server).await;
2520        mount_file(
2521            "sheet-1",
2522            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2523            &["folder-1"],
2524        )
2525        .mount(&server)
2526        .await;
2527        mount_folder("folder-1").mount(&server).await;
2528        mount_workbook(&server).await;
2529        let rules = vec![allow_rule(
2530            "folder-1",
2531            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2532        )];
2533        let opts = PasteOptions {
2534            spreadsheet_id: "sheet-1".to_string(),
2535            verb: PasteVerb::CopyPaste {
2536                sheet: None,
2537                source: "'Ghost'!A1:B2".to_string(),
2538                destination: "'Q1'!D1:E2".to_string(),
2539                paste_type: PasteType::Normal,
2540                orientation: PasteOrientation::Normal,
2541            },
2542            dry_run: true,
2543            lease_token: None,
2544            ledger_path: PathBuf::new(),
2545        };
2546        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2547        match outcome.result {
2548            PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
2549            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2550        }
2551    }
2552
2553    #[tokio::test]
2554    async fn a_source_with_an_unparseable_range_is_refused() {
2555        let server = wiremock::MockServer::start().await;
2556        let (drive, sheets) = clients(&server).await;
2557        mount_file(
2558            "sheet-1",
2559            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2560            &["folder-1"],
2561        )
2562        .mount(&server)
2563        .await;
2564        mount_folder("folder-1").mount(&server).await;
2565        mount_workbook(&server).await;
2566        let rules = vec![allow_rule(
2567            "folder-1",
2568            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2569        )];
2570        let opts = PasteOptions {
2571            spreadsheet_id: "sheet-1".to_string(),
2572            verb: PasteVerb::CopyPaste {
2573                sheet: None,
2574                source: "'Q1'!not a range".to_string(),
2575                destination: "'Q1'!D1:E2".to_string(),
2576                paste_type: PasteType::Normal,
2577                orientation: PasteOrientation::Normal,
2578            },
2579            dry_run: true,
2580            lease_token: None,
2581            ledger_path: PathBuf::new(),
2582        };
2583        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2584        assert!(
2585            matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
2586            "{:?}",
2587            outcome.result
2588        );
2589    }
2590
2591    #[tokio::test]
2592    async fn an_unbounded_source_is_refused() {
2593        let server = wiremock::MockServer::start().await;
2594        let (drive, sheets) = clients(&server).await;
2595        mount_file(
2596            "sheet-1",
2597            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2598            &["folder-1"],
2599        )
2600        .mount(&server)
2601        .await;
2602        mount_folder("folder-1").mount(&server).await;
2603        mount_workbook(&server).await;
2604        let rules = vec![allow_rule(
2605            "folder-1",
2606            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2607        )];
2608        let opts = PasteOptions {
2609            spreadsheet_id: "sheet-1".to_string(),
2610            verb: PasteVerb::CopyPaste {
2611                sheet: None,
2612                source: "'Q1'!A:A".to_string(),
2613                destination: "'Q1'!D1:E2".to_string(),
2614                paste_type: PasteType::Normal,
2615                orientation: PasteOrientation::Normal,
2616            },
2617            dry_run: true,
2618            lease_token: None,
2619            ledger_path: PathBuf::new(),
2620        };
2621        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2622        match outcome.result {
2623            PasteResult::RefusedInvalidRange { detail } => {
2624                assert!(
2625                    detail.contains("bounded") && detail.contains("--source"),
2626                    "{detail}"
2627                );
2628            }
2629            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2630        }
2631    }
2632
2633    // ── grid-edge caveat, values.get failures ──
2634
2635    #[tokio::test]
2636    async fn a_destination_past_the_sheets_allocated_grid_gets_a_caveat() {
2637        let server = wiremock::MockServer::start().await;
2638        let (drive, sheets) = clients(&server).await;
2639        mount_file(
2640            "sheet-1",
2641            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2642            &["folder-1"],
2643        )
2644        .mount(&server)
2645        .await;
2646        mount_folder("folder-1").mount(&server).await;
2647        wiremock::Mock::given(wiremock::matchers::method("GET"))
2648            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2649            .respond_with(
2650                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2651                    "spreadsheetId": "sheet-1",
2652                    "properties": {"title": "Budget"},
2653                    "sheets": [
2654                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2655                                         "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2656                    ],
2657                })),
2658            )
2659            .mount(&server)
2660            .await;
2661        // Format-only: no `values.get` mock is needed to reach `WouldChange`.
2662        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
2663        let opts = PasteOptions {
2664            spreadsheet_id: "sheet-1".to_string(),
2665            verb: PasteVerb::CopyPaste {
2666                sheet: Some("Q1".to_string()),
2667                source: "A1:F1".to_string(),
2668                destination: "A1:B1".to_string(),
2669                paste_type: PasteType::Format,
2670                orientation: PasteOrientation::Normal,
2671            },
2672            dry_run: true,
2673            lease_token: None,
2674            ledger_path: PathBuf::new(),
2675        };
2676        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2677        match outcome.result {
2678            PasteResult::WouldChange(change) => {
2679                assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2680            }
2681            other => panic!("expected WouldChange, got {other:?}"),
2682        }
2683    }
2684
2685    #[tokio::test]
2686    async fn a_values_get_failure_for_the_destination_preview_surfaces_as_failed() {
2687        let server = wiremock::MockServer::start().await;
2688        let (drive, sheets) = clients(&server).await;
2689        mount_file(
2690            "sheet-1",
2691            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2692            &["folder-1"],
2693        )
2694        .mount(&server)
2695        .await;
2696        mount_folder("folder-1").mount(&server).await;
2697        mount_workbook(&server).await;
2698        wiremock::Mock::given(wiremock::matchers::method("GET"))
2699            .and(wiremock::matchers::path(
2700                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2701            ))
2702            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2703            .mount(&server)
2704            .await;
2705        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2706        let opts = PasteOptions {
2707            spreadsheet_id: "sheet-1".to_string(),
2708            verb: PasteVerb::CopyPaste {
2709                sheet: Some("Q1".to_string()),
2710                source: "A1:B2".to_string(),
2711                destination: "D1:E2".to_string(),
2712                paste_type: PasteType::Values,
2713                orientation: PasteOrientation::Normal,
2714            },
2715            dry_run: true,
2716            lease_token: None,
2717            ledger_path: PathBuf::new(),
2718        };
2719        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2720        assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2721    }
2722
2723    #[tokio::test]
2724    async fn a_values_get_failure_for_the_cut_paste_source_clear_preview_surfaces_as_failed() {
2725        let server = wiremock::MockServer::start().await;
2726        let (drive, sheets) = clients(&server).await;
2727        mount_file(
2728            "sheet-1",
2729            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2730            &["folder-1"],
2731        )
2732        .mount(&server)
2733        .await;
2734        mount_folder("folder-1").mount(&server).await;
2735        mount_workbook(&server).await;
2736        wiremock::Mock::given(wiremock::matchers::method("GET"))
2737            .and(wiremock::matchers::path(
2738                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2739            ))
2740            .respond_with(
2741                wiremock::ResponseTemplate::new(200)
2742                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
2743            )
2744            .mount(&server)
2745            .await;
2746        wiremock::Mock::given(wiremock::matchers::method("GET"))
2747            .and(wiremock::matchers::path(
2748                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2749            ))
2750            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2751            .mount(&server)
2752            .await;
2753        let rules = vec![allow_rule(
2754            "folder-1",
2755            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2756        )];
2757        let opts = PasteOptions {
2758            spreadsheet_id: "sheet-1".to_string(),
2759            verb: PasteVerb::CutPaste {
2760                sheet: Some("Q1".to_string()),
2761                source: "A1:B2".to_string(),
2762                destination: "D1".to_string(),
2763                paste_type: PasteType::Normal,
2764            },
2765            dry_run: true,
2766            lease_token: None,
2767            ledger_path: PathBuf::new(),
2768        };
2769        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2770        assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2771    }
2772
2773    #[tokio::test]
2774    async fn paste_data_with_empty_data_is_refused_before_any_network_call() {
2775        let server = wiremock::MockServer::start().await;
2776        let (drive, sheets) = clients(&server).await;
2777        // No mocks at all: an empty paste must be refused before the
2778        // metadata fetch, let alone before an extent is computed from it
2779        // (a zero-by-zero extent names no range a read could describe).
2780        let opts = PasteOptions {
2781            spreadsheet_id: "sheet-1".to_string(),
2782            verb: PasteVerb::PasteData {
2783                sheet: Some("Q1".to_string()),
2784                destination: "A1".to_string(),
2785                data: String::new(),
2786                delimiter: "\t".to_string(),
2787                paste_type: PasteType::Values,
2788            },
2789            dry_run: true,
2790            lease_token: None,
2791            ledger_path: PathBuf::new(),
2792        };
2793        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2794        assert_eq!(outcome.result, PasteResult::RefusedEmptyData);
2795        let lines = describe_lines(&outcome).join("\n");
2796        assert!(lines.contains("no data to paste"), "{lines}");
2797    }
2798
2799    #[tokio::test]
2800    async fn paste_data_with_an_empty_delimiter_is_refused_before_any_network_call() {
2801        let server = wiremock::MockServer::start().await;
2802        let (drive, sheets) = clients(&server).await;
2803        // No mocks at all: `--delimiter ''` must be refused before the
2804        // metadata fetch. The API rejects it live with `HTTP 400: Must
2805        // either set html=true or a delimiter` (#1941) — refuse it
2806        // locally instead, matching `text-to-columns --custom-delimiter ''`.
2807        let opts = PasteOptions {
2808            spreadsheet_id: "sheet-1".to_string(),
2809            verb: PasteVerb::PasteData {
2810                sheet: Some("Q1".to_string()),
2811                destination: "A1".to_string(),
2812                data: "a\tb".to_string(),
2813                delimiter: String::new(),
2814                paste_type: PasteType::Values,
2815            },
2816            dry_run: true,
2817            lease_token: None,
2818            ledger_path: PathBuf::new(),
2819        };
2820        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2821        assert_eq!(
2822            outcome.result,
2823            PasteResult::RefusedInvalidDelimiter {
2824                detail: "--delimiter must not be empty".to_string()
2825            }
2826        );
2827        let lines = describe_lines(&outcome).join("\n");
2828        assert!(lines.contains("--delimiter must not be empty"), "{lines}");
2829    }
2830
2831    #[tokio::test]
2832    async fn a_sheet_flag_no_range_can_use_is_refused_before_any_network_call() {
2833        let server = wiremock::MockServer::start().await;
2834        let (drive, sheets) = clients(&server).await;
2835        let opts = PasteOptions {
2836            spreadsheet_id: "sheet-1".to_string(),
2837            verb: PasteVerb::CopyPaste {
2838                sheet: Some("Q1".to_string()),
2839                source: "'Q2'!A1:B2".to_string(),
2840                destination: "'Q3'!D1:E2".to_string(),
2841                paste_type: PasteType::Values,
2842                orientation: PasteOrientation::Normal,
2843            },
2844            dry_run: true,
2845            lease_token: None,
2846            ledger_path: PathBuf::new(),
2847        };
2848        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2849        match outcome.result {
2850            PasteResult::RefusedInvalidRange { detail } => {
2851                assert!(detail.contains("--sheet 'Q1'"), "{detail}");
2852            }
2853            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2854        }
2855    }
2856
2857    #[tokio::test]
2858    async fn a_preview_read_is_clipped_to_the_grid_so_the_caveat_survives() {
2859        let server = wiremock::MockServer::start().await;
2860        let (drive, sheets) = clients(&server).await;
2861        mount_file(
2862            "sheet-1",
2863            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2864            &["folder-1"],
2865        )
2866        .mount(&server)
2867        .await;
2868        mount_folder("folder-1").mount(&server).await;
2869        wiremock::Mock::given(wiremock::matchers::method("GET"))
2870            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2871            .respond_with(
2872                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2873                    "spreadsheetId": "sheet-1",
2874                    "properties": {"title": "Budget"},
2875                    "sheets": [
2876                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2877                                         "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2878                    ],
2879                })),
2880            )
2881            .mount(&server)
2882            .await;
2883        // The only `values.get` mock is the *clipped* range: a read of the
2884        // full `'Q1'!D1:I1` extent would 404 here, exactly as the real API
2885        // refuses a range past the grid's edge ("exceeds grid limits").
2886        wiremock::Mock::given(wiremock::matchers::method("GET"))
2887            .and(wiremock::matchers::path(
2888                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E1",
2889            ))
2890            .respond_with(
2891                wiremock::ResponseTemplate::new(200)
2892                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E1", "values": [["x"]]})),
2893            )
2894            .mount(&server)
2895            .await;
2896        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2897        let opts = PasteOptions {
2898            spreadsheet_id: "sheet-1".to_string(),
2899            verb: PasteVerb::CopyPaste {
2900                // A 6-wide source onto a 5-column sheet, anchored at D1:
2901                // the extent runs to column I, three columns past the edge.
2902                sheet: Some("Q1".to_string()),
2903                source: "A1:F1".to_string(),
2904                destination: "D1".to_string(),
2905                paste_type: PasteType::Values,
2906                orientation: PasteOrientation::Normal,
2907            },
2908            dry_run: true,
2909            lease_token: None,
2910            ledger_path: PathBuf::new(),
2911        };
2912        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2913        match outcome.result {
2914            PasteResult::WouldChange(change) => {
2915                // The extent is reported unclipped — it is what the request
2916                // writes — and the caveat says it runs past the grid.
2917                assert_eq!(change.written_extent, "'Q1'!D1:I1");
2918                assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2919                // …while the read that backs it saw only the cells that exist.
2920                assert_eq!(change.overwritten, Some(vec!["D1".to_string()]));
2921            }
2922            other => panic!("expected WouldChange, got {other:?}"),
2923        }
2924    }
2925
2926    #[tokio::test]
2927    async fn a_destination_entirely_past_the_grid_previews_no_overwrite_without_reading() {
2928        let server = wiremock::MockServer::start().await;
2929        let (drive, sheets) = clients(&server).await;
2930        mount_file(
2931            "sheet-1",
2932            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2933            &["folder-1"],
2934        )
2935        .mount(&server)
2936        .await;
2937        mount_folder("folder-1").mount(&server).await;
2938        wiremock::Mock::given(wiremock::matchers::method("GET"))
2939            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2940            .respond_with(
2941                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2942                    "spreadsheetId": "sheet-1",
2943                    "properties": {"title": "Budget"},
2944                    "sheets": [
2945                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2946                                         "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2947                    ],
2948                })),
2949            )
2950            .mount(&server)
2951            .await;
2952        // No `values.get` mock: nothing of the extent lies inside the grid,
2953        // so there is nothing to read and no cell that could be overwritten.
2954        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2955        let opts = PasteOptions {
2956            spreadsheet_id: "sheet-1".to_string(),
2957            verb: PasteVerb::CopyPaste {
2958                sheet: Some("Q1".to_string()),
2959                source: "A1:A1".to_string(),
2960                destination: "A20".to_string(),
2961                paste_type: PasteType::Values,
2962                orientation: PasteOrientation::Normal,
2963            },
2964            dry_run: true,
2965            lease_token: None,
2966            ledger_path: PathBuf::new(),
2967        };
2968        let outcome = paste(&drive, &sheets, &opts, &rules).await;
2969        match outcome.result {
2970            PasteResult::WouldChange(change) => {
2971                assert_eq!(change.overwritten, Some(Vec::new()));
2972                assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2973            }
2974            other => panic!("expected WouldChange, got {other:?}"),
2975        }
2976    }
2977
2978    #[tokio::test]
2979    async fn no_cell_value_ever_reaches_the_rendered_output() {
2980        // ADR-0083 §6: a paste preview reports counts and A1 locations, and
2981        // `merge-cells` stays the one verb in this crate that prints cell
2982        // contents. Asserted on the rendered lines, not on the struct, since
2983        // the rendering is where a value would leak.
2984        let server = wiremock::MockServer::start().await;
2985        let (drive, sheets) = clients(&server).await;
2986        mount_file(
2987            "sheet-1",
2988            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2989            &["folder-1"],
2990        )
2991        .mount(&server)
2992        .await;
2993        mount_folder("folder-1").mount(&server).await;
2994        mount_workbook(&server).await;
2995        wiremock::Mock::given(wiremock::matchers::method("GET"))
2996            .and(wiremock::matchers::path(
2997                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2998            ))
2999            .respond_with(
3000                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3001                    "range": "'Q1'!D1:E2",
3002                    "values": [["destination-secret", ""], ["", "another-secret"]],
3003                })),
3004            )
3005            .mount(&server)
3006            .await;
3007        wiremock::Mock::given(wiremock::matchers::method("GET"))
3008            .and(wiremock::matchers::path(
3009                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3010            ))
3011            .respond_with(
3012                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3013                    "range": "'Q1'!A1:B2",
3014                    "values": [["source-secret", "x"], ["y", "z"]],
3015                })),
3016            )
3017            .mount(&server)
3018            .await;
3019        let rules = rules_allowing_everything();
3020        let opts = PasteOptions {
3021            spreadsheet_id: "sheet-1".to_string(),
3022            verb: PasteVerb::CutPaste {
3023                sheet: Some("Q1".to_string()),
3024                source: "A1:B2".to_string(),
3025                destination: "D1".to_string(),
3026                paste_type: PasteType::Normal,
3027            },
3028            dry_run: true,
3029            lease_token: None,
3030            ledger_path: PathBuf::new(),
3031        };
3032        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3033        let rendered = describe_lines(&outcome).join("\n");
3034        for secret in ["destination-secret", "another-secret", "source-secret"] {
3035            assert!(
3036                !rendered.contains(secret),
3037                "{secret} leaked into: {rendered}"
3038            );
3039        }
3040        // The counts and locations are there instead.
3041        assert!(
3042            rendered.contains("2 non-blank cell(s) in the destination"),
3043            "{rendered}"
3044        );
3045        assert!(
3046            rendered.contains("in the destination would be overwritten: D1"),
3047            "{rendered}"
3048        );
3049        assert!(
3050            rendered.contains("4 non-blank cell(s) in the source"),
3051            "{rendered}"
3052        );
3053    }
3054
3055    // ── real (non-dry-run) mutations ──
3056
3057    #[tokio::test]
3058    async fn cut_paste_real_run_succeeds_and_reports_changed() {
3059        let server = wiremock::MockServer::start().await;
3060        let (drive, sheets) = clients(&server).await;
3061        mount_file(
3062            "sheet-1",
3063            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3064            &["folder-1"],
3065        )
3066        .mount(&server)
3067        .await;
3068        mount_folder("folder-1").mount(&server).await;
3069        mount_workbook(&server).await;
3070        wiremock::Mock::given(wiremock::matchers::method("GET"))
3071            .and(wiremock::matchers::path(
3072                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3073            ))
3074            .respond_with(
3075                wiremock::ResponseTemplate::new(200)
3076                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3077            )
3078            .mount(&server)
3079            .await;
3080        wiremock::Mock::given(wiremock::matchers::method("GET"))
3081            .and(wiremock::matchers::path(
3082                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3083            ))
3084            .respond_with(
3085                wiremock::ResponseTemplate::new(200)
3086                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3087            )
3088            .mount(&server)
3089            .await;
3090        wiremock::Mock::given(wiremock::matchers::method("POST"))
3091            .and(wiremock::matchers::path(
3092                "/v4/spreadsheets/sheet-1:batchUpdate",
3093            ))
3094            .respond_with(
3095                wiremock::ResponseTemplate::new(200)
3096                    .set_body_json(serde_json::json!({"replies": [{}]})),
3097            )
3098            .mount(&server)
3099            .await;
3100        let rules = vec![allow_rule(
3101            "folder-1",
3102            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3103        )];
3104        let opts = PasteOptions {
3105            spreadsheet_id: "sheet-1".to_string(),
3106            verb: PasteVerb::CutPaste {
3107                sheet: Some("Q1".to_string()),
3108                source: "A1:B2".to_string(),
3109                destination: "D1".to_string(),
3110                paste_type: PasteType::Normal,
3111            },
3112            dry_run: false,
3113            lease_token: None,
3114            ledger_path: PathBuf::new(),
3115        };
3116        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3117        assert!(
3118            matches!(outcome.result, PasteResult::Changed(_)),
3119            "{:?}",
3120            outcome.result
3121        );
3122    }
3123
3124    #[tokio::test]
3125    async fn cut_paste_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3126        let server = wiremock::MockServer::start().await;
3127        let (drive, sheets) = clients(&server).await;
3128        mount_file(
3129            "sheet-1",
3130            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3131            &["folder-1"],
3132        )
3133        .mount(&server)
3134        .await;
3135        mount_folder("folder-1").mount(&server).await;
3136        mount_workbook(&server).await;
3137        wiremock::Mock::given(wiremock::matchers::method("GET"))
3138            .and(wiremock::matchers::path(
3139                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3140            ))
3141            .respond_with(
3142                wiremock::ResponseTemplate::new(200)
3143                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3144            )
3145            .mount(&server)
3146            .await;
3147        wiremock::Mock::given(wiremock::matchers::method("GET"))
3148            .and(wiremock::matchers::path(
3149                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3150            ))
3151            .respond_with(
3152                wiremock::ResponseTemplate::new(200)
3153                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3154            )
3155            .mount(&server)
3156            .await;
3157        // 2xx, so the paste WAS applied — but the body is not the reply shape.
3158        wiremock::Mock::given(wiremock::matchers::method("POST"))
3159            .and(wiremock::matchers::path(
3160                "/v4/spreadsheets/sheet-1:batchUpdate",
3161            ))
3162            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3163            .expect(1)
3164            .mount(&server)
3165            .await;
3166        let rules = vec![allow_rule(
3167            "folder-1",
3168            &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3169        )];
3170        let opts = PasteOptions {
3171            spreadsheet_id: "sheet-1".to_string(),
3172            verb: PasteVerb::CutPaste {
3173                sheet: Some("Q1".to_string()),
3174                source: "A1:B2".to_string(),
3175                destination: "D1".to_string(),
3176                paste_type: PasteType::Normal,
3177            },
3178            dry_run: false,
3179            lease_token: None,
3180            ledger_path: PathBuf::new(),
3181        };
3182        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3183        assert!(
3184            matches!(outcome.result, PasteResult::AppliedReplyUnreadable { .. }),
3185            "{:?}",
3186            outcome.result
3187        );
3188        assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3189        let lines = describe_lines(&outcome);
3190        assert!(
3191            lines[0].starts_with("Applied, but the reply could not be read"),
3192            "{lines:?}"
3193        );
3194        assert!(lines[0].contains("do not retry"), "{lines:?}");
3195    }
3196
3197    #[test]
3198    fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
3199        let outcome = |result| PasteOutcome {
3200            spreadsheet_id: "sheet-1".to_string(),
3201            file_name: Some("Budget".to_string()),
3202            resolved_folder_id: None,
3203            verb: every_paste_verb().remove(0),
3204            result,
3205        };
3206        let unreadable = describe_lines(&outcome(PasteResult::AppliedReplyUnreadable {
3207            summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3208            change: change_for_unreadable(),
3209            detail: "bad reply".to_string(),
3210        }));
3211        let changed = describe_lines(&outcome(PasteResult::Changed(change_for_unreadable())));
3212        assert_eq!(
3213            unreadable[0],
3214            "Applied, but the reply could not be read: cut-paste into 'Q1'!D1 (writing \
3215             'Q1'!D1:E2) in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
3216        );
3217        // Overwritten, cleared and grid-edge lines all survive.
3218        assert!(unreadable.len() > 3, "{unreadable:?}");
3219        assert_eq!(unreadable[1..], changed[1..]);
3220    }
3221
3222    /// An applied paste whose reply was unreadable serialises the same
3223    /// plan-time fields `Changed` does, flattened beside the tag.
3224    #[test]
3225    fn an_unreadable_reply_serialises_the_change_fields_beside_the_status() {
3226        let value = serde_json::to_value(PasteResult::AppliedReplyUnreadable {
3227            summary: "s".to_string(),
3228            change: change_for_unreadable(),
3229            detail: "bad reply".to_string(),
3230        })
3231        .unwrap();
3232        assert_eq!(value["status"], "applied-reply-unreadable");
3233        assert_eq!(value["destination"], "'Q1'!D1");
3234        assert_eq!(value["overwritten"], serde_json::json!(["D1"]));
3235        assert_eq!(value["cleared"], serde_json::json!(["A1"]));
3236        assert_eq!(value["detail"], "bad reply");
3237    }
3238
3239    #[test]
3240    fn an_unreadable_reply_logs_the_summary_as_fields_changed() {
3241        let verb = every_paste_verb().remove(0);
3242        let opts = PasteOptions {
3243            spreadsheet_id: "sheet-1".to_string(),
3244            verb: verb.clone(),
3245            dry_run: false,
3246            lease_token: None,
3247            ledger_path: PathBuf::new(),
3248        };
3249        let record = mutation_record(
3250            &PasteOutcome {
3251                spreadsheet_id: "sheet-1".to_string(),
3252                file_name: Some("Budget".to_string()),
3253                resolved_folder_id: None,
3254                verb,
3255                result: PasteResult::AppliedReplyUnreadable {
3256                    summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3257                    change: change_for_unreadable(),
3258                    detail: "bad reply".to_string(),
3259                },
3260            },
3261            &opts,
3262            Duration::from_millis(1),
3263        );
3264        assert_eq!(
3265            record.fields_changed.as_deref(),
3266            Some("cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)")
3267        );
3268        assert_eq!(record.error.as_deref(), Some("bad reply"));
3269    }
3270
3271    #[tokio::test]
3272    async fn copy_paste_real_run_succeeds_and_reports_changed() {
3273        let server = wiremock::MockServer::start().await;
3274        let (drive, sheets) = clients(&server).await;
3275        mount_file(
3276            "sheet-1",
3277            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3278            &["folder-1"],
3279        )
3280        .mount(&server)
3281        .await;
3282        mount_folder("folder-1").mount(&server).await;
3283        mount_workbook(&server).await;
3284        wiremock::Mock::given(wiremock::matchers::method("GET"))
3285            .and(wiremock::matchers::path(
3286                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3287            ))
3288            .respond_with(
3289                wiremock::ResponseTemplate::new(200)
3290                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3291            )
3292            .mount(&server)
3293            .await;
3294        wiremock::Mock::given(wiremock::matchers::method("POST"))
3295            .and(wiremock::matchers::path(
3296                "/v4/spreadsheets/sheet-1:batchUpdate",
3297            ))
3298            .respond_with(
3299                wiremock::ResponseTemplate::new(200)
3300                    .set_body_json(serde_json::json!({"replies": [{}]})),
3301            )
3302            .mount(&server)
3303            .await;
3304        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
3305        let opts = PasteOptions {
3306            spreadsheet_id: "sheet-1".to_string(),
3307            verb: PasteVerb::CopyPaste {
3308                sheet: Some("Q1".to_string()),
3309                source: "A1:B2".to_string(),
3310                destination: "D1:E2".to_string(),
3311                paste_type: PasteType::Values,
3312                orientation: PasteOrientation::Normal,
3313            },
3314            dry_run: false,
3315            lease_token: None,
3316            ledger_path: PathBuf::new(),
3317        };
3318        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3319        assert!(
3320            matches!(outcome.result, PasteResult::Changed(_)),
3321            "{:?}",
3322            outcome.result
3323        );
3324    }
3325
3326    /// The single `batchUpdate` body the server received, as JSON.
3327    async fn sent_batch_update(server: &wiremock::MockServer) -> serde_json::Value {
3328        let requests = server.received_requests().await.expect("recorded requests");
3329        let body = requests
3330            .iter()
3331            .find(|r| r.url.path().ends_with(":batchUpdate"))
3332            .map(|r| r.body.clone())
3333            .expect("a batchUpdate request");
3334        serde_json::from_slice(&body).expect("a JSON body")
3335    }
3336
3337    #[tokio::test]
3338    async fn cut_paste_sends_the_source_range_and_a_destination_coordinate() {
3339        // The engine→wire mapping: `types.rs` pins how a `CutPasteRequest`
3340        // serialises, this pins that the right one is built — that the
3341        // destination becomes a `GridCoordinate` at the range's top-left
3342        // and the two ends are not transposed.
3343        let server = wiremock::MockServer::start().await;
3344        let (drive, sheets) = clients(&server).await;
3345        mount_file(
3346            "sheet-1",
3347            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3348            &["folder-1"],
3349        )
3350        .mount(&server)
3351        .await;
3352        mount_folder("folder-1").mount(&server).await;
3353        mount_workbook(&server).await;
3354        for range in ["'Q1'!D1:E2", "'Q1'!A1:B2"] {
3355            wiremock::Mock::given(wiremock::matchers::method("GET"))
3356                .and(wiremock::matchers::path(format!(
3357                    "/v4/spreadsheets/sheet-1/values/{range}"
3358                )))
3359                .respond_with(
3360                    wiremock::ResponseTemplate::new(200)
3361                        .set_body_json(serde_json::json!({"range": range, "values": []})),
3362                )
3363                .mount(&server)
3364                .await;
3365        }
3366        wiremock::Mock::given(wiremock::matchers::method("POST"))
3367            .and(wiremock::matchers::path(
3368                "/v4/spreadsheets/sheet-1:batchUpdate",
3369            ))
3370            .respond_with(
3371                wiremock::ResponseTemplate::new(200)
3372                    .set_body_json(serde_json::json!({"replies": [{}]})),
3373            )
3374            .mount(&server)
3375            .await;
3376        let opts = PasteOptions {
3377            spreadsheet_id: "sheet-1".to_string(),
3378            verb: PasteVerb::CutPaste {
3379                sheet: Some("Q1".to_string()),
3380                source: "A1:B2".to_string(),
3381                destination: "D1".to_string(),
3382                paste_type: PasteType::Values,
3383            },
3384            dry_run: false,
3385            lease_token: None,
3386            ledger_path: PathBuf::new(),
3387        };
3388        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3389        assert!(
3390            matches!(outcome.result, PasteResult::Changed(_)),
3391            "{:?}",
3392            outcome.result
3393        );
3394        assert_eq!(
3395            sent_batch_update(&server).await,
3396            serde_json::json!({
3397                "requests": [{
3398                    "cutPaste": {
3399                        "source": {
3400                            "sheetId": 0,
3401                            "startRowIndex": 0, "endRowIndex": 2,
3402                            "startColumnIndex": 0, "endColumnIndex": 2,
3403                        },
3404                        "destination": {"sheetId": 0, "rowIndex": 0, "columnIndex": 3},
3405                        "pasteType": "PASTE_VALUES",
3406                    },
3407                }],
3408            })
3409        );
3410    }
3411
3412    #[tokio::test]
3413    async fn copy_paste_sends_both_ranges_with_the_requested_orientation() {
3414        let server = wiremock::MockServer::start().await;
3415        let (drive, sheets) = clients(&server).await;
3416        mount_file(
3417            "sheet-1",
3418            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3419            &["folder-1"],
3420        )
3421        .mount(&server)
3422        .await;
3423        mount_folder("folder-1").mount(&server).await;
3424        mount_workbook(&server).await;
3425        wiremock::Mock::given(wiremock::matchers::method("GET"))
3426            .and(wiremock::matchers::path(
3427                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3428            ))
3429            .respond_with(
3430                wiremock::ResponseTemplate::new(200)
3431                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3432            )
3433            .mount(&server)
3434            .await;
3435        wiremock::Mock::given(wiremock::matchers::method("POST"))
3436            .and(wiremock::matchers::path(
3437                "/v4/spreadsheets/sheet-1:batchUpdate",
3438            ))
3439            .respond_with(
3440                wiremock::ResponseTemplate::new(200)
3441                    .set_body_json(serde_json::json!({"replies": [{}]})),
3442            )
3443            .mount(&server)
3444            .await;
3445        let opts = PasteOptions {
3446            spreadsheet_id: "sheet-1".to_string(),
3447            verb: PasteVerb::CopyPaste {
3448                sheet: Some("Q1".to_string()),
3449                source: "A1:B2".to_string(),
3450                destination: "D1:E2".to_string(),
3451                paste_type: PasteType::Values,
3452                orientation: PasteOrientation::Transpose,
3453            },
3454            dry_run: false,
3455            lease_token: None,
3456            ledger_path: PathBuf::new(),
3457        };
3458        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3459        assert!(
3460            matches!(outcome.result, PasteResult::Changed(_)),
3461            "{:?}",
3462            outcome.result
3463        );
3464        assert_eq!(
3465            sent_batch_update(&server).await,
3466            serde_json::json!({
3467                "requests": [{
3468                    "copyPaste": {
3469                        "source": {
3470                            "sheetId": 0,
3471                            "startRowIndex": 0, "endRowIndex": 2,
3472                            "startColumnIndex": 0, "endColumnIndex": 2,
3473                        },
3474                        "destination": {
3475                            "sheetId": 0,
3476                            "startRowIndex": 0, "endRowIndex": 2,
3477                            "startColumnIndex": 3, "endColumnIndex": 5,
3478                        },
3479                        "pasteType": "PASTE_VALUES",
3480                        "pasteOrientation": "TRANSPOSE",
3481                    },
3482                }],
3483            })
3484        );
3485    }
3486
3487    #[tokio::test]
3488    async fn paste_data_sends_the_delimiter_form_verbatim() {
3489        let server = wiremock::MockServer::start().await;
3490        let (drive, sheets) = clients(&server).await;
3491        mount_file(
3492            "sheet-1",
3493            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3494            &["folder-1"],
3495        )
3496        .mount(&server)
3497        .await;
3498        mount_folder("folder-1").mount(&server).await;
3499        mount_workbook(&server).await;
3500        wiremock::Mock::given(wiremock::matchers::method("GET"))
3501            .and(wiremock::matchers::path(
3502                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3503            ))
3504            .respond_with(
3505                wiremock::ResponseTemplate::new(200)
3506                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3507            )
3508            .mount(&server)
3509            .await;
3510        wiremock::Mock::given(wiremock::matchers::method("POST"))
3511            .and(wiremock::matchers::path(
3512                "/v4/spreadsheets/sheet-1:batchUpdate",
3513            ))
3514            .respond_with(
3515                wiremock::ResponseTemplate::new(200)
3516                    .set_body_json(serde_json::json!({"replies": [{}]})),
3517            )
3518            .mount(&server)
3519            .await;
3520        let opts = PasteOptions {
3521            spreadsheet_id: "sheet-1".to_string(),
3522            verb: PasteVerb::PasteData {
3523                sheet: Some("Q1".to_string()),
3524                destination: "A1".to_string(),
3525                // The trailing terminator is sent as the caller gave it —
3526                // only the *preview's* row count ignores it.
3527                data: "1\t2\n3\t4\n".to_string(),
3528                delimiter: "\t".to_string(),
3529                paste_type: PasteType::Values,
3530            },
3531            dry_run: false,
3532            lease_token: None,
3533            ledger_path: PathBuf::new(),
3534        };
3535        let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3536        assert!(
3537            matches!(outcome.result, PasteResult::Changed(_)),
3538            "{:?}",
3539            outcome.result
3540        );
3541        assert_eq!(
3542            sent_batch_update(&server).await,
3543            serde_json::json!({
3544                "requests": [{
3545                    "pasteData": {
3546                        "coordinate": {"sheetId": 0, "rowIndex": 0, "columnIndex": 0},
3547                        "data": "1\t2\n3\t4\n",
3548                        "delimiter": "\t",
3549                        "type": "PASTE_VALUES",
3550                    },
3551                }],
3552            })
3553        );
3554    }
3555
3556    #[tokio::test]
3557    async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
3558        let server = wiremock::MockServer::start().await;
3559        let (drive, sheets) = clients(&server).await;
3560        mount_file(
3561            "sheet-1",
3562            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3563            &["folder-1"],
3564        )
3565        .mount(&server)
3566        .await;
3567        mount_folder("folder-1").mount(&server).await;
3568        mount_workbook(&server).await;
3569        wiremock::Mock::given(wiremock::matchers::method("GET"))
3570            .and(wiremock::matchers::path(
3571                "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3572            ))
3573            .respond_with(
3574                wiremock::ResponseTemplate::new(200)
3575                    .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3576            )
3577            .mount(&server)
3578            .await;
3579        wiremock::Mock::given(wiremock::matchers::method("POST"))
3580            .and(wiremock::matchers::path(
3581                "/v4/spreadsheets/sheet-1:batchUpdate",
3582            ))
3583            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3584            .mount(&server)
3585            .await;
3586        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
3587        let opts = PasteOptions {
3588            spreadsheet_id: "sheet-1".to_string(),
3589            verb: PasteVerb::CopyPaste {
3590                sheet: Some("Q1".to_string()),
3591                source: "A1:B2".to_string(),
3592                destination: "D1:E2".to_string(),
3593                paste_type: PasteType::Values,
3594                orientation: PasteOrientation::Normal,
3595            },
3596            dry_run: false,
3597            lease_token: None,
3598            ledger_path: PathBuf::new(),
3599        };
3600        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3601        assert!(
3602            matches!(outcome.result, PasteResult::Failed { .. }),
3603            "{:?}",
3604            outcome.result
3605        );
3606    }
3607
3608    // ── lease refusals ──
3609
3610    #[tokio::test]
3611    async fn paste_data_refuses_an_unknown_lease_token() {
3612        let server = wiremock::MockServer::start().await;
3613        let (drive, sheets) = clients(&server).await;
3614        mount_file(
3615            "sheet-1",
3616            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3617            &["folder-1"],
3618        )
3619        .mount(&server)
3620        .await;
3621        mount_folder("folder-1").mount(&server).await;
3622        mount_workbook(&server).await;
3623        wiremock::Mock::given(wiremock::matchers::method("GET"))
3624            .and(wiremock::matchers::path(
3625                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3626            ))
3627            .respond_with(
3628                wiremock::ResponseTemplate::new(200)
3629                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3630            )
3631            .mount(&server)
3632            .await;
3633        let rules = vec![FolderPermissionRule::folder("folder-1")
3634            .allowing([DriveOperation::SheetsWrite])
3635            .requiring_lease(true)];
3636        let ledger_path = tempfile::tempdir()
3637            .unwrap()
3638            .keep()
3639            .join("lease-ledger.jsonl");
3640        let opts = PasteOptions {
3641            spreadsheet_id: "sheet-1".to_string(),
3642            verb: PasteVerb::PasteData {
3643                sheet: Some("Q1".to_string()),
3644                destination: "A1".to_string(),
3645                data: "1\t2".to_string(),
3646                delimiter: "\t".to_string(),
3647                paste_type: PasteType::Values,
3648            },
3649            dry_run: false,
3650            lease_token: Some("bogus-token".to_string()),
3651            ledger_path,
3652        };
3653        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3654        assert!(matches!(outcome.result, PasteResult::RefusedLeaseExpired));
3655    }
3656
3657    #[tokio::test]
3658    async fn paste_data_refuses_a_lease_bound_to_a_different_file() {
3659        let server = wiremock::MockServer::start().await;
3660        let (drive, sheets) = clients(&server).await;
3661        mount_file(
3662            "sheet-1",
3663            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3664            &["folder-1"],
3665        )
3666        .mount(&server)
3667        .await;
3668        mount_folder("folder-1").mount(&server).await;
3669        mount_workbook(&server).await;
3670        wiremock::Mock::given(wiremock::matchers::method("GET"))
3671            .and(wiremock::matchers::path(
3672                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3673            ))
3674            .respond_with(
3675                wiremock::ResponseTemplate::new(200)
3676                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3677            )
3678            .mount(&server)
3679            .await;
3680        let rules = vec![FolderPermissionRule::folder("folder-1")
3681            .allowing([DriveOperation::SheetsWrite])
3682            .requiring_lease(true)];
3683        let ledger_path = tempfile::tempdir()
3684            .unwrap()
3685            .keep()
3686            .join("lease-ledger.jsonl");
3687        let token = crate::drive::test_support::seed_lease(&ledger_path, "some-other-sheet", "1");
3688        let opts = PasteOptions {
3689            spreadsheet_id: "sheet-1".to_string(),
3690            verb: PasteVerb::PasteData {
3691                sheet: Some("Q1".to_string()),
3692                destination: "A1".to_string(),
3693                data: "1\t2".to_string(),
3694                delimiter: "\t".to_string(),
3695                paste_type: PasteType::Values,
3696            },
3697            dry_run: false,
3698            lease_token: Some(token),
3699            ledger_path,
3700        };
3701        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3702        assert!(matches!(outcome.result, PasteResult::RefusedLeaseWrongFile));
3703    }
3704
3705    #[tokio::test]
3706    async fn paste_data_refuses_a_stale_lease_when_the_file_has_moved() {
3707        let server = wiremock::MockServer::start().await;
3708        let (drive, sheets) = clients(&server).await;
3709        mount_file(
3710            "sheet-1",
3711            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3712            &["folder-1"],
3713        )
3714        .mount(&server)
3715        .await;
3716        mount_folder("folder-1").mount(&server).await;
3717        mount_workbook(&server).await;
3718        wiremock::Mock::given(wiremock::matchers::method("GET"))
3719            .and(wiremock::matchers::path(
3720                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3721            ))
3722            .respond_with(
3723                wiremock::ResponseTemplate::new(200)
3724                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3725            )
3726            .mount(&server)
3727            .await;
3728        let rules = vec![FolderPermissionRule::folder("folder-1")
3729            .allowing([DriveOperation::SheetsWrite])
3730            .requiring_lease(true)];
3731        let ledger_path = tempfile::tempdir()
3732            .unwrap()
3733            .keep()
3734            .join("lease-ledger.jsonl");
3735        let token = crate::drive::test_support::seed_lease(&ledger_path, "sheet-1", "0");
3736        let opts = PasteOptions {
3737            spreadsheet_id: "sheet-1".to_string(),
3738            verb: PasteVerb::PasteData {
3739                sheet: Some("Q1".to_string()),
3740                destination: "A1".to_string(),
3741                data: "1\t2".to_string(),
3742                delimiter: "\t".to_string(),
3743                paste_type: PasteType::Values,
3744            },
3745            dry_run: false,
3746            lease_token: Some(token),
3747            ledger_path,
3748        };
3749        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3750        assert!(matches!(outcome.result, PasteResult::RefusedLeaseStale));
3751    }
3752
3753    #[tokio::test]
3754    async fn a_required_lease_without_one_refuses_before_batch_update() {
3755        let server = wiremock::MockServer::start().await;
3756        let (drive, sheets) = clients(&server).await;
3757        mount_file(
3758            "sheet-1",
3759            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3760            &["folder-1"],
3761        )
3762        .mount(&server)
3763        .await;
3764        mount_folder("folder-1").mount(&server).await;
3765        mount_workbook(&server).await;
3766        wiremock::Mock::given(wiremock::matchers::method("GET"))
3767            .and(wiremock::matchers::path(
3768                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3769            ))
3770            .respond_with(
3771                wiremock::ResponseTemplate::new(200)
3772                    .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3773            )
3774            .mount(&server)
3775            .await;
3776        // No `batchUpdate` mock — reaching it would 404 and mask the real
3777        // failure as `Failed` rather than `RefusedNoLease`.
3778        let rules = vec![FolderPermissionRule::folder("folder-1")
3779            .allowing([DriveOperation::SheetsWrite])
3780            .requiring_lease(true)];
3781
3782        let opts = PasteOptions {
3783            spreadsheet_id: "sheet-1".to_string(),
3784            verb: PasteVerb::PasteData {
3785                sheet: Some("Q1".to_string()),
3786                destination: "A1".to_string(),
3787                data: "1\t2".to_string(),
3788                delimiter: "\t".to_string(),
3789                paste_type: PasteType::Values,
3790            },
3791            dry_run: false,
3792            lease_token: None,
3793            ledger_path: PathBuf::from("/nonexistent/ledger.yaml"),
3794        };
3795        let outcome = paste(&drive, &sheets, &opts, &rules).await;
3796        assert_eq!(outcome.result, PasteResult::RefusedNoLease);
3797    }
3798
3799    #[tokio::test]
3800    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
3801        let server = wiremock::MockServer::start().await;
3802        let (drive, sheets) = clients(&server).await;
3803        mount_file(
3804            "sheet-1",
3805            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3806            &["folder-1"],
3807        )
3808        .mount(&server)
3809        .await;
3810        mount_folder("folder-1").mount(&server).await;
3811        let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
3812        let verb = PasteVerb::CopyPaste {
3813            sheet: Some("Q1".to_string()),
3814            source: "A1:B2".to_string(),
3815            destination: "D1:E2".to_string(),
3816            paste_type: PasteType::Values,
3817            orientation: PasteOrientation::Normal,
3818        };
3819
3820        let dry = paste(
3821            &drive,
3822            &sheets,
3823            &PasteOptions {
3824                spreadsheet_id: "sheet-1".to_string(),
3825                verb: verb.clone(),
3826                dry_run: true,
3827                lease_token: None,
3828                ledger_path: PathBuf::new(),
3829            },
3830            &rules,
3831        )
3832        .await;
3833        let real = paste(
3834            &drive,
3835            &sheets,
3836            &PasteOptions {
3837                spreadsheet_id: "sheet-1".to_string(),
3838                verb,
3839                dry_run: false,
3840                lease_token: None,
3841                ledger_path: PathBuf::new(),
3842            },
3843            &rules,
3844        )
3845        .await;
3846        assert_eq!(dry.result, real.result);
3847    }
3848
3849    // ── `describe_lines` over every result variant, every verb ──
3850
3851    fn change_for_unreadable() -> PasteChange {
3852        PasteChange {
3853            destination: "'Q1'!D1".to_string(),
3854            source: Some("'Q1'!A1:B2".to_string()),
3855            written_extent: "'Q1'!D1:E2".to_string(),
3856            overwritten: Some(vec!["D1".to_string()]),
3857            cleared: Some(vec!["A1".to_string()]),
3858            grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
3859        }
3860    }
3861
3862    fn every_paste_result() -> Vec<PasteResult> {
3863        let change = PasteChange {
3864            destination: "'Q1'!D1".to_string(),
3865            source: Some("'Q1'!A1:B2".to_string()),
3866            written_extent: "'Q1'!D1:E2".to_string(),
3867            overwritten: Some(vec!["D1".to_string()]),
3868            cleared: Some(vec!["A1".to_string()]),
3869            grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
3870        };
3871        vec![
3872            PasteResult::WouldChange(change.clone()),
3873            PasteResult::Changed(change),
3874            PasteResult::RefusedNotASpreadsheet {
3875                mime_type: "text/plain".to_string(),
3876            },
3877            PasteResult::RefusedShortcut,
3878            PasteResult::RefusedNoVisibleParents,
3879            PasteResult::RefusedSheetNotFound {
3880                title: "Ghost".to_string(),
3881                available: vec!["Q1".to_string()],
3882            },
3883            PasteResult::RefusedInvalidRange {
3884                detail: "bad range".to_string(),
3885            },
3886            PasteResult::RefusedEmptyData,
3887            PasteResult::Blocked {
3888                operation: DriveOperation::SheetsWrite,
3889                decided_by: None,
3890            },
3891            PasteResult::RefusedNoLease,
3892            PasteResult::RefusedLeaseExpired,
3893            PasteResult::RefusedLeaseWrongFile,
3894            PasteResult::RefusedLeaseStale,
3895            PasteResult::AppliedReplyUnreadable {
3896                summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3897                change: change_for_unreadable(),
3898                detail: "bad reply".to_string(),
3899            },
3900            PasteResult::Failed {
3901                detail: "boom".to_string(),
3902            },
3903        ]
3904    }
3905
3906    fn every_paste_verb() -> Vec<PasteVerb> {
3907        vec![
3908            PasteVerb::CutPaste {
3909                sheet: Some("Q1".to_string()),
3910                source: "A1:B2".to_string(),
3911                destination: "D1".to_string(),
3912                paste_type: PasteType::Normal,
3913            },
3914            PasteVerb::CopyPaste {
3915                sheet: Some("Q1".to_string()),
3916                source: "A1:B2".to_string(),
3917                destination: "D1:E2".to_string(),
3918                paste_type: PasteType::Values,
3919                orientation: PasteOrientation::Transpose,
3920            },
3921            PasteVerb::PasteData {
3922                sheet: Some("Q1".to_string()),
3923                destination: "D1".to_string(),
3924                data: "1\t2".to_string(),
3925                delimiter: "\t".to_string(),
3926                paste_type: PasteType::Values,
3927            },
3928        ]
3929    }
3930
3931    #[test]
3932    fn no_describe_line_contains_a_control_character() {
3933        for verb in every_paste_verb() {
3934            for result in every_paste_result() {
3935                let outcome = PasteOutcome {
3936                    spreadsheet_id: "sheet-1".to_string(),
3937                    file_name: Some("Budget".to_string()),
3938                    resolved_folder_id: None,
3939                    verb: verb.clone(),
3940                    result,
3941                };
3942                for rendered in describe_lines(&outcome) {
3943                    assert!(
3944                        !rendered.chars().any(char::is_control),
3945                        "describe_lines emitted a control character for {:?}/{:?}: {rendered:?}",
3946                        outcome.verb,
3947                        outcome.result
3948                    );
3949                }
3950            }
3951        }
3952    }
3953
3954    #[test]
3955    fn describe_lines_reports_no_overwritten_cells_when_the_extent_is_all_blank() {
3956        let change = PasteChange {
3957            destination: "'Q1'!D1".to_string(),
3958            source: None,
3959            written_extent: "'Q1'!D1:E2".to_string(),
3960            overwritten: Some(vec![]),
3961            cleared: None,
3962            grid_edge_caveat: None,
3963        };
3964        let outcome = PasteOutcome {
3965            spreadsheet_id: "sheet-1".to_string(),
3966            file_name: Some("Budget".to_string()),
3967            resolved_folder_id: None,
3968            verb: PasteVerb::PasteData {
3969                sheet: Some("Q1".to_string()),
3970                destination: "D1".to_string(),
3971                data: "1".to_string(),
3972                delimiter: "\t".to_string(),
3973                paste_type: PasteType::Values,
3974            },
3975            result: PasteResult::WouldChange(change),
3976        };
3977        let lines = describe_lines(&outcome);
3978        assert!(
3979            lines.iter().any(|l| l.contains("no non-blank cells")),
3980            "{lines:?}"
3981        );
3982    }
3983
3984    #[test]
3985    fn describe_lines_elides_a_destination_overwrite_past_the_render_limit() {
3986        let overwritten: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
3987        let change = PasteChange {
3988            destination: "'Q1'!D1".to_string(),
3989            source: Some("'Q1'!A1:B60".to_string()),
3990            written_extent: "'Q1'!D1:D60".to_string(),
3991            overwritten: Some(overwritten.clone()),
3992            cleared: None,
3993            grid_edge_caveat: None,
3994        };
3995        let outcome = PasteOutcome {
3996            spreadsheet_id: "sheet-1".to_string(),
3997            file_name: Some("Budget".to_string()),
3998            resolved_folder_id: None,
3999            verb: PasteVerb::CopyPaste {
4000                sheet: Some("Q1".to_string()),
4001                source: "A1:B60".to_string(),
4002                destination: "D1".to_string(),
4003                paste_type: PasteType::Values,
4004                orientation: PasteOrientation::Normal,
4005            },
4006            result: PasteResult::WouldChange(change),
4007        };
4008        let rendered = describe_lines(&outcome).join("\n");
4009        assert!(rendered.contains("60 non-blank cell(s)"), "{rendered}");
4010        assert!(rendered.contains("… and 10 more"), "{rendered}");
4011        assert_eq!(overwritten.len(), 60);
4012    }
4013
4014    #[test]
4015    fn describe_lines_elides_a_cut_paste_cleared_source_past_the_render_limit() {
4016        let cleared: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
4017        let change = PasteChange {
4018            destination: "'Q1'!D1".to_string(),
4019            source: Some("'Q1'!A1:B60".to_string()),
4020            written_extent: "'Q1'!D1:D60".to_string(),
4021            overwritten: Some(vec![]),
4022            cleared: Some(cleared.clone()),
4023            grid_edge_caveat: None,
4024        };
4025        let outcome = PasteOutcome {
4026            spreadsheet_id: "sheet-1".to_string(),
4027            file_name: Some("Budget".to_string()),
4028            resolved_folder_id: None,
4029            verb: PasteVerb::CutPaste {
4030                sheet: Some("Q1".to_string()),
4031                source: "A1:B60".to_string(),
4032                destination: "D1".to_string(),
4033                paste_type: PasteType::Normal,
4034            },
4035            result: PasteResult::WouldChange(change),
4036        };
4037        let rendered = describe_lines(&outcome).join("\n");
4038        assert!(
4039            rendered.contains("60 non-blank cell(s) in the source"),
4040            "{rendered}"
4041        );
4042        assert!(rendered.contains("… and 10 more"), "{rendered}");
4043        assert_eq!(cleared.len(), 60);
4044    }
4045
4046    #[test]
4047    fn describe_lines_notes_that_a_format_only_paste_previews_no_overwrite() {
4048        let change = PasteChange {
4049            destination: "'Q1'!D1:E2".to_string(),
4050            source: Some("'Q1'!A1:B2".to_string()),
4051            written_extent: "'Q1'!D1:E2".to_string(),
4052            overwritten: None,
4053            cleared: None,
4054            grid_edge_caveat: None,
4055        };
4056        let outcome = PasteOutcome {
4057            spreadsheet_id: "sheet-1".to_string(),
4058            file_name: Some("Budget".to_string()),
4059            resolved_folder_id: None,
4060            verb: PasteVerb::CopyPaste {
4061                sheet: Some("Q1".to_string()),
4062                source: "A1:B2".to_string(),
4063                destination: "D1:E2".to_string(),
4064                paste_type: PasteType::Format,
4065                orientation: PasteOrientation::Normal,
4066            },
4067            result: PasteResult::WouldChange(change),
4068        };
4069        let lines = describe_lines(&outcome);
4070        assert!(
4071            lines.iter().any(|l| l.contains("not previewed")),
4072            "{lines:?}"
4073        );
4074    }
4075
4076    #[test]
4077    fn describe_lines_reports_no_cells_to_clear_when_the_source_is_blank() {
4078        let change = PasteChange {
4079            destination: "'Q1'!D1".to_string(),
4080            source: Some("'Q1'!A1:B2".to_string()),
4081            written_extent: "'Q1'!D1:E2".to_string(),
4082            overwritten: Some(vec![]),
4083            cleared: Some(vec![]),
4084            grid_edge_caveat: None,
4085        };
4086        let outcome = PasteOutcome {
4087            spreadsheet_id: "sheet-1".to_string(),
4088            file_name: Some("Budget".to_string()),
4089            resolved_folder_id: None,
4090            verb: PasteVerb::CutPaste {
4091                sheet: Some("Q1".to_string()),
4092                source: "A1:B2".to_string(),
4093                destination: "D1".to_string(),
4094                paste_type: PasteType::Normal,
4095            },
4096            result: PasteResult::WouldChange(change),
4097        };
4098        let lines = describe_lines(&outcome);
4099        assert!(
4100            lines
4101                .iter()
4102                .any(|l| l.contains("no non-blank cells to clear")),
4103            "{lines:?}"
4104        );
4105    }
4106
4107    #[test]
4108    fn describe_lines_falls_back_to_the_spreadsheet_id_when_no_file_name_is_known() {
4109        let outcome = PasteOutcome {
4110            spreadsheet_id: "sheet-1".to_string(),
4111            file_name: None,
4112            resolved_folder_id: None,
4113            verb: PasteVerb::CopyPaste {
4114                sheet: Some("Q1".to_string()),
4115                source: "A1:B2".to_string(),
4116                destination: "D1:E2".to_string(),
4117                paste_type: PasteType::Values,
4118                orientation: PasteOrientation::Normal,
4119            },
4120            result: PasteResult::RefusedShortcut,
4121        };
4122        let lines = describe_lines(&outcome);
4123        assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
4124    }
4125
4126    #[test]
4127    fn describe_lines_names_sheets_structure_alone_for_a_no_visible_parents_refusal_on_a_format_only_verb(
4128    ) {
4129        let outcome = PasteOutcome {
4130            spreadsheet_id: "sheet-1".to_string(),
4131            file_name: Some("Budget".to_string()),
4132            resolved_folder_id: None,
4133            verb: PasteVerb::CopyPaste {
4134                sheet: Some("Q1".to_string()),
4135                source: "A1:B2".to_string(),
4136                destination: "D1:E2".to_string(),
4137                paste_type: PasteType::Format,
4138                orientation: PasteOrientation::Normal,
4139            },
4140            result: PasteResult::RefusedNoVisibleParents,
4141        };
4142        let lines = describe_lines(&outcome);
4143        assert!(lines[0].contains("\"sheets-structure\""), "{lines:?}");
4144    }
4145
4146    #[test]
4147    fn describe_lines_reports_no_available_sheets_when_the_workbook_has_none() {
4148        let outcome = PasteOutcome {
4149            spreadsheet_id: "sheet-1".to_string(),
4150            file_name: Some("Budget".to_string()),
4151            resolved_folder_id: None,
4152            verb: PasteVerb::PasteData {
4153                sheet: Some("Q1".to_string()),
4154                destination: "D1".to_string(),
4155                data: "1".to_string(),
4156                delimiter: "\t".to_string(),
4157                paste_type: PasteType::Values,
4158            },
4159            result: PasteResult::RefusedSheetNotFound {
4160                title: "Ghost".to_string(),
4161                available: vec![],
4162            },
4163        };
4164        let lines = describe_lines(&outcome);
4165        assert!(lines[0].contains("Available: none"), "{lines:?}");
4166    }
4167
4168    #[test]
4169    fn describe_lines_names_the_deciding_folder_rule_when_present() {
4170        let outcome = PasteOutcome {
4171            spreadsheet_id: "sheet-1".to_string(),
4172            file_name: Some("Budget".to_string()),
4173            resolved_folder_id: None,
4174            verb: PasteVerb::CutPaste {
4175                sheet: Some("Q1".to_string()),
4176                source: "A1:B2".to_string(),
4177                destination: "D1".to_string(),
4178                paste_type: PasteType::Normal,
4179            },
4180            result: PasteResult::Blocked {
4181                operation: DriveOperation::SheetsWrite,
4182                decided_by: Some(DecidingRule::Folder {
4183                    folder_id: "folder-1".to_string(),
4184                    depth: 2,
4185                }),
4186            },
4187        };
4188        let lines = describe_lines(&outcome);
4189        assert!(lines[0].contains("folder folder-1 (depth 2)"), "{lines:?}");
4190    }
4191}