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