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