Skip to main content

omni_dev/drive/sheets/
auto_fill.rs

1//! `auto-fill` — extends a series from source cells into an adjacent
2//! destination via `spreadsheets.batchUpdate`'s `autoFill` request (issue
3//! #1840, [ADR-0083](../../../docs/adrs/adr-0083.md)).
4//!
5//! Unblocked by issue #1831. Gated by [`DriveOperation::SheetsWrite`] alone
6//! (ADR-0083 §1): the fill writes ordinary cell content into a range the
7//! request names or derives, doing nothing a `sheets clear` followed by a
8//! `sheets write` of the same range could not already do under the same
9//! grant. **This is provisional on one live-verification item ADR-0083 §5
10//! names**: if Sheets is found to carry the source cells' *formatting*
11//! along with a fill, this module's gate must move to
12//! `target_gate::resolve_all([SheetsWrite, SheetsStructure])`, the same
13//! union `pivot.rs`'s `add-pivot-table` uses — see that section for the
14//! fixed consequence. Until verified, the gate stays `SheetsWrite` alone.
15//!
16//! **The filled values are never previewable, and this crate never sees
17//! them even after a real run.** `autoFill` carries no response object, and
18//! which values it writes is entirely Sheets' own series-detection
19//! heuristic (dates, numbers, days-of-week, or whatever pattern the source
20//! cells show) — the same "server decides, so `--dry-run` cannot fully
21//! predict it" limit ADR-0081 §5 names for a pivot table's extent. What
22//! `--dry-run` (and the real run) *can* say, per ADR-0083 §6, is the
23//! destination range and the **count and A1 locations** of the non-blank
24//! cells within it that would be (or were) overwritten — never their
25//! values, matching every preview in this tranche but `merge-cells`.
26//!
27//! ## The two input forms
28//!
29//! The API's request is a oneof, mirrored here as [`AutoFillForm`] so the
30//! two shapes are mutually exclusive by construction rather than by
31//! runtime validation of parallel `Option` fields:
32//!
33//! - **Form A** (`--range`): the whole region. Sheets "examines the range
34//!   and detects the location that has data" itself, so which cells are
35//!   source and which are destination is server-decided — the destination
36//!   this writes is knowable only as an **upper bound** (every non-blank
37//!   cell in the named range) before the request is sent.
38//! - **Form B** (`--source`/`--dimension`/`--fill-length`): an explicit
39//!   source range extended by a caller-chosen length and direction. The
40//!   destination is computed locally ([`compute_destination`]) and so is
41//!   **exact**. `--fill-length` may be negative (fills backward — up or
42//!   left — instead of forward), which needs a two-sided bounds check
43//!   (ADR-0077 §6's shape): a negative length is refused if it would reach
44//!   before the start of the sheet.
45//!
46//! Both forms require a **fully bounded** source ([`grid_range::is_bounded`],
47//! `merge-cells`'s own requirement, ADR-0078 §4): form B's arithmetic has
48//! no fixed edge to extend from against an open-ended source, and form A's
49//! preview has no fixed extent to read. A documented cut, not a silent gap.
50//!
51//! ## The grid edge
52//!
53//! **Verified live (#1937): `autoFill` clips to the sheet's current
54//! `rowCount`/`columnCount` rather than growing it or erroring**, on both
55//! axes (rows were measured first; columns confirmed identically — a
56//! 5-column sheet stayed 5 columns after a fill requesting 10 more). This
57//! is the opposite of `copyPaste`/`cutPaste`/`pasteData` (see
58//! [`paste`](super::paste)'s module docs), which do grow the grid — so a
59//! destination past the extent is reported as **clipped**, not as a
60//! caveat naming an unknown outcome: [`AutoFillResult::WouldChange`]/
61//! [`AutoFillResult::Changed`] carry the *applied* (clipped) destination
62//! as `destination`, plus the unclipped one as `requested_destination`
63//! when the two differ, and the summary's caveat line names both. The
64//! request itself is never rewritten to match — `--fill-length` reaches
65//! the API exactly as given, since clipping is this crate's read of what
66//! the API will do with it, not a request this crate gets to make. A
67//! destination that lies **wholly** past the grid on some axis is
68//! refused before any lease is checked or `batchUpdate` is called
69//! ([`AutoFillResult::RefusedDestinationPastGrid`]), identically for
70//! `--dry-run` and a real run: nothing would be written, so reporting
71//! `Changed`/`WouldChange` would misstate what happened. An axis the
72//! sheet reports no `gridProperties` for is left alone (extent unknown ⇒
73//! don't act on it), matching the pre-#1937 stance for that case. The
74//! verb still never prepends a `updateSheetProperties`/`appendDimension`
75//! request to grow the sheet first (ADR-0083 §5's smuggling rule) — there
76//! is nothing to smuggle now that clipping is the verified answer.
77//!
78//! ## Shape
79//!
80//! Single-target, `write.rs`/`dimension_group.rs`'s linear shape: resolve
81//! the target and gate, resolve the source range, compute the destination,
82//! read its current values once (shared by `--dry-run` and the real run,
83//! `merge-cells`'s own precedent), dry-run return, build the request,
84//! lease, mutate, log.
85
86use std::path::PathBuf;
87use std::time::{Duration, Instant};
88
89use serde::Serialize;
90
91use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
92use crate::drive::client::DriveClient;
93use crate::drive::files_api::FilesApi;
94use crate::drive::lease::check::{
95    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
96    LeasedWrite,
97};
98use crate::drive::sheets::a1;
99use crate::drive::sheets::api::{
100    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
101};
102use crate::drive::sheets::client::SheetsClient;
103use crate::drive::sheets::grid_range;
104use crate::drive::sheets::target_gate;
105use crate::drive::sheets::types::{
106    AutoFillRequest, BatchUpdateRequestItem, Dimension, GridRange, Sheet, SourceAndDestination,
107};
108use crate::drive::types::SheetTargetRefusal;
109use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
110use crate::request_log::{self, DriveMutationOutcome};
111
112/// The one operation this module ever logs or leases under — there is
113/// only one verb, so `dimension_group.rs`'s per-verb `log_operation()`
114/// dispatch would have nothing to dispatch on. Named once so the lease
115/// site and the request-log site can't drift.
116const LOG_OPERATION: &str = "sheets-auto-fill";
117
118/// Which of the API's two input forms was given — the API's own oneof.
119///
120/// Modelled as an enum rather than parallel `Option` fields so "both forms
121/// at once" is unrepresentable. See the module docs for the two shapes.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum AutoFillForm {
124    /// `--range`, optionally with `--sheet`: Sheets decides source vs.
125    /// destination itself.
126    Range {
127        /// Sheet (tab) title. Supplies the prefix for a bare `range`.
128        sheet: Option<String>,
129        /// A1 range, optionally carrying its own `Sheet!` prefix.
130        range: Option<String>,
131    },
132    /// `--source`/`--dimension`/`--fill-length`, `--sheet` supplying the
133    /// prefix for a bare `source`.
134    SourceAndDestination {
135        /// Sheet (tab) title. Supplies the prefix for a bare `source`.
136        sheet: Option<String>,
137        /// A1 range holding the series to extend, optionally carrying its
138        /// own `Sheet!` prefix.
139        source: Option<String>,
140        /// Which axis `fill_length` extends along.
141        dimension: Dimension,
142        /// How many rows/columns to fill. Negative fills backward (up or
143        /// left) instead of forward (down or right).
144        fill_length: i64,
145    },
146}
147
148impl AutoFillForm {
149    /// The `--sheet`/range-or-source pair, for `a1::compose` — the same
150    /// shape `format.rs::FormatVerb::sheet_and_range` exposes.
151    fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
152        match self {
153            Self::Range { sheet, range } => (sheet.as_deref(), range.as_deref()),
154            Self::SourceAndDestination { sheet, source, .. } => {
155                (sheet.as_deref(), source.as_deref())
156            }
157        }
158    }
159}
160
161/// Per-call options.
162#[derive(Debug, Clone)]
163pub struct AutoFillOptions {
164    /// Spreadsheet id.
165    pub spreadsheet_id: String,
166    /// Which input form was given, and its data.
167    pub form: AutoFillForm,
168    /// `--alternate-series`: fills using the alternate series Sheets would
169    /// not otherwise choose.
170    pub use_alternate_series: bool,
171    /// Classify and describe only; never call `batchUpdate`.
172    pub dry_run: bool,
173    /// The lease token presented via `--lease`. Checked only when the
174    /// deciding rule requires one
175    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
176    /// `None` is only ever valid when it does not.
177    pub lease_token: Option<String>,
178    /// Path to the lease ledger the token is checked against. Production
179    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
180    /// result; tests pass a path under a `tempdir`.
181    pub ledger_path: PathBuf,
182}
183
184/// What happened (or, under `--dry-run`, would happen).
185#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
186#[serde(tag = "status", rename_all = "kebab-case")]
187pub enum AutoFillResult {
188    /// `--dry-run`, and the gate would allow it.
189    WouldChange {
190        /// A human-readable, **tense-neutral** summary of the effect —
191        /// also the request log's `fields_changed`. Names the destination,
192        /// the source it extends (form B), the direction, and the
193        /// alternate-series flag; the overwrite count and the two caveats
194        /// are separate, tense-varying lines built by [`describe_lines`],
195        /// so this same string reads correctly under both `Would …` and
196        /// `Applied: …`.
197        summary: String,
198        /// The destination range's A1 address, **as `autoFill` will
199        /// actually fill it** — clamped to the sheet's current extent
200        /// when the computed/named destination runs past it (verified
201        /// live, #1937: `autoFill` clips rather than growing the sheet).
202        /// This is what the request log's `range` records.
203        destination: String,
204        /// The unclamped destination, when it differs from `destination`
205        /// — i.e. when clipping happened. `None` in the common case
206        /// (destination inside the grid, or the sheet's extent is
207        /// unknown), so the JSON output carries no new key then.
208        #[serde(skip_serializing_if = "Option::is_none")]
209        requested_destination: Option<String>,
210        /// The non-blank cells within the destination that would be
211        /// overwritten, as bare A1 addresses — **never their values**,
212        /// unlike `merge-cells`' own `discarded_cells` (ADR-0083 §6).
213        #[serde(skip_serializing_if = "Vec::is_empty")]
214        overwritten_cells: Vec<String>,
215        /// `true` for form A (`--range`): Sheets decides the source/
216        /// destination split itself, so `overwritten_cells` names every
217        /// non-blank cell in the named range, an upper bound on what the
218        /// fill will actually touch. `false` for form B, where the
219        /// destination — and so this list — is exact.
220        destination_is_upper_bound: bool,
221    },
222    /// The target is not a Google Sheet.
223    RefusedNotASpreadsheet {
224        /// The target's actual MIME type.
225        mime_type: String,
226    },
227    /// The target is a shortcut, which we do not follow.
228    RefusedShortcut,
229    /// The target has no parents this account can see.
230    RefusedNoVisibleParents,
231    /// The named sheet does not exist in this workbook.
232    RefusedSheetNotFound {
233        /// The title that was not found.
234        title: String,
235        /// The titles that do exist.
236        available: Vec<String>,
237    },
238    /// The `--sheet`/range-or-source pair, `--fill-length`, or the
239    /// resulting destination was invalid.
240    RefusedInvalidRange {
241        /// What was wrong and why.
242        detail: String,
243    },
244    /// The destination lies wholly past the sheet's current extent on some
245    /// axis — clipping (see the module docs' "The grid edge" section)
246    /// would leave nothing to fill, and reporting `Changed`/`WouldChange`
247    /// for a no-op would misstate what happened. Checked, and refused,
248    /// before any lease is checked or `batchUpdate` is called — identically
249    /// for `--dry-run` and a real run.
250    RefusedDestinationPastGrid {
251        /// The unclamped destination A1 address that was requested.
252        requested_destination: String,
253        /// The sheet's current row count, when known.
254        row_count: Option<i64>,
255        /// The sheet's current column count, when known.
256        column_count: Option<i64>,
257    },
258    /// The folder write-permission gate refused it.
259    Blocked {
260        /// The rule that decided the refusal, if any.
261        decided_by: Option<DecidingRule>,
262    },
263    /// No `--lease` was presented, and the deciding rule requires one
264    /// (ADR-0080 §9).
265    RefusedNoLease,
266    /// The presented lease has expired, or was never a token this ledger
267    /// knows about.
268    RefusedLeaseExpired,
269    /// The presented lease is bound to a different file id.
270    RefusedLeaseWrongFile,
271    /// The file has moved since the lease's recorded `version` — the
272    /// staleness check (ADR-0080 §6).
273    RefusedLeaseStale,
274    /// The mutation succeeded.
275    Changed {
276        /// The same tense-neutral summary [`Self::WouldChange`] carries,
277        /// built by the same call — which is why it reads correctly after
278        /// the fact as well as before it.
279        summary: String,
280        /// Same as [`Self::WouldChange`].
281        destination: String,
282        /// Same as [`Self::WouldChange`].
283        #[serde(skip_serializing_if = "Option::is_none")]
284        requested_destination: Option<String>,
285        /// Same as [`Self::WouldChange`] — read *before* the mutating call
286        /// (the same read serves both paths), so this names what was
287        /// overwritten, not a post-hoc guess.
288        #[serde(skip_serializing_if = "Vec::is_empty")]
289        overwritten_cells: Vec<String>,
290        /// Same as [`Self::WouldChange`].
291        destination_is_upper_bound: bool,
292    },
293    /// The API answered 2xx — the mutation **was applied** — but its reply
294    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: the
295    /// fill happened, so the sheet already holds it.
296    AppliedReplyUnreadable {
297        /// Same summary as [`Self::Changed`].
298        summary: String,
299        /// Same as [`Self::Changed`] — all of it is plan-time data, so none
300        /// of it is lost with the reply.
301        destination: String,
302        /// Same as [`Self::Changed`].
303        #[serde(skip_serializing_if = "Option::is_none")]
304        requested_destination: Option<String>,
305        /// Same as [`Self::Changed`].
306        #[serde(skip_serializing_if = "Vec::is_empty")]
307        overwritten_cells: Vec<String>,
308        /// Same as [`Self::Changed`].
309        destination_is_upper_bound: bool,
310        /// Why the reply could not be read.
311        detail: String,
312    },
313    /// An API or validation error.
314    Failed {
315        /// A human-readable summary of what failed.
316        detail: String,
317    },
318}
319
320impl FromLeaseRefusal for AutoFillResult {
321    fn from_no_lease() -> Self {
322        Self::RefusedNoLease
323    }
324    fn from_lease_expired() -> Self {
325        Self::RefusedLeaseExpired
326    }
327    fn from_lease_wrong_file() -> Self {
328        Self::RefusedLeaseWrongFile
329    }
330    fn from_lease_stale() -> Self {
331        Self::RefusedLeaseStale
332    }
333    fn from_lease_failed(detail: String) -> Self {
334        Self::Failed { detail }
335    }
336}
337
338impl AutoFillResult {
339    fn log_status(&self) -> &'static str {
340        match self {
341            Self::WouldChange { .. } => "would-change",
342            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
343            Self::RefusedShortcut => "refused-shortcut",
344            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
345            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
346            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
347            Self::RefusedDestinationPastGrid { .. } => "refused-destination-past-grid",
348            Self::Blocked { .. } => "blocked",
349            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
350            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
351            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
352            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
353            Self::Changed { .. } => "changed",
354            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
355            Self::Failed { .. } => "failed",
356        }
357    }
358}
359
360/// The full outcome of one attempt.
361#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
362pub struct AutoFillOutcome {
363    /// The spreadsheet acted on.
364    pub spreadsheet_id: String,
365    /// Its Drive file name, when the metadata fetch got that far.
366    #[serde(skip_serializing_if = "Option::is_none")]
367    pub file_name: Option<String>,
368    /// The folder the gate evaluated against.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub resolved_folder_id: Option<String>,
371    /// The sheet the fill is (or would be) on, once known.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub sheet_id: Option<i64>,
374    /// Which form/data was requested. Not serialised.
375    #[serde(skip)]
376    pub form: AutoFillForm,
377    /// What happened.
378    pub result: AutoFillResult,
379}
380
381impl JsonlSerialize for AutoFillOutcome {
382    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
383        write_scalar_jsonl(self, out)
384    }
385}
386
387/// Runs one auto-fill, logging every attempt that isn't a dry run.
388pub async fn auto_fill(
389    drive: &DriveClient,
390    sheets: &SheetsClient,
391    opts: &AutoFillOptions,
392    rules: &[FolderPermissionRule],
393) -> AutoFillOutcome {
394    let started = Instant::now();
395    let outcome = auto_fill_inner(drive, sheets, opts, rules).await;
396    if !opts.dry_run {
397        record_attempt(&outcome, started.elapsed());
398    }
399    outcome
400}
401
402async fn auto_fill_inner(
403    drive: &DriveClient,
404    sheets: &SheetsClient,
405    opts: &AutoFillOptions,
406    rules: &[FolderPermissionRule],
407) -> AutoFillOutcome {
408    let bare = |result| AutoFillOutcome {
409        spreadsheet_id: opts.spreadsheet_id.clone(),
410        file_name: None,
411        resolved_folder_id: None,
412        sheet_id: None,
413        form: opts.form.clone(),
414        result,
415    };
416
417    // Compose the range/source first, like `write_inner`/`format_inner`:
418    // pure, and a conflicting --sheet/--range(--source) pair, or a
419    // --fill-length of 0, should fail without spending a request.
420    let (sheet, range) = opts.form.sheet_and_range();
421    let composed = match a1::compose(sheet, range) {
422        Ok(composed) => composed,
423        Err(err) => {
424            return bare(AutoFillResult::RefusedInvalidRange {
425                detail: err.to_string(),
426            })
427        }
428    };
429    if let AutoFillForm::SourceAndDestination { fill_length, .. } = &opts.form {
430        if *fill_length == 0 {
431            return bare(AutoFillResult::RefusedInvalidRange {
432                detail: "--fill-length must not be 0".to_string(),
433            });
434        }
435    }
436
437    // ── Target resolution, pre-gate refusals, and the gate itself ──────
438    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
439        drive,
440        &opts.spreadsheet_id,
441        DriveOperation::SheetsWrite,
442        rules,
443    )
444    .await
445    {
446        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
447            return bare(AutoFillResult::Failed { detail })
448        }
449        target_gate::TargetGateOutcome::Refused { target, refusal } => {
450            let result = match refusal {
451                SheetTargetRefusal::Shortcut => AutoFillResult::RefusedShortcut,
452                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
453                    AutoFillResult::RefusedNotASpreadsheet { mime_type }
454                }
455                SheetTargetRefusal::NoVisibleParents => AutoFillResult::RefusedNoVisibleParents,
456            };
457            return AutoFillOutcome {
458                spreadsheet_id: opts.spreadsheet_id.clone(),
459                file_name: Some(target.name),
460                resolved_folder_id: None,
461                sheet_id: None,
462                form: opts.form.clone(),
463                result,
464            };
465        }
466        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
467            return AutoFillOutcome {
468                spreadsheet_id: opts.spreadsheet_id.clone(),
469                file_name: Some(target.name),
470                resolved_folder_id: None,
471                sheet_id: None,
472                form: opts.form.clone(),
473                result: AutoFillResult::Failed { detail },
474            };
475        }
476        target_gate::TargetGateOutcome::Gated {
477            target,
478            decision,
479            resolved_folder_id,
480            requires_lease,
481        } => (target, decision, resolved_folder_id, requires_lease),
482    };
483
484    let pre_gated = |result| AutoFillOutcome {
485        spreadsheet_id: opts.spreadsheet_id.clone(),
486        file_name: Some(target.name.clone()),
487        resolved_folder_id: resolved_folder_id.clone(),
488        sheet_id: None,
489        form: opts.form.clone(),
490        result,
491    };
492
493    if decision.verdict == write_gate::Verdict::Deny {
494        return pre_gated(AutoFillResult::Blocked {
495            decided_by: decision.decided_by,
496        });
497    }
498
499    let api = SheetsApi::new(sheets);
500    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
501        Ok(workbook) => workbook,
502        Err(err) => {
503            return pre_gated(AutoFillResult::Failed {
504                detail: format!("{err:#}"),
505            })
506        }
507    };
508
509    let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
510        &workbook,
511        &composed,
512        |detail| AutoFillResult::RefusedInvalidRange { detail },
513        |title, available| AutoFillResult::RefusedSheetNotFound { title, available },
514    ) {
515        Ok(resolved) => resolved,
516        Err(result) => return pre_gated(result),
517    };
518
519    let gated = |result| AutoFillOutcome {
520        spreadsheet_id: opts.spreadsheet_id.clone(),
521        file_name: Some(target.name.clone()),
522        resolved_folder_id: resolved_folder_id.clone(),
523        sheet_id: Some(source_grid.sheet_id),
524        form: opts.form.clone(),
525        result,
526    };
527
528    if !grid_range::is_bounded(&source_grid) {
529        return gated(AutoFillResult::RefusedInvalidRange {
530            detail: format!(
531                "{composed} is open-ended; auto-fill needs a fully bounded range (e.g. A1:D10)"
532            ),
533        });
534    }
535
536    let (destination, is_upper_bound) = match &opts.form {
537        AutoFillForm::Range { .. } => (source_grid, true),
538        AutoFillForm::SourceAndDestination {
539            dimension,
540            fill_length,
541            ..
542        } => match compute_destination(source_grid, *dimension, *fill_length) {
543            Ok(destination) => (destination, false),
544            Err(detail) => return gated(AutoFillResult::RefusedInvalidRange { detail }),
545        },
546    };
547    let requested_destination_a1 = grid_range_to_a1(&sheet_title, &destination);
548    // Form B's summary names the source it extends: the request log's
549    // `range` key is the *destination*, so without this the record could
550    // not say what was extended. Form A's source is the range itself, and
551    // its summary says so instead.
552    let source_a1 = grid_range_to_a1(&sheet_title, &source_grid);
553
554    // `autoFill` clips a destination that runs past the sheet's current
555    // extent rather than growing it or erroring (verified live, #1937) —
556    // see the module docs' "The grid edge" section. `applied_destination`
557    // is what the pre-read predicts the API will actually fill, and is
558    // what `destination`/the request log's `range` report from here on;
559    // the request itself (built later, from `source_grid`/`fill_length`)
560    // is never rewritten to match. An axis the sheet reports no
561    // `gridProperties` for is left alone — extent unknown ⇒ don't act on
562    // it, the pre-#1937 stance for that case.
563    let sheet = grid_range::find_sheet_by_id(&workbook, source_grid.sheet_id);
564    let applied_destination = if let Some(sheet) = sheet {
565        if let Some(applied) = clamp_to_grid(sheet, &destination) {
566            applied
567        } else {
568            // The destination lies wholly past the grid on some axis:
569            // nothing would be written. Refused before any lease is
570            // checked or `batchUpdate` is called, identically for
571            // `--dry-run` and a real run — reporting `Changed`/
572            // `WouldChange` for a no-op would misstate what happened.
573            let grid = sheet
574                .properties
575                .as_ref()
576                .and_then(|props| props.grid_properties.as_ref());
577            return gated(AutoFillResult::RefusedDestinationPastGrid {
578                requested_destination: requested_destination_a1,
579                row_count: grid.and_then(|g| g.row_count),
580                column_count: grid.and_then(|g| g.column_count),
581            });
582        }
583    } else {
584        destination // omni-dev: coverage ignore-line reason="unreachable by construction: source_grid.sheet_id was itself resolved from this same workbook by resolve_grid_range above, so find_sheet_by_id always finds it"
585    };
586    let destination_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
587    // `None` in the common case (no clipping happened), so the JSON output
588    // carries no new key then.
589    let requested_destination =
590        (applied_destination != destination).then_some(requested_destination_a1);
591
592    // The destination's current values are read once, off the *applied*
593    // (already-clipped) range, before the --dry-run branch, so both paths
594    // report the same overwritten cells from the same read — `merge-cells`'
595    // own `read_discarded_cells` precedent. Since the wholly-past case is
596    // refused above, `applied_destination` always names cells that exist.
597    let overwritten_cells = {
598        let read_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
599        match api
600            .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
601            .await
602        {
603            Ok(values) => grid_range::non_blank_locations(
604                &values,
605                applied_destination.start_row_index.unwrap_or(0),
606                applied_destination.start_column_index.unwrap_or(0),
607            ),
608            Err(err) => {
609                return gated(AutoFillResult::Failed {
610                    detail: format!("{err:#}"),
611                })
612            }
613        }
614    };
615
616    let summary = describe_effect(
617        &opts.form,
618        &source_a1,
619        &destination_a1,
620        applied_fill_length(&opts.form, &source_grid, &applied_destination),
621        opts.use_alternate_series,
622    );
623
624    if opts.dry_run {
625        return gated(AutoFillResult::WouldChange {
626            summary,
627            destination: destination_a1,
628            requested_destination,
629            overwritten_cells,
630            destination_is_upper_bound: is_upper_bound,
631        });
632    }
633
634    // Built before the gate, not after — #1688/#1742's ordering invariant,
635    // shared verbatim by every leased engine. `build_request` is
636    // infallible here (every fallible step already happened above:
637    // composing, resolving, bounding, computing the destination), but the
638    // shape is kept identical to every sibling engine's so a future
639    // fallible step lands in the right place by default.
640    let request = build_request(&opts.form, source_grid, opts.use_alternate_series);
641
642    let files_api = FilesApi::new(drive);
643    let leased = LeasedWrite {
644        log_prefix: "drive sheets auto-fill",
645        operation: LOG_OPERATION,
646        ledger_path: &opts.ledger_path,
647        file_id: &opts.spreadsheet_id,
648    };
649    let lease_grant = match gate_optional_leased_write(
650        leased,
651        &files_api,
652        requires_lease,
653        opts.lease_token.as_deref(),
654    )
655    .await
656    {
657        Ok(grant) => grant,
658        Err(err) => return gated(err.into_result()),
659    };
660
661    let result = match conclude_native_leased_write(
662        leased,
663        &lease_grant,
664        &files_api,
665        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
666        |err| format!("{err:#}"),
667    )
668    .await
669    {
670        Ok(BatchUpdateOutcome::Applied(_response)) => AutoFillResult::Changed {
671            summary,
672            destination: destination_a1,
673            requested_destination,
674            overwritten_cells,
675            destination_is_upper_bound: is_upper_bound,
676        },
677        // `RetryHint::Idempotent`: re-running the same fill is harmless, so the
678        // reply is only needed to confirm the write (#2021).
679        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
680            AutoFillResult::AppliedReplyUnreadable {
681                summary,
682                destination: destination_a1,
683                requested_destination,
684                overwritten_cells,
685                destination_is_upper_bound: is_upper_bound,
686                detail,
687            }
688        }
689        Err(err) => AutoFillResult::Failed {
690            detail: format!("{err:#}"),
691        },
692    };
693    drop(lease_grant);
694    gated(result)
695}
696
697/// Computes form B's destination [`GridRange`] from its bounded `source`,
698/// extending `fill_length` rows/columns from `source`'s edge along
699/// `dimension`. Positive fills forward (down/right) from `source`'s far
700/// edge; negative fills backward (up/left) from `source`'s near edge, and
701/// is refused two-sidedly (ADR-0077 §6's shape) if it would reach before
702/// the start of the sheet. `fill_length == 0` is refused by the caller
703/// before this is ever called.
704///
705/// Pure and independently tested — the one conversion site, never inlined.
706fn compute_destination(
707    source: GridRange,
708    dimension: Dimension,
709    fill_length: i64,
710) -> Result<GridRange, String> {
711    let (start, end) = match dimension {
712        Dimension::Rows => (source.start_row_index, source.end_row_index),
713        Dimension::Columns => (source.start_column_index, source.end_column_index),
714    };
715    // `is_bounded` is checked by the caller before this runs, but each
716    // axis is read independently here, so this stays a defined error
717    // rather than an `unwrap` if that ever changes.
718    let (Some(start), Some(end)) = (start, end) else {
719        return Err(format!(
720            "--source needs a fully bounded range on the {} axis to compute a destination from",
721            dimension.noun()
722        ));
723    };
724    let (new_start, new_end) = if fill_length > 0 {
725        let Some(new_end) = end.checked_add(fill_length) else {
726            return Err(format!("--fill-length {fill_length} is out of range"));
727        };
728        (end, new_end)
729    } else {
730        let Some(new_start) = start.checked_add(fill_length) else {
731            return Err(format!("--fill-length {fill_length} is out of range"));
732        };
733        if new_start < 0 {
734            return Err(format!(
735                "--fill-length {fill_length} would fill {} {}(s) before the start of the sheet",
736                new_start.unsigned_abs(),
737                dimension.noun()
738            ));
739        }
740        (new_start, start)
741    };
742    let mut destination = source;
743    match dimension {
744        Dimension::Rows => {
745            destination.start_row_index = Some(new_start);
746            destination.end_row_index = Some(new_end);
747        }
748        Dimension::Columns => {
749            destination.start_column_index = Some(new_start);
750            destination.end_column_index = Some(new_end);
751        }
752    }
753    Ok(destination)
754}
755
756/// `destination` with each axis' end clamped to `sheet`'s current extent —
757/// what `autoFill` actually fills (verified live, #1937: it clips rather
758/// than growing the sheet or erroring) — or `None` when that leaves
759/// nothing: the destination lies wholly past the grid on some axis, so
760/// there is no cell to fill or read back, and the caller refuses instead.
761///
762/// An axis the sheet reports no extent for is left alone: there is nothing
763/// to clamp against, so "extent unknown ⇒ don't act on it".
764fn clamp_to_grid(sheet: &Sheet, destination: &GridRange) -> Option<GridRange> {
765    let grid = sheet
766        .properties
767        .as_ref()
768        .and_then(|props| props.grid_properties.as_ref());
769    let mut clamped = *destination;
770    if let (Some(end), Some(count)) = (destination.end_row_index, grid.and_then(|g| g.row_count)) {
771        clamped.end_row_index = Some(end.min(count));
772    }
773    if let (Some(end), Some(count)) = (
774        destination.end_column_index,
775        grid.and_then(|g| g.column_count),
776    ) {
777        clamped.end_column_index = Some(end.min(count));
778    }
779    let empty = |start: Option<i64>, end: Option<i64>| match (start, end) {
780        (Some(start), Some(end)) => start >= end,
781        _ => false,
782    };
783    if empty(clamped.start_row_index, clamped.end_row_index)
784        || empty(clamped.start_column_index, clamped.end_column_index)
785    {
786        return None;
787    }
788    Some(clamped)
789}
790
791/// Renders a fully bounded numeric [`GridRange`] back to an A1 string.
792/// Only ever called on a `source`/`destination` this module has already
793/// refused unless fully bounded — see `is_bounded`'s call site above.
794fn grid_range_to_a1(sheet_title: &str, grid: &GridRange) -> String {
795    let (Some(r0), Some(r1), Some(c0), Some(c1)) = (
796        grid.start_row_index,
797        grid.end_row_index,
798        grid.start_column_index,
799        grid.end_column_index,
800    ) else {
801        unreachable!("auto-fill ranges are refused earlier unless fully bounded")
802        // omni-dev: coverage ignore-line reason="every call site resolves grid from a range already checked with grid_range::is_bounded, or computes it from one via compute_destination, which only ever produces a fully bounded range from a fully bounded source; this else-arm exists only to unwrap the shared Option fields"
803    };
804    let start = format!("{}{}", grid_range::column_index_to_letters(c0), r0 + 1);
805    let end = format!("{}{}", grid_range::column_index_to_letters(c1 - 1), r1);
806    match a1::compose(Some(sheet_title), Some(&format!("{start}:{end}"))) {
807        Ok(composed) => composed,
808        // Not `unwrap_or_default()`: an empty range here would surface as
809        // an opaque `values.get` failure rather than as itself. `compose`
810        // can only reject a sheet-prefixed or whole-sheet range, and the
811        // `{start}:{end}` built just above is neither. The marker is a
812        // *trailing* comment because `ignore-line` silences its own line
813        // only (`coverage/markers.rs`: `start: line, end: line`), so on a
814        // line of its own it would silence nothing.
815        Err(err) => unreachable!("auto-fill composes only bounded numeric ranges: {err}"), // omni-dev: coverage ignore-line reason="unreachable by construction: compose rejects only a sheet-prefixed or whole-sheet range, and the numeric {start}:{end} built just above is neither"
816    }
817}
818
819/// The caveat every auto-fill carries, in both tenses at once — the
820/// values are never reported, before *or* after the request, so unlike
821/// [`past_grid_extent_caveat_dry_run`]/[`past_grid_extent_caveat`] this is
822/// one constant rather than two ([`structure.rs`](super::structure)'s
823/// `FORMULA_CAVEAT` shape).
824const UNPREVIEWABLE_VALUES_CAVEAT: &str =
825    "  the filled values are computed by Sheets' own series detection and are never reported, \
826     before or after the request";
827
828/// The grid-edge caveat, dry-run tense: names both the applied (clipped)
829/// destination and the requested (unclipped) one, since `--dry-run`
830/// reports what the pre-read predicts `autoFill` will do (verified live,
831/// #1937: it clips rather than growing the sheet or erroring). A format
832/// function rather than a constant, unlike [`UNPREVIEWABLE_VALUES_CAVEAT`],
833/// since the two ranges are call-specific; shared by both tenses' call
834/// sites so the wording can't drift — `structure.rs`'s
835/// `INSERT_RANGE_EDGE_CAVEAT` pair's own reason for being two functions
836/// rather than one.
837fn past_grid_extent_caveat_dry_run(applied: &str, requested: &str) -> String {
838    format!(
839        "  the destination runs past the sheet's current extent; auto-fill clips to it, so only \
840         {applied} would be filled — {requested} was requested"
841    )
842}
843
844/// The grid-edge caveat, past tense — see
845/// [`past_grid_extent_caveat_dry_run`].
846fn past_grid_extent_caveat(applied: &str, requested: &str) -> String {
847    format!(
848        "  the destination ran past the sheet's current extent; auto-fill clipped to it, so \
849         only {applied} was filled — {requested} was requested"
850    )
851}
852
853/// The signed fill length actually applied, form B only — same sign as
854/// the requested `--fill-length`, but its magnitude shrinks to match
855/// `applied_destination` when clipping happened (verified live, #1937:
856/// the *destination* is clipped, so the head line's "extending N row(s)"
857/// count must be clipped too, or it overstates how many cells were
858/// touched — the triage plan's "applied count in the head" decision).
859/// `0` for form A, whose head line never reads this value.
860///
861/// Sound because `clamp_to_grid` only ever shrinks a range's *end*: a
862/// forward fill's destination starts at the source's far edge, so only
863/// its end can be clipped; a backward fill's destination *ends* at the
864/// source's near edge, so it is never clipped and this always returns
865/// exactly `fill_length`.
866fn applied_fill_length(
867    form: &AutoFillForm,
868    source_grid: &GridRange,
869    applied_destination: &GridRange,
870) -> i64 {
871    let AutoFillForm::SourceAndDestination {
872        dimension,
873        fill_length,
874        ..
875    } = form
876    else {
877        return 0;
878    };
879    let (source_start, source_end, applied_start, applied_end) = match dimension {
880        Dimension::Rows => (
881            source_grid.start_row_index,
882            source_grid.end_row_index,
883            applied_destination.start_row_index,
884            applied_destination.end_row_index,
885        ),
886        Dimension::Columns => (
887            source_grid.start_column_index,
888            source_grid.end_column_index,
889            applied_destination.start_column_index,
890            applied_destination.end_column_index,
891        ),
892    };
893    if *fill_length >= 0 {
894        applied_end.unwrap_or(0) - source_end.unwrap_or(0)
895    } else {
896        applied_start.unwrap_or(0) - source_start.unwrap_or(0)
897    }
898}
899
900/// The **tense-neutral** head of the summary: the destination, the source
901/// it extends and the direction (form B), or the server-decided-split
902/// note (form A), plus the alternate-series flag.
903///
904/// `applied_fill_length` (form B only, see its own docs) is what names
905/// the count and the direction — never the form's raw `--fill-length`,
906/// which would overstate a clipped fill.
907///
908/// Deliberately carries neither the overwrite count nor either caveat.
909/// Both of those vary with tense, and this string is reused verbatim by
910/// [`AutoFillResult::Changed`] and by the request log's `fields_changed`,
911/// where a conditional ("would be overwritten") would describe a mutation
912/// that already happened. They are separate, indented lines instead —
913/// which also keeps `describe_lines`' trailing `in {book}` attached to the
914/// range clause rather than to a prose caveat.
915fn describe_effect(
916    form: &AutoFillForm,
917    source_a1: &str,
918    destination_a1: &str,
919    applied_fill_length: i64,
920    use_alternate_series: bool,
921) -> String {
922    let mut summary = match form {
923        AutoFillForm::Range { .. } => format!(
924            "auto-fill within {destination_a1} (Sheets decides which cells are the source and \
925             which are filled)"
926        ),
927        AutoFillForm::SourceAndDestination { dimension, .. } => {
928            let direction = match (dimension, applied_fill_length >= 0) {
929                (Dimension::Rows, true) => "down",
930                (Dimension::Rows, false) => "up",
931                (Dimension::Columns, true) => "right",
932                (Dimension::Columns, false) => "left",
933            };
934            format!(
935                "auto-fill {destination_a1} from source {source_a1}, extending {} {}(s) \
936                 {direction}",
937                applied_fill_length.abs(),
938                dimension.noun(),
939            )
940        }
941    };
942    if use_alternate_series {
943        summary.push_str(", using the alternate series");
944    }
945    summary
946}
947
948/// The indented overwrite line. `up to` marks form A's upper bound (Sheets
949/// picks the source/destination split itself, so some of these cells are
950/// the source and will not be touched); the tense follows `dry_run`, the
951/// same rule [`past_grid_extent_caveat_dry_run`]/[`past_grid_extent_caveat`]
952/// follow.
953fn overwritten_line(
954    overwritten_cells: &[String],
955    destination_is_upper_bound: bool,
956    dry_run: bool,
957) -> String {
958    if overwritten_cells.is_empty() {
959        return "  no non-blank cells in the destination".to_string();
960    }
961    let bound = if destination_is_upper_bound {
962        "up to "
963    } else {
964        ""
965    };
966    let tense = if dry_run {
967        "would be overwritten"
968    } else {
969        "were overwritten"
970    };
971    format!(
972        "  {bound}{} non-blank cell(s) {tense}: {}",
973        overwritten_cells.len(),
974        grid_range::render_locations(overwritten_cells)
975    )
976}
977
978/// Builds the request for `form`. Placed after the `--dry-run` branch in
979/// `auto_fill_inner` deliberately — see `banding.rs::banding_inner`'s
980/// comment just before its own call site for the full `#1688` ordering
981/// reasoning, shared verbatim by every leased engine.
982fn build_request(
983    form: &AutoFillForm,
984    source_grid: GridRange,
985    use_alternate_series: bool,
986) -> BatchUpdateRequestItem {
987    let request = match form {
988        AutoFillForm::Range { .. } => AutoFillRequest {
989            range: Some(source_grid),
990            source_and_destination: None,
991            use_alternate_series,
992        },
993        AutoFillForm::SourceAndDestination {
994            dimension,
995            fill_length,
996            ..
997        } => AutoFillRequest {
998            range: None,
999            source_and_destination: Some(SourceAndDestination {
1000                source: source_grid,
1001                dimension: *dimension,
1002                fill_length: *fill_length,
1003            }),
1004            use_alternate_series,
1005        },
1006    };
1007    BatchUpdateRequestItem::AutoFill(request)
1008}
1009
1010fn record_attempt(outcome: &AutoFillOutcome, duration: Duration) {
1011    request_log::record_drive_mutation(mutation_record(outcome, duration));
1012}
1013
1014/// The request-log record for `outcome`, split from [`record_attempt`] so a
1015/// test can read the fields it carries.
1016fn mutation_record(outcome: &AutoFillOutcome, duration: Duration) -> DriveMutationOutcome {
1017    let error = match &outcome.result {
1018        AutoFillResult::Failed { detail }
1019        | AutoFillResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
1020        _ => None,
1021    };
1022    let decided_by = match &outcome.result {
1023        AutoFillResult::Blocked { decided_by } => decided_by.as_ref(),
1024        _ => None,
1025    };
1026    let decided_by = write_gate::decided_by_log_fields(decided_by);
1027    let (range, fields_changed, overwritten_cells, overwritten_cells_upper_bound) =
1028        match &outcome.result {
1029            AutoFillResult::Changed {
1030                summary,
1031                destination,
1032                overwritten_cells,
1033                destination_is_upper_bound,
1034                ..
1035            }
1036            | AutoFillResult::AppliedReplyUnreadable {
1037                summary,
1038                destination,
1039                overwritten_cells,
1040                destination_is_upper_bound,
1041                ..
1042            } => (
1043                Some(destination.clone()),
1044                Some(summary.clone()),
1045                overwritten_cells.clone(),
1046                *destination_is_upper_bound,
1047            ),
1048            _ => (None, None, Vec::new(), false),
1049        };
1050
1051    DriveMutationOutcome {
1052        operation: LOG_OPERATION,
1053        file_id: outcome.spreadsheet_id.clone(),
1054        file_name: outcome.file_name.clone().unwrap_or_default(),
1055        status: outcome.result.log_status().to_string(),
1056        resolved_folder_id: outcome.resolved_folder_id.clone(),
1057        decided_by_folder_id: decided_by.folder_id,
1058        decided_by_depth: decided_by.depth,
1059        decided_by_file_id: decided_by.file_id,
1060        sheet_id: outcome.sheet_id,
1061        range,
1062        fields_changed,
1063        overwritten_cells,
1064        overwritten_cells_upper_bound,
1065        error,
1066        duration,
1067        ..Default::default()
1068    }
1069}
1070
1071/// Renders an outcome as human-readable text.
1072#[must_use]
1073pub fn describe(outcome: &AutoFillOutcome) -> String {
1074    describe_lines(outcome).join("\n")
1075}
1076
1077/// Renders an outcome as its individual lines, none of which contains a
1078/// newline.
1079#[must_use]
1080pub fn describe_lines(outcome: &AutoFillOutcome) -> Vec<String> {
1081    let book = outcome.file_name.as_deref().map_or_else(
1082        || format!("'{}'", outcome.spreadsheet_id),
1083        |n| format!("'{n}'"),
1084    );
1085    match &outcome.result {
1086        AutoFillResult::WouldChange {
1087            summary,
1088            destination,
1089            requested_destination,
1090            overwritten_cells,
1091            destination_is_upper_bound,
1092        } => change_lines(
1093            &format!("Would {summary} in {book}"),
1094            overwritten_cells,
1095            *destination_is_upper_bound,
1096            destination,
1097            requested_destination.as_deref(),
1098            true,
1099        ),
1100        AutoFillResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1101            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1102             `drive sheets auto-fill` only works on spreadsheets"
1103        )],
1104        AutoFillResult::RefusedShortcut => vec![format!(
1105            "Refused: {book} is a shortcut; `drive sheets auto-fill` doesn't follow shortcuts"
1106        )],
1107        AutoFillResult::RefusedNoVisibleParents => vec![format!(
1108            "Refused: {book} has no parent folder visible to this account, so no folder \
1109             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1110             id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1111        )],
1112        AutoFillResult::RefusedSheetNotFound { title, available } => {
1113            vec![grid_range::no_sheet_refusal(&book, title, available)]
1114        }
1115        AutoFillResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1116        AutoFillResult::RefusedDestinationPastGrid {
1117            requested_destination,
1118            row_count,
1119            column_count,
1120        } => {
1121            let extent = match (row_count, column_count) {
1122                (Some(rows), Some(cols)) => format!(" ({rows} rows x {cols} columns)"),
1123                (Some(rows), None) => format!(" ({rows} rows)"),
1124                (None, Some(cols)) => format!(" ({cols} columns)"),
1125                (None, None) => String::new(),
1126            };
1127            vec![format!(
1128                "Refused: {requested_destination} lies wholly past {book}'s current grid{extent}; \
1129                 auto-fill does not grow the sheet. Grow it first (e.g. `sheets append`, \
1130                 `sheets insert-rows`/`insert-columns`), or choose a destination within the grid."
1131            )]
1132        }
1133        AutoFillResult::Blocked { decided_by } => vec![match decided_by {
1134            Some(rule) => format!(
1135                "Blocked: auto-fill on {book} refused by rule on {} {}{}",
1136                rule.kind_label(),
1137                rule.id(),
1138                rule.depth_suffix()
1139            ),
1140            None => format!(
1141                "Blocked: auto-fill on {book} refused by default policy (no matching rule for \
1142                 sheets-write)"
1143            ),
1144        }],
1145        AutoFillResult::RefusedNoLease => LeaseGateRefusal::NoLease
1146            .describe_line(&outcome.spreadsheet_id, &book)
1147            .into_iter()
1148            .collect(),
1149        AutoFillResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1150            .describe_line(&outcome.spreadsheet_id, &book)
1151            .into_iter()
1152            .collect(),
1153        AutoFillResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1154            .describe_line(&outcome.spreadsheet_id, &book)
1155            .into_iter()
1156            .collect(),
1157        AutoFillResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1158            .describe_line(&outcome.spreadsheet_id, &book)
1159            .into_iter()
1160            .collect(),
1161        AutoFillResult::Changed {
1162            summary,
1163            destination,
1164            requested_destination,
1165            overwritten_cells,
1166            destination_is_upper_bound,
1167        } => change_lines(
1168            &format!("Applied: {summary} in {book}"),
1169            overwritten_cells,
1170            *destination_is_upper_bound,
1171            destination,
1172            requested_destination.as_deref(),
1173            false,
1174        ),
1175        // Idempotent: filling the same source over the same destination again
1176        // yields the same cells.
1177        AutoFillResult::AppliedReplyUnreadable {
1178            summary,
1179            destination,
1180            requested_destination,
1181            overwritten_cells,
1182            destination_is_upper_bound,
1183            detail,
1184        } => {
1185            let mut lines = vec![applied_reply_unreadable_line(
1186                summary,
1187                &book,
1188                RetryHint::Idempotent,
1189                detail,
1190            )];
1191            // The same detail lines `Changed` prints: all plan-time data.
1192            lines.extend(change_detail_lines(
1193                overwritten_cells,
1194                *destination_is_upper_bound,
1195                destination,
1196                requested_destination.as_deref(),
1197                false,
1198            ));
1199            lines
1200        }
1201        AutoFillResult::Failed { detail } => vec![format!("Failed: {detail}")],
1202    }
1203}
1204
1205/// The head line plus its indented detail lines, shared by
1206/// [`AutoFillResult::WouldChange`] and [`AutoFillResult::Changed`] so the
1207/// two can only differ in `head` and in the tense `dry_run` selects —
1208/// `structure.rs`'s `vec![summary, detail]` shape.
1209fn change_lines(
1210    head: &str,
1211    overwritten_cells: &[String],
1212    destination_is_upper_bound: bool,
1213    destination: &str,
1214    requested_destination: Option<&str>,
1215    dry_run: bool,
1216) -> Vec<String> {
1217    let mut lines = vec![head.to_string()];
1218    lines.extend(change_detail_lines(
1219        overwritten_cells,
1220        destination_is_upper_bound,
1221        destination,
1222        requested_destination,
1223        dry_run,
1224    ));
1225    lines
1226}
1227
1228/// The indented lines under a change's head line — split out so
1229/// [`AutoFillResult::AppliedReplyUnreadable`] can print exactly what
1230/// [`AutoFillResult::Changed`] does after its own head.
1231fn change_detail_lines(
1232    overwritten_cells: &[String],
1233    destination_is_upper_bound: bool,
1234    destination: &str,
1235    requested_destination: Option<&str>,
1236    dry_run: bool,
1237) -> Vec<String> {
1238    let mut lines = vec![overwritten_line(
1239        overwritten_cells,
1240        destination_is_upper_bound,
1241        dry_run,
1242    )];
1243    if let Some(requested) = requested_destination {
1244        lines.push(if dry_run {
1245            past_grid_extent_caveat_dry_run(destination, requested)
1246        } else {
1247            past_grid_extent_caveat(destination, requested)
1248        });
1249    }
1250    lines.push(UNPREVIEWABLE_VALUES_CAVEAT.to_string());
1251    lines
1252}
1253
1254#[cfg(test)]
1255#[allow(clippy::unwrap_used, clippy::expect_used)]
1256mod tests {
1257    use super::*;
1258    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1259    use crate::drive::sheets::client::SHEETS_API_URL;
1260    use crate::drive::sheets::types::{GridProperties, SheetProperties};
1261    use crate::drive::test_support::seed_lease;
1262    use crate::test_support::env::MapEnv;
1263    use crate::utils::secret::Secret;
1264    use std::collections::HashSet;
1265
1266    // ── pure helpers ─────────────────────────────────────────────────────
1267
1268    fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1269        GridRange {
1270            sheet_id,
1271            start_row_index: Some(r0),
1272            end_row_index: Some(r1),
1273            start_column_index: Some(c0),
1274            end_column_index: Some(c1),
1275        }
1276    }
1277
1278    #[test]
1279    fn compute_destination_extends_rows_forward() {
1280        let source = bounded(0, 0, 3, 0, 2); // A1:B3
1281        let dest = compute_destination(source, Dimension::Rows, 4).unwrap();
1282        assert_eq!(dest, bounded(0, 3, 7, 0, 2)); // A4:B7
1283    }
1284
1285    #[test]
1286    fn compute_destination_extends_rows_backward() {
1287        let source = bounded(0, 5, 8, 0, 2); // A6:B8
1288        let dest = compute_destination(source, Dimension::Rows, -3).unwrap();
1289        assert_eq!(dest, bounded(0, 2, 5, 0, 2)); // A3:B5
1290    }
1291
1292    #[test]
1293    fn compute_destination_extends_columns_forward() {
1294        let source = bounded(0, 0, 2, 0, 1); // A1:A2
1295        let dest = compute_destination(source, Dimension::Columns, 2).unwrap();
1296        assert_eq!(dest, bounded(0, 0, 2, 1, 3)); // B1:C2
1297    }
1298
1299    #[test]
1300    fn compute_destination_extends_columns_backward() {
1301        let source = bounded(0, 0, 2, 5, 6); // F1:F2
1302        let dest = compute_destination(source, Dimension::Columns, -5).unwrap();
1303        assert_eq!(dest, bounded(0, 0, 2, 0, 5)); // A1:E2
1304    }
1305
1306    #[test]
1307    fn compute_destination_refuses_going_before_the_start_of_the_sheet() {
1308        let source = bounded(0, 2, 5, 0, 2); // A3:B5
1309        let err = compute_destination(source, Dimension::Rows, -3).unwrap_err();
1310        assert!(err.contains("before the start"), "{err}");
1311    }
1312
1313    #[test]
1314    fn compute_destination_allows_landing_exactly_at_the_start() {
1315        let source = bounded(0, 2, 5, 0, 2); // A3:B5
1316        let dest = compute_destination(source, Dimension::Rows, -2).unwrap();
1317        assert_eq!(dest, bounded(0, 0, 2, 0, 2)); // A1:B2
1318    }
1319
1320    #[test]
1321    fn compute_destination_refuses_overflow() {
1322        let source = bounded(0, i64::MAX - 1, i64::MAX, 0, 1);
1323        let err = compute_destination(source, Dimension::Rows, i64::MAX).unwrap_err();
1324        assert!(err.contains("out of range"), "{err}");
1325    }
1326
1327    #[test]
1328    fn compute_destination_refuses_an_unbounded_axis_and_backward_overflow() {
1329        let unbounded = GridRange {
1330            sheet_id: 0,
1331            ..Default::default()
1332        };
1333        assert!(compute_destination(unbounded, Dimension::Columns, 1)
1334            .unwrap_err()
1335            .contains("fully bounded"));
1336        assert!(
1337            compute_destination(bounded(0, 0, 1, 0, 1), Dimension::Rows, i64::MIN)
1338                .unwrap_err()
1339                .contains("9223372036854775808 row(s) before the start")
1340        );
1341        assert!(
1342            compute_destination(bounded(0, -1, 1, 0, 1), Dimension::Rows, i64::MIN)
1343                .unwrap_err()
1344                .contains("out of range")
1345        );
1346    }
1347
1348    #[test]
1349    fn lease_refusals_map_to_their_auto_fill_results() {
1350        assert_eq!(
1351            AutoFillResult::from_lease_expired(),
1352            AutoFillResult::RefusedLeaseExpired
1353        );
1354        assert_eq!(
1355            AutoFillResult::from_lease_wrong_file(),
1356            AutoFillResult::RefusedLeaseWrongFile
1357        );
1358        assert_eq!(
1359            AutoFillResult::from_lease_stale(),
1360            AutoFillResult::RefusedLeaseStale
1361        );
1362        assert_eq!(
1363            AutoFillResult::from_lease_failed("ledger unavailable".into()),
1364            AutoFillResult::Failed {
1365                detail: "ledger unavailable".into()
1366            }
1367        );
1368    }
1369
1370    #[test]
1371    fn jsonl_outcome_serializes_one_record_without_the_input_form() {
1372        let outcome = AutoFillOutcome {
1373            spreadsheet_id: "sheet-1".into(),
1374            file_name: None,
1375            resolved_folder_id: None,
1376            sheet_id: None,
1377            form: range_form(),
1378            result: AutoFillResult::RefusedShortcut,
1379        };
1380        let mut out = Vec::new();
1381        outcome.write_jsonl(&mut out).unwrap();
1382        let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
1383        assert_eq!(value["result"]["status"], "refused-shortcut");
1384        assert!(value.get("form").is_none());
1385        assert_eq!(std::str::from_utf8(&out).unwrap().lines().count(), 1);
1386    }
1387
1388    fn sheet_with_extent(row_count: i64, column_count: i64) -> Sheet {
1389        Sheet {
1390            properties: Some(SheetProperties {
1391                sheet_id: Some(0),
1392                title: "Q1".to_string(),
1393                index: Some(0),
1394                hidden: None,
1395                grid_properties: Some(GridProperties {
1396                    row_count: Some(row_count),
1397                    column_count: Some(column_count),
1398                    frozen_row_count: None,
1399                    frozen_column_count: None,
1400                    hide_gridlines: None,
1401                }),
1402                right_to_left: None,
1403            }),
1404            ..Default::default()
1405        }
1406    }
1407
1408    #[test]
1409    fn clamp_to_grid_trims_a_destination_that_overhangs_the_extent() {
1410        let sheet = sheet_with_extent(10, 5);
1411        // Rows 8..14 against a 10-row sheet -> 8..10.
1412        let clamped = clamp_to_grid(&sheet, &bounded(0, 8, 14, 0, 1)).unwrap();
1413        assert_eq!(clamped.end_row_index, Some(10));
1414        assert_eq!(clamped.start_row_index, Some(8));
1415        // Columns 3..9 against a 5-column sheet -> 3..5.
1416        let clamped = clamp_to_grid(&sheet, &bounded(0, 0, 1, 3, 9)).unwrap();
1417        assert_eq!(clamped.end_column_index, Some(5));
1418    }
1419
1420    #[test]
1421    fn clamp_to_grid_leaves_a_destination_within_the_extent_untouched() {
1422        let sheet = sheet_with_extent(10, 5);
1423        let destination = bounded(0, 2, 5, 1, 3);
1424        assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1425    }
1426
1427    #[test]
1428    fn clamp_to_grid_is_none_when_the_destination_lies_wholly_past_the_extent() {
1429        let sheet = sheet_with_extent(10, 5);
1430        // Starts at row 10 on a 10-row sheet: nothing left to read.
1431        assert_eq!(clamp_to_grid(&sheet, &bounded(0, 10, 14, 0, 1)), None);
1432        assert_eq!(clamp_to_grid(&sheet, &bounded(0, 0, 1, 5, 9)), None);
1433    }
1434
1435    #[test]
1436    fn clamp_to_grid_leaves_an_axis_with_no_known_extent_alone() {
1437        let mut sheet = sheet_with_extent(10, 5);
1438        sheet
1439            .properties
1440            .as_mut()
1441            .expect("fixture always sets properties")
1442            .grid_properties = None;
1443        let destination = bounded(0, 0, 9_999, 0, 1);
1444        assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
1445    }
1446
1447    #[test]
1448    fn grid_range_to_a1_round_trips_a_bounded_range() {
1449        let grid = bounded(0, 0, 2, 0, 2); // A1:B2
1450        assert_eq!(grid_range_to_a1("Q1", &grid), "'Q1'!A1:B2");
1451    }
1452
1453    #[test]
1454    #[should_panic(expected = "fully bounded")]
1455    fn grid_range_to_a1_panics_on_an_unbounded_range() {
1456        let grid = GridRange {
1457            sheet_id: 0,
1458            ..Default::default()
1459        };
1460        let _ = grid_range_to_a1("Q1", &grid);
1461    }
1462
1463    #[test]
1464    fn build_request_range_form_carries_the_range_and_no_source_and_destination() {
1465        let source = bounded(0, 0, 2, 0, 2);
1466        let form = AutoFillForm::Range {
1467            sheet: Some("Q1".to_string()),
1468            range: Some("A1:B2".to_string()),
1469        };
1470        let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, true) else {
1471            panic!("expected AutoFill"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns AutoFill"
1472        };
1473        assert_eq!(request.range, Some(source));
1474        assert_eq!(request.source_and_destination, None);
1475        assert!(request.use_alternate_series);
1476    }
1477
1478    #[test]
1479    fn build_request_source_and_destination_form_carries_dimension_and_fill_length() {
1480        let source = bounded(0, 0, 2, 0, 2);
1481        let form = AutoFillForm::SourceAndDestination {
1482            sheet: Some("Q1".to_string()),
1483            source: Some("A1:B2".to_string()),
1484            dimension: Dimension::Rows,
1485            fill_length: 5,
1486        };
1487        let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, false) else {
1488            panic!("expected AutoFill"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns AutoFill"
1489        };
1490        assert_eq!(request.range, None);
1491        let sad = request.source_and_destination.unwrap();
1492        assert_eq!(sad.source, source);
1493        assert_eq!(sad.dimension, Dimension::Rows);
1494        assert_eq!(sad.fill_length, 5);
1495        assert!(!request.use_alternate_series);
1496    }
1497
1498    #[test]
1499    fn describe_effect_names_the_direction_for_every_axis_and_sign() {
1500        let form = |dimension, fill_length| AutoFillForm::SourceAndDestination {
1501            sheet: None,
1502            source: None,
1503            dimension,
1504            fill_length,
1505        };
1506        assert!(
1507            describe_effect(&form(Dimension::Rows, 3), "A1:A3", "A4:A6", 3, false)
1508                .contains("3 row(s) down")
1509        );
1510        assert!(
1511            describe_effect(&form(Dimension::Rows, -3), "A4:A6", "A1:A3", -3, false)
1512                .contains("3 row(s) up")
1513        );
1514        assert!(
1515            describe_effect(&form(Dimension::Columns, 2), "A1:A1", "B1:C1", 2, false)
1516                .contains("2 column(s) right")
1517        );
1518        assert!(
1519            describe_effect(&form(Dimension::Columns, -2), "C1:C1", "A1:B1", -2, false)
1520                .contains("2 column(s) left")
1521        );
1522    }
1523
1524    #[test]
1525    fn describe_effect_names_the_source_it_extends() {
1526        let form = AutoFillForm::SourceAndDestination {
1527            sheet: None,
1528            source: None,
1529            dimension: Dimension::Rows,
1530            fill_length: 7,
1531        };
1532        // The request log's `range` key is the destination, so without
1533        // this the record could not say what was extended.
1534        let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A10", 7, false);
1535        assert!(summary.contains("from source 'Q1'!A1:A3"), "{summary}");
1536    }
1537
1538    /// The triage plan's decision: the head line names the *applied*
1539    /// (clipped) count, never the raw `--fill-length` — a destination
1540    /// clipped to fit the grid must not have the head line claim more
1541    /// rows/columns were touched than actually were.
1542    #[test]
1543    fn describe_effect_uses_the_applied_fill_length_not_the_requested_one() {
1544        let form = AutoFillForm::SourceAndDestination {
1545            sheet: None,
1546            source: None,
1547            dimension: Dimension::Rows,
1548            fill_length: 1000,
1549        };
1550        let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A1000", 997, false);
1551        assert!(summary.contains("997 row(s) down"), "{summary}");
1552        // "1000" legitimately appears in the destination A1 address
1553        // (A4:A1000); the count itself must not be 1000.
1554        assert!(!summary.contains("1000 row(s)"), "{summary}");
1555    }
1556
1557    #[test]
1558    fn describe_effect_names_the_range_form_as_server_decided() {
1559        let form = AutoFillForm::Range {
1560            sheet: None,
1561            range: None,
1562        };
1563        let summary = describe_effect(&form, "A1:A10", "A1:A10", 0, false);
1564        assert!(summary.contains("Sheets decides"), "{summary}");
1565    }
1566
1567    #[test]
1568    fn describe_effect_names_the_alternate_series_flag() {
1569        let form = AutoFillForm::SourceAndDestination {
1570            sheet: None,
1571            source: None,
1572            dimension: Dimension::Rows,
1573            fill_length: 1,
1574        };
1575        let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1576        assert!(summary.contains("alternate series"), "{summary}");
1577    }
1578
1579    /// The summary is reused verbatim by `Changed` and by the request
1580    /// log's `fields_changed`, so it must carry no clause that only reads
1581    /// correctly before the fact.
1582    #[test]
1583    fn describe_effect_is_tense_neutral() {
1584        let form = AutoFillForm::SourceAndDestination {
1585            sheet: None,
1586            source: None,
1587            dimension: Dimension::Rows,
1588            fill_length: 1,
1589        };
1590        let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
1591        for conditional in ["would", "cannot", "may "] {
1592            assert!(!summary.contains(conditional), "{summary}");
1593        }
1594    }
1595
1596    #[test]
1597    fn applied_fill_length_shrinks_to_the_clipped_destination_forward() {
1598        let form = AutoFillForm::SourceAndDestination {
1599            sheet: None,
1600            source: None,
1601            dimension: Dimension::Rows,
1602            fill_length: 1000,
1603        };
1604        let source = bounded(0, 0, 3, 0, 1); // A1:A3
1605        let applied = bounded(0, 3, 1000, 0, 1); // clipped to A4:A1000
1606        assert_eq!(applied_fill_length(&form, &source, &applied), 997);
1607    }
1608
1609    #[test]
1610    fn applied_fill_length_is_unaffected_by_a_backward_fill() {
1611        let form = AutoFillForm::SourceAndDestination {
1612            sheet: None,
1613            source: None,
1614            dimension: Dimension::Rows,
1615            fill_length: -3,
1616        };
1617        let source = bounded(0, 5, 8, 0, 1); // A6:A8
1618        let applied = bounded(0, 2, 5, 0, 1); // A3:A5 — clamp_to_grid never
1619                                              // touches a range's start.
1620        assert_eq!(applied_fill_length(&form, &source, &applied), -3);
1621    }
1622
1623    #[test]
1624    fn applied_fill_length_is_zero_for_the_range_form() {
1625        let form = AutoFillForm::Range {
1626            sheet: None,
1627            range: None,
1628        };
1629        let grid = bounded(0, 0, 1, 0, 1);
1630        assert_eq!(applied_fill_length(&form, &grid, &grid), 0);
1631    }
1632
1633    #[test]
1634    fn overwritten_line_reports_cells_never_their_values() {
1635        let cells = vec!["A4".to_string(), "A5".to_string()];
1636        let line = overwritten_line(&cells, false, true);
1637        assert_eq!(line, "  2 non-blank cell(s) would be overwritten: A4, A5");
1638    }
1639
1640    #[test]
1641    fn overwritten_line_marks_an_upper_bound_count_as_up_to() {
1642        let cells = vec!["A1".to_string()];
1643        let line = overwritten_line(&cells, true, true);
1644        assert_eq!(line, "  up to 1 non-blank cell(s) would be overwritten: A1");
1645    }
1646
1647    #[test]
1648    fn overwritten_line_uses_the_past_tense_after_a_real_run() {
1649        let cells = vec!["A4".to_string()];
1650        assert_eq!(
1651            overwritten_line(&cells, false, false),
1652            "  1 non-blank cell(s) were overwritten: A4"
1653        );
1654        assert_eq!(
1655            overwritten_line(&[], false, false),
1656            "  no non-blank cells in the destination"
1657        );
1658    }
1659
1660    #[test]
1661    fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1662        let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1663        let line = overwritten_line(&cells, false, true);
1664        assert!(line.contains("… and 10 more"), "{line}");
1665        assert!(
1666            line.starts_with("  60 non-blank cell(s) would be overwritten:"),
1667            "{line}"
1668        );
1669        assert_eq!(cells.len(), 60);
1670    }
1671
1672    #[test]
1673    fn change_lines_name_the_grid_extent_caveat_in_the_matching_tense() {
1674        let dry = change_lines(
1675            "Would x in 'B'",
1676            &[],
1677            false,
1678            "'Q1'!A1:A10",
1679            Some("'Q1'!A1:A20"),
1680            true,
1681        );
1682        assert!(
1683            dry.contains(&past_grid_extent_caveat_dry_run(
1684                "'Q1'!A1:A10",
1685                "'Q1'!A1:A20"
1686            )),
1687            "{dry:?}"
1688        );
1689        let real = change_lines(
1690            "Applied: x in 'B'",
1691            &[],
1692            false,
1693            "'Q1'!A1:A10",
1694            Some("'Q1'!A1:A20"),
1695            false,
1696        );
1697        assert!(
1698            real.contains(&past_grid_extent_caveat("'Q1'!A1:A10", "'Q1'!A1:A20")),
1699            "{real:?}"
1700        );
1701        // Omitted entirely when the destination fits (`requested_destination`
1702        // is `None`).
1703        let within = change_lines("Would x in 'B'", &[], false, "'Q1'!A1:A10", None, true);
1704        assert!(
1705            !within.iter().any(|l| l.contains("current extent")),
1706            "{within:?}"
1707        );
1708    }
1709
1710    #[test]
1711    fn change_lines_always_state_the_server_decides_the_values() {
1712        for dry_run in [true, false] {
1713            let lines = change_lines("head", &[], false, "'Q1'!A1:A10", None, dry_run);
1714            assert!(
1715                lines.contains(&UNPREVIEWABLE_VALUES_CAVEAT.to_string()),
1716                "{lines:?}"
1717            );
1718        }
1719    }
1720
1721    #[test]
1722    fn auto_fill_result_log_status_names_every_variant() {
1723        assert_eq!(
1724            AutoFillResult::WouldChange {
1725                summary: String::new(),
1726                destination: String::new(),
1727                requested_destination: None,
1728                overwritten_cells: Vec::new(),
1729                destination_is_upper_bound: false,
1730            }
1731            .log_status(),
1732            "would-change"
1733        );
1734        assert_eq!(
1735            AutoFillResult::RefusedNotASpreadsheet {
1736                mime_type: String::new()
1737            }
1738            .log_status(),
1739            "refused-not-a-spreadsheet"
1740        );
1741        assert_eq!(
1742            AutoFillResult::RefusedShortcut.log_status(),
1743            "refused-shortcut"
1744        );
1745        assert_eq!(
1746            AutoFillResult::RefusedNoVisibleParents.log_status(),
1747            "refused-no-visible-parents"
1748        );
1749        assert_eq!(
1750            AutoFillResult::RefusedSheetNotFound {
1751                title: String::new(),
1752                available: Vec::new(),
1753            }
1754            .log_status(),
1755            "refused-sheet-not-found"
1756        );
1757        assert_eq!(
1758            AutoFillResult::RefusedInvalidRange {
1759                detail: String::new()
1760            }
1761            .log_status(),
1762            "refused-invalid-range"
1763        );
1764        assert_eq!(
1765            AutoFillResult::RefusedDestinationPastGrid {
1766                requested_destination: String::new(),
1767                row_count: None,
1768                column_count: None,
1769            }
1770            .log_status(),
1771            "refused-destination-past-grid"
1772        );
1773        assert_eq!(
1774            AutoFillResult::Blocked { decided_by: None }.log_status(),
1775            "blocked"
1776        );
1777        assert_eq!(
1778            AutoFillResult::RefusedNoLease.log_status(),
1779            LeaseGateRefusal::NoLease.log_status()
1780        );
1781        assert_eq!(
1782            AutoFillResult::RefusedLeaseExpired.log_status(),
1783            LeaseGateRefusal::Expired.log_status()
1784        );
1785        assert_eq!(
1786            AutoFillResult::RefusedLeaseWrongFile.log_status(),
1787            LeaseGateRefusal::WrongFile.log_status()
1788        );
1789        assert_eq!(
1790            AutoFillResult::RefusedLeaseStale.log_status(),
1791            LeaseGateRefusal::Stale.log_status()
1792        );
1793        assert_eq!(
1794            AutoFillResult::Changed {
1795                summary: String::new(),
1796                destination: String::new(),
1797                requested_destination: None,
1798                overwritten_cells: Vec::new(),
1799                destination_is_upper_bound: false,
1800            }
1801            .log_status(),
1802            "changed"
1803        );
1804        assert_eq!(
1805            AutoFillResult::AppliedReplyUnreadable {
1806                summary: String::new(),
1807                destination: String::new(),
1808                requested_destination: None,
1809                overwritten_cells: Vec::new(),
1810                destination_is_upper_bound: false,
1811                detail: String::new(),
1812            }
1813            .log_status(),
1814            "applied-reply-unreadable"
1815        );
1816        assert_eq!(
1817            AutoFillResult::Failed {
1818                detail: String::new()
1819            }
1820            .log_status(),
1821            "failed"
1822        );
1823    }
1824
1825    #[test]
1826    fn sheet_and_range_reads_source_for_the_source_and_destination_form() {
1827        let form = AutoFillForm::SourceAndDestination {
1828            sheet: Some("Q1".to_string()),
1829            source: Some("A1:A3".to_string()),
1830            dimension: Dimension::Rows,
1831            fill_length: 1,
1832        };
1833        assert_eq!(form.sheet_and_range(), (Some("Q1"), Some("A1:A3")));
1834    }
1835
1836    /// No arm ever emits a literal newline of its own — `write.rs`'s own
1837    /// `every_describe_arm_renders_a_single_line` contract: `describe_lines`
1838    /// interpolates a Drive-supplied file name and server-supplied A1
1839    /// strings, and its CLI caller (`sanitize_for_terminal`) can only
1840    /// sanitize the **whole rendered line** rather than each interpolation.
1841    /// `WouldChange`/`Changed` return several lines (a head plus indented
1842    /// detail lines, `structure.rs`'s shape); every other arm returns one.
1843    #[test]
1844    fn no_describe_line_contains_an_embedded_newline() {
1845        let base = AutoFillOutcome {
1846            spreadsheet_id: "sheet-1".to_string(),
1847            file_name: Some("Budget".to_string()),
1848            resolved_folder_id: None,
1849            sheet_id: Some(0),
1850            form: range_form(),
1851            result: AutoFillResult::Failed {
1852                detail: String::new(),
1853            },
1854        };
1855        let results = vec![
1856            AutoFillResult::WouldChange {
1857                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1858                destination: "'Q1'!A1:A10".to_string(),
1859                requested_destination: Some("'Q1'!A1:A20".to_string()),
1860                overwritten_cells: vec!["A1".to_string()],
1861                destination_is_upper_bound: true,
1862            },
1863            AutoFillResult::RefusedNotASpreadsheet {
1864                mime_type: "application/pdf".to_string(),
1865            },
1866            AutoFillResult::RefusedShortcut,
1867            AutoFillResult::RefusedNoVisibleParents,
1868            AutoFillResult::RefusedSheetNotFound {
1869                title: "Q9".to_string(),
1870                available: vec!["Q1".to_string()],
1871            },
1872            AutoFillResult::RefusedInvalidRange {
1873                detail: "bad range".to_string(),
1874            },
1875            AutoFillResult::RefusedDestinationPastGrid {
1876                requested_destination: "'Q1'!A2001:A2010".to_string(),
1877                row_count: Some(1000),
1878                column_count: Some(26),
1879            },
1880            AutoFillResult::Blocked { decided_by: None },
1881            AutoFillResult::RefusedNoLease,
1882            AutoFillResult::RefusedLeaseExpired,
1883            AutoFillResult::RefusedLeaseWrongFile,
1884            AutoFillResult::RefusedLeaseStale,
1885            AutoFillResult::Changed {
1886                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1887                destination: "'Q1'!A1:A10".to_string(),
1888                requested_destination: None,
1889                overwritten_cells: Vec::new(),
1890                destination_is_upper_bound: true,
1891            },
1892            AutoFillResult::AppliedReplyUnreadable {
1893                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
1894                destination: "'Q1'!A1:A10".to_string(),
1895                requested_destination: Some("'Q1'!A1:A20".to_string()),
1896                overwritten_cells: vec!["A1".to_string()],
1897                destination_is_upper_bound: true,
1898                detail: "bad reply".to_string(),
1899            },
1900            AutoFillResult::Failed {
1901                detail: "boom".to_string(),
1902            },
1903        ];
1904        for result in results {
1905            let outcome = AutoFillOutcome {
1906                result,
1907                ..base.clone()
1908            };
1909            let lines = describe_lines(&outcome);
1910            assert!(!lines.is_empty(), "{lines:?}");
1911            for line in &lines {
1912                assert!(!line.contains('\n') && !line.contains('\r'), "{line:?}");
1913            }
1914        }
1915    }
1916
1917    #[test]
1918    fn describe_handles_missing_metadata_and_identifies_a_deciding_rule() {
1919        let base = AutoFillOutcome {
1920            spreadsheet_id: "sheet-1".into(),
1921            file_name: None,
1922            resolved_folder_id: None,
1923            sheet_id: None,
1924            form: range_form(),
1925            result: AutoFillResult::RefusedSheetNotFound {
1926                title: "Missing".into(),
1927                available: Vec::new(),
1928            },
1929        };
1930        assert!(describe(&base).contains("Available: none"));
1931        assert!(describe(&base).contains("'sheet-1'"));
1932
1933        let blocked = AutoFillOutcome {
1934            result: AutoFillResult::Blocked {
1935                decided_by: Some(DecidingRule::Folder {
1936                    folder_id: "folder-1".into(),
1937                    depth: 1,
1938                }),
1939            },
1940            ..base
1941        };
1942        assert!(describe(&blocked).contains("folder-1"));
1943    }
1944
1945    /// `RefusedDestinationPastGrid`'s message names whichever axis extent
1946    /// the sheet actually reported — `describe_handles_missing_metadata_and_identifies_a_deciding_rule`
1947    /// and the live-clip tests above only exercise the both-known and
1948    /// both-unknown corners; this pins the two single-axis-known ones too.
1949    #[test]
1950    fn refused_destination_past_grid_message_names_only_the_known_axes() {
1951        let base = AutoFillOutcome {
1952            spreadsheet_id: "sheet-1".into(),
1953            file_name: None,
1954            resolved_folder_id: None,
1955            sheet_id: None,
1956            form: range_form(),
1957            result: AutoFillResult::RefusedDestinationPastGrid {
1958                requested_destination: "'Q1'!A2001:A2010".into(),
1959                row_count: Some(1000),
1960                column_count: None,
1961            },
1962        };
1963        assert!(describe(&base).contains("(1000 rows)"));
1964
1965        let column_only = AutoFillOutcome {
1966            result: AutoFillResult::RefusedDestinationPastGrid {
1967                requested_destination: "'Q1'!AA1:AZ10".into(),
1968                row_count: None,
1969                column_count: Some(26),
1970            },
1971            ..base.clone()
1972        };
1973        assert!(describe(&column_only).contains("(26 columns)"));
1974
1975        let neither_known = AutoFillOutcome {
1976            result: AutoFillResult::RefusedDestinationPastGrid {
1977                requested_destination: "'Q1'!A2001:A2010".into(),
1978                row_count: None,
1979                column_count: None,
1980            },
1981            ..base
1982        };
1983        assert!(describe(&neither_known).contains("current grid;"));
1984    }
1985
1986    fn range_form() -> AutoFillForm {
1987        AutoFillForm::Range {
1988            sheet: Some("Q1".to_string()),
1989            range: Some("A1:A10".to_string()),
1990        }
1991    }
1992
1993    fn source_and_destination_form(dimension: Dimension, fill_length: i64) -> AutoFillForm {
1994        AutoFillForm::SourceAndDestination {
1995            sheet: Some("Q1".to_string()),
1996            source: Some("A1:A3".to_string()),
1997            dimension,
1998            fill_length,
1999        }
2000    }
2001
2002    // ── end-to-end (wiremock) ────────────────────────────────────────────
2003
2004    fn test_credentials() -> DriveCredentials {
2005        DriveCredentials {
2006            client_id: "client-1".to_string(),
2007            client_secret: Secret::new("secret-1"),
2008            refresh_token: Secret::new("refresh-1"),
2009            scope: DriveGrantedScopes::READONLY,
2010        }
2011    }
2012
2013    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
2014        wiremock::Mock::given(wiremock::matchers::method("POST"))
2015            .and(wiremock::matchers::path("/token"))
2016            .respond_with(
2017                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2018                    "access_token": "test-token", "expires_in": 3600,
2019                })),
2020            )
2021            .mount(server)
2022            .await;
2023        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
2024        crate::drive::client::test_support::replace_session(
2025            &mut drive,
2026            &test_credentials(),
2027            &format!("{}/token", server.uri()),
2028        );
2029        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
2030        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
2031        (drive, sheets)
2032    }
2033
2034    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
2035        let parents: Vec<&str> = parents.to_vec();
2036        wiremock::Mock::given(wiremock::matchers::method("GET"))
2037            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
2038            .respond_with(
2039                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2040                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
2041                    "version": "1",
2042                })),
2043            )
2044    }
2045
2046    fn mount_folder(id: &str) -> wiremock::Mock {
2047        mount_file(id, "application/vnd.google-apps.folder", &[])
2048    }
2049
2050    fn mount_workbook() -> wiremock::Mock {
2051        wiremock::Mock::given(wiremock::matchers::method("GET"))
2052            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2053            .respond_with(
2054                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2055                    "spreadsheetId": "sheet-1",
2056                    "properties": {"title": "Budget"},
2057                    "sheets": [
2058                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2059                            "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2060                    ],
2061                })),
2062            )
2063    }
2064
2065    fn allow_rule(folder: &str) -> FolderPermissionRule {
2066        FolderPermissionRule {
2067            folder_id: Some(folder.to_string()),
2068            file_id: None,
2069            recursive: true,
2070            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
2071            deny: HashSet::default(),
2072            require_lease: true,
2073        }
2074    }
2075
2076    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
2077        let ledger_path = tempfile::tempdir()
2078            .unwrap()
2079            .keep()
2080            .join("lease-ledger.jsonl");
2081        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
2082        (Some(token), ledger_path)
2083    }
2084
2085    fn base_opts(form: AutoFillForm, dry_run: bool) -> AutoFillOptions {
2086        AutoFillOptions {
2087            spreadsheet_id: "sheet-1".to_string(),
2088            form,
2089            use_alternate_series: false,
2090            dry_run,
2091            lease_token: None,
2092            ledger_path: std::path::PathBuf::new(),
2093        }
2094    }
2095
2096    #[tokio::test]
2097    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
2098        let server = wiremock::MockServer::start().await;
2099        let (drive, sheets) = clients(&server).await;
2100        mount_file(
2101            "sheet-1",
2102            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2103            &["folder-1"],
2104        )
2105        .mount(&server)
2106        .await;
2107        mount_folder("folder-1").mount(&server).await;
2108        let rules: Vec<FolderPermissionRule> = Vec::new();
2109        let opts = base_opts(range_form(), false);
2110        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2111        assert!(matches!(outcome.result, AutoFillResult::Blocked { .. }));
2112    }
2113
2114    #[tokio::test]
2115    async fn invalid_composed_range_is_refused_before_fetching_metadata() {
2116        let server = wiremock::MockServer::start().await;
2117        let (drive, sheets) = clients(&server).await;
2118        let form = AutoFillForm::Range {
2119            sheet: Some("Q1".into()),
2120            range: Some("Other!A1:A2".into()),
2121        };
2122        let outcome = auto_fill_inner(&drive, &sheets, &base_opts(form, true), &[]).await;
2123        assert!(matches!(
2124            outcome.result,
2125            AutoFillResult::RefusedInvalidRange { .. }
2126        ));
2127        assert!(server
2128            .received_requests()
2129            .await
2130            .unwrap()
2131            .iter()
2132            .all(|r| r.url.path() != "/drive/v3/files/sheet-1"));
2133    }
2134
2135    #[tokio::test]
2136    async fn target_resolution_refusals_keep_the_target_name() {
2137        for (mime, parents, expected) in [
2138            (
2139                crate::drive::types::GOOGLE_SHORTCUT_MIME_TYPE,
2140                vec!["folder-1"],
2141                "shortcut",
2142            ),
2143            ("application/pdf", vec!["folder-1"], "not-spreadsheet"),
2144            (
2145                crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2146                vec![],
2147                "no-parents",
2148            ),
2149        ] {
2150            let server = wiremock::MockServer::start().await;
2151            let (drive, sheets) = clients(&server).await;
2152            mount_file("sheet-1", mime, &parents).mount(&server).await;
2153            let outcome =
2154                auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2155            assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2156            assert!(outcome.sheet_id.is_none());
2157            assert!(
2158                match expected {
2159                    "shortcut" => matches!(outcome.result, AutoFillResult::RefusedShortcut),
2160                    "not-spreadsheet" => matches!(
2161                        outcome.result,
2162                        AutoFillResult::RefusedNotASpreadsheet { .. }
2163                    ),
2164                    _ => matches!(outcome.result, AutoFillResult::RefusedNoVisibleParents),
2165                },
2166                "{outcome:?}"
2167            );
2168        }
2169    }
2170
2171    #[tokio::test]
2172    async fn metadata_and_workbook_failures_are_reported_at_their_respective_stages() {
2173        let server = wiremock::MockServer::start().await;
2174        let (drive, sheets) = clients(&server).await;
2175        wiremock::Mock::given(wiremock::matchers::method("GET"))
2176            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2177            .respond_with(wiremock::ResponseTemplate::new(500))
2178            .mount(&server)
2179            .await;
2180        let outcome = auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
2181        assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2182        assert!(outcome.file_name.is_none());
2183
2184        let server = wiremock::MockServer::start().await;
2185        let (drive, sheets) = clients(&server).await;
2186        mount_file(
2187            "sheet-1",
2188            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2189            &["folder-1"],
2190        )
2191        .mount(&server)
2192        .await;
2193        mount_folder("folder-1").mount(&server).await;
2194        wiremock::Mock::given(wiremock::matchers::method("GET"))
2195            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2196            .respond_with(wiremock::ResponseTemplate::new(500))
2197            .mount(&server)
2198            .await;
2199        let outcome = auto_fill_inner(
2200            &drive,
2201            &sheets,
2202            &base_opts(range_form(), true),
2203            &[allow_rule("folder-1")],
2204        )
2205        .await;
2206        assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2207        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2208    }
2209
2210    #[tokio::test]
2211    async fn a_folder_lookup_failure_preserves_the_target_name() {
2212        let server = wiremock::MockServer::start().await;
2213        let (drive, sheets) = clients(&server).await;
2214        mount_file(
2215            "sheet-1",
2216            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2217            &["folder-1"],
2218        )
2219        .mount(&server)
2220        .await;
2221        wiremock::Mock::given(wiremock::matchers::method("GET"))
2222            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2223            .respond_with(wiremock::ResponseTemplate::new(500))
2224            .mount(&server)
2225            .await;
2226        let outcome = auto_fill_inner(
2227            &drive,
2228            &sheets,
2229            &base_opts(range_form(), true),
2230            &[allow_rule("folder-1")],
2231        )
2232        .await;
2233        assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2234        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
2235        assert!(outcome.sheet_id.is_none());
2236    }
2237
2238    #[tokio::test]
2239    async fn backward_fill_before_the_sheet_start_is_refused_before_values_get() {
2240        let server = wiremock::MockServer::start().await;
2241        let (drive, sheets) = clients(&server).await;
2242        mount_file(
2243            "sheet-1",
2244            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2245            &["folder-1"],
2246        )
2247        .mount(&server)
2248        .await;
2249        mount_folder("folder-1").mount(&server).await;
2250        mount_workbook().mount(&server).await;
2251        let outcome = auto_fill_inner(
2252            &drive,
2253            &sheets,
2254            &base_opts(source_and_destination_form(Dimension::Rows, -1), true),
2255            &[allow_rule("folder-1")],
2256        )
2257        .await;
2258        assert!(matches!(
2259            outcome.result,
2260            AutoFillResult::RefusedInvalidRange { .. }
2261        ));
2262        assert_eq!(outcome.sheet_id, Some(0));
2263        assert!(server
2264            .received_requests()
2265            .await
2266            .unwrap()
2267            .iter()
2268            .all(|r| !r.url.path().contains("/values/")));
2269    }
2270
2271    #[tokio::test]
2272    async fn source_and_destination_dry_run_reports_would_change_and_makes_no_batch_update_call() {
2273        let server = wiremock::MockServer::start().await;
2274        let (drive, sheets) = clients(&server).await;
2275        mount_file(
2276            "sheet-1",
2277            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2278            &["folder-1"],
2279        )
2280        .mount(&server)
2281        .await;
2282        mount_folder("folder-1").mount(&server).await;
2283        mount_workbook().mount(&server).await;
2284        wiremock::Mock::given(wiremock::matchers::method("GET"))
2285            .and(wiremock::matchers::path(
2286                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2287            ))
2288            .respond_with(
2289                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2290                    "values": [["existing"], [], ["also here"]]
2291                })),
2292            )
2293            .mount(&server)
2294            .await;
2295        let rules = vec![allow_rule("folder-1")];
2296        let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2297        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2298        match outcome.result {
2299            AutoFillResult::WouldChange {
2300                destination,
2301                overwritten_cells,
2302                destination_is_upper_bound,
2303                ..
2304            } => {
2305                assert_eq!(destination, "'Q1'!A4:A10");
2306                assert_eq!(overwritten_cells, vec!["A4".to_string(), "A6".to_string()]);
2307                assert!(!destination_is_upper_bound);
2308            }
2309            other => panic!("expected WouldChange, got {other:?}"),
2310        }
2311        // No mock is registered for POST batchUpdate, so a stray call
2312        // would fail this test outright.
2313    }
2314
2315    #[tokio::test]
2316    async fn range_form_dry_run_reports_the_destination_as_an_upper_bound() {
2317        let server = wiremock::MockServer::start().await;
2318        let (drive, sheets) = clients(&server).await;
2319        mount_file(
2320            "sheet-1",
2321            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2322            &["folder-1"],
2323        )
2324        .mount(&server)
2325        .await;
2326        mount_folder("folder-1").mount(&server).await;
2327        mount_workbook().mount(&server).await;
2328        wiremock::Mock::given(wiremock::matchers::method("GET"))
2329            .and(wiremock::matchers::path(
2330                "/v4/spreadsheets/sheet-1/values/'Q1'!A1:A10",
2331            ))
2332            .respond_with(
2333                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2334                    "values": [["1"], ["2"]]
2335                })),
2336            )
2337            .mount(&server)
2338            .await;
2339        let rules = vec![allow_rule("folder-1")];
2340        let opts = base_opts(range_form(), true);
2341        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2342        match outcome.result {
2343            AutoFillResult::WouldChange {
2344                destination_is_upper_bound,
2345                overwritten_cells,
2346                ..
2347            } => {
2348                assert!(destination_is_upper_bound);
2349                assert_eq!(overwritten_cells, vec!["A1".to_string(), "A2".to_string()]);
2350            }
2351            other => panic!("expected WouldChange, got {other:?}"),
2352        }
2353    }
2354
2355    #[tokio::test]
2356    async fn an_open_ended_source_is_refused() {
2357        let server = wiremock::MockServer::start().await;
2358        let (drive, sheets) = clients(&server).await;
2359        mount_file(
2360            "sheet-1",
2361            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2362            &["folder-1"],
2363        )
2364        .mount(&server)
2365        .await;
2366        mount_folder("folder-1").mount(&server).await;
2367        mount_workbook().mount(&server).await;
2368        let rules = vec![allow_rule("folder-1")];
2369        let form = AutoFillForm::SourceAndDestination {
2370            sheet: Some("Q1".to_string()),
2371            source: Some("A:A".to_string()),
2372            dimension: Dimension::Rows,
2373            fill_length: 1,
2374        };
2375        let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2376        match outcome.result {
2377            AutoFillResult::RefusedInvalidRange { detail } => {
2378                assert!(detail.contains("fully bounded"), "{detail}");
2379                assert!(detail.starts_with("'Q1'!A:A is open-ended"), "{detail}");
2380            }
2381            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2382        }
2383    }
2384
2385    #[tokio::test]
2386    async fn a_fill_length_of_zero_is_refused_before_any_request() {
2387        let server = wiremock::MockServer::start().await;
2388        let (drive, sheets) = clients(&server).await;
2389        let rules: Vec<FolderPermissionRule> = Vec::new();
2390        let outcome = auto_fill(
2391            &drive,
2392            &sheets,
2393            &base_opts(source_and_destination_form(Dimension::Rows, 0), true),
2394            &rules,
2395        )
2396        .await;
2397        match outcome.result {
2398            AutoFillResult::RefusedInvalidRange { detail } => {
2399                assert!(detail.contains("--fill-length"), "{detail}");
2400            }
2401            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2402        }
2403        // No mock server call is registered at all, so a network attempt
2404        // would fail this test outright.
2405    }
2406
2407    #[tokio::test]
2408    async fn a_real_run_sends_a_source_and_destination_auto_fill_request_and_reports_changed() {
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().mount(&server).await;
2420        wiremock::Mock::given(wiremock::matchers::method("GET"))
2421            .and(wiremock::matchers::path(
2422                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2423            ))
2424            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2425            .mount(&server)
2426            .await;
2427        wiremock::Mock::given(wiremock::matchers::method("POST"))
2428            .and(wiremock::matchers::path(
2429                "/v4/spreadsheets/sheet-1:batchUpdate",
2430            ))
2431            .and(wiremock::matchers::body_json(serde_json::json!({
2432                "requests": [{
2433                    "autoFill": {
2434                        "sourceAndDestination": {
2435                            "source": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 3,
2436                                "startColumnIndex": 0, "endColumnIndex": 1},
2437                            "dimension": "ROWS",
2438                            "fillLength": 7
2439                        },
2440                        "useAlternateSeries": false
2441                    }
2442                }]
2443            })))
2444            .respond_with(
2445                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2446                    "spreadsheetId": "sheet-1", "replies": [{}]
2447                })),
2448            )
2449            .mount(&server)
2450            .await;
2451        let rules = vec![allow_rule("folder-1")];
2452        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2453        let opts = AutoFillOptions {
2454            lease_token,
2455            ledger_path,
2456            ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2457        };
2458        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2459        assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2460    }
2461
2462    /// Verified live (#1937): `autoFill` clips rather than growing the
2463    /// sheet or erroring — but a destination that lies *wholly* past the
2464    /// grid on some axis would leave nothing to fill, so this is refused
2465    /// before any lease is checked or `batchUpdate` is called, identically
2466    /// for `--dry-run` and a real run. Neither the read nor the mutation
2467    /// is ever attempted: with no mock mounted for either, a stray call
2468    /// would fail this test outright.
2469    ///
2470    /// Only form A can reach this: form B extends from a source range's
2471    /// own edge, so its destination always *starts* inside the grid and
2472    /// can only straddle the edge (the test below), never clear it.
2473    #[tokio::test]
2474    async fn a_destination_wholly_past_the_grid_extent_is_refused_before_any_lease_or_mutation() {
2475        let server = wiremock::MockServer::start().await;
2476        let (drive, sheets) = clients(&server).await;
2477        mount_file(
2478            "sheet-1",
2479            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2480            &["folder-1"],
2481        )
2482        .mount(&server)
2483        .await;
2484        mount_folder("folder-1").mount(&server).await;
2485        mount_workbook().mount(&server).await;
2486        let rules = vec![allow_rule("folder-1")];
2487        // The fixture sheet has 1000 rows; this --range names rows
2488        // 2000..2010, wholly past the extent.
2489        let form = AutoFillForm::Range {
2490            sheet: Some("Q1".to_string()),
2491            range: Some("A2001:A2010".to_string()),
2492        };
2493        for dry_run in [true, false] {
2494            let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2495            let opts = AutoFillOptions {
2496                lease_token,
2497                ledger_path,
2498                form: form.clone(),
2499                ..base_opts(range_form(), dry_run)
2500            };
2501            let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2502            let AutoFillResult::RefusedDestinationPastGrid {
2503                requested_destination,
2504                row_count,
2505                column_count,
2506            } = &outcome.result
2507            else {
2508                panic!("dry_run={dry_run}: {:?}", outcome.result);
2509            };
2510            assert_eq!(requested_destination, "'Q1'!A2001:A2010");
2511            assert_eq!(*row_count, Some(1000));
2512            assert_eq!(*column_count, Some(26));
2513            let lines = describe_lines(&outcome);
2514            assert_eq!(lines.len(), 1, "{lines:?}");
2515            assert!(lines[0].contains("lies wholly past"), "{lines:?}");
2516        }
2517        assert!(
2518            !server
2519                .received_requests()
2520                .await
2521                .unwrap_or_default()
2522                .iter()
2523                .any(
2524                    |r| r.url.path().contains("/values/") || r.url.path().ends_with(":batchUpdate")
2525                ),
2526            "neither the read nor the mutation must be attempted"
2527        );
2528    }
2529
2530    /// The partial case: the destination straddles the grid edge, so the
2531    /// *applied* (clipped) destination is what `destination` reports and
2532    /// what backs the read; the unclipped one survives only as
2533    /// `requested_destination`.
2534    #[tokio::test]
2535    async fn a_destination_straddling_the_grid_edge_is_clipped_and_reports_both_ranges() {
2536        let server = wiremock::MockServer::start().await;
2537        let (drive, sheets) = clients(&server).await;
2538        mount_file(
2539            "sheet-1",
2540            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2541            &["folder-1"],
2542        )
2543        .mount(&server)
2544        .await;
2545        mount_folder("folder-1").mount(&server).await;
2546        mount_workbook().mount(&server).await;
2547        // Source A1:A3 filled 1000 rows down would land at rows 4..1003
2548        // (unclipped); the 1000-row sheet clips the applied destination —
2549        // and the read backing it — to 'Q1'!A4:A1000.
2550        wiremock::Mock::given(wiremock::matchers::method("GET"))
2551            .and(wiremock::matchers::path(
2552                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1000",
2553            ))
2554            .respond_with(
2555                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2556                    "values": [["keep"]]
2557                })),
2558            )
2559            .mount(&server)
2560            .await;
2561        let rules = vec![allow_rule("folder-1")];
2562        let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
2563        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2564        let AutoFillResult::WouldChange {
2565            destination,
2566            requested_destination,
2567            overwritten_cells,
2568            ..
2569        } = &outcome.result
2570        else {
2571            panic!("{:?}", outcome.result);
2572        };
2573        assert_eq!(destination, "'Q1'!A4:A1000");
2574        assert_eq!(requested_destination.as_deref(), Some("'Q1'!A4:A1003"));
2575        assert_eq!(overwritten_cells, &vec!["A4".to_string()]);
2576        let lines = describe_lines(&outcome);
2577        // The head line's count is the *applied* span (1000 - 3 = 997),
2578        // never the raw --fill-length (1000) — the triage plan's decision,
2579        // and the bug this test guards against: a clipped destination
2580        // must not have its head line claim more rows were touched than
2581        // actually were.
2582        assert_eq!(
2583            lines[0],
2584            "Would auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) down in \
2585             'sheet-1'"
2586        );
2587        assert_eq!(
2588            lines[2],
2589            past_grid_extent_caveat_dry_run("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2590        );
2591    }
2592
2593    /// The real-run (past-tense) counterpart of the head-line assertion
2594    /// above: `Changed`'s head line also names the applied count, not the
2595    /// requested one.
2596    #[tokio::test]
2597    async fn a_clipped_real_run_reports_the_applied_count_in_the_head_line() {
2598        let server = wiremock::MockServer::start().await;
2599        let (drive, sheets) = clients(&server).await;
2600        mount_file(
2601            "sheet-1",
2602            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2603            &["folder-1"],
2604        )
2605        .mount(&server)
2606        .await;
2607        mount_folder("folder-1").mount(&server).await;
2608        mount_workbook().mount(&server).await;
2609        wiremock::Mock::given(wiremock::matchers::method("GET"))
2610            .and(wiremock::matchers::path(
2611                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1000",
2612            ))
2613            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2614            .mount(&server)
2615            .await;
2616        wiremock::Mock::given(wiremock::matchers::method("POST"))
2617            .and(wiremock::matchers::path(
2618                "/v4/spreadsheets/sheet-1:batchUpdate",
2619            ))
2620            .respond_with(
2621                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2622                    "spreadsheetId": "sheet-1", "replies": [{}]
2623                })),
2624            )
2625            .mount(&server)
2626            .await;
2627        let rules = vec![allow_rule("folder-1")];
2628        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2629        let opts = AutoFillOptions {
2630            lease_token,
2631            ledger_path,
2632            ..base_opts(source_and_destination_form(Dimension::Rows, 1000), false)
2633        };
2634        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2635        assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
2636        let lines = describe_lines(&outcome);
2637        assert_eq!(
2638            lines[0],
2639            "Applied: auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) \
2640             down in 'sheet-1'"
2641        );
2642        assert_eq!(
2643            lines[2],
2644            past_grid_extent_caveat("'Q1'!A4:A1000", "'Q1'!A4:A1003")
2645        );
2646    }
2647
2648    /// The same clip, along the COLUMNS axis instead of rows — verified
2649    /// live (#1937): a 5-column sheet stayed 5 columns after a fill that
2650    /// requested 10 more, exactly mirroring the rows behaviour above.
2651    #[tokio::test]
2652    async fn a_destination_straddling_the_grid_edge_clips_along_columns_too() {
2653        let server = wiremock::MockServer::start().await;
2654        let (drive, sheets) = clients(&server).await;
2655        mount_file(
2656            "sheet-1",
2657            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2658            &["folder-1"],
2659        )
2660        .mount(&server)
2661        .await;
2662        mount_folder("folder-1").mount(&server).await;
2663        mount_workbook().mount(&server).await;
2664        // Source A1:A3 (column A, rows 1-3) filled 30 columns right would
2665        // land at columns 1..31 (unclipped); the 26-column fixture sheet
2666        // clips the applied destination to columns 1..26 ('Q1'!B1:Z3).
2667        wiremock::Mock::given(wiremock::matchers::method("GET"))
2668            .and(wiremock::matchers::path(
2669                "/v4/spreadsheets/sheet-1/values/'Q1'!B1:Z3",
2670            ))
2671            .respond_with(
2672                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2673                    "values": [["keep"]]
2674                })),
2675            )
2676            .mount(&server)
2677            .await;
2678        let rules = vec![allow_rule("folder-1")];
2679        let opts = base_opts(source_and_destination_form(Dimension::Columns, 30), true);
2680        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2681        let AutoFillResult::WouldChange {
2682            destination,
2683            requested_destination,
2684            overwritten_cells,
2685            ..
2686        } = &outcome.result
2687        else {
2688            panic!("{:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always leads to WouldChange"
2689        };
2690        assert_eq!(destination, "'Q1'!B1:Z3");
2691        assert!(requested_destination.is_some(), "{requested_destination:?}");
2692        assert_eq!(overwritten_cells, &vec!["B1".to_string()]);
2693    }
2694
2695    /// Form A (`--range`) reaches the same clip: `resolve_grid_range`
2696    /// performs no grid-extent validation of its own, so a `--range`
2697    /// straddling the edge is clipped exactly like form B's computed
2698    /// destination.
2699    #[tokio::test]
2700    async fn a_range_form_destination_straddling_the_grid_edge_is_clipped_too() {
2701        let server = wiremock::MockServer::start().await;
2702        let (drive, sheets) = clients(&server).await;
2703        mount_file(
2704            "sheet-1",
2705            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2706            &["folder-1"],
2707        )
2708        .mount(&server)
2709        .await;
2710        mount_folder("folder-1").mount(&server).await;
2711        mount_workbook().mount(&server).await;
2712        wiremock::Mock::given(wiremock::matchers::method("GET"))
2713            .and(wiremock::matchers::path(
2714                "/v4/spreadsheets/sheet-1/values/'Q1'!A990:A1000",
2715            ))
2716            .respond_with(
2717                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2718                    "values": [["keep"]]
2719                })),
2720            )
2721            .mount(&server)
2722            .await;
2723        let rules = vec![allow_rule("folder-1")];
2724        let form = AutoFillForm::Range {
2725            sheet: Some("Q1".to_string()),
2726            range: Some("A990:A1010".to_string()),
2727        };
2728        let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
2729        let AutoFillResult::WouldChange {
2730            destination,
2731            requested_destination,
2732            destination_is_upper_bound,
2733            ..
2734        } = &outcome.result
2735        else {
2736            panic!("{:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always leads to WouldChange"
2737        };
2738        assert_eq!(destination, "'Q1'!A990:A1000");
2739        assert_eq!(requested_destination.as_deref(), Some("'Q1'!A990:A1010"));
2740        assert!(destination_is_upper_bound);
2741    }
2742
2743    /// A destination inside the grid carries no `requested_destination`,
2744    /// and the JSON output has no corresponding key — a regression pin on
2745    /// the wire shape in the common (unclipped) case.
2746    #[tokio::test]
2747    async fn a_destination_inside_the_grid_has_no_requested_destination_key_in_json() {
2748        let server = wiremock::MockServer::start().await;
2749        let (drive, sheets) = clients(&server).await;
2750        mount_file(
2751            "sheet-1",
2752            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2753            &["folder-1"],
2754        )
2755        .mount(&server)
2756        .await;
2757        mount_folder("folder-1").mount(&server).await;
2758        mount_workbook().mount(&server).await;
2759        wiremock::Mock::given(wiremock::matchers::method("GET"))
2760            .and(wiremock::matchers::path(
2761                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2762            ))
2763            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2764            .mount(&server)
2765            .await;
2766        let rules = vec![allow_rule("folder-1")];
2767        let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2768        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2769        let AutoFillResult::WouldChange {
2770            requested_destination,
2771            ..
2772        } = &outcome.result
2773        else {
2774            panic!("{:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always leads to WouldChange"
2775        };
2776        assert!(requested_destination.is_none());
2777        let mut out = Vec::new();
2778        outcome.write_jsonl(&mut out).unwrap();
2779        let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
2780        assert!(value["result"].get("requested_destination").is_none());
2781    }
2782
2783    /// An axis the sheet reports no `gridProperties` for is left alone —
2784    /// "extent unknown ⇒ don't act on it", the pre-#1937 stance for that
2785    /// case — so no clipping and no refusal happen even for a destination
2786    /// that would be wildly past a typical sheet's extent.
2787    #[tokio::test]
2788    async fn unknown_grid_extent_leaves_the_destination_unclamped_and_never_refuses() {
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                    ],
2808                })),
2809            )
2810            .mount(&server)
2811            .await;
2812        wiremock::Mock::given(wiremock::matchers::method("GET"))
2813            .and(wiremock::matchers::path(
2814                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1003",
2815            ))
2816            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2817            .mount(&server)
2818            .await;
2819        let rules = vec![allow_rule("folder-1")];
2820        let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
2821        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2822        let AutoFillResult::WouldChange {
2823            destination,
2824            requested_destination,
2825            ..
2826        } = &outcome.result
2827        else {
2828            panic!("{:?}", outcome.result); // omni-dev: coverage ignore-line reason="guards this test's assumption; the mocked response above always leads to WouldChange"
2829        };
2830        assert_eq!(destination, "'Q1'!A4:A1003");
2831        assert!(requested_destination.is_none());
2832    }
2833
2834    #[tokio::test]
2835    async fn a_values_get_failure_is_reported_as_failed() {
2836        let server = wiremock::MockServer::start().await;
2837        let (drive, sheets) = clients(&server).await;
2838        mount_file(
2839            "sheet-1",
2840            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2841            &["folder-1"],
2842        )
2843        .mount(&server)
2844        .await;
2845        mount_folder("folder-1").mount(&server).await;
2846        mount_workbook().mount(&server).await;
2847        wiremock::Mock::given(wiremock::matchers::method("GET"))
2848            .and(wiremock::matchers::path(
2849                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2850            ))
2851            .respond_with(wiremock::ResponseTemplate::new(500))
2852            .mount(&server)
2853            .await;
2854        let rules = vec![allow_rule("folder-1")];
2855        let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
2856        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2857        assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
2858    }
2859
2860    /// The fixture the three engine-level lease tests below share: an
2861    /// allowed target whose destination read succeeds, so the only thing
2862    /// left to refuse is the lease itself.
2863    ///
2864    /// Those tests are *not* duplicates of
2865    /// [`lease_refusals_map_to_their_auto_fill_results`], which checks the
2866    /// [`FromLeaseRefusal`] impl in isolation. A mis-wired `requires_lease`,
2867    /// or a gate evaluated in the wrong order, would leave that unit test
2868    /// green while the engine sailed past the lease into `batchUpdate` —
2869    /// which is exactly what these assert does not happen.
2870    async fn mount_leasable_fixture(server: &wiremock::MockServer) {
2871        mount_file(
2872            "sheet-1",
2873            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2874            &["folder-1"],
2875        )
2876        .mount(server)
2877        .await;
2878        mount_folder("folder-1").mount(server).await;
2879        mount_workbook().mount(server).await;
2880        wiremock::Mock::given(wiremock::matchers::method("GET"))
2881            .and(wiremock::matchers::path(
2882                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2883            ))
2884            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
2885            .mount(server)
2886            .await;
2887    }
2888
2889    /// Runs a real (non-dry) fill against [`mount_leasable_fixture`] with
2890    /// the given lease, and asserts no `batchUpdate` was ever issued —
2891    /// every refusal below must happen before the mutating call.
2892    async fn run_with_lease(
2893        server: &wiremock::MockServer,
2894        lease_token: Option<String>,
2895        ledger_path: std::path::PathBuf,
2896    ) -> AutoFillOutcome {
2897        let (drive, sheets) = clients(server).await;
2898        mount_leasable_fixture(server).await;
2899        let rules = vec![allow_rule("folder-1")];
2900        let opts = AutoFillOptions {
2901            lease_token,
2902            ledger_path,
2903            ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
2904        };
2905        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
2906        assert!(
2907            !server
2908                .received_requests()
2909                .await
2910                .unwrap_or_default()
2911                .iter()
2912                .any(|r| r.url.path().ends_with(":batchUpdate")),
2913            "a refused lease must not reach batchUpdate"
2914        );
2915        outcome
2916    }
2917
2918    fn ledger_in_a_tempdir() -> std::path::PathBuf {
2919        tempfile::tempdir()
2920            .unwrap()
2921            .keep()
2922            .join("lease-ledger.jsonl")
2923    }
2924
2925    #[tokio::test]
2926    async fn an_expired_or_unknown_lease_is_refused() {
2927        let server = wiremock::MockServer::start().await;
2928        // A token this ledger has never heard of is the same refusal as an
2929        // expired one (ADR-0080 §1).
2930        let outcome = run_with_lease(
2931            &server,
2932            Some("never-issued".to_string()),
2933            ledger_in_a_tempdir(),
2934        )
2935        .await;
2936        assert!(matches!(
2937            outcome.result,
2938            AutoFillResult::RefusedLeaseExpired
2939        ));
2940    }
2941
2942    #[tokio::test]
2943    async fn a_lease_bound_to_another_file_is_refused() {
2944        let server = wiremock::MockServer::start().await;
2945        let ledger_path = ledger_in_a_tempdir();
2946        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2947        let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2948        assert!(matches!(
2949            outcome.result,
2950            AutoFillResult::RefusedLeaseWrongFile
2951        ));
2952    }
2953
2954    #[tokio::test]
2955    async fn refuses_a_stale_lease_when_the_file_has_moved() {
2956        let server = wiremock::MockServer::start().await;
2957        let ledger_path = ledger_in_a_tempdir();
2958        // `mount_file` reports version "1"; the lease recorded "0", so the
2959        // file has moved under it — ADR-0080 §6's staleness check.
2960        let token = seed_lease(&ledger_path, "sheet-1", "0");
2961        let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2962        assert!(matches!(outcome.result, AutoFillResult::RefusedLeaseStale));
2963    }
2964
2965    /// The rendered `Changed` text, not just the variant — the summary is
2966    /// shared with `WouldChange` and reused as the request log's
2967    /// `fields_changed`, so a conditional clause leaking into it would
2968    /// describe a mutation that already happened.
2969    #[tokio::test]
2970    async fn a_real_run_renders_in_the_past_tense_and_names_the_source() {
2971        let server = wiremock::MockServer::start().await;
2972        let (drive, sheets) = clients(&server).await;
2973        mount_file(
2974            "sheet-1",
2975            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2976            &["folder-1"],
2977        )
2978        .mount(&server)
2979        .await;
2980        mount_folder("folder-1").mount(&server).await;
2981        mount_workbook().mount(&server).await;
2982        wiremock::Mock::given(wiremock::matchers::method("GET"))
2983            .and(wiremock::matchers::path(
2984                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
2985            ))
2986            .respond_with(
2987                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2988                    "values": [["keep"], [], ["also"]]
2989                })),
2990            )
2991            .mount(&server)
2992            .await;
2993        wiremock::Mock::given(wiremock::matchers::method("POST"))
2994            .and(wiremock::matchers::path(
2995                "/v4/spreadsheets/sheet-1:batchUpdate",
2996            ))
2997            .respond_with(
2998                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2999                    "spreadsheetId": "sheet-1", "replies": [{}]
3000                })),
3001            )
3002            .mount(&server)
3003            .await;
3004        let rules = vec![allow_rule("folder-1")];
3005        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3006        let opts = AutoFillOptions {
3007            lease_token,
3008            ledger_path,
3009            ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3010        };
3011        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3012        let lines = describe_lines(&outcome);
3013        assert_eq!(
3014            lines[0],
3015            "Applied: auto-fill 'Q1'!A4:A10 from source 'Q1'!A1:A3, extending 7 row(s) down in \
3016             'sheet-1'"
3017        );
3018        assert_eq!(lines[1], "  2 non-blank cell(s) were overwritten: A4, A6");
3019        assert_eq!(lines[2], UNPREVIEWABLE_VALUES_CAVEAT);
3020        let AutoFillResult::Changed { summary, .. } = &outcome.result else {
3021            panic!("{:?}", outcome.result);
3022        };
3023        // The same string lands in the request log's `fields_changed`.
3024        assert!(!summary.contains("would"), "{summary}");
3025    }
3026
3027    #[tokio::test]
3028    async fn auto_fill_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3029        let server = wiremock::MockServer::start().await;
3030        let (drive, sheets) = clients(&server).await;
3031        mount_file(
3032            "sheet-1",
3033            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3034            &["folder-1"],
3035        )
3036        .mount(&server)
3037        .await;
3038        mount_folder("folder-1").mount(&server).await;
3039        mount_workbook().mount(&server).await;
3040        wiremock::Mock::given(wiremock::matchers::method("GET"))
3041            .and(wiremock::matchers::path(
3042                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3043            ))
3044            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3045            .mount(&server)
3046            .await;
3047        // 2xx, so the fill WAS applied — but the body is not the reply shape.
3048        wiremock::Mock::given(wiremock::matchers::method("POST"))
3049            .and(wiremock::matchers::path(
3050                "/v4/spreadsheets/sheet-1:batchUpdate",
3051            ))
3052            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3053            .expect(1)
3054            .mount(&server)
3055            .await;
3056        let rules = vec![allow_rule("folder-1")];
3057        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3058        let opts = AutoFillOptions {
3059            lease_token,
3060            ledger_path,
3061            ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3062        };
3063        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3064        assert_eq!(
3065            outcome.result.log_status(),
3066            "applied-reply-unreadable",
3067            "{:?}",
3068            outcome.result
3069        );
3070        assert!(
3071            describe(&outcome).starts_with("Applied, but the reply could not be read"),
3072            "{}",
3073            describe(&outcome)
3074        );
3075    }
3076
3077    #[test]
3078    fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
3079        let outcome = AutoFillOutcome {
3080            spreadsheet_id: "sheet-1".to_string(),
3081            file_name: Some("Budget".to_string()),
3082            resolved_folder_id: None,
3083            sheet_id: Some(0),
3084            form: range_form(),
3085            result: AutoFillResult::AppliedReplyUnreadable {
3086                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3087                destination: "'Q1'!A1:A10".to_string(),
3088                requested_destination: Some("'Q1'!A1:A20".to_string()),
3089                overwritten_cells: vec!["A2".to_string()],
3090                destination_is_upper_bound: true,
3091                detail: "bad reply".to_string(),
3092            },
3093        };
3094        let lines = describe_lines(&outcome);
3095        assert_eq!(
3096            lines[0],
3097            "Applied, but the reply could not be read: auto-fill within 'Q1'!A1:A10 in \
3098             'Budget' — check the spreadsheet to confirm (bad reply)"
3099        );
3100        // Everything after the head is exactly what `Changed` prints.
3101        let changed = describe_lines(&AutoFillOutcome {
3102            result: AutoFillResult::Changed {
3103                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3104                destination: "'Q1'!A1:A10".to_string(),
3105                requested_destination: Some("'Q1'!A1:A20".to_string()),
3106                overwritten_cells: vec!["A2".to_string()],
3107                destination_is_upper_bound: true,
3108            },
3109            ..outcome
3110        });
3111        assert!(lines.len() > 1, "{lines:?}");
3112        assert_eq!(lines[1..], changed[1..]);
3113        assert!(lines[1].contains("A2"), "{lines:?}");
3114    }
3115
3116    /// An applied fill whose reply was unreadable still logs everything
3117    /// `Changed` does: plan-time data is not lost with the reply.
3118    #[test]
3119    fn an_unreadable_reply_logs_the_same_fields_as_changed() {
3120        let outcome = |result| AutoFillOutcome {
3121            spreadsheet_id: "sheet-1".to_string(),
3122            file_name: Some("Budget".to_string()),
3123            resolved_folder_id: None,
3124            sheet_id: Some(7),
3125            form: range_form(),
3126            result,
3127        };
3128        let changed = mutation_record(
3129            &outcome(AutoFillResult::Changed {
3130                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3131                destination: "'Q1'!A1:A10".to_string(),
3132                requested_destination: None,
3133                overwritten_cells: vec!["A2".to_string()],
3134                destination_is_upper_bound: true,
3135            }),
3136            Duration::from_millis(1),
3137        );
3138        let unreadable = mutation_record(
3139            &outcome(AutoFillResult::AppliedReplyUnreadable {
3140                summary: "auto-fill within 'Q1'!A1:A10".to_string(),
3141                destination: "'Q1'!A1:A10".to_string(),
3142                requested_destination: None,
3143                overwritten_cells: vec!["A2".to_string()],
3144                destination_is_upper_bound: true,
3145                detail: "bad reply".to_string(),
3146            }),
3147            Duration::from_millis(1),
3148        );
3149        assert_eq!(unreadable.range.as_deref(), Some("'Q1'!A1:A10"));
3150        assert_eq!(unreadable.range, changed.range);
3151        assert_eq!(
3152            unreadable.fields_changed.as_deref(),
3153            Some("auto-fill within 'Q1'!A1:A10")
3154        );
3155        assert_eq!(unreadable.overwritten_cells, vec!["A2".to_string()]);
3156        assert!(unreadable.overwritten_cells_upper_bound);
3157        assert_eq!(unreadable.sheet_id, Some(7));
3158        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
3159    }
3160
3161    #[tokio::test]
3162    async fn a_batch_update_failure_is_reported_as_failed() {
3163        let server = wiremock::MockServer::start().await;
3164        let (drive, sheets) = clients(&server).await;
3165        mount_file(
3166            "sheet-1",
3167            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3168            &["folder-1"],
3169        )
3170        .mount(&server)
3171        .await;
3172        mount_folder("folder-1").mount(&server).await;
3173        mount_workbook().mount(&server).await;
3174        wiremock::Mock::given(wiremock::matchers::method("GET"))
3175            .and(wiremock::matchers::path(
3176                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3177            ))
3178            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3179            .mount(&server)
3180            .await;
3181        wiremock::Mock::given(wiremock::matchers::method("POST"))
3182            .and(wiremock::matchers::path(
3183                "/v4/spreadsheets/sheet-1:batchUpdate",
3184            ))
3185            .respond_with(wiremock::ResponseTemplate::new(500))
3186            .mount(&server)
3187            .await;
3188        let rules = vec![allow_rule("folder-1")];
3189        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3190        let opts = AutoFillOptions {
3191            lease_token,
3192            ledger_path,
3193            ..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
3194        };
3195        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3196        assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
3197    }
3198
3199    #[tokio::test]
3200    async fn a_missing_lease_is_refused_when_the_deciding_rule_requires_one() {
3201        let server = wiremock::MockServer::start().await;
3202        let (drive, sheets) = clients(&server).await;
3203        mount_file(
3204            "sheet-1",
3205            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3206            &["folder-1"],
3207        )
3208        .mount(&server)
3209        .await;
3210        mount_folder("folder-1").mount(&server).await;
3211        mount_workbook().mount(&server).await;
3212        wiremock::Mock::given(wiremock::matchers::method("GET"))
3213            .and(wiremock::matchers::path(
3214                "/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
3215            ))
3216            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
3217            .mount(&server)
3218            .await;
3219        let rules = vec![allow_rule("folder-1")];
3220        let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), false);
3221        let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
3222        assert!(matches!(outcome.result, AutoFillResult::RefusedNoLease));
3223    }
3224
3225    #[tokio::test]
3226    async fn the_sheet_not_found_refusal_names_the_available_sheets() {
3227        let server = wiremock::MockServer::start().await;
3228        let (drive, sheets) = clients(&server).await;
3229        mount_file(
3230            "sheet-1",
3231            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3232            &["folder-1"],
3233        )
3234        .mount(&server)
3235        .await;
3236        mount_folder("folder-1").mount(&server).await;
3237        mount_workbook().mount(&server).await;
3238        let rules = vec![allow_rule("folder-1")];
3239        let form = AutoFillForm::Range {
3240            sheet: Some("Missing".to_string()),
3241            range: Some("A1:A2".to_string()),
3242        };
3243        let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
3244        match outcome.result {
3245            AutoFillResult::RefusedSheetNotFound { title, available } => {
3246                assert_eq!(title, "Missing");
3247                assert_eq!(available, vec!["Q1".to_string()]);
3248            }
3249            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
3250        }
3251    }
3252}