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