Skip to main content

omni_dev/drive/sheets/
trim_whitespace.rs

1//! `trim-whitespace` — trim whitespace in every cell of a range (issue
2//! #1844).
3//!
4//! [ADR-0083](../../../docs/adrs/adr-0083.md) §1 places this under
5//! `DriveOperation::SheetsWrite`: it rewrites ordinary cell content in
6//! place, which a `sheets clear` followed by a `sheets write` of the same
7//! range could already do under that grant.
8//!
9//! That placement is measured, not provisional: issue #1877 ran §5's live
10//! formatting check and nothing moved. Backgrounds, text and number
11//! formats, borders, notes and validation rules all survive a trim, and
12//! cells the trim skips are left untouched. Rich-text runs are rebased by
13//! the API so every character that remains keeps its own formatting;
14//! formatting on removed whitespace goes with it. That is gentler than a
15//! plain `sheets write`, which drops those runs outright (§5's #1877
16//! addendum).
17//!
18//! The trim rule, measured live against the API (issue #1844): leading
19//! and trailing whitespace is stripped **and each internal run collapses
20//! to a single space** (`a   b` becomes `a b`), a cell of nothing but
21//! whitespace becomes blank, a formula's text is untouched, and text that
22//! trims to something starting `=` or `+` stays a string rather than
23//! becoming a formula.
24//!
25//! **The preview never predicts which cells change.** §6 puts this verb in
26//! the first `--dry-run` tier — one bounded read, reporting the *count and
27//! A1 locations* of the range's non-blank cells and never their values —
28//! but stops short of naming the cells that *would* change, because
29//! deciding that means reproducing the server's trim rule locally, the
30//! same "preview that lies" risk §6 declined for `findReplace`. So every
31//! non-blank cell is a candidate and the wording is always "may be
32//! trimmed". The gap is real, not theoretical: a live run over a 13-cell
33//! range previewed all 13 as candidates and the server changed 5.
34//!
35//! **The read happens on `--dry-run` only**, unlike `auto_fill.rs` and
36//! `paste.rs`, which read on both paths so their real runs can report the
37//! same cells. This verb's real run has something strictly better: the
38//! server's own `cellsChangedCount`, an exact count of what it actually
39//! changed. Reading the range again would spend a call to print a worse
40//! number beside the good one.
41
42#![allow(missing_docs)] // The CLI-facing outcome models are self-describing in JSON.
43
44use std::path::PathBuf;
45use std::time::{Duration, Instant};
46
47use serde::Serialize;
48
49use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
50use crate::drive::client::DriveClient;
51use crate::drive::files_api::FilesApi;
52use crate::drive::lease::check::{
53    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
54    LeasedWrite,
55};
56use crate::drive::sheets::api::{
57    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
58};
59use crate::drive::sheets::client::SheetsClient;
60use crate::drive::sheets::types::{
61    BatchUpdateRequestItem, GridRange, Sheet, Spreadsheet, TrimWhitespaceRequest,
62};
63use crate::drive::sheets::{a1, grid_range, target_gate};
64use crate::drive::types::SheetTargetRefusal;
65use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
66use crate::request_log::{self, DriveMutationOutcome};
67
68const LOG_OPERATION: &str = "sheets-trim-whitespace";
69
70/// Which cells the caller named.
71///
72/// `--whole-sheet` is offered here but deliberately *not* on
73/// `delete-duplicates` — see that module's docs for why a whole-sheet
74/// dedupe is a different proposition from a whole-sheet trim.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum TrimScope {
77    /// A `--range`, already carrying its own `Sheet!` prefix or supplied
78    /// one from `--sheet`. May be open-ended (`A:A`); open bounds are
79    /// materialised from the sheet's grid extent.
80    Range,
81    /// `--whole-sheet`: every cell of the sheet named by `--sheet`.
82    WholeSheet,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct TrimWhitespaceOptions {
87    pub spreadsheet_id: String,
88    pub sheet: Option<String>,
89    pub range: Option<String>,
90    pub scope: TrimScope,
91    pub dry_run: bool,
92    pub lease_token: Option<String>,
93    pub ledger_path: PathBuf,
94}
95
96#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
97#[serde(tag = "status", rename_all = "kebab-case")]
98pub enum TrimWhitespaceResult {
99    WouldChange {
100        /// The range as sent, with any open bound materialised.
101        range: String,
102        /// Every non-blank cell in the range, as bare A1 addresses —
103        /// **never their values**. An upper bound on what the server will
104        /// actually trim, never a prediction: see the module docs.
105        candidate_cells: Vec<String>,
106        /// The candidate list is always an upper bound, including when it
107        /// is empty. The server alone decides which cells actually change.
108        candidate_cells_upper_bound: bool,
109        /// The values read covered only the part of the requested range
110        /// inside the sheet's currently allocated grid.
111        read_clamped_to_sheet: bool,
112    },
113    Changed {
114        range: String,
115        /// The server's own count. `None` when the reply carried no
116        /// `trimWhitespace` object — `find-replace`'s precedent for a
117        /// response that succeeded but reported nothing.
118        cells_changed_count: Option<i64>,
119    },
120    RefusedInvalidRequest {
121        detail: String,
122    },
123    RefusedNotASpreadsheet {
124        mime_type: String,
125    },
126    RefusedShortcut,
127    RefusedNoVisibleParents,
128    RefusedSheetNotFound {
129        title: String,
130        available: Vec<String>,
131    },
132    Blocked {
133        decided_by: Option<DecidingRule>,
134    },
135    RefusedNoLease,
136    RefusedLeaseExpired,
137    RefusedLeaseWrongFile,
138    RefusedLeaseStale,
139    /// The API answered 2xx — the trim **was applied** — but its reply could
140    /// not be read (issue #2021). Distinct from [`Self::Failed`]. The server's
141    /// `cellsChangedCount` was in that reply, so it is unknown.
142    AppliedReplyUnreadable {
143        range: String,
144        /// What was applied, in the same terms as a `Changed` line.
145        summary: String,
146        /// Why the reply could not be read.
147        detail: String,
148    },
149    Failed {
150        detail: String,
151    },
152}
153
154impl FromLeaseRefusal for TrimWhitespaceResult {
155    fn from_no_lease() -> Self {
156        Self::RefusedNoLease
157    }
158    fn from_lease_expired() -> Self {
159        Self::RefusedLeaseExpired
160    }
161    fn from_lease_wrong_file() -> Self {
162        Self::RefusedLeaseWrongFile
163    }
164    fn from_lease_stale() -> Self {
165        Self::RefusedLeaseStale
166    }
167    fn from_lease_failed(detail: String) -> Self {
168        Self::Failed { detail }
169    }
170}
171
172impl TrimWhitespaceResult {
173    fn log_status(&self) -> &'static str {
174        match self {
175            Self::WouldChange { .. } => "would-change",
176            Self::Changed { .. } => "changed",
177            Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
178            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
179            Self::RefusedShortcut => "refused-shortcut",
180            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
181            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
182            Self::Blocked { .. } => "blocked",
183            Self::RefusedNoLease => "refused-no-lease",
184            Self::RefusedLeaseExpired => "refused-lease-expired",
185            Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
186            Self::RefusedLeaseStale => "refused-lease-stale",
187            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
188            Self::Failed { .. } => "failed",
189        }
190    }
191}
192
193#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
194pub struct TrimWhitespaceOutcome {
195    pub spreadsheet_id: String,
196    pub file_name: Option<String>,
197    pub resolved_folder_id: Option<String>,
198    pub result: TrimWhitespaceResult,
199}
200
201impl JsonlSerialize for TrimWhitespaceOutcome {
202    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
203        write_scalar_jsonl(self, out)
204    }
205}
206
207pub async fn trim_whitespace(
208    drive: &DriveClient,
209    sheets: &SheetsClient,
210    opts: &TrimWhitespaceOptions,
211    rules: &[FolderPermissionRule],
212) -> TrimWhitespaceOutcome {
213    let started = Instant::now();
214    let outcome = trim_whitespace_inner(drive, sheets, opts, rules).await;
215    if !opts.dry_run {
216        record_attempt(&outcome, started.elapsed());
217    }
218    outcome
219}
220
221async fn trim_whitespace_inner(
222    drive: &DriveClient,
223    sheets: &SheetsClient,
224    opts: &TrimWhitespaceOptions,
225    rules: &[FolderPermissionRule],
226) -> TrimWhitespaceOutcome {
227    let bare = |result| TrimWhitespaceOutcome {
228        spreadsheet_id: opts.spreadsheet_id.clone(),
229        file_name: None,
230        resolved_folder_id: None,
231        result,
232    };
233    if let Err(detail) = validate_scope_syntax(opts) {
234        return bare(TrimWhitespaceResult::RefusedInvalidRequest { detail });
235    }
236    // Composed only for the `Range` scope: `--whole-sheet` resolves its
237    // sheet id by title instead, so there is no range string to compose.
238    let composed = match opts.scope {
239        TrimScope::WholeSheet => None,
240        TrimScope::Range => match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
241            Ok(range) => Some(range),
242            Err(err) => {
243                return bare(TrimWhitespaceResult::RefusedInvalidRequest {
244                    detail: err.to_string(),
245                })
246            }
247        },
248    };
249    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
250        drive,
251        &opts.spreadsheet_id,
252        DriveOperation::SheetsWrite,
253        rules,
254    )
255    .await
256    {
257        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
258            return bare(TrimWhitespaceResult::Failed { detail })
259        }
260        target_gate::TargetGateOutcome::Refused { target, refusal } => {
261            let result = match refusal {
262                SheetTargetRefusal::Shortcut => TrimWhitespaceResult::RefusedShortcut,
263                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
264                    TrimWhitespaceResult::RefusedNotASpreadsheet { mime_type }
265                }
266                SheetTargetRefusal::NoVisibleParents => {
267                    TrimWhitespaceResult::RefusedNoVisibleParents
268                }
269            };
270            return TrimWhitespaceOutcome {
271                spreadsheet_id: opts.spreadsheet_id.clone(),
272                file_name: Some(target.name),
273                resolved_folder_id: None,
274                result,
275            };
276        }
277        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
278            return TrimWhitespaceOutcome {
279                spreadsheet_id: opts.spreadsheet_id.clone(),
280                file_name: Some(target.name),
281                resolved_folder_id: None,
282                result: TrimWhitespaceResult::Failed { detail },
283            }
284        }
285        target_gate::TargetGateOutcome::Gated {
286            target,
287            decision,
288            resolved_folder_id,
289            requires_lease,
290        } => (target, decision, resolved_folder_id, requires_lease),
291    };
292    let gated = |result| TrimWhitespaceOutcome {
293        spreadsheet_id: opts.spreadsheet_id.clone(),
294        file_name: Some(target.name.clone()),
295        resolved_folder_id: resolved_folder_id.clone(),
296        result,
297    };
298    if decision.verdict == write_gate::Verdict::Deny {
299        return gated(TrimWhitespaceResult::Blocked {
300            decided_by: decision.decided_by,
301        });
302    }
303    let api = SheetsApi::new(sheets);
304    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
305        Ok(book) => book,
306        Err(err) => {
307            return gated(TrimWhitespaceResult::Failed {
308                detail: format!("{err:#}"),
309            })
310        }
311    };
312    let (sheet_title, grid) = match resolve_scope(opts, composed.as_deref(), &workbook) {
313        Ok(value) => value,
314        Err(result) => return gated(result),
315    };
316    // Open bounds are materialised from the sheet's own extent so the
317    // request, the reported range and the preview read all describe the
318    // same rectangle. A `--whole-sheet` request could equally be sent with
319    // every bound absent, but then the outcome could not name what it
320    // acted on.
321    let grid = materialise_bounds(
322        &grid,
323        grid_range::find_sheet_by_id(&workbook, grid.sheet_id),
324    );
325    let Some(range_a1) = grid_range::bounded_range_to_a1(&sheet_title, &grid) else {
326        return gated(TrimWhitespaceResult::RefusedInvalidRequest {
327            detail: format!(
328                "'{}' resolves to no cells in the sheet's current grid",
329                composed.as_deref().unwrap_or(&sheet_title)
330            ),
331        });
332    };
333
334    // Built before the gate, not after — #1688/#1742's ordering invariant,
335    // shared verbatim by every leased engine.
336    let request = BatchUpdateRequestItem::TrimWhitespace(TrimWhitespaceRequest { range: grid });
337
338    if opts.dry_run {
339        // A bounded request can still extend past the allocated grid. The
340        // mutation keeps the caller's range, but values.get must read only
341        // its intersection with the sheet's current cells.
342        let Some(read_grid) = grid_range::clamp_to_sheet(&workbook, &grid) else {
343            return gated(TrimWhitespaceResult::RefusedInvalidRequest {
344                detail: format!("{range_a1} has no cells inside the sheet's current grid"),
345            });
346        };
347        let Some(read_a1) = grid_range::bounded_range_to_a1(&sheet_title, &read_grid) else {
348            return gated(TrimWhitespaceResult::RefusedInvalidRequest {
349                detail: format!("{range_a1} has no cells inside the sheet's current grid"),
350            });
351        };
352        let candidate_cells =
353            match read_non_blank(&api, &opts.spreadsheet_id, &read_a1, &read_grid).await {
354                Ok(cells) => cells,
355                Err(detail) => return gated(TrimWhitespaceResult::Failed { detail }),
356            };
357        return gated(TrimWhitespaceResult::WouldChange {
358            range: range_a1,
359            candidate_cells,
360            candidate_cells_upper_bound: true,
361            read_clamped_to_sheet: read_grid != grid,
362        });
363    }
364
365    let leased = LeasedWrite {
366        log_prefix: "drive sheets trim-whitespace",
367        operation: LOG_OPERATION,
368        ledger_path: &opts.ledger_path,
369        file_id: &opts.spreadsheet_id,
370    };
371    let files_api = FilesApi::new(drive);
372    let lease_grant = match gate_optional_leased_write(
373        leased,
374        &files_api,
375        requires_lease,
376        opts.lease_token.as_deref(),
377    )
378    .await
379    {
380        Ok(grant) => grant,
381        Err(err) => return gated(err.into_result()),
382    };
383    let result = match conclude_native_leased_write(
384        leased,
385        &lease_grant,
386        &files_api,
387        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
388        |err| format!("{err:#}"),
389    )
390    .await
391    {
392        Ok(BatchUpdateOutcome::Applied(response)) => TrimWhitespaceResult::Changed {
393            range: range_a1,
394            cells_changed_count: response
395                .replies
396                .into_iter()
397                .next()
398                .and_then(|reply| reply.trim_whitespace)
399                .map(|trim| trim.cells_changed_count),
400        },
401        // Idempotent (`RetryHint::Idempotent`): trimming an already-trimmed
402        // range changes nothing, so a re-run is safe.
403        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
404            TrimWhitespaceResult::AppliedReplyUnreadable {
405                summary: format!("trim whitespace in {range_a1}"),
406                range: range_a1,
407                detail,
408            }
409        }
410        Err(err) => TrimWhitespaceResult::Failed {
411            detail: format!("{err:#}"),
412        },
413    };
414    drop(lease_grant);
415    gated(result)
416}
417
418/// Refuses the scope combinations clap cannot express on its own, so the
419/// engine is safe for a non-clap caller too — `find_replace.rs`'s pattern.
420fn validate_scope_syntax(opts: &TrimWhitespaceOptions) -> Result<(), String> {
421    match opts.scope {
422        TrimScope::WholeSheet => {
423            if opts.range.is_some() {
424                return Err("--whole-sheet cannot be combined with --range".to_string());
425            }
426            if opts.sheet.as_ref().is_none_or(|s| s.trim().is_empty()) {
427                return Err("--whole-sheet needs --sheet to name the sheet to trim".to_string());
428            }
429        }
430        TrimScope::Range => {
431            if opts.range.as_ref().is_none_or(|r| r.trim().is_empty()) {
432                return Err("pass --range, or --whole-sheet to trim an entire sheet".to_string());
433            }
434        }
435    }
436    Ok(())
437}
438
439/// The sheet title and grid range the caller's scope names.
440fn resolve_scope(
441    opts: &TrimWhitespaceOptions,
442    composed: Option<&str>,
443    workbook: &Spreadsheet,
444) -> Result<(String, GridRange), TrimWhitespaceResult> {
445    match opts.scope {
446        TrimScope::WholeSheet => {
447            let title = opts.sheet.clone().unwrap_or_default();
448            let sheet_id = grid_range::find_sheet_id(workbook, &title, |title, available| {
449                TrimWhitespaceResult::RefusedSheetNotFound { title, available }
450            })?;
451            Ok((
452                title,
453                GridRange {
454                    sheet_id,
455                    start_row_index: None,
456                    end_row_index: None,
457                    start_column_index: None,
458                    end_column_index: None,
459                },
460            ))
461        }
462        TrimScope::Range => {
463            let composed = composed.unwrap_or_default();
464            grid_range::resolve_grid_range(
465                workbook,
466                composed,
467                |detail| TrimWhitespaceResult::RefusedInvalidRequest { detail },
468                |title, available| TrimWhitespaceResult::RefusedSheetNotFound { title, available },
469            )
470        }
471    }
472}
473
474/// Fills any absent bound of `grid` from `sheet`'s current grid extent.
475///
476/// A bound the sheet reports no count for is left absent, as is every
477/// bound when `sheet` is `None`. Either way the caller treats a range that
478/// still isn't fully bounded as naming no cells, since it cannot be read
479/// or rendered — which is also why a missing sheet needs no branch of its
480/// own here: it cannot happen (the id came from this same workbook a
481/// moment earlier) and would refuse correctly if it did.
482fn materialise_bounds(grid: &GridRange, sheet: Option<&Sheet>) -> GridRange {
483    let props = sheet
484        .and_then(|sheet| sheet.properties.as_ref())
485        .and_then(|p| p.grid_properties.as_ref());
486    GridRange {
487        sheet_id: grid.sheet_id,
488        start_row_index: grid.start_row_index.or(Some(0)),
489        end_row_index: grid
490            .end_row_index
491            .or_else(|| props.and_then(|g| g.row_count)),
492        start_column_index: grid.start_column_index.or(Some(0)),
493        end_column_index: grid
494            .end_column_index
495            .or_else(|| props.and_then(|g| g.column_count)),
496    }
497}
498
499/// The range's non-blank cells as A1 addresses, from one `values.get`.
500///
501/// `grid` has already been clamped to the sheet's current extent by the
502/// caller; the batch request itself retains the caller's original range.
503async fn read_non_blank(
504    api: &SheetsApi<'_>,
505    spreadsheet_id: &str,
506    range_a1: &str,
507    grid: &GridRange,
508) -> Result<Vec<String>, String> {
509    let values = api
510        .values_get(spreadsheet_id, range_a1, ValueRenderOption::Formatted)
511        .await
512        .map_err(|err| format!("{err:#}"))?;
513    Ok(grid_range::non_blank_locations(
514        &values,
515        grid.start_row_index.unwrap_or(0),
516        grid.start_column_index.unwrap_or(0),
517    ))
518}
519
520/// The `fields_changed` summary a result logs. An unreadable-reply result
521/// always logs its summary, though not the count that was in the reply.
522fn logged_fields_changed(result: &TrimWhitespaceResult) -> Option<String> {
523    match result {
524        TrimWhitespaceResult::Changed {
525            range,
526            cells_changed_count,
527        } => Some(match cells_changed_count {
528            Some(count) => format!("trimmed {count} cell(s) in {range}"),
529            None => format!("trimmed {range}; the API reported no count"),
530        }),
531        TrimWhitespaceResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
532        _ => None,
533    }
534}
535
536fn record_attempt(outcome: &TrimWhitespaceOutcome, duration: Duration) {
537    let decided_by = match &outcome.result {
538        TrimWhitespaceResult::Blocked { decided_by } => decided_by.as_ref(),
539        _ => None,
540    };
541    let decided_by = write_gate::decided_by_log_fields(decided_by);
542    let fields_changed = logged_fields_changed(&outcome.result);
543    let error = match &outcome.result {
544        TrimWhitespaceResult::RefusedInvalidRequest { detail }
545        | TrimWhitespaceResult::AppliedReplyUnreadable { detail, .. }
546        | TrimWhitespaceResult::Failed { detail } => Some(detail.clone()),
547        _ => None,
548    };
549    request_log::record_drive_mutation(DriveMutationOutcome {
550        operation: LOG_OPERATION,
551        file_id: outcome.spreadsheet_id.clone(),
552        file_name: outcome.file_name.clone().unwrap_or_default(),
553        status: outcome.result.log_status().to_string(),
554        resolved_folder_id: outcome.resolved_folder_id.clone(),
555        decided_by_folder_id: decided_by.folder_id,
556        decided_by_depth: decided_by.depth,
557        decided_by_file_id: decided_by.file_id,
558        fields_changed,
559        error,
560        duration,
561        ..Default::default()
562    });
563}
564
565pub fn describe_lines(outcome: &TrimWhitespaceOutcome) -> Vec<String> {
566    let book = outcome.file_name.as_deref().map_or_else(
567        || format!("'{}'", outcome.spreadsheet_id),
568        |name| format!("'{name}'"),
569    );
570    match &outcome.result {
571        TrimWhitespaceResult::WouldChange {
572            range,
573            candidate_cells,
574            read_clamped_to_sheet,
575            ..
576        } => {
577            let mut lines = vec![
578                format!("Would trim whitespace in {range} of {book}"),
579                candidate_line(candidate_cells),
580            ];
581            if *read_clamped_to_sheet {
582                lines.push(
583                    "  the preview read was clipped to the sheet's current grid; the real run sends the requested range to Sheets".to_string(),
584                );
585            }
586            lines
587        }
588        TrimWhitespaceResult::Changed {
589            range,
590            cells_changed_count,
591        } => vec![match cells_changed_count {
592            Some(count) => format!("Trimmed whitespace in {count} cell(s) of {range} in {book}"),
593            None => {
594                format!("Trimmed whitespace in {range} of {book}; the API reported no cell count")
595            }
596        }],
597        TrimWhitespaceResult::RefusedInvalidRequest { detail } => {
598            vec![format!("Refused: {detail}")]
599        }
600        TrimWhitespaceResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
601            "Refused: {book} is not a Google Sheet (mimeType: {mime_type})"
602        )],
603        TrimWhitespaceResult::RefusedShortcut => vec![format!(
604            "Refused: {book} is a shortcut; trim-whitespace doesn't follow shortcuts"
605        )],
606        TrimWhitespaceResult::RefusedNoVisibleParents => {
607            vec![format!("Refused: {book} has no visible parent folder")]
608        }
609        TrimWhitespaceResult::RefusedSheetNotFound { title, available } => {
610            vec![grid_range::no_sheet_refusal(&book, title, available)]
611        }
612        TrimWhitespaceResult::Blocked { .. } => vec![format!(
613            "Blocked: trim-whitespace on {book} requires an allowing sheets-write rule"
614        )],
615        TrimWhitespaceResult::RefusedNoLease => LeaseGateRefusal::NoLease
616            .describe_line(&outcome.spreadsheet_id, &book)
617            .into_iter()
618            .collect(),
619        TrimWhitespaceResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
620            .describe_line(&outcome.spreadsheet_id, &book)
621            .into_iter()
622            .collect(),
623        TrimWhitespaceResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
624            .describe_line(&outcome.spreadsheet_id, &book)
625            .into_iter()
626            .collect(),
627        TrimWhitespaceResult::RefusedLeaseStale => LeaseGateRefusal::Stale
628            .describe_line(&outcome.spreadsheet_id, &book)
629            .into_iter()
630            .collect(),
631        TrimWhitespaceResult::AppliedReplyUnreadable {
632            summary, detail, ..
633        } => vec![
634            applied_reply_unreadable_line(summary, &book, RetryHint::Idempotent, detail),
635            "  the cell count was in the unreadable reply; check the spreadsheet".to_string(),
636        ],
637        TrimWhitespaceResult::Failed { detail } => vec![format!("Failed: {detail}")],
638    }
639}
640
641/// The indented candidate line. Always "may be trimmed", never "would be":
642/// the server owns the trim rule, so a non-blank cell is a candidate, not
643/// a prediction (module docs; ADR-0083 §6).
644fn candidate_line(candidate_cells: &[String]) -> String {
645    if candidate_cells.is_empty() {
646        return "  no non-blank cells in the range".to_string();
647    }
648    format!(
649        "  {} non-blank cell(s) may be trimmed: {}",
650        candidate_cells.len(),
651        grid_range::render_locations(candidate_cells)
652    )
653}
654
655#[cfg(test)]
656#[allow(clippy::unwrap_used)]
657mod tests {
658    use super::*;
659    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
660    use crate::drive::sheets::client::SHEETS_API_URL;
661    use crate::drive::test_support::seed_lease;
662    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
663    use crate::test_support::env::MapEnv;
664    use crate::utils::secret::Secret;
665    use std::collections::HashSet;
666
667    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
668        let credentials = DriveCredentials {
669            client_id: "client-1".into(),
670            client_secret: Secret::new("secret-1"),
671            refresh_token: Secret::new("refresh-1"),
672            scope: DriveGrantedScopes::READONLY,
673        };
674        wiremock::Mock::given(wiremock::matchers::method("POST"))
675            .and(wiremock::matchers::path("/token"))
676            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
677                serde_json::json!({"access_token": "test-token", "expires_in": 3600}),
678            ))
679            .mount(server)
680            .await;
681        let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
682        crate::drive::client::test_support::replace_session(
683            &mut drive,
684            &credentials,
685            &format!("{}/token", server.uri()),
686        );
687        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
688        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
689        (drive, sheets)
690    }
691
692    async fn mount_metadata(server: &wiremock::MockServer) {
693        wiremock::Mock::given(wiremock::matchers::method("GET"))
694            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
695            .respond_with(
696                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
697                    "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
698                    "parents": ["parent-1"], "version": "1"
699                })),
700            )
701            .mount(server)
702            .await;
703        wiremock::Mock::given(wiremock::matchers::method("GET"))
704            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
705            .respond_with(
706                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
707                    "id": "parent-1", "name": "parent-1",
708                    "mimeType": "application/vnd.google-apps.folder", "parents": []
709                })),
710            )
711            .mount(server)
712            .await;
713        wiremock::Mock::given(wiremock::matchers::method("GET"))
714            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
715            .respond_with(
716                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
717                    "spreadsheetId": "sheet-1",
718                    "sheets": [{"properties": {
719                        "sheetId": 0, "title": "Q1",
720                        "gridProperties": {"rowCount": 100, "columnCount": 6}
721                    }}]
722                })),
723            )
724            .mount(server)
725            .await;
726    }
727
728    /// A `values.get` reply for any range, carrying `values`.
729    fn mount_values(values: serde_json::Value) -> wiremock::Mock {
730        wiremock::Mock::given(wiremock::matchers::method("GET"))
731            .and(wiremock::matchers::path_regex(
732                r"^/v4/spreadsheets/sheet-1/values/.*$",
733            ))
734            .respond_with(
735                wiremock::ResponseTemplate::new(200)
736                    .set_body_json(serde_json::json!({"values": values})),
737            )
738    }
739
740    fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
741        wiremock::Mock::given(wiremock::matchers::method("POST"))
742            .and(wiremock::matchers::path(
743                "/v4/spreadsheets/sheet-1:batchUpdate",
744            ))
745            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
746    }
747
748    fn options(dry_run: bool) -> TrimWhitespaceOptions {
749        TrimWhitespaceOptions {
750            spreadsheet_id: "sheet-1".into(),
751            sheet: Some("Q1".into()),
752            range: Some("A2:C10".into()),
753            scope: TrimScope::Range,
754            dry_run,
755            lease_token: None,
756            ledger_path: PathBuf::from("/tmp/unused-trim-whitespace-ledger"),
757        }
758    }
759
760    fn rule() -> FolderPermissionRule {
761        FolderPermissionRule {
762            folder_id: Some("parent-1".into()),
763            file_id: None,
764            recursive: true,
765            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
766            deny: HashSet::default(),
767            require_lease: false,
768        }
769    }
770
771    fn rule_allowing(operation: DriveOperation) -> FolderPermissionRule {
772        FolderPermissionRule {
773            allow: std::iter::once(operation).collect(),
774            ..rule()
775        }
776    }
777
778    fn rule_requiring_lease() -> FolderPermissionRule {
779        FolderPermissionRule {
780            require_lease: true,
781            ..rule()
782        }
783    }
784
785    // ── scope validation (pure) ─────────────────────────────────────────
786
787    #[test]
788    fn whole_sheet_refuses_a_range_and_demands_a_sheet() {
789        let mut opts = options(true);
790        opts.scope = TrimScope::WholeSheet;
791        assert!(validate_scope_syntax(&opts)
792            .unwrap_err()
793            .contains("cannot be combined with --range"));
794
795        opts.range = None;
796        opts.sheet = None;
797        assert!(validate_scope_syntax(&opts)
798            .unwrap_err()
799            .contains("needs --sheet"));
800
801        // A blank `--sheet` is as useless as an absent one.
802        opts.sheet = Some("   ".into());
803        assert!(validate_scope_syntax(&opts).is_err());
804
805        opts.sheet = Some("Q1".into());
806        assert!(validate_scope_syntax(&opts).is_ok());
807    }
808
809    #[test]
810    fn range_scope_demands_a_non_empty_range_and_names_the_alternative() {
811        let mut opts = options(true);
812        opts.range = None;
813        let err = validate_scope_syntax(&opts).unwrap_err();
814        assert!(err.contains("--range"), "{err}");
815        assert!(err.contains("--whole-sheet"), "{err}");
816
817        opts.range = Some("  ".into());
818        assert!(validate_scope_syntax(&opts).is_err());
819    }
820
821    // ── bound materialisation (pure) ────────────────────────────────────
822
823    fn sheet_with(row_count: Option<i64>, column_count: Option<i64>) -> Sheet {
824        let json = serde_json::json!({"properties": {
825            "sheetId": 0, "title": "Q1",
826            "gridProperties": {"rowCount": row_count, "columnCount": column_count}
827        }});
828        serde_json::from_value(json).unwrap()
829    }
830
831    #[test]
832    fn materialise_bounds_fills_open_axes_from_the_sheet_extent() {
833        let open = GridRange {
834            sheet_id: 0,
835            start_row_index: None,
836            end_row_index: None,
837            start_column_index: None,
838            end_column_index: None,
839        };
840        let filled = materialise_bounds(&open, Some(&sheet_with(Some(100), Some(6))));
841        assert_eq!(filled.start_row_index, Some(0));
842        assert_eq!(filled.end_row_index, Some(100));
843        assert_eq!(filled.start_column_index, Some(0));
844        assert_eq!(filled.end_column_index, Some(6));
845    }
846
847    #[test]
848    fn materialise_bounds_leaves_a_bound_the_sheet_reports_no_count_for() {
849        let open = GridRange {
850            sheet_id: 0,
851            start_row_index: None,
852            end_row_index: None,
853            start_column_index: Some(0),
854            end_column_index: Some(3),
855        };
856        let filled = materialise_bounds(&open, Some(&sheet_with(None, Some(6))));
857        // A start always materialises to 0; an end can only come from the
858        // sheet, so it stays absent and the caller refuses the range.
859        assert_eq!(filled.start_row_index, Some(0));
860        assert_eq!(filled.end_row_index, None);
861    }
862
863    /// With no sheet the ends cannot be filled, so the range stays
864    /// unbounded and the caller refuses it as naming no cells — the
865    /// behaviour that lets the engine skip a "sheet vanished" branch.
866    #[test]
867    fn materialise_bounds_without_a_sheet_leaves_the_ends_open() {
868        let open = GridRange {
869            sheet_id: 0,
870            start_row_index: None,
871            end_row_index: None,
872            start_column_index: None,
873            end_column_index: None,
874        };
875        let filled = materialise_bounds(&open, None);
876        assert_eq!(filled.end_row_index, None);
877        assert_eq!(filled.end_column_index, None);
878        assert!(!grid_range::is_bounded(&filled));
879    }
880
881    #[test]
882    fn materialise_bounds_never_widens_an_already_bounded_range() {
883        let bounded = GridRange {
884            sheet_id: 0,
885            start_row_index: Some(1),
886            end_row_index: Some(10),
887            start_column_index: Some(0),
888            end_column_index: Some(3),
889        };
890        assert_eq!(
891            materialise_bounds(&bounded, Some(&sheet_with(Some(100), Some(6)))),
892            bounded
893        );
894    }
895
896    // ── rendering (pure) ────────────────────────────────────────────────
897
898    #[test]
899    fn the_candidate_line_says_may_be_trimmed_never_would_be() {
900        let line = candidate_line(&["A1".to_string(), "B2".to_string()]);
901        assert!(
902            line.contains("2 non-blank cell(s) may be trimmed"),
903            "{line}"
904        );
905        assert!(!line.contains("would be"), "{line}");
906        assert_eq!(candidate_line(&[]), "  no non-blank cells in the range");
907    }
908
909    #[test]
910    fn the_candidate_line_elides_past_the_render_limit_but_keeps_the_true_count() {
911        let cells: Vec<String> = (1..=120).map(|row| format!("A{row}")).collect();
912        let line = candidate_line(&cells);
913        assert!(line.contains("120 non-blank cell(s)"), "{line}");
914        assert!(line.contains("… and 70 more"), "{line}");
915        assert!(!line.contains("A120"), "{line}");
916    }
917
918    #[test]
919    fn lease_refusals_map_to_their_trim_whitespace_results() {
920        assert_eq!(
921            TrimWhitespaceResult::from_no_lease(),
922            TrimWhitespaceResult::RefusedNoLease
923        );
924        assert_eq!(
925            TrimWhitespaceResult::from_lease_expired(),
926            TrimWhitespaceResult::RefusedLeaseExpired
927        );
928        assert_eq!(
929            TrimWhitespaceResult::from_lease_wrong_file(),
930            TrimWhitespaceResult::RefusedLeaseWrongFile
931        );
932        assert_eq!(
933            TrimWhitespaceResult::from_lease_stale(),
934            TrimWhitespaceResult::RefusedLeaseStale
935        );
936        assert_eq!(
937            TrimWhitespaceResult::from_lease_failed("boom".into()),
938            TrimWhitespaceResult::Failed {
939                detail: "boom".into()
940            }
941        );
942    }
943
944    fn every_result() -> Vec<TrimWhitespaceResult> {
945        vec![
946            TrimWhitespaceResult::WouldChange {
947                range: "Q1!A2:C10".into(),
948                candidate_cells: vec!["A2".into()],
949                candidate_cells_upper_bound: true,
950                read_clamped_to_sheet: false,
951            },
952            TrimWhitespaceResult::WouldChange {
953                range: "Q1!A2:C10".into(),
954                candidate_cells: Vec::new(),
955                candidate_cells_upper_bound: true,
956                read_clamped_to_sheet: false,
957            },
958            TrimWhitespaceResult::Changed {
959                range: "Q1!A2:C10".into(),
960                cells_changed_count: Some(3),
961            },
962            TrimWhitespaceResult::Changed {
963                range: "Q1!A2:C10".into(),
964                cells_changed_count: None,
965            },
966            TrimWhitespaceResult::RefusedInvalidRequest {
967                detail: "bad".into(),
968            },
969            TrimWhitespaceResult::RefusedNotASpreadsheet {
970                mime_type: "application/pdf".into(),
971            },
972            TrimWhitespaceResult::RefusedShortcut,
973            TrimWhitespaceResult::RefusedNoVisibleParents,
974            TrimWhitespaceResult::RefusedSheetNotFound {
975                title: "Nope".into(),
976                available: vec!["Q1".into()],
977            },
978            TrimWhitespaceResult::Blocked { decided_by: None },
979            TrimWhitespaceResult::RefusedNoLease,
980            TrimWhitespaceResult::RefusedLeaseExpired,
981            TrimWhitespaceResult::RefusedLeaseWrongFile,
982            TrimWhitespaceResult::RefusedLeaseStale,
983            TrimWhitespaceResult::AppliedReplyUnreadable {
984                range: "Q1!A2:C10".into(),
985                summary: "trim whitespace in Q1!A2:C10".into(),
986                detail: "bad reply".into(),
987            },
988            TrimWhitespaceResult::Failed {
989                detail: "boom".into(),
990            },
991        ]
992    }
993
994    #[test]
995    fn every_result_renders_and_carries_a_distinct_log_status() {
996        let mut statuses = HashSet::new();
997        for result in every_result() {
998            let outcome = TrimWhitespaceOutcome {
999                spreadsheet_id: "sheet-1".into(),
1000                file_name: Some("Budget".into()),
1001                resolved_folder_id: None,
1002                result,
1003            };
1004            assert!(
1005                !describe_lines(&outcome).is_empty(),
1006                "{:?} rendered nothing",
1007                outcome.result
1008            );
1009            statuses.insert(outcome.result.log_status());
1010        }
1011        // 16 results, 14 distinct statuses: the two `WouldChange`s share
1012        // one and the two `Changed`s share another.
1013        assert_eq!(statuses.len(), 14);
1014    }
1015
1016    #[test]
1017    fn describe_lines_renders_applied_reply_unreadable_with_a_count_hint() {
1018        let outcome = TrimWhitespaceOutcome {
1019            spreadsheet_id: "sheet-1".into(),
1020            file_name: Some("Budget".into()),
1021            resolved_folder_id: None,
1022            result: TrimWhitespaceResult::AppliedReplyUnreadable {
1023                range: "Q1!A2:C10".into(),
1024                summary: "trim whitespace in Q1!A2:C10".into(),
1025                detail: "bad reply".into(),
1026            },
1027        };
1028        assert_eq!(
1029            describe_lines(&outcome),
1030            [
1031                "Applied, but the reply could not be read: trim whitespace in Q1!A2:C10 in \
1032                 'Budget' — check the spreadsheet to confirm (bad reply)",
1033                "  the cell count was in the unreadable reply; check the spreadsheet",
1034            ]
1035        );
1036    }
1037
1038    #[test]
1039    fn an_unreadable_reply_logs_its_summary_and_serializes_its_range() {
1040        let result = TrimWhitespaceResult::AppliedReplyUnreadable {
1041            range: "Q1!A2:C10".into(),
1042            summary: "trim whitespace in Q1!A2:C10".into(),
1043            detail: "bad reply".into(),
1044        };
1045        assert_eq!(
1046            logged_fields_changed(&result).as_deref(),
1047            Some("trim whitespace in Q1!A2:C10")
1048        );
1049        let json = serde_json::to_value(&result).unwrap();
1050        assert_eq!(json["status"], "applied-reply-unreadable");
1051        assert_eq!(json["range"], "Q1!A2:C10");
1052    }
1053
1054    #[test]
1055    fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1056        let outcome = TrimWhitespaceOutcome {
1057            spreadsheet_id: "sheet-1".into(),
1058            file_name: None,
1059            resolved_folder_id: None,
1060            result: TrimWhitespaceResult::RefusedShortcut,
1061        };
1062        assert!(describe_lines(&outcome).join("\n").contains("'sheet-1'"));
1063    }
1064
1065    #[test]
1066    fn write_jsonl_emits_one_line_of_json() {
1067        let outcome = TrimWhitespaceOutcome {
1068            spreadsheet_id: "sheet-1".into(),
1069            file_name: Some("Budget".into()),
1070            resolved_folder_id: None,
1071            result: TrimWhitespaceResult::Changed {
1072                range: "Q1!A2:C10".into(),
1073                cells_changed_count: Some(3),
1074            },
1075        };
1076        let mut buf = Vec::new();
1077        outcome.write_jsonl(&mut buf).unwrap();
1078        let text = String::from_utf8(buf).unwrap();
1079        assert_eq!(text.lines().count(), 1);
1080        assert!(text.contains("\"cells_changed_count\":3"), "{text}");
1081    }
1082
1083    // ── the gate ────────────────────────────────────────────────────────
1084
1085    /// A real (non-dry) run records its attempt, so a denied one must reach
1086    /// the request log's `Blocked` arm too.
1087    #[tokio::test]
1088    async fn a_denied_real_run_is_blocked_and_recorded() {
1089        let server = wiremock::MockServer::start().await;
1090        let (drive, sheets) = clients(&server).await;
1091        mount_metadata(&server).await;
1092        let outcome = trim_whitespace(&drive, &sheets, &options(false), &[]).await;
1093        assert_eq!(outcome.result.log_status(), "blocked", "{outcome:?}");
1094        assert!(matches!(
1095            outcome.result,
1096            TrimWhitespaceResult::Blocked { .. }
1097        ));
1098    }
1099
1100    #[tokio::test]
1101    async fn gate_denies_sheets_write_by_default() {
1102        let server = wiremock::MockServer::start().await;
1103        let (drive, sheets) = clients(&server).await;
1104        mount_metadata(&server).await;
1105        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1106        assert!(
1107            matches!(outcome.result, TrimWhitespaceResult::Blocked { .. }),
1108            "{outcome:?}"
1109        );
1110    }
1111
1112    /// The other half of `delete-duplicates`' non-widening test: the two
1113    /// verbs in issue #1844 take different operations, so neither grant
1114    /// may stand in for the other.
1115    #[tokio::test]
1116    async fn a_sheets_delete_grant_alone_does_not_permit_trim_whitespace() {
1117        let server = wiremock::MockServer::start().await;
1118        let (drive, sheets) = clients(&server).await;
1119        mount_metadata(&server).await;
1120        let outcome = trim_whitespace(
1121            &drive,
1122            &sheets,
1123            &options(true),
1124            &[rule_allowing(DriveOperation::SheetsDelete)],
1125        )
1126        .await;
1127        assert!(
1128            matches!(outcome.result, TrimWhitespaceResult::Blocked { .. }),
1129            "{outcome:?}"
1130        );
1131    }
1132
1133    #[tokio::test]
1134    async fn a_denied_target_makes_no_sheets_calls_at_all() {
1135        let server = wiremock::MockServer::start().await;
1136        let (drive, sheets) = clients(&server).await;
1137        mount_metadata(&server).await;
1138        let _ = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1139        let requests = server.received_requests().await.unwrap();
1140        assert!(
1141            !requests
1142                .iter()
1143                .any(|request| request.url.path().starts_with("/v4/spreadsheets")),
1144            "a blocked run reached the Sheets API"
1145        );
1146    }
1147
1148    // ── target-gate failures and refusals ───────────────────────────────
1149
1150    #[tokio::test]
1151    async fn a_metadata_fetch_failure_surfaces_as_failed_without_a_file_name() {
1152        let server = wiremock::MockServer::start().await;
1153        let (drive, sheets) = clients(&server).await;
1154        wiremock::Mock::given(wiremock::matchers::method("GET"))
1155            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1156            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
1157            .mount(&server)
1158            .await;
1159
1160        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1161
1162        assert!(matches!(
1163            outcome.result,
1164            TrimWhitespaceResult::Failed { .. }
1165        ));
1166        assert!(outcome.file_name.is_none());
1167    }
1168
1169    #[tokio::test]
1170    async fn a_shortcut_target_is_refused_without_following_it() {
1171        let server = wiremock::MockServer::start().await;
1172        let (drive, sheets) = clients(&server).await;
1173        wiremock::Mock::given(wiremock::matchers::method("GET"))
1174            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1175            .respond_with(
1176                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1177                    "id": "sheet-1", "name": "Budget",
1178                    "mimeType": "application/vnd.google-apps.shortcut",
1179                    "parents": ["parent-1"], "version": "1"
1180                })),
1181            )
1182            .mount(&server)
1183            .await;
1184
1185        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1186
1187        assert_eq!(outcome.result, TrimWhitespaceResult::RefusedShortcut);
1188        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1189    }
1190
1191    #[tokio::test]
1192    async fn a_non_spreadsheet_target_is_refused_with_its_mime_type() {
1193        let server = wiremock::MockServer::start().await;
1194        let (drive, sheets) = clients(&server).await;
1195        wiremock::Mock::given(wiremock::matchers::method("GET"))
1196            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1197            .respond_with(
1198                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1199                    "id": "sheet-1", "name": "Budget",
1200                    "mimeType": "application/vnd.google-apps.document",
1201                    "parents": ["parent-1"], "version": "1"
1202                })),
1203            )
1204            .mount(&server)
1205            .await;
1206
1207        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1208
1209        assert_eq!(
1210            outcome.result,
1211            TrimWhitespaceResult::RefusedNotASpreadsheet {
1212                mime_type: "application/vnd.google-apps.document".into(),
1213            }
1214        );
1215        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1216    }
1217
1218    #[tokio::test]
1219    async fn a_target_with_no_visible_parents_is_refused() {
1220        let server = wiremock::MockServer::start().await;
1221        let (drive, sheets) = clients(&server).await;
1222        wiremock::Mock::given(wiremock::matchers::method("GET"))
1223            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1224            .respond_with(
1225                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1226                    "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1227                    "parents": [], "version": "1"
1228                })),
1229            )
1230            .mount(&server)
1231            .await;
1232
1233        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1234
1235        assert_eq!(
1236            outcome.result,
1237            TrimWhitespaceResult::RefusedNoVisibleParents
1238        );
1239        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1240    }
1241
1242    #[tokio::test]
1243    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed_with_the_file_name() {
1244        let server = wiremock::MockServer::start().await;
1245        let (drive, sheets) = clients(&server).await;
1246        wiremock::Mock::given(wiremock::matchers::method("GET"))
1247            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1248            .respond_with(
1249                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1250                    "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1251                    "parents": ["parent-1"], "version": "1"
1252                })),
1253            )
1254            .mount(&server)
1255            .await;
1256        wiremock::Mock::given(wiremock::matchers::method("GET"))
1257            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1258            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1259            .mount(&server)
1260            .await;
1261
1262        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[]).await;
1263
1264        assert!(matches!(
1265            outcome.result,
1266            TrimWhitespaceResult::Failed { .. }
1267        ));
1268        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1269        assert!(outcome.resolved_folder_id.is_none());
1270    }
1271
1272    #[tokio::test]
1273    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
1274        let server = wiremock::MockServer::start().await;
1275        let (drive, sheets) = clients(&server).await;
1276        mount_metadata(&server).await;
1277        wiremock::Mock::given(wiremock::matchers::method("GET"))
1278            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1279            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1280            .mount(&server)
1281            .await;
1282
1283        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1284
1285        assert!(matches!(
1286            outcome.result,
1287            TrimWhitespaceResult::Failed { .. }
1288        ));
1289        assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1290    }
1291
1292    // ── the two read paths ──────────────────────────────────────────────
1293
1294    #[tokio::test]
1295    async fn dry_run_reads_values_once_and_never_mutates() {
1296        let server = wiremock::MockServer::start().await;
1297        let (drive, sheets) = clients(&server).await;
1298        mount_metadata(&server).await;
1299        mount_values(serde_json::json!([
1300            ["  padded  ", "", "x"],
1301            [null, "y", null]
1302        ]))
1303        .mount(&server)
1304        .await;
1305        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1306        let json = serde_json::to_value(&outcome).unwrap();
1307        assert_eq!(json["result"]["candidate_cells_upper_bound"], true);
1308        let TrimWhitespaceResult::WouldChange {
1309            range,
1310            candidate_cells,
1311            candidate_cells_upper_bound,
1312            read_clamped_to_sheet,
1313        } = outcome.result
1314        else {
1315            panic!("expected would-change"); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; the mocked run always produces it"
1316        };
1317        assert_eq!(range, "'Q1'!A2:C10");
1318        // Offsets are the range's own start (row 1, column 0 zero-based).
1319        assert_eq!(candidate_cells, vec!["A2", "C2", "B3"]);
1320        assert!(candidate_cells_upper_bound);
1321        assert!(!read_clamped_to_sheet);
1322
1323        let requests = server.received_requests().await.unwrap();
1324        assert_eq!(
1325            requests
1326                .iter()
1327                .filter(|r| r.url.path().contains("/values/"))
1328                .count(),
1329            1
1330        );
1331        assert!(
1332            !requests
1333                .iter()
1334                .any(|r| r.url.path().ends_with(":batchUpdate")),
1335            "a dry run mutated"
1336        );
1337    }
1338
1339    #[tokio::test]
1340    async fn dry_run_clamps_an_explicit_range_to_the_sheet_grid_for_its_read() {
1341        let server = wiremock::MockServer::start().await;
1342        let (drive, sheets) = clients(&server).await;
1343        mount_metadata(&server).await;
1344        mount_values(serde_json::json!([["  padded  "]]))
1345            .mount(&server)
1346            .await;
1347        let mut opts = options(true);
1348        opts.range = Some("E90:J120".into());
1349
1350        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1351        assert_eq!(
1352            outcome.result,
1353            TrimWhitespaceResult::WouldChange {
1354                range: "'Q1'!E90:J120".into(),
1355                candidate_cells: vec!["E90".into()],
1356                candidate_cells_upper_bound: true,
1357                read_clamped_to_sheet: true,
1358            }
1359        );
1360        assert_eq!(
1361            serde_json::to_value(&outcome).unwrap()["result"]["read_clamped_to_sheet"],
1362            true
1363        );
1364        assert!(describe_lines(&outcome)
1365            .join("\n")
1366            .contains("preview read was clipped"));
1367        let requests = server.received_requests().await.unwrap();
1368        let reads: Vec<_> = requests
1369            .iter()
1370            .filter(|request| request.url.path().contains("/values/"))
1371            .collect();
1372        assert_eq!(reads.len(), 1);
1373        let read_path = reads[0].url.path();
1374        assert!(
1375            read_path.contains("E90") && read_path.contains("F100"),
1376            "{read_path}"
1377        );
1378        assert!(!read_path.contains("J120"), "{read_path}");
1379    }
1380
1381    /// The deviation from `auto_fill.rs`/`paste.rs` recorded in the module
1382    /// docs: the real run reports the server's exact count, so re-reading
1383    /// the range would buy nothing.
1384    #[tokio::test]
1385    async fn real_run_sends_one_trim_request_and_reads_no_values() {
1386        let server = wiremock::MockServer::start().await;
1387        let (drive, sheets) = clients(&server).await;
1388        mount_metadata(&server).await;
1389        mount_batch_update(serde_json::json!({
1390            "replies": [{"trimWhitespace": {"cellsChangedCount": 7}}]
1391        }))
1392        .mount(&server)
1393        .await;
1394        let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1395        assert_eq!(
1396            outcome.result,
1397            TrimWhitespaceResult::Changed {
1398                range: "'Q1'!A2:C10".into(),
1399                cells_changed_count: Some(7),
1400            }
1401        );
1402
1403        let requests = server.received_requests().await.unwrap();
1404        assert!(
1405            !requests.iter().any(|r| r.url.path().contains("/values/")),
1406            "the real run read values it did not need"
1407        );
1408        let batch = requests
1409            .iter()
1410            .find(|r| r.url.path().ends_with(":batchUpdate"))
1411            .unwrap();
1412        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1413        assert_eq!(
1414            body["requests"][0]["trimWhitespace"]["range"],
1415            serde_json::json!({
1416                "sheetId": 0, "startRowIndex": 1, "endRowIndex": 10,
1417                "startColumnIndex": 0, "endColumnIndex": 3
1418            })
1419        );
1420        assert_eq!(body["requests"].as_array().unwrap().len(), 1);
1421    }
1422
1423    #[tokio::test]
1424    async fn whole_sheet_sends_the_materialised_grid_extent() {
1425        let server = wiremock::MockServer::start().await;
1426        let (drive, sheets) = clients(&server).await;
1427        mount_metadata(&server).await;
1428        mount_batch_update(serde_json::json!({"replies": [{}]}))
1429            .mount(&server)
1430            .await;
1431        let mut opts = options(false);
1432        opts.scope = TrimScope::WholeSheet;
1433        opts.range = None;
1434        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1435        let TrimWhitespaceResult::Changed { range, .. } = &outcome.result else {
1436            panic!("expected changed"); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; the mocked run always produces it"
1437        };
1438        assert_eq!(range, "'Q1'!A1:F100");
1439
1440        let requests = server.received_requests().await.unwrap();
1441        let batch = requests
1442            .iter()
1443            .find(|r| r.url.path().ends_with(":batchUpdate"))
1444            .unwrap();
1445        let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1446        assert_eq!(
1447            body["requests"][0]["trimWhitespace"]["range"]["endColumnIndex"],
1448            6
1449        );
1450    }
1451
1452    #[tokio::test]
1453    async fn a_reply_without_a_trim_object_reports_changed_with_no_count() {
1454        let server = wiremock::MockServer::start().await;
1455        let (drive, sheets) = clients(&server).await;
1456        mount_metadata(&server).await;
1457        mount_batch_update(serde_json::json!({"replies": [{}]}))
1458            .mount(&server)
1459            .await;
1460        let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1461        assert_eq!(
1462            outcome.result,
1463            TrimWhitespaceResult::Changed {
1464                range: "'Q1'!A2:C10".into(),
1465                cells_changed_count: None,
1466            }
1467        );
1468        assert!(describe_lines(&outcome)
1469            .join("\n")
1470            .contains("reported no cell count"));
1471    }
1472
1473    // ── the "never a cell value" guard (ADR-0083 §6) ─────────────────────
1474
1475    #[tokio::test]
1476    async fn no_cell_value_ever_reaches_the_outcome_or_its_rendering() {
1477        const SECRET: &str = "  zzsecretzz  ";
1478        let server = wiremock::MockServer::start().await;
1479        let (drive, sheets) = clients(&server).await;
1480        mount_metadata(&server).await;
1481        mount_values(serde_json::json!([[SECRET]]))
1482            .mount(&server)
1483            .await;
1484        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1485
1486        let json = serde_json::to_string(&outcome).unwrap();
1487        assert!(!json.contains("zzsecretzz"), "{json}");
1488        assert!(!describe_lines(&outcome).join("\n").contains("zzsecretzz"));
1489    }
1490
1491    // ── failure paths ───────────────────────────────────────────────────
1492
1493    #[tokio::test]
1494    async fn a_values_read_failure_surfaces_as_failed() {
1495        let server = wiremock::MockServer::start().await;
1496        let (drive, sheets) = clients(&server).await;
1497        mount_metadata(&server).await;
1498        wiremock::Mock::given(wiremock::matchers::method("GET"))
1499            .and(wiremock::matchers::path_regex(
1500                r"^/v4/spreadsheets/sheet-1/values/.*$",
1501            ))
1502            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1503            .mount(&server)
1504            .await;
1505        let outcome = trim_whitespace(&drive, &sheets, &options(true), &[rule()]).await;
1506        assert!(
1507            matches!(outcome.result, TrimWhitespaceResult::Failed { .. }),
1508            "{outcome:?}"
1509        );
1510    }
1511
1512    #[tokio::test]
1513    async fn a_batch_update_failure_surfaces_as_failed() {
1514        let server = wiremock::MockServer::start().await;
1515        let (drive, sheets) = clients(&server).await;
1516        mount_metadata(&server).await;
1517        wiremock::Mock::given(wiremock::matchers::method("POST"))
1518            .and(wiremock::matchers::path(
1519                "/v4/spreadsheets/sheet-1:batchUpdate",
1520            ))
1521            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1522            .mount(&server)
1523            .await;
1524        let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1525        assert!(
1526            matches!(outcome.result, TrimWhitespaceResult::Failed { .. }),
1527            "{outcome:?}"
1528        );
1529    }
1530
1531    #[tokio::test]
1532    async fn trim_whitespace_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1533        let server = wiremock::MockServer::start().await;
1534        let (drive, sheets) = clients(&server).await;
1535        mount_metadata(&server).await;
1536        // 2xx, so the trim WAS applied — but the body is not the reply shape.
1537        wiremock::Mock::given(wiremock::matchers::method("POST"))
1538            .and(wiremock::matchers::path(
1539                "/v4/spreadsheets/sheet-1:batchUpdate",
1540            ))
1541            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1542            .expect(1)
1543            .mount(&server)
1544            .await;
1545        let outcome = trim_whitespace(&drive, &sheets, &options(false), &[rule()]).await;
1546        assert_eq!(
1547            outcome.result.log_status(),
1548            "applied-reply-unreadable",
1549            "{outcome:?}"
1550        );
1551        let lines = describe_lines(&outcome);
1552        assert!(
1553            lines[0].starts_with("Applied, but the reply could not be read"),
1554            "{lines:?}"
1555        );
1556        assert!(
1557            lines[0].contains("check the spreadsheet to confirm"),
1558            "{lines:?}"
1559        );
1560    }
1561
1562    #[tokio::test]
1563    async fn an_unknown_sheet_is_refused_with_the_available_titles() {
1564        let server = wiremock::MockServer::start().await;
1565        let (drive, sheets) = clients(&server).await;
1566        mount_metadata(&server).await;
1567        let mut opts = options(true);
1568        opts.scope = TrimScope::WholeSheet;
1569        opts.sheet = Some("Nope".into());
1570        opts.range = None;
1571        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1572        assert_eq!(
1573            outcome.result,
1574            TrimWhitespaceResult::RefusedSheetNotFound {
1575                title: "Nope".into(),
1576                available: vec!["Q1".into()],
1577            }
1578        );
1579    }
1580
1581    #[tokio::test]
1582    async fn a_scope_refusal_happens_before_any_network_call() {
1583        let server = wiremock::MockServer::start().await;
1584        let (drive, sheets) = clients(&server).await;
1585        let mut opts = options(true);
1586        opts.range = None;
1587        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule()]).await;
1588        assert!(matches!(
1589            outcome.result,
1590            TrimWhitespaceResult::RefusedInvalidRequest { .. }
1591        ));
1592        assert!(server.received_requests().await.unwrap().is_empty());
1593    }
1594
1595    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1596
1597    #[tokio::test]
1598    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1599        let server = wiremock::MockServer::start().await;
1600        let (drive, sheets) = clients(&server).await;
1601        mount_metadata(&server).await;
1602        // No batchUpdate mock: a refusal must make zero mutating calls.
1603        let outcome =
1604            trim_whitespace(&drive, &sheets, &options(false), &[rule_requiring_lease()]).await;
1605        assert_eq!(outcome.result, TrimWhitespaceResult::RefusedNoLease);
1606    }
1607
1608    #[tokio::test]
1609    async fn refuses_a_lease_bound_to_a_different_file() {
1610        let server = wiremock::MockServer::start().await;
1611        let (drive, sheets) = clients(&server).await;
1612        mount_metadata(&server).await;
1613        let dir = tempfile::tempdir().unwrap();
1614        let ledger_path = dir.path().join("leases.json");
1615        let token = seed_lease(&ledger_path, "other-file", "1");
1616        let mut opts = options(false);
1617        opts.lease_token = Some(token);
1618        opts.ledger_path = ledger_path;
1619        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1620        assert_eq!(outcome.result, TrimWhitespaceResult::RefusedLeaseWrongFile);
1621    }
1622
1623    #[tokio::test]
1624    async fn a_valid_lease_lets_the_write_through() {
1625        let server = wiremock::MockServer::start().await;
1626        let (drive, sheets) = clients(&server).await;
1627        mount_metadata(&server).await;
1628        mount_batch_update(serde_json::json!({
1629            "replies": [{"trimWhitespace": {"cellsChangedCount": 1}}]
1630        }))
1631        .mount(&server)
1632        .await;
1633        let dir = tempfile::tempdir().unwrap();
1634        let ledger_path = dir.path().join("leases.json");
1635        let token = seed_lease(&ledger_path, "sheet-1", "1");
1636        let mut opts = options(false);
1637        opts.lease_token = Some(token);
1638        opts.ledger_path = ledger_path;
1639        let outcome = trim_whitespace(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1640        assert!(
1641            matches!(
1642                outcome.result,
1643                TrimWhitespaceResult::Changed {
1644                    cells_changed_count: Some(1),
1645                    ..
1646                }
1647            ),
1648            "{outcome:?}"
1649        );
1650    }
1651}