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