Skip to main content

omni_dev/drive/sheets/
text_to_columns.rs

1//! `text-to-columns` — splits a single column's delimited text into the
2//! adjacent columns to its right via `spreadsheets.batchUpdate`'s
3//! `textToColumns` request.
4//!
5//! Issue #1843, [ADR-0083](../../../docs/adrs/adr-0083.md). Unblocked by
6//! issue #1831. Gated by **both** [`DriveOperation::SheetsWrite`] and
7//! [`DriveOperation::SheetsStructure`], through
8//! [`target_gate::resolve_all`] — the same union `pivot.rs`'s
9//! `add-pivot-table` uses.
10//!
11//! ADR-0083 §1 proposed `SheetsWrite` alone, on the argument that the
12//! split writes ordinary cell content and so does nothing a `sheets
13//! clear` followed by a `sheets write` of the same span could not already
14//! do. §5 made that provisional on one live-verification item, and the
15//! live run settled it against the proposal: splitting a **bold, pink**
16//! source column on a live workbook left every spill cell bold and pink,
17//! where they had been unformatted before. A `sheets-write` grant cannot
18//! confer that — formatting is exactly what `SheetsStructure` gates — so
19//! the union is the honest gate and §5's fixed consequence applies. (The
20//! same run also saw Sheets grow the sheet from 26 to 28 columns for a
21//! spill past its last column, which `sheets append` already does under
22//! `sheets-write` alone and so is not itself the reason.)
23//!
24//! **How many columns a split writes is never knowable exactly before the
25//! request is sent, and this crate never sees the split pieces even after
26//! a real run.** `textToColumns` carries no response object, and how many
27//! columns the API's own splitting needs for each row is entirely its own
28//! decision — the "API decides how many columns a `textToColumns` split
29//! needs" limit ADR-0083 §6 names explicitly. What `--dry-run` (and the
30//! real run) *can* say is an **upper bound** width, computed locally by a
31//! naive, non-quote-aware split of the source's formatted values, and the
32//! count and A1 locations of the non-blank cells within that upper bound
33//! that would be (or were) overwritten — never their values or the split
34//! pieces, matching every preview in this tranche but `merge-cells`.
35//!
36//! **`--delimiter auto` is the one exception to the bound.** For every
37//! other delimiter the local split uses the same separator the API is
38//! told to use, so it can only over-count; under `auto` the separator is
39//! the API's own choice, and [`Delimiter::local_split_candidates`] can
40//! only guess it by trying the four fixed types. Whether Sheets' own
41//! detection is confined to those four is undocumented and unverified
42//! (ADR-0083 §5's live-verification list), so an `auto` run's width is
43//! reported as an estimate — see [`AUTO_DELIMITER_CAVEAT`], the extra
44//! line those runs carry.
45//!
46//! ## `source` must span exactly one column
47//!
48//! The API's own constraint. This v1 additionally requires `source` to be
49//! **fully bounded** (`A2:A100`, not the open-ended `A:A`) —
50//! `merge-cells`'/`auto-fill`'s own requirement — since both the local
51//! width computation and the preview read need a fixed row extent to work
52//! from. The source column itself is excluded from the reported overwrite
53//! list: its content is the input the split reads, not a casualty of the
54//! request.
55//!
56//! ## The grid edge
57//!
58//! A spill that runs past the sheet's current `columnCount` is **not
59//! refused client-side** — ADR-0083 §5: growth by writing past the grid's
60//! edge is what `sheets append` already does under `sheets-write`. It is
61//! surfaced as a caveat in the summary text instead, and the verb never
62//! prepends an `updateSheetProperties`/`appendDimension` request to grow
63//! the sheet first (ADR-0083 §5's smuggling rule).
64//!
65//! ## Shape
66//!
67//! Single-target, `auto_fill.rs`/`write.rs`'s linear shape: resolve the
68//! target and gate, resolve the source range, read the source column once
69//! to compute the upper-bound width, read the spill span (clamped to the
70//! grid) once, dry-run return, build the request, lease, mutate, log.
71
72use std::path::PathBuf;
73use std::time::{Duration, Instant};
74
75use serde::Serialize;
76
77use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
78use crate::drive::client::DriveClient;
79use crate::drive::files_api::FilesApi;
80use crate::drive::lease::check::{
81    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
82    LeasedWrite,
83};
84use crate::drive::sheets::a1;
85use crate::drive::sheets::api::{
86    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
87};
88use crate::drive::sheets::client::SheetsClient;
89use crate::drive::sheets::grid_range;
90use crate::drive::sheets::target_gate;
91use crate::drive::sheets::types::{
92    BatchUpdateRequestItem, DelimiterType, GridRange, Spreadsheet, TextToColumnsRequest, ValueRange,
93};
94use crate::drive::types::SheetTargetRefusal;
95use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
96use crate::request_log::{self, DriveMutationOutcome};
97
98/// The one operation this module ever logs or leases under — there is
99/// only one verb, so `dimension_group.rs`'s per-verb `log_operation()`
100/// dispatch would have nothing to dispatch on. Named once so the lease
101/// site and the request-log site can't drift.
102const LOG_OPERATION: &str = "sheets-text-to-columns";
103
104/// The operations this verb's gate is the union of, in the order a
105/// refusal reports them. Both are required: `SheetsWrite` for the cell
106/// content the split writes, `SheetsStructure` for the source cell's
107/// formatting it was measured carrying into the spill cells — see the
108/// module docs for the live-run evidence.
109const GATE_OPERATIONS: &[DriveOperation] =
110    &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
111
112/// Which separator to split on — the CLI/engine's own enum.
113///
114/// The API's `DELIMITER_TYPE_UNSPECIFIED` is undocumented and has no
115/// default this crate resolves to, so every constructible value maps onto
116/// exactly one wire-representable choice. [`Self::Custom`] carries its
117/// separator text inline, so "custom with no text" and "a fixed delimiter
118/// with stray text" are unrepresentable — `AutoFillForm`'s own
119/// oneof-as-enum idiom.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum Delimiter {
122    /// Split on `,`.
123    Comma,
124    /// Split on `;`.
125    Semicolon,
126    /// Split on `.`.
127    Period,
128    /// Split on a single space.
129    Space,
130    /// Sheets picks the separator itself.
131    Auto,
132    /// Split on the carried separator text.
133    Custom(String),
134}
135
136impl Delimiter {
137    /// The wire `delimiterType`.
138    fn wire_type(&self) -> DelimiterType {
139        match self {
140            Self::Comma => DelimiterType::Comma,
141            Self::Semicolon => DelimiterType::Semicolon,
142            Self::Period => DelimiterType::Period,
143            Self::Space => DelimiterType::Space,
144            Self::Auto => DelimiterType::Autodetect,
145            Self::Custom(_) => DelimiterType::Custom,
146        }
147    }
148
149    /// The literal separator(s) this module's local, non-quote-aware
150    /// preview split tries — one for a fixed delimiter, the four fixed
151    /// ones for [`Self::Auto`]. Unlike `auto_fill.rs`'s form-A upper
152    /// bound, there is no fixed span to bound `Auto`'s guess against
153    /// other than trying every fixed candidate and keeping the widest.
154    /// That over-counts against whichever *one* of the four Sheets
155    /// settles on, but it is only a bound at all while Sheets' detection
156    /// stays within the four — undocumented and unverified, which is why
157    /// an `Auto` run carries [`AUTO_DELIMITER_CAVEAT`] instead of
158    /// claiming one.
159    fn local_split_candidates(&self) -> Vec<&str> {
160        match self {
161            Self::Comma => vec![","],
162            Self::Semicolon => vec![";"],
163            Self::Period => vec!["."],
164            Self::Space => vec![" "],
165            Self::Custom(text) => vec![text.as_str()],
166            Self::Auto => vec![",", ";", ".", " "],
167        }
168    }
169
170    /// Human-readable name for summaries and log text.
171    fn describe(&self) -> String {
172        match self {
173            Self::Comma => "comma".to_string(),
174            Self::Semicolon => "semicolon".to_string(),
175            Self::Period => "period".to_string(),
176            Self::Space => "space".to_string(),
177            Self::Auto => "an auto-detected separator".to_string(),
178            Self::Custom(text) => format!("custom ({text:?})"),
179        }
180    }
181}
182
183/// Per-call options.
184#[derive(Debug, Clone)]
185pub struct TextToColumnsOptions {
186    /// Spreadsheet id.
187    pub spreadsheet_id: String,
188    /// Sheet (tab) title. Supplies the prefix for a bare `source`.
189    pub sheet: Option<String>,
190    /// A1 range holding the column to split, optionally carrying its own
191    /// `Sheet!` prefix. Must resolve to a fully bounded, single-column
192    /// range.
193    pub source: Option<String>,
194    /// Which separator to split on.
195    pub delimiter: Delimiter,
196    /// Classify and describe only; never call `batchUpdate`.
197    pub dry_run: bool,
198    /// The lease token presented via `--lease`. Checked only when the
199    /// deciding rule requires one
200    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
201    /// `None` is only ever valid when it does not.
202    pub lease_token: Option<String>,
203    /// Path to the lease ledger the token is checked against. Production
204    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
205    /// result; tests pass a path under a `tempdir`.
206    pub ledger_path: PathBuf,
207}
208
209/// What happened (or, under `--dry-run`, would happen).
210#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
211#[serde(tag = "status", rename_all = "kebab-case")]
212pub enum TextToColumnsResult {
213    /// `--dry-run`, and the gate would allow it.
214    WouldChange {
215        /// A human-readable, **tense-neutral** summary of the effect —
216        /// also the request log's `fields_changed`. Names the source, the
217        /// delimiter, and the upper-bound width and spill span; the
218        /// overwrite count and the grid-extent caveat are separate,
219        /// tense-varying lines built by [`describe_lines`], so this same
220        /// string reads correctly under both `Would …` and `Applied: …`.
221        summary: String,
222        /// The source column's A1 address.
223        source: String,
224        /// The spill span's A1 address — the columns to the right of
225        /// `source` a split of `width_upper_bound` columns would reach.
226        /// `None` when the local split never produces more than one
227        /// column, so nothing would be written beyond `source`.
228        #[serde(skip_serializing_if = "Option::is_none")]
229        spill: Option<String>,
230        /// An upper bound on how many columns the split needs, computed
231        /// locally with a naive, non-quote-aware split — never the
232        /// server's real answer, which this crate cannot predict. Under
233        /// [`Delimiter::Auto`] it is an estimate rather than a bound,
234        /// and the rendered output says so
235        /// ([`AUTO_DELIMITER_CAVEAT`]).
236        width_upper_bound: usize,
237        /// The non-blank cells within the spill span that would be
238        /// overwritten, as bare A1 addresses — **never their values**,
239        /// unlike `merge-cells`' own `discarded_cells` (ADR-0083 §6).
240        /// Always an upper bound: the API decides for itself how many
241        /// columns the split needs, so some of the listed cells may not
242        /// actually be touched — and under [`Delimiter::Auto`] it picks
243        /// the separator too, which can reach cells this list does not
244        /// name ([`AUTO_DELIMITER_CAVEAT`]).
245        #[serde(skip_serializing_if = "Vec::is_empty")]
246        overwritten_cells: Vec<String>,
247        /// The spill span runs past the sheet's current row/column count.
248        /// Never a refusal (ADR-0083 §5) — it only earns the summary's
249        /// grid-extent caveat line.
250        past_grid_extent: bool,
251    },
252    /// The target is not a Google Sheet.
253    RefusedNotASpreadsheet {
254        /// The target's actual MIME type.
255        mime_type: String,
256    },
257    /// The target is a shortcut, which we do not follow.
258    RefusedShortcut,
259    /// The target has no parents this account can see.
260    RefusedNoVisibleParents,
261    /// The named sheet does not exist in this workbook.
262    RefusedSheetNotFound {
263        /// The title that was not found.
264        title: String,
265        /// The titles that do exist.
266        available: Vec<String>,
267    },
268    /// The `--sheet`/`--source` pair, or the resolved range, was invalid —
269    /// not fully bounded, or spanning more than one column.
270    RefusedInvalidRange {
271        /// What was wrong and why.
272        detail: String,
273    },
274    /// `--custom-delimiter` was empty.
275    RefusedInvalidDelimiter {
276        /// What was wrong and why.
277        detail: String,
278    },
279    /// The folder write-permission gate refused it.
280    Blocked {
281        /// Which of [`GATE_OPERATIONS`] denied first — the union gate
282        /// refuses as soon as one of the two does, and which one it was
283        /// is the only actionable part of the message.
284        operation: DriveOperation,
285        /// The rule that decided the refusal, if any.
286        decided_by: Option<DecidingRule>,
287    },
288    /// No `--lease` was presented, and the deciding rule requires one
289    /// (ADR-0080 §9).
290    RefusedNoLease,
291    /// The presented lease has expired, or was never a token this ledger
292    /// knows about.
293    RefusedLeaseExpired,
294    /// The presented lease is bound to a different file id.
295    RefusedLeaseWrongFile,
296    /// The file has moved since the lease's recorded `version` — the
297    /// staleness check (ADR-0080 §6).
298    RefusedLeaseStale,
299    /// The mutation succeeded.
300    Changed {
301        /// The same tense-neutral summary [`Self::WouldChange`] carries,
302        /// built by the same call — which is why it reads correctly after
303        /// the fact as well as before it.
304        summary: String,
305        /// Same as [`Self::WouldChange`].
306        source: String,
307        /// Same as [`Self::WouldChange`].
308        #[serde(skip_serializing_if = "Option::is_none")]
309        spill: Option<String>,
310        /// Same as [`Self::WouldChange`].
311        width_upper_bound: usize,
312        /// Same as [`Self::WouldChange`] — read *before* the mutating call
313        /// (the same read serves both paths), so this names what was
314        /// overwritten, not a post-hoc guess.
315        #[serde(skip_serializing_if = "Vec::is_empty")]
316        overwritten_cells: Vec<String>,
317        /// Same as [`Self::WouldChange`].
318        past_grid_extent: bool,
319    },
320    /// The API answered 2xx — the split **was applied** — but its reply could
321    /// not be read (issue #2021). Distinct from [`Self::Failed`]: a retry
322    /// would split the already-split data again.
323    AppliedReplyUnreadable {
324        /// Same summary as [`Self::Changed`].
325        summary: String,
326        /// Why the reply could not be read.
327        detail: String,
328        /// Same as [`Self::Changed`] — plan-time, not from the reply.
329        source: String,
330        /// Same as [`Self::Changed`].
331        #[serde(skip_serializing_if = "Option::is_none")]
332        spill: Option<String>,
333        /// Same as [`Self::Changed`].
334        width_upper_bound: usize,
335        /// Same as [`Self::Changed`] — the cells that were read before the
336        /// mutation, so the audit of what the split overwrote survives an
337        /// unreadable reply.
338        #[serde(skip_serializing_if = "Vec::is_empty")]
339        overwritten_cells: Vec<String>,
340        /// Same as [`Self::Changed`].
341        past_grid_extent: bool,
342    },
343    /// An API or validation error.
344    Failed {
345        /// A human-readable summary of what failed.
346        detail: String,
347    },
348}
349
350impl FromLeaseRefusal for TextToColumnsResult {
351    fn from_no_lease() -> Self {
352        Self::RefusedNoLease
353    }
354    fn from_lease_expired() -> Self {
355        Self::RefusedLeaseExpired
356    }
357    fn from_lease_wrong_file() -> Self {
358        Self::RefusedLeaseWrongFile
359    }
360    fn from_lease_stale() -> Self {
361        Self::RefusedLeaseStale
362    }
363    fn from_lease_failed(detail: String) -> Self {
364        Self::Failed { detail }
365    }
366}
367
368impl TextToColumnsResult {
369    fn log_status(&self) -> &'static str {
370        match self {
371            Self::WouldChange { .. } => "would-change",
372            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
373            Self::RefusedShortcut => "refused-shortcut",
374            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
375            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
376            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
377            Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
378            Self::Blocked { .. } => "blocked",
379            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
380            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
381            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
382            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
383            Self::Changed { .. } => "changed",
384            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
385            Self::Failed { .. } => "failed",
386        }
387    }
388}
389
390/// The full outcome of one attempt.
391#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
392pub struct TextToColumnsOutcome {
393    /// The spreadsheet acted on.
394    pub spreadsheet_id: String,
395    /// Its Drive file name, when the metadata fetch got that far.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub file_name: Option<String>,
398    /// The folder the gate evaluated against.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub resolved_folder_id: Option<String>,
401    /// The sheet the split is (or would be) on, once known.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub sheet_id: Option<i64>,
404    /// Which delimiter was requested. Not serialised.
405    #[serde(skip)]
406    pub delimiter: Delimiter,
407    /// What happened.
408    pub result: TextToColumnsResult,
409}
410
411impl JsonlSerialize for TextToColumnsOutcome {
412    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
413        write_scalar_jsonl(self, out)
414    }
415}
416
417/// Runs one text-to-columns split, logging every attempt that isn't a dry
418/// run.
419pub async fn text_to_columns(
420    drive: &DriveClient,
421    sheets: &SheetsClient,
422    opts: &TextToColumnsOptions,
423    rules: &[FolderPermissionRule],
424) -> TextToColumnsOutcome {
425    let started = Instant::now();
426    let outcome = text_to_columns_inner(drive, sheets, opts, rules).await;
427    if !opts.dry_run {
428        record_attempt(&outcome, started.elapsed());
429    }
430    outcome
431}
432
433async fn text_to_columns_inner(
434    drive: &DriveClient,
435    sheets: &SheetsClient,
436    opts: &TextToColumnsOptions,
437    rules: &[FolderPermissionRule],
438) -> TextToColumnsOutcome {
439    let bare = |result| TextToColumnsOutcome {
440        spreadsheet_id: opts.spreadsheet_id.clone(),
441        file_name: None,
442        resolved_folder_id: None,
443        sheet_id: None,
444        delimiter: opts.delimiter.clone(),
445        result,
446    };
447
448    // Compose the range and validate the delimiter first, like
449    // `auto_fill_inner`/`write_inner`: pure, and a conflicting
450    // --sheet/--source pair or an empty --custom-delimiter should fail
451    // without spending a request.
452    let composed = match a1::compose(opts.sheet.as_deref(), opts.source.as_deref()) {
453        Ok(composed) => composed,
454        Err(err) => {
455            return bare(TextToColumnsResult::RefusedInvalidRange {
456                detail: err.to_string(),
457            })
458        }
459    };
460    if let Delimiter::Custom(text) = &opts.delimiter {
461        if text.is_empty() {
462            return bare(TextToColumnsResult::RefusedInvalidDelimiter {
463                detail: "--custom-delimiter must not be empty".to_string(),
464            });
465        }
466    }
467
468    // ── Target resolution, pre-gate refusals, and the gate itself ──────
469    let (target, verdict, denied, resolved_folder_id, requires_lease) =
470        match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
471            target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
472                return bare(TextToColumnsResult::Failed { detail })
473            }
474            target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
475                let result = match refusal {
476                    SheetTargetRefusal::Shortcut => TextToColumnsResult::RefusedShortcut,
477                    SheetTargetRefusal::NotASpreadsheet { mime_type } => {
478                        TextToColumnsResult::RefusedNotASpreadsheet { mime_type }
479                    }
480                    SheetTargetRefusal::NoVisibleParents => {
481                        TextToColumnsResult::RefusedNoVisibleParents
482                    }
483                };
484                return TextToColumnsOutcome {
485                    spreadsheet_id: opts.spreadsheet_id.clone(),
486                    file_name: Some(target.name),
487                    resolved_folder_id: None,
488                    sheet_id: None,
489                    delimiter: opts.delimiter.clone(),
490                    result,
491                };
492            }
493            target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
494                return TextToColumnsOutcome {
495                    spreadsheet_id: opts.spreadsheet_id.clone(),
496                    file_name: Some(target.name),
497                    resolved_folder_id: None,
498                    sheet_id: None,
499                    delimiter: opts.delimiter.clone(),
500                    result: TextToColumnsResult::Failed { detail },
501                };
502            }
503            target_gate::TargetGateUnionOutcome::Gated {
504                target,
505                verdict,
506                denied,
507                resolved_folder_id,
508                requires_lease,
509            } => (target, verdict, denied, resolved_folder_id, requires_lease),
510        };
511
512    let pre_gated = |result| TextToColumnsOutcome {
513        spreadsheet_id: opts.spreadsheet_id.clone(),
514        file_name: Some(target.name.clone()),
515        resolved_folder_id: resolved_folder_id.clone(),
516        sheet_id: None,
517        delimiter: opts.delimiter.clone(),
518        result,
519    };
520
521    if verdict == write_gate::Verdict::Deny {
522        // `denied` is `Some` whenever the verdict is `Deny`; the fallback
523        // names the first operation rather than inventing one, matching
524        // `pivot.rs`'s own arm.
525        let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
526        return pre_gated(TextToColumnsResult::Blocked {
527            operation,
528            decided_by,
529        });
530    }
531
532    let api = SheetsApi::new(sheets);
533    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
534        Ok(workbook) => workbook,
535        Err(err) => {
536            return pre_gated(TextToColumnsResult::Failed {
537                detail: format!("{err:#}"),
538            })
539        }
540    };
541
542    let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
543        &workbook,
544        &composed,
545        |detail| TextToColumnsResult::RefusedInvalidRange { detail },
546        |title, available| TextToColumnsResult::RefusedSheetNotFound { title, available },
547    ) {
548        Ok(resolved) => resolved,
549        Err(result) => return pre_gated(result),
550    };
551
552    let gated = |result| TextToColumnsOutcome {
553        spreadsheet_id: opts.spreadsheet_id.clone(),
554        file_name: Some(target.name.clone()),
555        resolved_folder_id: resolved_folder_id.clone(),
556        sheet_id: Some(source_grid.sheet_id),
557        delimiter: opts.delimiter.clone(),
558        result,
559    };
560
561    if !grid_range::is_bounded(&source_grid) {
562        return gated(TextToColumnsResult::RefusedInvalidRange {
563            detail: format!(
564                "{composed} is open-ended; text-to-columns needs a fully bounded single \
565                 column (e.g. A2:A100)"
566            ),
567        });
568    }
569    if !is_single_column(&source_grid) {
570        return gated(TextToColumnsResult::RefusedInvalidRange {
571            detail: format!(
572                "{composed} spans more than one column; text-to-columns' source must span \
573                 exactly one column"
574            ),
575        });
576    }
577
578    // `source_grid` is fully bounded, so this always renders.
579    let source_a1 = grid_range::bounded_range_to_a1(&sheet_title, &source_grid).unwrap_or(composed);
580
581    let source_values = match api
582        .values_get(
583            &opts.spreadsheet_id,
584            &source_a1,
585            ValueRenderOption::Formatted,
586        )
587        .await
588    {
589        Ok(values) => values,
590        Err(err) => {
591            return gated(TextToColumnsResult::Failed {
592                detail: format!("{err:#}"),
593            })
594        }
595    };
596
597    let width_upper_bound = split_width(&source_values, &opts.delimiter);
598    let spill_grid = spill_span(source_grid, width_upper_bound);
599    let past_grid_extent = spill_grid.is_some_and(|spill| extends_past_grid(&workbook, &spill));
600
601    // The *read* is clamped to the grid; the request is not (ADR-0083
602    // §5's "left to the server" stance, `auto_fill.rs`'s own precedent).
603    let (spill_a1, overwritten_cells) = match spill_grid {
604        None => (None, Vec::new()),
605        Some(spill_grid) => {
606            let overwritten_cells = match grid_range::clamp_to_sheet(&workbook, &spill_grid) {
607                None => Vec::new(),
608                Some(read_grid) => {
609                    let read_a1 = grid_range::bounded_range_to_a1(&sheet_title, &read_grid)
610                        .unwrap_or_else(|| sheet_title.clone());
611                    match api
612                        .values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
613                        .await
614                    {
615                        Ok(values) => grid_range::non_blank_locations(
616                            &values,
617                            read_grid.start_row_index.unwrap_or(0),
618                            read_grid.start_column_index.unwrap_or(0),
619                        ),
620                        Err(err) => {
621                            return gated(TextToColumnsResult::Failed {
622                                detail: format!("{err:#}"),
623                            })
624                        }
625                    }
626                }
627            };
628            (
629                grid_range::bounded_range_to_a1(&sheet_title, &spill_grid),
630                overwritten_cells,
631            )
632        }
633    };
634
635    let summary = describe_effect(
636        &source_a1,
637        spill_a1.as_deref(),
638        width_upper_bound,
639        &opts.delimiter,
640    );
641
642    if opts.dry_run {
643        return gated(TextToColumnsResult::WouldChange {
644            summary,
645            source: source_a1,
646            spill: spill_a1,
647            width_upper_bound,
648            overwritten_cells,
649            past_grid_extent,
650        });
651    }
652
653    // Built before the gate check that follows, not after — #1688/#1742's
654    // ordering invariant, shared verbatim by every leased engine.
655    let request = build_request(source_grid, &opts.delimiter);
656
657    let files_api = FilesApi::new(drive);
658    let leased = LeasedWrite {
659        log_prefix: "drive sheets text-to-columns",
660        operation: LOG_OPERATION,
661        ledger_path: &opts.ledger_path,
662        file_id: &opts.spreadsheet_id,
663    };
664    let lease_grant = match gate_optional_leased_write(
665        leased,
666        &files_api,
667        requires_lease,
668        opts.lease_token.as_deref(),
669    )
670    .await
671    {
672        Ok(grant) => grant,
673        Err(err) => return gated(err.into_result()),
674    };
675
676    let result = match conclude_native_leased_write(
677        leased,
678        &lease_grant,
679        &files_api,
680        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
681        |err| format!("{err:#}"),
682    )
683    .await
684    {
685        Ok(BatchUpdateOutcome::Applied(_)) => TextToColumnsResult::Changed {
686            summary,
687            source: source_a1,
688            spill: spill_a1,
689            width_upper_bound,
690            overwritten_cells,
691            past_grid_extent,
692        },
693        // Not idempotent: a second split acts on the already-split data.
694        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
695            TextToColumnsResult::AppliedReplyUnreadable {
696                summary,
697                detail,
698                source: source_a1,
699                spill: spill_a1,
700                width_upper_bound,
701                overwritten_cells,
702                past_grid_extent,
703            }
704        }
705        Err(err) => TextToColumnsResult::Failed {
706            detail: format!("{err:#}"),
707        },
708    };
709    drop(lease_grant);
710    gated(result)
711}
712
713/// Whether `grid` names exactly one column. Only ever called after
714/// [`grid_range::is_bounded`] has confirmed all four indices are `Some`.
715fn is_single_column(grid: &GridRange) -> bool {
716    grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0) == 1
717}
718
719/// A cell's text content for the local split: its string form if the API
720/// returned one (the normal case — this module always reads with
721/// [`ValueRenderOption::Formatted`], which renders every cell as a
722/// string), empty for a blank/absent cell, or the raw JSON rendering for
723/// any other shape, so a cell this crate does not expect to see still
724/// contributes *some* text rather than silently vanishing from the width
725/// count — [`split_width`]'s "never under-counts" guarantee depends on
726/// this never returning less than the truth.
727fn cell_text(cell: &serde_json::Value) -> String {
728    match cell.as_str() {
729        Some(text) => text.to_string(),
730        None if cell.is_null() => String::new(),
731        None => cell.to_string(),
732    }
733}
734
735/// Upper bound on how many columns the split needs, computed locally with
736/// a naive, non-quote-aware `str::split` over the source's values. For
737/// every delimiter but [`Delimiter::Auto`] this can only ever over-count
738/// against the server's real behaviour — a quoted delimiter or a run of
739/// consecutive separators splits further locally than Sheets may actually
740/// split it — never under-count, which is what makes the destination this
741/// computes an upper bound rather than a guess (ADR-0083 §6). Under
742/// `Auto` the separator is Sheets' own choice rather than a given, so the
743/// same reasoning yields an estimate; see [`AUTO_DELIMITER_CAVEAT`]. A
744/// blank source row contributes no width: there is nothing in it to
745/// split. An empty source (no rows at all) is width `0`.
746fn split_width(values: &ValueRange, delimiter: &Delimiter) -> usize {
747    let candidates = delimiter.local_split_candidates();
748    values
749        .values
750        .iter()
751        .filter_map(|row| row.first())
752        .map(cell_text)
753        .filter(|text| !text.is_empty())
754        .map(|text| {
755            candidates
756                .iter()
757                .map(|sep| text.split(sep).count())
758                .max()
759                .unwrap_or(1)
760        })
761        .max()
762        .unwrap_or(0)
763}
764
765/// The columns to the right of a bounded, single-column `source` that a
766/// split of `width` columns would write into — `None` when `width <= 1`,
767/// since a source that never splits writes nothing beyond itself. Pure;
768/// `source`'s own column stays out of the result, since its content is
769/// the input the split reads, not a cell the request overwrites.
770fn spill_span(source: GridRange, width: usize) -> Option<GridRange> {
771    if width <= 1 {
772        return None;
773    }
774    let start_column = source.start_column_index?;
775    let width = i64::try_from(width).ok()?;
776    Some(GridRange {
777        sheet_id: source.sheet_id,
778        start_row_index: source.start_row_index,
779        end_row_index: source.end_row_index,
780        start_column_index: Some(start_column + 1),
781        end_column_index: Some(start_column + width),
782    })
783}
784
785/// Whether `extent` runs past `workbook`'s currently allocated rows or
786/// columns on `extent`'s sheet — `paste.rs`'s own
787/// `sheet_exceeds_dimensions`, this module's own copy since each is
788/// reached from a different construction pass. `auto_fill.rs` no longer
789/// has an equivalent: since #1937 it clips to the grid via
790/// `clamp_to_grid` instead of asking whether a destination exceeds it.
791/// `None` on either axis (the API reported no grid dimensions) never
792/// counts as exceeding — there's nothing to compare against.
793fn extends_past_grid(workbook: &Spreadsheet, extent: &GridRange) -> bool {
794    let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
795        return false;
796    };
797    let grid = sheet
798        .properties
799        .as_ref()
800        .and_then(|props| props.grid_properties.as_ref());
801    let row_count = grid.and_then(|g| g.row_count);
802    let column_count = grid.and_then(|g| g.column_count);
803    let rows_exceed = matches!(
804        (extent.end_row_index, row_count),
805        (Some(end), Some(count)) if end > count
806    );
807    let cols_exceed = matches!(
808        (extent.end_column_index, column_count),
809        (Some(end), Some(count)) if end > count
810    );
811    rows_exceed || cols_exceed
812}
813
814/// The caveat every text-to-columns split carries, in both tenses at
815/// once — the split pieces are never reported, before *or* after the
816/// request, so unlike [`PAST_GRID_EXTENT_CAVEAT_DRY_RUN`] this is one
817/// constant rather than two.
818const UNPREVIEWABLE_SPLIT_CAVEAT: &str =
819    "  the number of columns the split needs, and the values it writes, are computed by \
820     Sheets' own splitting and are never reported, before or after the request; the count above \
821     is a local upper-bound estimate only";
822
823/// Shared by the `--dry-run` and post-execution lines so the wording
824/// can't drift; tense differs, so this is two constants rather than one —
825/// `structure.rs`'s `INSERT_RANGE_EDGE_CAVEAT` pair, for the same reason.
826const PAST_GRID_EXTENT_CAVEAT_DRY_RUN: &str =
827    "  the spill extends past the sheet's current extent — Sheets may grow the sheet or refuse \
828     the request";
829const PAST_GRID_EXTENT_CAVEAT: &str =
830    "  the spill extended past the sheet's current extent, so Sheets may have grown it";
831
832/// The extra caveat [`Delimiter::Auto`] earns, in both tenses at once.
833///
834/// Every other delimiter's width is a true upper bound: the local split
835/// is the same separator the API is told to use, and a naive
836/// `str::split` can only ever find more pieces than a quote-aware one
837/// (see [`split_width`]). `auto` is the one case where that argument
838/// does not close, because the separator itself is the API's choice, and
839/// [`Delimiter::local_split_candidates`] can only guess it by trying the
840/// four fixed types.
841///
842/// **This is measured, not hypothetical.** On a live workbook, a column
843/// of tab-separated cells previewed under `auto` as "no row spills
844/// beyond the source" with an empty overwrite list — and the real
845/// request split it into three columns, destroying two cells the preview
846/// had just reported as safe. Sheets' detection is *not* confined to the
847/// four this preview tries, so under `auto` the overwrite list is
848/// neither an upper bound nor a lower one, and nothing rendered for an
849/// `auto` run may read as a reassurance. That is what
850/// [`overwritten_line`] suppresses and this line replaces.
851const AUTO_DELIMITER_CAVEAT: &str =
852    "  --delimiter auto lets Sheets detect the separator itself, and it detects separators this \
853     preview does not try (a tab-separated column splits under auto, though none of comma, \
854     semicolon, period or space appears in it) — so for auto the width above and the cells \
855     listed are a guess in both directions, not a bound";
856
857/// The **tense-neutral** head of the summary: the source, the delimiter,
858/// and the upper-bound width and spill span (or the "nothing to spill"
859/// note when the local split never exceeds one column).
860///
861/// Deliberately carries neither the overwrite count nor the grid-extent
862/// caveat. Both vary with tense, and this string is reused verbatim by
863/// [`TextToColumnsResult::Changed`] and by the request log's
864/// `fields_changed`, where a conditional ("would be overwritten") would
865/// describe a mutation that already happened — `auto_fill.rs::describe_effect`'s
866/// own reasoning.
867fn describe_effect(
868    source_a1: &str,
869    spill_a1: Option<&str>,
870    width_upper_bound: usize,
871    delimiter: &Delimiter,
872) -> String {
873    match spill_a1 {
874        Some(spill_a1) => format!(
875            "split {source_a1} on {} into up to {width_upper_bound} column(s), spill {spill_a1}",
876            delimiter.describe(),
877        ),
878        // Under `Auto` the local candidates found nothing to split on,
879        // which says nothing about what Sheets will detect — a live run
880        // split a tab-separated column this branch had just called
881        // single-column. Claiming "no row spills" there would be the
882        // same false all-clear `overwritten_line` suppresses.
883        None if matches!(delimiter, Delimiter::Auto) => format!(
884            "split {source_a1} on {} (none detected in the preview, so the spill span is \
885             unknown)",
886            delimiter.describe(),
887        ),
888        None => format!(
889            "split {source_a1} on {} into a single column each row; no row spills beyond \
890             the source under this delimiter",
891            delimiter.describe(),
892        ),
893    }
894}
895
896/// The indented overwrite line. Always prefixed `up to`: the API decides
897/// for itself how many columns each row's split needs, so the local
898/// upper-bound span can list cells the real split never reaches. The
899/// tense follows `dry_run`, the [`PAST_GRID_EXTENT_CAVEAT_DRY_RUN`] pair's
900/// own rule.
901///
902/// The empty case is deliberately **not** rendered for
903/// [`Delimiter::Auto`]: "no non-blank cells in the spill columns" is an
904/// affirmative all-clear, and that is the exact sentence a live `auto`
905/// run printed immediately before overwriting two cells (see
906/// [`AUTO_DELIMITER_CAVEAT`]). An `auto` run with nothing to list says
907/// nothing rather than something false; its caveat line carries the
908/// meaning.
909fn overwritten_line(
910    overwritten_cells: &[String],
911    delimiter: &Delimiter,
912    dry_run: bool,
913) -> Option<String> {
914    if overwritten_cells.is_empty() {
915        return match delimiter {
916            Delimiter::Auto => None,
917            _ => Some("  no non-blank cells in the spill columns".to_string()),
918        };
919    }
920    let tense = if dry_run {
921        "would be overwritten"
922    } else {
923        "were overwritten"
924    };
925    Some(format!(
926        "  up to {} non-blank cell(s) {tense}: {}",
927        overwritten_cells.len(),
928        grid_range::render_locations(overwritten_cells)
929    ))
930}
931
932/// Builds the `textToColumns` request. Placed after the `--dry-run`
933/// branch in `text_to_columns_inner` deliberately — see
934/// `banding.rs::banding_inner`'s comment just before its own call site
935/// for the full `#1688` ordering reasoning, shared verbatim by every
936/// leased engine.
937fn build_request(source: GridRange, delimiter: &Delimiter) -> BatchUpdateRequestItem {
938    let delimiter_text = match delimiter {
939        Delimiter::Custom(text) => Some(text.clone()),
940        _ => None,
941    };
942    BatchUpdateRequestItem::TextToColumns(TextToColumnsRequest {
943        source,
944        delimiter: delimiter_text,
945        delimiter_type: delimiter.wire_type(),
946    })
947}
948
949fn record_attempt(outcome: &TextToColumnsOutcome, duration: Duration) {
950    request_log::record_drive_mutation(mutation_outcome(outcome, duration));
951}
952
953/// The `drivemutation` record for one attempt. Split out from
954/// [`record_attempt`] so the logged fields are unit-testable.
955fn mutation_outcome(outcome: &TextToColumnsOutcome, duration: Duration) -> DriveMutationOutcome {
956    let error = match &outcome.result {
957        TextToColumnsResult::Failed { detail }
958        | TextToColumnsResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
959        _ => None,
960    };
961    let decided_by = match &outcome.result {
962        TextToColumnsResult::Blocked { decided_by, .. } => decided_by.as_ref(),
963        _ => None,
964    };
965    let decided_by = write_gate::decided_by_log_fields(decided_by);
966    let (range, fields_changed, overwritten_cells) = match &outcome.result {
967        // The unreadable reply logs everything `Changed` does: the mutation
968        // was applied, so what it overwrote must stay auditable.
969        TextToColumnsResult::Changed {
970            summary,
971            source,
972            overwritten_cells,
973            ..
974        }
975        | TextToColumnsResult::AppliedReplyUnreadable {
976            summary,
977            source,
978            overwritten_cells,
979            ..
980        } => (
981            Some(source.clone()),
982            Some(summary.clone()),
983            overwritten_cells.clone(),
984        ),
985        _ => (None, None, Vec::new()),
986    };
987
988    DriveMutationOutcome {
989        operation: LOG_OPERATION,
990        file_id: outcome.spreadsheet_id.clone(),
991        file_name: outcome.file_name.clone().unwrap_or_default(),
992        status: outcome.result.log_status().to_string(),
993        resolved_folder_id: outcome.resolved_folder_id.clone(),
994        decided_by_folder_id: decided_by.folder_id,
995        decided_by_depth: decided_by.depth,
996        decided_by_file_id: decided_by.file_id,
997        sheet_id: outcome.sheet_id,
998        range,
999        fields_changed,
1000        overwritten_cells,
1001        // Always an upper bound for this verb — the API decides for
1002        // itself how many columns each row's split needs, unlike
1003        // `auto-fill`'s form B, where the destination is exact. Under
1004        // `--delimiter auto` it is not even that (the separator is the
1005        // API's choice too), but the key has no third state and the
1006        // rendered summary carries `AUTO_DELIMITER_CAVEAT` instead.
1007        overwritten_cells_upper_bound: true,
1008        error,
1009        duration,
1010        ..Default::default()
1011    }
1012}
1013
1014/// Renders an outcome as human-readable text.
1015#[must_use]
1016pub fn describe(outcome: &TextToColumnsOutcome) -> String {
1017    describe_lines(outcome).join("\n")
1018}
1019
1020/// Renders an outcome as its individual lines, none of which contains a
1021/// newline.
1022#[must_use]
1023pub fn describe_lines(outcome: &TextToColumnsOutcome) -> Vec<String> {
1024    let book = outcome.file_name.as_deref().map_or_else(
1025        || format!("'{}'", outcome.spreadsheet_id),
1026        |n| format!("'{n}'"),
1027    );
1028    match &outcome.result {
1029        TextToColumnsResult::WouldChange {
1030            summary,
1031            overwritten_cells,
1032            past_grid_extent,
1033            ..
1034        } => change_lines(
1035            &format!("Would {summary} in {book}"),
1036            overwritten_cells,
1037            *past_grid_extent,
1038            &outcome.delimiter,
1039            true,
1040        ),
1041        TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1042            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1043             `drive sheets text-to-columns` only works on spreadsheets"
1044        )],
1045        TextToColumnsResult::RefusedShortcut => vec![format!(
1046            "Refused: {book} is a shortcut; `drive sheets text-to-columns` doesn't follow \
1047             shortcuts"
1048        )],
1049        TextToColumnsResult::RefusedNoVisibleParents => vec![format!(
1050            "Refused: {book} has no parent folder visible to this account, so no folder \
1051             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1052             id>\", \"allow\": [\"sheets-write\"]}} to write_permissions.rules."
1053        )],
1054        TextToColumnsResult::RefusedSheetNotFound { title, available } => {
1055            vec![grid_range::no_sheet_refusal(&book, title, available)]
1056        }
1057        TextToColumnsResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1058        TextToColumnsResult::RefusedInvalidDelimiter { detail } => {
1059            vec![format!("Refused: {detail}")]
1060        }
1061        TextToColumnsResult::Blocked {
1062            operation,
1063            decided_by,
1064        } => vec![match decided_by {
1065            Some(rule) => format!(
1066                "Blocked: text-to-columns on {book} refused by rule on {} {}{}",
1067                rule.kind_label(),
1068                rule.id(),
1069                rule.depth_suffix()
1070            ),
1071            None => format!(
1072                "Blocked: text-to-columns on {book} refused by default policy (no matching rule \
1073                 for {operation})"
1074            ),
1075        }],
1076        TextToColumnsResult::RefusedNoLease => LeaseGateRefusal::NoLease
1077            .describe_line(&outcome.spreadsheet_id, &book)
1078            .into_iter()
1079            .collect(),
1080        TextToColumnsResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1081            .describe_line(&outcome.spreadsheet_id, &book)
1082            .into_iter()
1083            .collect(),
1084        TextToColumnsResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1085            .describe_line(&outcome.spreadsheet_id, &book)
1086            .into_iter()
1087            .collect(),
1088        TextToColumnsResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1089            .describe_line(&outcome.spreadsheet_id, &book)
1090            .into_iter()
1091            .collect(),
1092        TextToColumnsResult::Changed {
1093            summary,
1094            overwritten_cells,
1095            past_grid_extent,
1096            ..
1097        } => change_lines(
1098            &format!("Applied: {summary} in {book}"),
1099            overwritten_cells,
1100            *past_grid_extent,
1101            &outcome.delimiter,
1102            false,
1103        ),
1104        TextToColumnsResult::AppliedReplyUnreadable {
1105            summary,
1106            detail,
1107            overwritten_cells,
1108            past_grid_extent,
1109            ..
1110        } => change_lines(
1111            &applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
1112            overwritten_cells,
1113            *past_grid_extent,
1114            &outcome.delimiter,
1115            false,
1116        ),
1117        TextToColumnsResult::Failed { detail } => vec![format!("Failed: {detail}")],
1118    }
1119}
1120
1121/// The head line plus its indented detail lines, shared by
1122/// [`TextToColumnsResult::WouldChange`] and [`TextToColumnsResult::Changed`]
1123/// so the two can only differ in `head` and in the tense `dry_run`
1124/// selects — `structure.rs`'s `vec![summary, detail]` shape. `delimiter`
1125/// is read only to decide whether [`AUTO_DELIMITER_CAVEAT`] applies.
1126fn change_lines(
1127    head: &str,
1128    overwritten_cells: &[String],
1129    past_grid_extent: bool,
1130    delimiter: &Delimiter,
1131    dry_run: bool,
1132) -> Vec<String> {
1133    let mut lines = vec![head.to_string()];
1134    lines.extend(overwritten_line(overwritten_cells, delimiter, dry_run));
1135    if past_grid_extent {
1136        lines.push(
1137            if dry_run {
1138                PAST_GRID_EXTENT_CAVEAT_DRY_RUN
1139            } else {
1140                PAST_GRID_EXTENT_CAVEAT
1141            }
1142            .to_string(),
1143        );
1144    }
1145    if matches!(delimiter, Delimiter::Auto) {
1146        lines.push(AUTO_DELIMITER_CAVEAT.to_string());
1147    }
1148    lines.push(UNPREVIEWABLE_SPLIT_CAVEAT.to_string());
1149    lines
1150}
1151
1152#[cfg(test)]
1153#[allow(clippy::unwrap_used, clippy::expect_used)]
1154mod tests {
1155    use super::*;
1156    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1157    use crate::drive::sheets::client::SHEETS_API_URL;
1158    use crate::drive::test_support::seed_lease;
1159    use crate::test_support::env::MapEnv;
1160    use crate::utils::secret::Secret;
1161
1162    // ── pure helpers ─────────────────────────────────────────────────────
1163
1164    fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1165        GridRange {
1166            sheet_id,
1167            start_row_index: Some(r0),
1168            end_row_index: Some(r1),
1169            start_column_index: Some(c0),
1170            end_column_index: Some(c1),
1171        }
1172    }
1173
1174    fn values(rows: &[&str]) -> ValueRange {
1175        ValueRange {
1176            range: None,
1177            values: rows
1178                .iter()
1179                .map(|cell| {
1180                    if cell.is_empty() {
1181                        vec![]
1182                    } else {
1183                        vec![serde_json::json!(cell)]
1184                    }
1185                })
1186                .collect(),
1187        }
1188    }
1189
1190    /// Every constructible [`Delimiter`] maps onto exactly one
1191    /// wire-representable choice, tries the separator(s) that choice
1192    /// implies, and has a name for the summary. Table-driven so a new
1193    /// variant cannot be added with one of the three left behind.
1194    #[test]
1195    fn every_delimiter_maps_to_a_wire_type_its_candidates_and_a_name() {
1196        let cases = [
1197            (Delimiter::Comma, DelimiterType::Comma, vec![","], "comma"),
1198            (
1199                Delimiter::Semicolon,
1200                DelimiterType::Semicolon,
1201                vec![";"],
1202                "semicolon",
1203            ),
1204            (
1205                Delimiter::Period,
1206                DelimiterType::Period,
1207                vec!["."],
1208                "period",
1209            ),
1210            (Delimiter::Space, DelimiterType::Space, vec![" "], "space"),
1211            (
1212                Delimiter::Auto,
1213                DelimiterType::Autodetect,
1214                vec![",", ";", ".", " "],
1215                "an auto-detected separator",
1216            ),
1217            (
1218                Delimiter::Custom("||".to_string()),
1219                DelimiterType::Custom,
1220                vec!["||"],
1221                "custom (\"||\")",
1222            ),
1223        ];
1224        for (delimiter, wire, candidates, name) in cases {
1225            assert_eq!(delimiter.wire_type(), wire, "{delimiter:?}");
1226            assert_eq!(
1227                delimiter.local_split_candidates(),
1228                candidates,
1229                "{delimiter:?}"
1230            );
1231            assert_eq!(delimiter.describe(), name, "{delimiter:?}");
1232        }
1233    }
1234
1235    /// The log status is the only field the request log keys refusals
1236    /// on, and the four lease statuses must be the shared ones rather
1237    /// than this module's own spelling of them.
1238    #[test]
1239    fn every_result_variant_has_its_own_log_status() {
1240        let statuses = [
1241            TextToColumnsResult::RefusedNotASpreadsheet {
1242                mime_type: String::new(),
1243            },
1244            TextToColumnsResult::RefusedShortcut,
1245            TextToColumnsResult::RefusedNoVisibleParents,
1246            TextToColumnsResult::RefusedSheetNotFound {
1247                title: String::new(),
1248                available: Vec::new(),
1249            },
1250            TextToColumnsResult::RefusedInvalidRange {
1251                detail: String::new(),
1252            },
1253            TextToColumnsResult::RefusedInvalidDelimiter {
1254                detail: String::new(),
1255            },
1256            TextToColumnsResult::Blocked {
1257                operation: DriveOperation::SheetsWrite,
1258                decided_by: None,
1259            },
1260            TextToColumnsResult::RefusedNoLease,
1261            TextToColumnsResult::RefusedLeaseExpired,
1262            TextToColumnsResult::RefusedLeaseWrongFile,
1263            TextToColumnsResult::RefusedLeaseStale,
1264            TextToColumnsResult::AppliedReplyUnreadable {
1265                summary: String::new(),
1266                detail: String::new(),
1267                source: String::new(),
1268                spill: None,
1269                width_upper_bound: 0,
1270                overwritten_cells: Vec::new(),
1271                past_grid_extent: false,
1272            },
1273            TextToColumnsResult::Failed {
1274                detail: String::new(),
1275            },
1276        ];
1277        let mut seen: Vec<&str> = statuses
1278            .iter()
1279            .map(TextToColumnsResult::log_status)
1280            .collect();
1281        seen.push(would_change_outcome(Vec::new(), false).result.log_status());
1282        let unique: std::collections::HashSet<&str> = seen.iter().copied().collect();
1283        assert_eq!(unique.len(), seen.len(), "duplicate log status in {seen:?}");
1284        assert_eq!(
1285            TextToColumnsResult::RefusedLeaseStale.log_status(),
1286            LeaseGateRefusal::Stale.log_status()
1287        );
1288    }
1289
1290    /// The `-o jsonl` renderer writes one scalar record; the delimiter
1291    /// is `#[serde(skip)]`, so a custom separator never reaches it.
1292    #[test]
1293    fn jsonl_writes_one_record_and_omits_the_delimiter() {
1294        let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1295        outcome.delimiter = Delimiter::Custom("not-in-the-record".to_string());
1296        let mut out = Vec::new();
1297        outcome.write_jsonl(&mut out).unwrap();
1298        let text = String::from_utf8(out).unwrap();
1299        assert_eq!(text.lines().count(), 1);
1300        assert!(!text.contains("not-in-the-record"));
1301        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1302        assert_eq!(parsed["result"]["status"], "would-change");
1303    }
1304
1305    #[test]
1306    fn is_single_column_accepts_a_one_column_span() {
1307        assert!(is_single_column(&bounded(0, 0, 10, 2, 3)));
1308    }
1309
1310    #[test]
1311    fn is_single_column_rejects_a_wider_span() {
1312        assert!(!is_single_column(&bounded(0, 0, 10, 2, 4)));
1313    }
1314
1315    #[test]
1316    fn cell_text_renders_a_string_verbatim() {
1317        assert_eq!(cell_text(&serde_json::json!("a,b")), "a,b");
1318    }
1319
1320    #[test]
1321    fn cell_text_is_empty_for_null() {
1322        assert_eq!(cell_text(&serde_json::Value::Null), "");
1323    }
1324
1325    #[test]
1326    fn cell_text_falls_back_to_json_rendering_for_a_non_string_non_null_cell() {
1327        assert_eq!(cell_text(&serde_json::json!(42)), "42");
1328    }
1329
1330    #[test]
1331    fn split_width_is_zero_for_no_rows() {
1332        assert_eq!(split_width(&values(&[]), &Delimiter::Comma), 0);
1333    }
1334
1335    #[test]
1336    fn split_width_ignores_a_blank_row() {
1337        assert_eq!(split_width(&values(&[""]), &Delimiter::Comma), 0);
1338    }
1339
1340    #[test]
1341    fn split_width_counts_comma_pieces() {
1342        assert_eq!(split_width(&values(&["a,b,c"]), &Delimiter::Comma), 3);
1343    }
1344
1345    #[test]
1346    fn split_width_is_the_widest_row() {
1347        assert_eq!(
1348            split_width(&values(&["a,b", "x,y,z", "solo"]), &Delimiter::Comma),
1349            3
1350        );
1351    }
1352
1353    #[test]
1354    fn split_width_with_a_custom_delimiter() {
1355        assert_eq!(
1356            split_width(&values(&["a|b|c|d"]), &Delimiter::Custom("|".to_string())),
1357            4
1358        );
1359    }
1360
1361    #[test]
1362    fn split_width_auto_takes_the_widest_of_the_four_fixed_delimiters() {
1363        // "a.b.c" splits into 3 on PERIOD and 1 on the other three.
1364        assert_eq!(split_width(&values(&["a.b.c"]), &Delimiter::Auto), 3);
1365    }
1366
1367    #[test]
1368    fn split_width_with_no_delimiter_present_is_one() {
1369        assert_eq!(split_width(&values(&["solo"]), &Delimiter::Comma), 1);
1370    }
1371
1372    #[test]
1373    fn spill_span_is_none_for_a_width_of_one_or_less() {
1374        assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 1), None);
1375        assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 0), None);
1376    }
1377
1378    #[test]
1379    fn spill_span_covers_the_columns_to_the_right_of_the_source() {
1380        // Source A2:A10 (column index 0), width 3 -> spill B2:C10.
1381        let spill = spill_span(bounded(0, 1, 10, 0, 1), 3).unwrap();
1382        assert_eq!(spill.start_column_index, Some(1));
1383        assert_eq!(spill.end_column_index, Some(3));
1384        assert_eq!(spill.start_row_index, Some(1));
1385        assert_eq!(spill.end_row_index, Some(10));
1386    }
1387
1388    #[test]
1389    fn extends_past_grid_flags_a_spill_past_the_column_count() {
1390        let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1391            "spreadsheetId": "sheet-1",
1392            "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1393                "gridProperties": {"rowCount": 100, "columnCount": 3}}}],
1394        }))
1395        .unwrap();
1396        assert!(extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1397        assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 3)));
1398    }
1399
1400    #[test]
1401    fn extends_past_grid_is_false_when_the_sheet_is_unknown() {
1402        let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
1403            "spreadsheetId": "sheet-1",
1404            "sheets": [],
1405        }))
1406        .unwrap();
1407        assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
1408    }
1409
1410    #[test]
1411    fn describe_effect_with_a_spill() {
1412        let summary = describe_effect("'Q1'!A2:A10", Some("'Q1'!B2:D10"), 3, &Delimiter::Comma);
1413        assert_eq!(
1414            summary,
1415            "split 'Q1'!A2:A10 on comma into up to 3 column(s), spill 'Q1'!B2:D10"
1416        );
1417    }
1418
1419    #[test]
1420    fn describe_effect_with_no_spill() {
1421        let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Space);
1422        assert_eq!(
1423            summary,
1424            "split 'Q1'!A2:A10 on space into a single column each row; no row spills beyond \
1425             the source under this delimiter"
1426        );
1427    }
1428
1429    /// `Auto` earns its own no-spill wording: unlike every fixed
1430    /// delimiter, a local no-match says nothing about what Sheets will
1431    /// detect, so the summary reports the span as unknown rather than
1432    /// claiming no row spills.
1433    #[test]
1434    fn describe_effect_with_no_spill_under_auto() {
1435        let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Auto);
1436        assert_eq!(
1437            summary,
1438            "split 'Q1'!A2:A10 on an auto-detected separator (none detected in the preview, so \
1439             the spill span is unknown)"
1440        );
1441    }
1442
1443    /// The summary is reused verbatim by `Changed` and by the request
1444    /// log's `fields_changed`, so it must read correctly *after* the
1445    /// fact too — a conditional clause here would describe a mutation
1446    /// that already happened. The no-spill branch is the one that used
1447    /// to carry a "would".
1448    #[test]
1449    fn every_summary_branch_is_tense_neutral() {
1450        for (spill, width) in [(Some("'Q1'!B2:B10"), 2), (None, 1)] {
1451            let summary = describe_effect("'Q1'!A2:A10", spill, width, &Delimiter::Comma);
1452            assert!(
1453                !summary.contains("would"),
1454                "summary is reused in the past tense: {summary}"
1455            );
1456        }
1457    }
1458
1459    #[test]
1460    fn overwritten_line_is_always_prefixed_up_to() {
1461        let line = overwritten_line(&["B2".to_string()], &Delimiter::Comma, true).unwrap();
1462        assert!(line.starts_with("  up to 1 non-blank cell(s) would be overwritten"));
1463    }
1464
1465    #[test]
1466    fn overwritten_line_empty_case() {
1467        assert_eq!(
1468            overwritten_line(&[], &Delimiter::Comma, false),
1469            Some("  no non-blank cells in the spill columns".to_string())
1470        );
1471    }
1472
1473    #[test]
1474    fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
1475        let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
1476        let line = overwritten_line(&cells, &Delimiter::Comma, true).unwrap();
1477        assert!(line.contains("… and 10 more"), "{line}");
1478        assert!(
1479            line.starts_with("  up to 60 non-blank cell(s) would be overwritten:"),
1480            "{line}"
1481        );
1482        assert_eq!(cells.len(), 60);
1483    }
1484
1485    /// An `auto` run with nothing to list says nothing, rather than
1486    /// printing the all-clear a live run was measured contradicting.
1487    #[test]
1488    fn auto_never_prints_an_all_clear() {
1489        assert_eq!(overwritten_line(&[], &Delimiter::Auto, true), None);
1490        // It still lists cells it did find — that half is informative.
1491        assert!(overwritten_line(&["B2".to_string()], &Delimiter::Auto, true).is_some());
1492    }
1493
1494    #[test]
1495    fn build_request_omits_delimiter_for_a_fixed_type() {
1496        let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Comma);
1497        match request {
1498            BatchUpdateRequestItem::TextToColumns(req) => {
1499                assert_eq!(req.delimiter, None);
1500                assert_eq!(req.delimiter_type, DelimiterType::Comma);
1501            }
1502            other => panic!("unexpected request: {other:?}"), // omni-dev: coverage ignore-line reason="this match's catch-all only runs if build_request failed to return the request variant this test constructs it to build; that never happens, so the branch never executes"
1503        }
1504    }
1505
1506    #[test]
1507    fn build_request_carries_the_custom_delimiter_text() {
1508        let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Custom("|".to_string()));
1509        match request {
1510            BatchUpdateRequestItem::TextToColumns(req) => {
1511                assert_eq!(req.delimiter, Some("|".to_string()));
1512                assert_eq!(req.delimiter_type, DelimiterType::Custom);
1513            }
1514            other => panic!("unexpected request: {other:?}"), // omni-dev: coverage ignore-line reason="this match's catch-all only runs if build_request failed to return the request variant this test constructs it to build; that never happens, so the branch never executes"
1515        }
1516    }
1517
1518    // ── describe_lines ───────────────────────────────────────────────────
1519
1520    fn would_change_outcome(
1521        overwritten_cells: Vec<String>,
1522        past_grid_extent: bool,
1523    ) -> TextToColumnsOutcome {
1524        TextToColumnsOutcome {
1525            spreadsheet_id: "sheet-1".to_string(),
1526            file_name: Some("Budget".to_string()),
1527            resolved_folder_id: Some("folder-1".to_string()),
1528            sheet_id: Some(0),
1529            delimiter: Delimiter::Comma,
1530            result: TextToColumnsResult::WouldChange {
1531                summary: "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4"
1532                    .to_string(),
1533                source: "'Q1'!A2:A4".to_string(),
1534                spill: Some("'Q1'!B2:B4".to_string()),
1535                width_upper_bound: 2,
1536                overwritten_cells,
1537                past_grid_extent,
1538            },
1539        }
1540    }
1541
1542    #[test]
1543    fn would_change_lines_carry_the_upper_bound_and_the_unpreviewable_caveat() {
1544        let outcome = would_change_outcome(vec!["B2".to_string()], false);
1545        let lines = describe_lines(&outcome);
1546        assert_eq!(
1547            lines[0],
1548            "Would split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4 in 'Budget'"
1549        );
1550        assert_eq!(
1551            lines[1],
1552            "  up to 1 non-blank cell(s) would be overwritten: B2"
1553        );
1554        assert_eq!(lines[2], UNPREVIEWABLE_SPLIT_CAVEAT);
1555    }
1556
1557    #[test]
1558    fn past_grid_extent_adds_the_dry_run_caveat_line() {
1559        let outcome = would_change_outcome(vec![], true);
1560        let lines = describe_lines(&outcome);
1561        assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT_DRY_RUN);
1562        assert_eq!(lines[3], UNPREVIEWABLE_SPLIT_CAVEAT);
1563    }
1564
1565    #[test]
1566    fn changed_lines_use_the_past_tense() {
1567        let mut outcome = would_change_outcome(vec!["B2".to_string()], true);
1568        outcome.result = match outcome.result {
1569            TextToColumnsResult::WouldChange {
1570                summary,
1571                source,
1572                spill,
1573                width_upper_bound,
1574                overwritten_cells,
1575                past_grid_extent,
1576            } => TextToColumnsResult::Changed {
1577                summary,
1578                source,
1579                spill,
1580                width_upper_bound,
1581                overwritten_cells,
1582                past_grid_extent,
1583            },
1584            other => other, // omni-dev: coverage ignore-line reason="would_change_outcome always constructs a WouldChange result, so this catch-all identity arm never runs"
1585        };
1586        let lines = describe_lines(&outcome);
1587        assert!(lines[0].starts_with("Applied: "));
1588        assert!(lines[1].contains("were overwritten"));
1589        assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT);
1590    }
1591
1592    /// Every variant renders: non-empty, and never with an embedded
1593    /// newline, which is [`describe_lines`]' own stated contract (the
1594    /// `-o table` renderer joins the lines itself, and a `\n` inside one
1595    /// would silently defeat the per-line terminal sanitising the CLI
1596    /// applies).
1597    #[test]
1598    fn every_result_variant_renders_as_non_empty_newline_free_lines() {
1599        let results = [
1600            TextToColumnsResult::RefusedNotASpreadsheet {
1601                mime_type: "application/pdf".to_string(),
1602            },
1603            TextToColumnsResult::RefusedShortcut,
1604            TextToColumnsResult::RefusedNoVisibleParents,
1605            TextToColumnsResult::RefusedSheetNotFound {
1606                title: "Q9".to_string(),
1607                available: vec!["Q1".to_string()],
1608            },
1609            // The workbook has no sheets at all, so there is nothing to
1610            // suggest — the "none" branch of the same arm.
1611            TextToColumnsResult::RefusedSheetNotFound {
1612                title: "Q9".to_string(),
1613                available: Vec::new(),
1614            },
1615            TextToColumnsResult::RefusedInvalidRange {
1616                detail: "'A1:B2' spans more than one column".to_string(),
1617            },
1618            TextToColumnsResult::RefusedInvalidDelimiter {
1619                detail: "--custom-delimiter must not be empty".to_string(),
1620            },
1621            TextToColumnsResult::Blocked {
1622                operation: DriveOperation::SheetsWrite,
1623                decided_by: None,
1624            },
1625            TextToColumnsResult::RefusedNoLease,
1626            TextToColumnsResult::RefusedLeaseExpired,
1627            TextToColumnsResult::RefusedLeaseWrongFile,
1628            TextToColumnsResult::RefusedLeaseStale,
1629            TextToColumnsResult::AppliedReplyUnreadable {
1630                summary: "split 'Q1'!A2:A4 on comma".to_string(),
1631                detail: "bad reply".to_string(),
1632                source: "'Q1'!A2:A4".to_string(),
1633                spill: Some("'Q1'!B2:B4".to_string()),
1634                width_upper_bound: 2,
1635                overwritten_cells: Vec::new(),
1636                past_grid_extent: false,
1637            },
1638            TextToColumnsResult::Failed {
1639                detail: "HTTP 500".to_string(),
1640            },
1641        ];
1642        for result in results {
1643            let mut outcome = would_change_outcome(Vec::new(), false);
1644            let status = result.log_status();
1645            outcome.result = result;
1646            let lines = describe_lines(&outcome);
1647            assert!(!lines.is_empty(), "{status} rendered nothing");
1648            for line in &lines {
1649                assert!(!line.is_empty(), "{status} rendered an empty line");
1650                assert!(
1651                    !line.contains('\n'),
1652                    "{status} rendered an embedded newline"
1653                );
1654            }
1655            // `describe` is just the joined form of the same lines.
1656            assert_eq!(describe(&outcome), lines.join("\n"), "{status}");
1657        }
1658    }
1659
1660    /// The lease helper's catch-all maps onto this module's `Failed`,
1661    /// so a ledger read that blows up is reported like any other error
1662    /// rather than as a refusal the user could act on.
1663    #[test]
1664    fn a_failed_lease_check_maps_to_failed() {
1665        match TextToColumnsResult::from_lease_failed("ledger unreadable".to_string()) {
1666            TextToColumnsResult::Failed { detail } => assert_eq!(detail, "ledger unreadable"),
1667            other => panic!("expected Failed, got {other:?}"), // omni-dev: coverage ignore-line reason="from_lease_failed always returns Failed; this test's catch-all guards that assumption and never runs"
1668        }
1669    }
1670
1671    /// No engine call site ever renders a book-using result with no file
1672    /// name (every path that resolves one already has the target's
1673    /// name), but `describe`/`describe_lines` are `pub` and take
1674    /// whatever `TextToColumnsOutcome` they are given — so this pins the
1675    /// fallback to the raw spreadsheet id directly.
1676    #[test]
1677    fn describe_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1678        let mut outcome = would_change_outcome(Vec::new(), false);
1679        outcome.file_name = None;
1680        assert!(describe(&outcome).contains("'sheet-1'"));
1681    }
1682
1683    #[test]
1684    fn auto_earns_its_own_caveat_line_and_no_other_delimiter_does() {
1685        let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
1686        assert!(!describe_lines(&outcome).contains(&AUTO_DELIMITER_CAVEAT.to_string()));
1687        outcome.delimiter = Delimiter::Auto;
1688        let lines = describe_lines(&outcome);
1689        assert_eq!(lines[lines.len() - 2], AUTO_DELIMITER_CAVEAT);
1690        // Still the last word on the subject: the unpreviewable caveat
1691        // every run carries stays at the end.
1692        assert_eq!(lines[lines.len() - 1], UNPREVIEWABLE_SPLIT_CAVEAT);
1693    }
1694
1695    #[test]
1696    fn refused_sheet_not_found_lists_available_titles() {
1697        let outcome = TextToColumnsOutcome {
1698            spreadsheet_id: "sheet-1".to_string(),
1699            file_name: Some("Budget".to_string()),
1700            resolved_folder_id: Some("folder-1".to_string()),
1701            sheet_id: None,
1702            delimiter: Delimiter::Comma,
1703            result: TextToColumnsResult::RefusedSheetNotFound {
1704                title: "Q2".to_string(),
1705                available: vec!["Q1".to_string()],
1706            },
1707        };
1708        assert_eq!(
1709            describe_lines(&outcome),
1710            vec!["Refused: 'Budget' has no sheet titled 'Q2'. Available: 'Q1'".to_string()]
1711        );
1712    }
1713
1714    #[test]
1715    fn blocked_with_no_deciding_rule_names_default_policy() {
1716        let outcome = TextToColumnsOutcome {
1717            spreadsheet_id: "sheet-1".to_string(),
1718            file_name: Some("Budget".to_string()),
1719            resolved_folder_id: Some("folder-1".to_string()),
1720            sheet_id: None,
1721            delimiter: Delimiter::Comma,
1722            result: TextToColumnsResult::Blocked {
1723                operation: DriveOperation::SheetsWrite,
1724                decided_by: None,
1725            },
1726        };
1727        assert_eq!(
1728            describe_lines(&outcome),
1729            vec![
1730                "Blocked: text-to-columns on 'Budget' refused by default policy (no matching \
1731                 rule for sheets-write)"
1732                    .to_string()
1733            ]
1734        );
1735    }
1736
1737    // ── end-to-end (wiremock) ────────────────────────────────────────────
1738
1739    fn test_credentials() -> DriveCredentials {
1740        DriveCredentials {
1741            client_id: "client-1".to_string(),
1742            client_secret: Secret::new("secret-1"),
1743            refresh_token: Secret::new("refresh-1"),
1744            scope: DriveGrantedScopes::READONLY,
1745        }
1746    }
1747
1748    /// Builds a `DriveClient`/`SheetsClient` pair pointed at `server`, with
1749    /// `SHEETS_API_URL` injected via `MapEnv` rather than
1750    /// `std::env::set_var` — the process environment is global state, and
1751    /// this module's tests run concurrently against their own mock
1752    /// servers (STYLE-0028; `auto_fill.rs`'s own precedent).
1753    async fn client(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1754        wiremock::Mock::given(wiremock::matchers::method("POST"))
1755            .and(wiremock::matchers::path("/token"))
1756            .respond_with(
1757                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1758                    "access_token": "test-token", "expires_in": 3600,
1759                })),
1760            )
1761            .mount(server)
1762            .await;
1763        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1764        crate::drive::client::test_support::replace_session(
1765            &mut drive,
1766            &test_credentials(),
1767            &format!("{}/token", server.uri()),
1768        );
1769        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1770        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1771        (drive, sheets)
1772    }
1773
1774    fn rule_allowing(ops: &[DriveOperation], require_lease: bool) -> FolderPermissionRule {
1775        FolderPermissionRule {
1776            folder_id: Some("parent-1".to_string()),
1777            file_id: None,
1778            recursive: true,
1779            allow: ops.iter().copied().collect(),
1780            deny: std::collections::HashSet::default(),
1781            require_lease,
1782        }
1783    }
1784
1785    /// The grant this verb actually needs: both halves of
1786    /// [`GATE_OPERATIONS`]. Named so a test that means "allowed" does
1787    /// not have to restate the union, and so the union moving would
1788    /// break one helper rather than twenty tests.
1789    fn rule(_op: DriveOperation, require_lease: bool) -> FolderPermissionRule {
1790        rule_allowing(GATE_OPERATIONS, require_lease)
1791    }
1792
1793    fn base_opts(dry_run: bool) -> TextToColumnsOptions {
1794        TextToColumnsOptions {
1795            spreadsheet_id: "sheet-1".to_string(),
1796            sheet: None,
1797            source: Some("Q1!A2:A4".to_string()),
1798            delimiter: Delimiter::Comma,
1799            dry_run,
1800            lease_token: None,
1801            ledger_path: PathBuf::default(),
1802        }
1803    }
1804
1805    /// The target's own Drive metadata. Split out of
1806    /// [`mount_metadata`] so the refusal tests can vary the one field
1807    /// each of them turns on (`mimeType`, `shortcutDetails`, `parents`)
1808    /// without restating the rest.
1809    async fn mount_file_metadata(server: &wiremock::MockServer, body: serde_json::Value) {
1810        wiremock::Mock::given(wiremock::matchers::method("GET"))
1811            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1812            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
1813            .mount(server)
1814            .await;
1815    }
1816
1817    /// The default target: a spreadsheet in `parent-1`, at version `1`
1818    /// (which is what [`seed_lease`] must record to look fresh).
1819    fn spreadsheet_metadata() -> serde_json::Value {
1820        serde_json::json!({
1821            "id": "sheet-1", "name": "Budget",
1822            "mimeType": "application/vnd.google-apps.spreadsheet",
1823            "parents": ["parent-1"], "version": "1",
1824        })
1825    }
1826
1827    async fn mount_parent(server: &wiremock::MockServer) {
1828        wiremock::Mock::given(wiremock::matchers::method("GET"))
1829            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1830            .respond_with(
1831                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1832                    "id": "parent-1", "name": "parent-1",
1833                    "mimeType": "application/vnd.google-apps.folder", "parents": [],
1834                })),
1835            )
1836            .mount(server)
1837            .await;
1838    }
1839
1840    /// The workbook, with a caller-chosen grid extent — the grid-edge
1841    /// tests below turn the sheet's `columnCount` down until the spill
1842    /// runs off it.
1843    async fn mount_workbook(server: &wiremock::MockServer, row_count: i64, column_count: i64) {
1844        wiremock::Mock::given(wiremock::matchers::method("GET"))
1845            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1846            .respond_with(
1847                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1848                    "spreadsheetId": "sheet-1",
1849                    "sheets": [{"properties": {"sheetId": 0, "title": "Q1",
1850                        "gridProperties": {
1851                            "rowCount": row_count, "columnCount": column_count,
1852                        }}}],
1853                })),
1854            )
1855            .mount(server)
1856            .await;
1857    }
1858
1859    async fn mount_metadata(server: &wiremock::MockServer) {
1860        mount_file_metadata(server, spreadsheet_metadata()).await;
1861        mount_parent(server).await;
1862        mount_workbook(server, 1000, 26).await;
1863    }
1864
1865    /// Mounts a `values.get` that fails, for the two reads this engine
1866    /// performs.
1867    async fn mount_values_failure(server: &wiremock::MockServer, range: &str) {
1868        wiremock::Mock::given(wiremock::matchers::method("GET"))
1869            .and(wiremock::matchers::path(format!(
1870                "/v4/spreadsheets/sheet-1/values/{range}"
1871            )))
1872            .respond_with(wiremock::ResponseTemplate::new(500))
1873            .mount(server)
1874            .await;
1875    }
1876
1877    async fn mount_batch_update(server: &wiremock::MockServer, status: u16) {
1878        let template = if status == 200 {
1879            wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1880                "spreadsheetId": "sheet-1", "replies": [{}],
1881            }))
1882        } else {
1883            wiremock::ResponseTemplate::new(status)
1884        };
1885        wiremock::Mock::given(wiremock::matchers::method("POST"))
1886            .and(wiremock::matchers::path(
1887                "/v4/spreadsheets/sheet-1:batchUpdate",
1888            ))
1889            .respond_with(template)
1890            .mount(server)
1891            .await;
1892    }
1893
1894    fn batch_update_was_called(requests: &[wiremock::Request]) -> bool {
1895        requests
1896            .iter()
1897            .any(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1898    }
1899
1900    /// Mounts one `values.get` response for an exact A1 range — the same
1901    /// literal-path style `auto_fill.rs`'s own tests use (`url::Url`'s
1902    /// path segment encoding leaves `'`, `!` and `:` untouched, so the
1903    /// range string can be matched on verbatim).
1904    async fn mount_values(server: &wiremock::MockServer, range: &str, rows: serde_json::Value) {
1905        wiremock::Mock::given(wiremock::matchers::method("GET"))
1906            .and(wiremock::matchers::path(format!(
1907                "/v4/spreadsheets/sheet-1/values/{range}"
1908            )))
1909            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(rows))
1910            .mount(server)
1911            .await;
1912    }
1913
1914    #[tokio::test]
1915    async fn dry_run_reads_source_and_spill_and_never_calls_batch_update() {
1916        let server = wiremock::MockServer::start().await;
1917        let (client, sheets) = client(&server).await;
1918        mount_metadata(&server).await;
1919        mount_values(
1920            &server,
1921            "'Q1'!A2:A4",
1922            serde_json::json!({"values": [["a,b"], ["x,y,z"], [""]]}),
1923        )
1924        .await;
1925        // Width 3 (from "x,y,z") means 2 spill columns (B:C) to the right
1926        // of the source column (A) — the source's own column holds the
1927        // first piece and stays out of the spill span.
1928        mount_values(
1929            &server,
1930            "'Q1'!B2:C4",
1931            serde_json::json!({"values": [["", ""], ["old", ""]]}),
1932        )
1933        .await;
1934        let opts = base_opts(true);
1935        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1936        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1937        match &outcome.result {
1938            TextToColumnsResult::WouldChange {
1939                width_upper_bound,
1940                spill,
1941                overwritten_cells,
1942                ..
1943            } => {
1944                assert_eq!(*width_upper_bound, 3);
1945                assert_eq!(spill.as_deref(), Some("'Q1'!B2:C4"));
1946                assert_eq!(overwritten_cells, &vec!["B3".to_string()]);
1947            }
1948            other => panic!("expected WouldChange, got {other:?}"),
1949        }
1950        assert!(server
1951            .received_requests()
1952            .await
1953            .unwrap()
1954            .iter()
1955            .all(|r| r.method != wiremock::http::Method::POST
1956                || r.url.path() != "/v4/spreadsheets/sheet-1:batchUpdate"));
1957    }
1958
1959    #[tokio::test]
1960    async fn real_run_posts_exactly_one_text_to_columns_request() {
1961        let server = wiremock::MockServer::start().await;
1962        let (client, sheets) = client(&server).await;
1963        mount_metadata(&server).await;
1964        mount_values(
1965            &server,
1966            "'Q1'!A2:A4",
1967            serde_json::json!({"values": [["a,b"]]}),
1968        )
1969        .await;
1970        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
1971        wiremock::Mock::given(wiremock::matchers::method("POST"))
1972            .and(wiremock::matchers::path(
1973                "/v4/spreadsheets/sheet-1:batchUpdate",
1974            ))
1975            .respond_with(
1976                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1977                    "spreadsheetId": "sheet-1", "replies": [{}],
1978                })),
1979            )
1980            .mount(&server)
1981            .await;
1982        let opts = base_opts(false);
1983        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
1984        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
1985        assert!(matches!(
1986            outcome.result,
1987            TextToColumnsResult::Changed { .. }
1988        ));
1989
1990        let batch_requests: Vec<_> = server
1991            .received_requests()
1992            .await
1993            .unwrap()
1994            .into_iter()
1995            .filter(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
1996            .collect();
1997        assert_eq!(batch_requests.len(), 1);
1998        let body: serde_json::Value = serde_json::from_slice(&batch_requests[0].body).unwrap();
1999        assert_eq!(body["requests"].as_array().unwrap().len(), 1);
2000        assert!(body["requests"][0]["textToColumns"].is_object());
2001    }
2002
2003    #[tokio::test]
2004    async fn default_policy_blocks_with_no_configured_rules() {
2005        let server = wiremock::MockServer::start().await;
2006        let (client, sheets) = client(&server).await;
2007        wiremock::Mock::given(wiremock::matchers::method("GET"))
2008            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2009            .respond_with(
2010                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2011                    "id": "sheet-1", "name": "Budget",
2012                    "mimeType": "application/vnd.google-apps.spreadsheet",
2013                    "parents": ["parent-1"], "version": "1",
2014                })),
2015            )
2016            .mount(&server)
2017            .await;
2018        wiremock::Mock::given(wiremock::matchers::method("GET"))
2019            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
2020            .respond_with(
2021                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2022                    "id": "parent-1", "name": "parent-1",
2023                    "mimeType": "application/vnd.google-apps.folder", "parents": [],
2024                })),
2025            )
2026            .mount(&server)
2027            .await;
2028        let opts = base_opts(true);
2029        let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2030        assert!(matches!(
2031            outcome.result,
2032            TextToColumnsResult::Blocked {
2033                operation: DriveOperation::SheetsWrite,
2034                decided_by: None,
2035            }
2036        ));
2037    }
2038
2039    /// The gate is the **union**, so neither half alone opens it —
2040    /// a live run measured the split carrying the source cell's
2041    /// formatting into the spill cells, which `sheets-write` does not
2042    /// confer, and the values it writes are not something
2043    /// `sheets-structure` confers either. Each half is refused naming
2044    /// the operation that was missing, which is the only actionable
2045    /// part of the message.
2046    #[tokio::test]
2047    async fn neither_half_of_the_gate_opens_it_alone() {
2048        for (granted, missing) in [
2049            (DriveOperation::SheetsWrite, DriveOperation::SheetsStructure),
2050            (DriveOperation::SheetsStructure, DriveOperation::SheetsWrite),
2051        ] {
2052            let server = wiremock::MockServer::start().await;
2053            let (client, sheets) = client(&server).await;
2054            mount_metadata(&server).await;
2055            let rules = vec![rule_allowing(&[granted], false)];
2056            let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
2057            match &outcome.result {
2058                TextToColumnsResult::Blocked { operation, .. } => {
2059                    assert_eq!(*operation, missing, "granted {granted}");
2060                }
2061                other => panic!("expected Blocked with {granted} granted, got {other:?}"),
2062            }
2063            assert!(describe_lines(&outcome)[0].contains(&missing.to_string()));
2064        }
2065    }
2066
2067    /// …and the union together does.
2068    #[tokio::test]
2069    async fn both_operations_together_open_the_gate() {
2070        let server = wiremock::MockServer::start().await;
2071        let (client, sheets) = client(&server).await;
2072        mount_metadata(&server).await;
2073        mount_values(
2074            &server,
2075            "'Q1'!A2:A4",
2076            serde_json::json!({"values": [["a,b"]]}),
2077        )
2078        .await;
2079        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2080        let rules = vec![rule_allowing(GATE_OPERATIONS, false)];
2081        let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
2082        assert!(matches!(
2083            outcome.result,
2084            TextToColumnsResult::WouldChange { .. }
2085        ));
2086    }
2087
2088    /// A `deny` entry — as opposed to the "no matching rule" default
2089    /// policy every other `Blocked` test exercises — names the deciding
2090    /// rule in the rendered message. `dry_run: false` also reaches
2091    /// `record_attempt`'s `Blocked` arm, which a dry run never does.
2092    #[tokio::test]
2093    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
2094        let server = wiremock::MockServer::start().await;
2095        let (client, sheets) = client(&server).await;
2096        mount_metadata(&server).await;
2097        let deny_by_rule = FolderPermissionRule {
2098            folder_id: Some("parent-1".to_string()),
2099            file_id: None,
2100            recursive: true,
2101            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
2102            deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
2103            require_lease: false,
2104        };
2105        let outcome = text_to_columns(&client, &sheets, &base_opts(false), &[deny_by_rule]).await;
2106        assert!(
2107            matches!(
2108                outcome.result,
2109                TextToColumnsResult::Blocked {
2110                    decided_by: Some(_),
2111                    ..
2112                }
2113            ),
2114            "{:?}",
2115            outcome.result
2116        );
2117        let text = describe(&outcome);
2118        assert!(
2119            text.contains("refused by rule on folder parent-1"),
2120            "{text}"
2121        );
2122    }
2123
2124    #[tokio::test]
2125    async fn open_ended_source_is_refused() {
2126        let server = wiremock::MockServer::start().await;
2127        let (client, sheets) = client(&server).await;
2128        mount_metadata(&server).await;
2129        let mut opts = base_opts(true);
2130        opts.source = Some("Q1!A:A".to_string());
2131        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2132        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2133        match outcome.result {
2134            TextToColumnsResult::RefusedInvalidRange { detail } => {
2135                assert!(detail.starts_with("Q1!A:A is open-ended"), "{detail}");
2136            }
2137            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2138        }
2139    }
2140
2141    #[tokio::test]
2142    async fn multi_column_source_is_refused() {
2143        let server = wiremock::MockServer::start().await;
2144        let (client, sheets) = client(&server).await;
2145        mount_metadata(&server).await;
2146        let mut opts = base_opts(true);
2147        opts.source = Some("Q1!A2:B4".to_string());
2148        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2149        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2150        match outcome.result {
2151            TextToColumnsResult::RefusedInvalidRange { detail } => {
2152                assert!(detail.contains("exactly one column"));
2153            }
2154            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2155        }
2156    }
2157
2158    #[tokio::test]
2159    async fn empty_custom_delimiter_is_refused_before_any_network_call() {
2160        let server = wiremock::MockServer::start().await;
2161        let (client, sheets) = client(&server).await;
2162        let mut opts = base_opts(true);
2163        opts.delimiter = Delimiter::Custom(String::new());
2164        let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2165        assert!(matches!(
2166            outcome.result,
2167            TextToColumnsResult::RefusedInvalidDelimiter { .. }
2168        ));
2169        assert!(server.received_requests().await.unwrap().is_empty());
2170        // Refused before any request, so there is no file name yet.
2171        assert!(outcome.file_name.is_none());
2172    }
2173
2174    #[tokio::test]
2175    async fn lease_required_and_absent_is_refused() {
2176        let server = wiremock::MockServer::start().await;
2177        let (client, sheets) = client(&server).await;
2178        mount_metadata(&server).await;
2179        mount_values(
2180            &server,
2181            "'Q1'!A2:A4",
2182            serde_json::json!({"values": [["a,b"]]}),
2183        )
2184        .await;
2185        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2186        let opts = base_opts(false);
2187        let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2188        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2189        assert!(matches!(
2190            outcome.result,
2191            TextToColumnsResult::RefusedNoLease
2192        ));
2193    }
2194
2195    #[tokio::test]
2196    async fn a_valid_lease_lets_the_real_run_succeed() {
2197        let ledger_dir = tempfile::tempdir().unwrap();
2198        let ledger_path = ledger_dir.path().join("leases.json");
2199        let token = seed_lease(&ledger_path, "sheet-1", "1");
2200
2201        let server = wiremock::MockServer::start().await;
2202        let (client, sheets) = client(&server).await;
2203        mount_metadata(&server).await;
2204        mount_values(
2205            &server,
2206            "'Q1'!A2:A4",
2207            serde_json::json!({"values": [["a,b"]]}),
2208        )
2209        .await;
2210        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2211        wiremock::Mock::given(wiremock::matchers::method("POST"))
2212            .and(wiremock::matchers::path(
2213                "/v4/spreadsheets/sheet-1:batchUpdate",
2214            ))
2215            .respond_with(
2216                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2217                    "spreadsheetId": "sheet-1", "replies": [{}],
2218                })),
2219            )
2220            .mount(&server)
2221            .await;
2222        wiremock::Mock::given(wiremock::matchers::method("PATCH"))
2223            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2224            .respond_with(
2225                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2226                    "id": "sheet-1", "name": "Budget", "version": "2",
2227                })),
2228            )
2229            .mount(&server)
2230            .await;
2231        let mut opts = base_opts(false);
2232        opts.lease_token = Some(token);
2233        opts.ledger_path = ledger_path;
2234        let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2235        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2236        assert!(matches!(
2237            outcome.result,
2238            TextToColumnsResult::Changed { .. }
2239        ));
2240    }
2241
2242    /// The lease-holding counterpart of `a_batch_update_failure_is_reported_as_failed`:
2243    /// with a lease actually granted, a failed `batchUpdate` must reach
2244    /// `conclude_native_leased_write`'s `Err` arm and record the failure
2245    /// against the held lease, not just report `Failed`.
2246    #[tokio::test]
2247    async fn a_valid_lease_records_the_failure_when_batch_update_fails() {
2248        let ledger_dir = tempfile::tempdir().unwrap();
2249        let ledger_path = ledger_dir.path().join("leases.json");
2250        let token = seed_lease(&ledger_path, "sheet-1", "1");
2251
2252        let server = wiremock::MockServer::start().await;
2253        let (client, sheets) = client(&server).await;
2254        mount_metadata(&server).await;
2255        mount_values(
2256            &server,
2257            "'Q1'!A2:A4",
2258            serde_json::json!({"values": [["a,b"]]}),
2259        )
2260        .await;
2261        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2262        mount_batch_update(&server, 400).await;
2263        let mut opts = base_opts(false);
2264        opts.lease_token = Some(token);
2265        opts.ledger_path = ledger_path;
2266        let rules = vec![rule(DriveOperation::SheetsWrite, true)];
2267        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2268        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2269    }
2270
2271    // ── the §6 "never a value" guard, end to end ─────────────────────────
2272
2273    /// The pinned form of ADR-0083 §6's rule for this verb: a
2274    /// distinctive value is fed through the *whole* path — the source
2275    /// read that computes the width, the spill read that names the
2276    /// overwritten cells, the rendered lines and the serialized
2277    /// outcome — and must appear in none of them. Asserting it against a
2278    /// hand-built outcome would only prove that the fields a test
2279    /// chose to fill are the fields it chose to read.
2280    #[tokio::test]
2281    async fn no_cell_value_from_either_read_reaches_the_output_or_the_outcome() {
2282        const SECRET: &str = "super-secret-cell-value";
2283        let server = wiremock::MockServer::start().await;
2284        let (client, sheets) = client(&server).await;
2285        mount_metadata(&server).await;
2286        mount_values(
2287            &server,
2288            "'Q1'!A2:A4",
2289            serde_json::json!({"values": [[format!("{SECRET},{SECRET}")]]}),
2290        )
2291        .await;
2292        mount_values(
2293            &server,
2294            "'Q1'!B2:B4",
2295            serde_json::json!({"values": [[SECRET]]}),
2296        )
2297        .await;
2298        let opts = base_opts(true);
2299        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2300        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2301
2302        // The spill cell was seen — its *address* is reported, so the
2303        // test is exercising the path that could leak, not an empty one.
2304        match &outcome.result {
2305            TextToColumnsResult::WouldChange {
2306                overwritten_cells, ..
2307            } => assert_eq!(overwritten_cells, &vec!["B2".to_string()]),
2308            other => panic!("expected WouldChange, got {other:?}"), // omni-dev: coverage ignore-line reason="this test's mocked responses always drive a WouldChange outcome; this catch-all guards that assumption and never runs"
2309        }
2310        let rendered = describe_lines(&outcome).join("\n");
2311        assert!(
2312            !rendered.contains(SECRET),
2313            "leaked into the text: {rendered}"
2314        );
2315        let serialized = serde_json::to_string(&outcome).unwrap();
2316        assert!(
2317            !serialized.contains(SECRET),
2318            "leaked into the outcome: {serialized}"
2319        );
2320    }
2321
2322    // ── the grid edge ────────────────────────────────────────────────────
2323
2324    /// The spill runs off the sheet's last column, so the *read* is
2325    /// clamped back onto the grid while the request is left alone —
2326    /// ADR-0083 §5's "left to the server", `auto_fill.rs`'s own
2327    /// `a_destination_straddling_the_grid_edge_reads_only_the_in_grid_part`.
2328    #[tokio::test]
2329    async fn a_spill_straddling_the_grid_edge_reads_only_the_in_grid_part() {
2330        let server = wiremock::MockServer::start().await;
2331        let (client, sheets) = client(&server).await;
2332        mount_file_metadata(&server, spreadsheet_metadata()).await;
2333        mount_parent(&server).await;
2334        mount_workbook(&server, 1000, 3).await; // columns A..C only
2335        mount_values(
2336            &server,
2337            "'Q1'!A2:A4",
2338            serde_json::json!({"values": [["a,b,c,d,e"]]}),
2339        )
2340        .await;
2341        // Width 5 spills into B:E, but only B:C exist — the read is
2342        // clamped to those, and a cell in the clamped part still counts.
2343        mount_values(
2344            &server,
2345            "'Q1'!B2:C4",
2346            serde_json::json!({"values": [["", "old"]]}),
2347        )
2348        .await;
2349        let opts = base_opts(true);
2350        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2351        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2352        match &outcome.result {
2353            TextToColumnsResult::WouldChange {
2354                spill,
2355                width_upper_bound,
2356                overwritten_cells,
2357                past_grid_extent,
2358                ..
2359            } => {
2360                // The reported spill is the *unclamped* span: what the
2361                // request may reach, not what could be read back.
2362                assert_eq!(spill.as_deref(), Some("'Q1'!B2:E4"));
2363                assert_eq!(*width_upper_bound, 5);
2364                assert_eq!(overwritten_cells, &vec!["C2".to_string()]);
2365                assert!(past_grid_extent);
2366            }
2367            other => panic!("expected WouldChange, got {other:?}"),
2368        }
2369        assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT_DRY_RUN.to_string()));
2370    }
2371
2372    /// Nothing of the spill is inside the grid, so there is no read to
2373    /// clamp — the caveat still fires and the request is still sent.
2374    #[tokio::test]
2375    async fn a_spill_wholly_past_the_grid_extent_skips_the_read_and_still_splits() {
2376        let server = wiremock::MockServer::start().await;
2377        let (client, sheets) = client(&server).await;
2378        mount_file_metadata(&server, spreadsheet_metadata()).await;
2379        mount_parent(&server).await;
2380        mount_workbook(&server, 1000, 3).await; // columns A..C only
2381        mount_values(
2382            &server,
2383            "'Q1'!C2:C4",
2384            serde_json::json!({"values": [["a,b"]]}),
2385        )
2386        .await;
2387        mount_batch_update(&server, 200).await;
2388        let mut opts = base_opts(false);
2389        opts.source = Some("Q1!C2:C4".to_string());
2390        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2391        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2392        match &outcome.result {
2393            TextToColumnsResult::Changed {
2394                spill,
2395                overwritten_cells,
2396                past_grid_extent,
2397                ..
2398            } => {
2399                assert_eq!(spill.as_deref(), Some("'Q1'!D2:D4"));
2400                assert!(overwritten_cells.is_empty());
2401                assert!(past_grid_extent);
2402            }
2403            other => panic!("expected Changed, got {other:?}"), // omni-dev: coverage ignore-line reason="this test's mocked responses always drive a Changed outcome; this catch-all guards that assumption and never runs"
2404        }
2405        // Only the source was read: there was no in-grid spill to ask about.
2406        let reads: Vec<_> = server
2407            .received_requests()
2408            .await
2409            .unwrap()
2410            .into_iter()
2411            .filter(|r| r.url.path().contains("/values/"))
2412            .collect();
2413        assert_eq!(reads.len(), 1);
2414        assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT.to_string()));
2415    }
2416
2417    // ── failure paths ────────────────────────────────────────────────────
2418
2419    #[tokio::test]
2420    async fn a_source_read_failure_is_reported_as_failed() {
2421        let server = wiremock::MockServer::start().await;
2422        let (client, sheets) = client(&server).await;
2423        mount_metadata(&server).await;
2424        mount_values_failure(&server, "'Q1'!A2:A4").await;
2425        let outcome = text_to_columns(
2426            &client,
2427            &sheets,
2428            &base_opts(true),
2429            &[rule(DriveOperation::SheetsWrite, false)],
2430        )
2431        .await;
2432        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2433    }
2434
2435    #[tokio::test]
2436    async fn a_spill_read_failure_is_reported_as_failed() {
2437        let server = wiremock::MockServer::start().await;
2438        let (client, sheets) = client(&server).await;
2439        mount_metadata(&server).await;
2440        mount_values(
2441            &server,
2442            "'Q1'!A2:A4",
2443            serde_json::json!({"values": [["a,b"]]}),
2444        )
2445        .await;
2446        mount_values_failure(&server, "'Q1'!B2:B4").await;
2447        let outcome = text_to_columns(
2448            &client,
2449            &sheets,
2450            &base_opts(true),
2451            &[rule(DriveOperation::SheetsWrite, false)],
2452        )
2453        .await;
2454        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2455    }
2456
2457    #[tokio::test]
2458    async fn text_to_columns_with_an_unreadable_reply_reports_applied_reply_unreadable() {
2459        let server = wiremock::MockServer::start().await;
2460        let (client, sheets) = client(&server).await;
2461        mount_metadata(&server).await;
2462        mount_values(
2463            &server,
2464            "'Q1'!A2:A4",
2465            serde_json::json!({"values": [["a,b"]]}),
2466        )
2467        .await;
2468        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2469        // 2xx, so the split WAS applied — but the body is not the reply shape.
2470        wiremock::Mock::given(wiremock::matchers::method("POST"))
2471            .and(wiremock::matchers::path(
2472                "/v4/spreadsheets/sheet-1:batchUpdate",
2473            ))
2474            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
2475            .expect(1)
2476            .mount(&server)
2477            .await;
2478        let outcome = text_to_columns(
2479            &client,
2480            &sheets,
2481            &base_opts(false),
2482            &[rule(DriveOperation::SheetsWrite, false)],
2483        )
2484        .await;
2485        assert_eq!(
2486            outcome.result.log_status(),
2487            "applied-reply-unreadable",
2488            "{outcome:?}"
2489        );
2490        assert!(
2491            describe(&outcome).starts_with("Applied, but the reply could not be read"),
2492            "{}",
2493            describe(&outcome)
2494        );
2495    }
2496
2497    fn unreadable_outcome(
2498        overwritten_cells: Vec<String>,
2499        past_grid_extent: bool,
2500    ) -> TextToColumnsOutcome {
2501        let mut outcome = would_change_outcome(Vec::new(), false);
2502        outcome.result = TextToColumnsResult::AppliedReplyUnreadable {
2503            summary: "split 'Q1'!A2:A4 on comma".to_string(),
2504            detail: "bad reply".to_string(),
2505            source: "'Q1'!A2:A4".to_string(),
2506            spill: Some("'Q1'!B2:B4".to_string()),
2507            width_upper_bound: 2,
2508            overwritten_cells,
2509            past_grid_extent,
2510        };
2511        outcome
2512    }
2513
2514    #[test]
2515    fn describe_lines_renders_applied_reply_unreadable_with_the_plan_time_lines() {
2516        let outcome = unreadable_outcome(vec!["B2".to_string()], true);
2517        let lines = describe_lines(&outcome);
2518        assert_eq!(
2519            lines[0],
2520            "Applied, but the reply could not be read: split 'Q1'!A2:A4 on comma in \
2521             'Budget' — do not retry; check the spreadsheet first (bad reply)"
2522        );
2523        assert_eq!(
2524            lines[1..],
2525            [
2526                "  up to 1 non-blank cell(s) were overwritten: B2".to_string(),
2527                PAST_GRID_EXTENT_CAVEAT.to_string(),
2528                UNPREVIEWABLE_SPLIT_CAVEAT.to_string(),
2529            ]
2530        );
2531        // Everything after the head is what `Changed` prints, unchanged.
2532        let mut changed = outcome;
2533        changed.result = TextToColumnsResult::Changed {
2534            summary: "split 'Q1'!A2:A4 on comma".to_string(),
2535            source: "'Q1'!A2:A4".to_string(),
2536            spill: Some("'Q1'!B2:B4".to_string()),
2537            width_upper_bound: 2,
2538            overwritten_cells: vec!["B2".to_string()],
2539            past_grid_extent: true,
2540        };
2541        assert_eq!(lines[1..], describe_lines(&changed)[1..]);
2542    }
2543
2544    #[test]
2545    fn the_unreadable_reply_logs_everything_changed_logs() {
2546        let outcome = unreadable_outcome(vec!["B2".to_string(), "B3".to_string()], false);
2547        let logged = mutation_outcome(&outcome, Duration::ZERO);
2548        assert_eq!(logged.status, "applied-reply-unreadable");
2549        assert_eq!(logged.sheet_id, Some(0));
2550        assert_eq!(logged.range.as_deref(), Some("'Q1'!A2:A4"));
2551        assert_eq!(
2552            logged.fields_changed.as_deref(),
2553            Some("split 'Q1'!A2:A4 on comma")
2554        );
2555        assert_eq!(logged.overwritten_cells, vec!["B2", "B3"]);
2556        assert!(logged.overwritten_cells_upper_bound);
2557        assert_eq!(logged.error.as_deref(), Some("bad reply"));
2558    }
2559
2560    #[tokio::test]
2561    async fn a_batch_update_failure_is_reported_as_failed() {
2562        let server = wiremock::MockServer::start().await;
2563        let (client, sheets) = client(&server).await;
2564        mount_metadata(&server).await;
2565        mount_values(
2566            &server,
2567            "'Q1'!A2:A4",
2568            serde_json::json!({"values": [["a,b"]]}),
2569        )
2570        .await;
2571        mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2572        mount_batch_update(&server, 400).await;
2573        let outcome = text_to_columns(
2574            &client,
2575            &sheets,
2576            &base_opts(false),
2577            &[rule(DriveOperation::SheetsWrite, false)],
2578        )
2579        .await;
2580        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2581    }
2582
2583    #[tokio::test]
2584    async fn a_workbook_fetch_failure_is_reported_as_failed() {
2585        let server = wiremock::MockServer::start().await;
2586        let (client, sheets) = client(&server).await;
2587        mount_file_metadata(&server, spreadsheet_metadata()).await;
2588        mount_parent(&server).await;
2589        wiremock::Mock::given(wiremock::matchers::method("GET"))
2590            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2591            .respond_with(wiremock::ResponseTemplate::new(500))
2592            .mount(&server)
2593            .await;
2594        let outcome = text_to_columns(
2595            &client,
2596            &sheets,
2597            &base_opts(true),
2598            &[rule(DriveOperation::SheetsWrite, false)],
2599        )
2600        .await;
2601        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2602    }
2603
2604    /// The target's own metadata fetch fails, before there is even a
2605    /// file name to report.
2606    #[tokio::test]
2607    async fn a_metadata_fetch_failure_is_reported_as_failed() {
2608        let server = wiremock::MockServer::start().await;
2609        let (client, sheets) = client(&server).await;
2610        wiremock::Mock::given(wiremock::matchers::method("GET"))
2611            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2612            .respond_with(wiremock::ResponseTemplate::new(500))
2613            .mount(&server)
2614            .await;
2615        let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2616        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2617        assert!(outcome.file_name.is_none());
2618    }
2619
2620    /// The parent lookup the gate needs fails, *after* the target
2621    /// resolved — so the file name is known and the folder id is not.
2622    #[tokio::test]
2623    async fn a_gate_fetch_failure_is_reported_as_failed_and_still_names_the_file() {
2624        let server = wiremock::MockServer::start().await;
2625        let (client, sheets) = client(&server).await;
2626        mount_file_metadata(&server, spreadsheet_metadata()).await;
2627        wiremock::Mock::given(wiremock::matchers::method("GET"))
2628            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
2629            .respond_with(wiremock::ResponseTemplate::new(500))
2630            .mount(&server)
2631            .await;
2632        let outcome = text_to_columns(
2633            &client,
2634            &sheets,
2635            &base_opts(true),
2636            &[rule(DriveOperation::SheetsWrite, false)],
2637        )
2638        .await;
2639        assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
2640        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
2641        assert!(outcome.resolved_folder_id.is_none());
2642    }
2643
2644    // ── target refusals ──────────────────────────────────────────────────
2645
2646    #[tokio::test]
2647    async fn a_target_that_is_not_a_spreadsheet_is_refused() {
2648        let server = wiremock::MockServer::start().await;
2649        let (client, sheets) = client(&server).await;
2650        mount_file_metadata(
2651            &server,
2652            serde_json::json!({
2653                "id": "sheet-1", "name": "Budget",
2654                "mimeType": "application/vnd.google-apps.document",
2655                "parents": ["parent-1"], "version": "1",
2656            }),
2657        )
2658        .await;
2659        let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2660        match &outcome.result {
2661            TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => {
2662                assert_eq!(mime_type, "application/vnd.google-apps.document");
2663            }
2664            other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
2665        }
2666        assert!(describe_lines(&outcome)[0].contains("is not a Google Sheet"));
2667    }
2668
2669    #[tokio::test]
2670    async fn a_shortcut_target_is_refused_rather_than_followed() {
2671        let server = wiremock::MockServer::start().await;
2672        let (client, sheets) = client(&server).await;
2673        mount_file_metadata(
2674            &server,
2675            serde_json::json!({
2676                "id": "sheet-1", "name": "Budget",
2677                "mimeType": "application/vnd.google-apps.shortcut",
2678                "parents": ["parent-1"], "version": "1",
2679                "shortcutDetails": {
2680                    "targetId": "real-sheet",
2681                    "targetMimeType": "application/vnd.google-apps.spreadsheet",
2682                },
2683            }),
2684        )
2685        .await;
2686        let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2687        assert!(matches!(
2688            outcome.result,
2689            TextToColumnsResult::RefusedShortcut
2690        ));
2691        assert!(describe_lines(&outcome)[0].contains("doesn't follow shortcuts"));
2692    }
2693
2694    #[tokio::test]
2695    async fn a_target_with_no_visible_parents_is_refused_with_the_by_id_hint() {
2696        let server = wiremock::MockServer::start().await;
2697        let (client, sheets) = client(&server).await;
2698        mount_file_metadata(
2699            &server,
2700            serde_json::json!({
2701                "id": "sheet-1", "name": "Budget",
2702                "mimeType": "application/vnd.google-apps.spreadsheet",
2703                "parents": [], "version": "1",
2704            }),
2705        )
2706        .await;
2707        let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
2708        assert!(matches!(
2709            outcome.result,
2710            TextToColumnsResult::RefusedNoVisibleParents
2711        ));
2712        assert!(describe_lines(&outcome)[0].contains("write_permissions.rules"));
2713    }
2714
2715    #[tokio::test]
2716    async fn a_source_on_a_missing_sheet_is_refused_and_names_the_available_titles() {
2717        let server = wiremock::MockServer::start().await;
2718        let (client, sheets) = client(&server).await;
2719        mount_metadata(&server).await;
2720        let mut opts = base_opts(true);
2721        opts.source = Some("Q9!A2:A4".to_string());
2722        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2723        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2724        match &outcome.result {
2725            TextToColumnsResult::RefusedSheetNotFound { title, available } => {
2726                assert_eq!(title, "Q9");
2727                assert_eq!(available, &vec!["Q1".to_string()]);
2728            }
2729            other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2730        }
2731    }
2732
2733    // ── lease refusals ───────────────────────────────────────────────────
2734
2735    fn ledger_in_a_tempdir() -> PathBuf {
2736        tempfile::tempdir()
2737            .unwrap()
2738            .keep()
2739            .join("lease-ledger.jsonl")
2740    }
2741
2742    /// Runs a real (non-dry) split against a fixture whose reads all
2743    /// succeed, so the only thing left to refuse is the lease — and
2744    /// asserts the refusal happened before `batchUpdate`, which a
2745    /// mis-ordered gate would not.
2746    async fn run_with_lease(
2747        server: &wiremock::MockServer,
2748        lease_token: Option<String>,
2749        ledger_path: PathBuf,
2750    ) -> TextToColumnsOutcome {
2751        let (client, sheets) = client(server).await;
2752        mount_metadata(server).await;
2753        mount_values(
2754            server,
2755            "'Q1'!A2:A4",
2756            serde_json::json!({"values": [["a,b"]]}),
2757        )
2758        .await;
2759        mount_values(server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
2760        mount_batch_update(server, 200).await;
2761        let opts = TextToColumnsOptions {
2762            lease_token,
2763            ledger_path,
2764            ..base_opts(false)
2765        };
2766        let outcome = text_to_columns(
2767            &client,
2768            &sheets,
2769            &opts,
2770            &[rule(DriveOperation::SheetsWrite, true)],
2771        )
2772        .await;
2773        assert!(
2774            !batch_update_was_called(&server.received_requests().await.unwrap()),
2775            "a refused lease must not reach batchUpdate"
2776        );
2777        outcome
2778    }
2779
2780    /// A `--sheet` that every range self-prefixes past cannot apply to
2781    /// anything, so it is refused rather than silently ignored — the
2782    /// pure `a1::compose` check that runs before any request.
2783    #[tokio::test]
2784    async fn a_sheet_that_conflicts_with_a_prefixed_source_is_refused_before_any_request() {
2785        let server = wiremock::MockServer::start().await;
2786        let (client, sheets) = client(&server).await;
2787        let mut opts = base_opts(true);
2788        opts.sheet = Some("Q1".to_string());
2789        opts.source = Some("'Q2'!A2:A4".to_string());
2790        let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
2791        assert!(matches!(
2792            outcome.result,
2793            TextToColumnsResult::RefusedInvalidRange { .. }
2794        ));
2795        assert!(server.received_requests().await.unwrap().is_empty());
2796    }
2797
2798    /// A range the resolver cannot parse at all, as opposed to one it
2799    /// parses and this module then refuses for being open-ended or too
2800    /// wide — a different arm, with the resolver's own message.
2801    #[tokio::test]
2802    async fn an_unparseable_source_is_refused_with_the_resolvers_own_detail() {
2803        let server = wiremock::MockServer::start().await;
2804        let (client, sheets) = client(&server).await;
2805        mount_metadata(&server).await;
2806        let mut opts = base_opts(true);
2807        opts.source = Some("Q1!not-a-range".to_string());
2808        let rules = vec![rule(DriveOperation::SheetsWrite, false)];
2809        let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
2810        match &outcome.result {
2811            TextToColumnsResult::RefusedInvalidRange { detail } => {
2812                assert!(
2813                    !detail.is_empty(),
2814                    "the resolver's detail is passed through"
2815                );
2816            }
2817            other => panic!("expected RefusedInvalidRange, got {other:?}"),
2818        }
2819    }
2820
2821    /// No row splits under the chosen delimiter, so there is no spill
2822    /// span to read or report — but the request is still sent, since
2823    /// the server's own splitting rule may differ from this preview's.
2824    #[tokio::test]
2825    async fn a_source_that_never_splits_reads_no_spill_and_still_sends_the_request() {
2826        let server = wiremock::MockServer::start().await;
2827        let (client, sheets) = client(&server).await;
2828        mount_metadata(&server).await;
2829        mount_values(
2830            &server,
2831            "'Q1'!A2:A4",
2832            serde_json::json!({"values": [["no separator here"]]}),
2833        )
2834        .await;
2835        mount_batch_update(&server, 200).await;
2836        let outcome = text_to_columns(
2837            &client,
2838            &sheets,
2839            &base_opts(false),
2840            &[rule(DriveOperation::SheetsWrite, false)],
2841        )
2842        .await;
2843        match &outcome.result {
2844            TextToColumnsResult::Changed {
2845                summary,
2846                spill,
2847                width_upper_bound,
2848                overwritten_cells,
2849                past_grid_extent,
2850                ..
2851            } => {
2852                assert_eq!(*width_upper_bound, 1);
2853                assert!(spill.is_none());
2854                assert!(overwritten_cells.is_empty());
2855                assert!(!past_grid_extent);
2856                assert!(summary.contains("no row spills beyond the source"));
2857            }
2858            other => panic!("expected Changed, got {other:?}"), // omni-dev: coverage ignore-line reason="this test's mocked responses always drive a Changed outcome; this catch-all guards that assumption and never runs"
2859        }
2860        let reads: Vec<_> = server
2861            .received_requests()
2862            .await
2863            .unwrap()
2864            .into_iter()
2865            .filter(|r| r.url.path().contains("/values/"))
2866            .collect();
2867        assert_eq!(reads.len(), 1, "no spill span means no second read");
2868    }
2869
2870    #[tokio::test]
2871    async fn an_expired_or_unknown_lease_is_refused() {
2872        let server = wiremock::MockServer::start().await;
2873        // A token this ledger has never heard of is the same refusal as
2874        // an expired one (ADR-0080 §1).
2875        let outcome = run_with_lease(
2876            &server,
2877            Some("never-issued".to_string()),
2878            ledger_in_a_tempdir(),
2879        )
2880        .await;
2881        assert!(matches!(
2882            outcome.result,
2883            TextToColumnsResult::RefusedLeaseExpired
2884        ));
2885    }
2886
2887    #[tokio::test]
2888    async fn a_lease_bound_to_another_file_is_refused() {
2889        let server = wiremock::MockServer::start().await;
2890        let ledger_path = ledger_in_a_tempdir();
2891        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2892        let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2893        assert!(matches!(
2894            outcome.result,
2895            TextToColumnsResult::RefusedLeaseWrongFile
2896        ));
2897    }
2898
2899    #[tokio::test]
2900    async fn a_stale_lease_is_refused_when_the_file_has_moved() {
2901        let server = wiremock::MockServer::start().await;
2902        let ledger_path = ledger_in_a_tempdir();
2903        // The fixture reports version "1"; the lease recorded "0", so
2904        // the file has moved under it — ADR-0080 §6's staleness check.
2905        let token = seed_lease(&ledger_path, "sheet-1", "0");
2906        let outcome = run_with_lease(&server, Some(token), ledger_path).await;
2907        assert!(matches!(
2908            outcome.result,
2909            TextToColumnsResult::RefusedLeaseStale
2910        ));
2911    }
2912}