Skip to main content

omni_dev/drive/sheets/
find_replace.rs

1//! Find and replace cell text through `spreadsheets.batchUpdate` (issue #1841).
2//!
3//! ADR-0083 §§1, 6 and 7 place every scope under `SheetsWrite`, require the
4//! ordinary optional lease flow, and prohibit a local match-count preview:
5//! Sheets owns the matching semantics, including Java regular expressions.
6
7#![allow(missing_docs)] // The CLI-facing outcome models are self-describing in JSON.
8
9use std::path::PathBuf;
10use std::time::{Duration, Instant};
11
12use serde::Serialize;
13
14use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
15use crate::drive::client::DriveClient;
16use crate::drive::files_api::FilesApi;
17use crate::drive::lease::check::{
18    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
19    LeasedWrite,
20};
21use crate::drive::sheets::api::{
22    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
23};
24use crate::drive::sheets::client::SheetsClient;
25use crate::drive::sheets::types::{
26    BatchUpdateRequestItem, FindReplaceRequest, FindReplaceResponse, FindReplaceScope, GridRange,
27    Spreadsheet,
28};
29use crate::drive::sheets::{a1, grid_range, target_gate};
30use crate::drive::types::SheetTargetRefusal;
31use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
32use crate::request_log::{self, DriveMutationOutcome};
33
34/// A find/replace request as received from the CLI.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FindReplaceOptions {
37    pub spreadsheet_id: String,
38    pub find: String,
39    pub replacement: String,
40    pub sheet: Option<String>,
41    pub range: Option<String>,
42    pub whole_sheet: bool,
43    pub all_sheets: bool,
44    pub match_case: bool,
45    pub match_entire_cell: bool,
46    pub search_by_regex: bool,
47    pub include_formulas: bool,
48    pub dry_run: bool,
49    pub lease_token: Option<String>,
50    pub ledger_path: PathBuf,
51}
52
53/// The resolved scope recorded and rendered without recording cell contents.
54#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
55#[serde(tag = "kind", rename_all = "kebab-case")]
56pub enum FindReplaceTarget {
57    Range { range: String, sheet_id: i64 },
58    Sheet { sheet: String, sheet_id: i64 },
59    AllSheets,
60}
61
62impl FindReplaceTarget {
63    fn request_scope(&self, grid: Option<GridRange>) -> Option<FindReplaceScope> {
64        match self {
65            Self::Range { .. } => grid.map(|range| FindReplaceScope::Range { range }),
66            Self::Sheet { sheet_id, .. } => Some(FindReplaceScope::Sheet {
67                sheet_id: *sheet_id,
68            }),
69            Self::AllSheets => Some(FindReplaceScope::AllSheets { all_sheets: true }),
70        }
71    }
72
73    fn describe(&self) -> String {
74        match self {
75            Self::Range { range, .. } => format!("range {range}"),
76            Self::Sheet { sheet, .. } => format!("sheet '{sheet}'"),
77            Self::AllSheets => "all sheets".to_string(),
78        }
79    }
80}
81
82/// Counts supplied by a successful Sheets reply.
83#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
84pub struct FindReplaceCounts {
85    pub values_changed: i64,
86    pub formulas_changed: i64,
87    pub rows_changed: i64,
88    pub sheets_changed: i64,
89    pub occurrences_changed: i64,
90}
91
92impl From<FindReplaceResponse> for FindReplaceCounts {
93    fn from(value: FindReplaceResponse) -> Self {
94        Self {
95            values_changed: value.values_changed,
96            formulas_changed: value.formulas_changed,
97            rows_changed: value.rows_changed,
98            sheets_changed: value.sheets_changed,
99            occurrences_changed: value.occurrences_changed,
100        }
101    }
102}
103
104/// Result of a find/replace attempt.
105#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
106#[serde(tag = "status", rename_all = "kebab-case")]
107pub enum FindReplaceResult {
108    WouldChange {
109        target: FindReplaceTarget,
110        summary: String,
111    },
112    Changed {
113        target: FindReplaceTarget,
114        counts: Option<FindReplaceCounts>,
115    },
116    RefusedInvalidRequest {
117        detail: String,
118    },
119    RefusedNotASpreadsheet {
120        mime_type: String,
121    },
122    RefusedShortcut,
123    RefusedNoVisibleParents,
124    RefusedSheetNotFound {
125        title: String,
126        available: Vec<String>,
127    },
128    Blocked {
129        decided_by: Option<DecidingRule>,
130    },
131    RefusedNoLease,
132    RefusedLeaseExpired,
133    RefusedLeaseWrongFile,
134    RefusedLeaseStale,
135    /// The API answered 2xx — the replacement **was applied** — but its reply
136    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: a
137    /// retry here could compound (`a` -> `aa`). The replacement counts were
138    /// in that reply, so they are unknown.
139    AppliedReplyUnreadable {
140        target: FindReplaceTarget,
141        /// What was applied, in the same terms as a `Changed` line.
142        summary: String,
143        /// Why the reply could not be read.
144        detail: String,
145    },
146    Failed {
147        detail: String,
148    },
149}
150
151impl FromLeaseRefusal for FindReplaceResult {
152    fn from_no_lease() -> Self {
153        Self::RefusedNoLease
154    }
155    fn from_lease_expired() -> Self {
156        Self::RefusedLeaseExpired
157    }
158    fn from_lease_wrong_file() -> Self {
159        Self::RefusedLeaseWrongFile
160    }
161    fn from_lease_stale() -> Self {
162        Self::RefusedLeaseStale
163    }
164    fn from_lease_failed(detail: String) -> Self {
165        Self::Failed { detail }
166    }
167}
168
169impl FindReplaceResult {
170    fn log_status(&self) -> &'static str {
171        match self {
172            Self::WouldChange { .. } => "would-change",
173            Self::Changed { .. } => "changed",
174            Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
175            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
176            Self::RefusedShortcut => "refused-shortcut",
177            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
178            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
179            Self::Blocked { .. } => "blocked",
180            Self::RefusedNoLease => "refused-no-lease",
181            Self::RefusedLeaseExpired => "refused-lease-expired",
182            Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
183            Self::RefusedLeaseStale => "refused-lease-stale",
184            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
185            Self::Failed { .. } => "failed",
186        }
187    }
188}
189
190/// Full outcome, including metadata useful for structured output and logs.
191#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
192pub struct FindReplaceOutcome {
193    pub spreadsheet_id: String,
194    pub file_name: Option<String>,
195    pub resolved_folder_id: Option<String>,
196    pub result: FindReplaceResult,
197}
198
199impl JsonlSerialize for FindReplaceOutcome {
200    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
201        write_scalar_jsonl(self, out)
202    }
203}
204
205/// Runs one attempt, logging every real attempt.
206pub async fn find_replace(
207    drive: &DriveClient,
208    sheets: &SheetsClient,
209    opts: &FindReplaceOptions,
210    rules: &[FolderPermissionRule],
211) -> FindReplaceOutcome {
212    let started = Instant::now();
213    let outcome = find_replace_inner(drive, sheets, opts, rules).await;
214    if !opts.dry_run {
215        record_attempt(&outcome, opts, started.elapsed());
216    }
217    outcome
218}
219
220async fn find_replace_inner(
221    drive: &DriveClient,
222    sheets: &SheetsClient,
223    opts: &FindReplaceOptions,
224    rules: &[FolderPermissionRule],
225) -> FindReplaceOutcome {
226    let bare = |result| FindReplaceOutcome {
227        spreadsheet_id: opts.spreadsheet_id.clone(),
228        file_name: None,
229        resolved_folder_id: None,
230        result,
231    };
232    if opts.find.is_empty() {
233        return bare(FindReplaceResult::RefusedInvalidRequest {
234            detail: "--find must not be empty".to_string(),
235        });
236    }
237    if let Err(detail) = validate_scope_syntax(opts) {
238        return bare(FindReplaceResult::RefusedInvalidRequest { detail });
239    }
240    let (target_meta, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
241        drive,
242        &opts.spreadsheet_id,
243        DriveOperation::SheetsWrite,
244        rules,
245    )
246    .await
247    {
248        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
249            return bare(FindReplaceResult::Failed { detail })
250        }
251        target_gate::TargetGateOutcome::Refused { target, refusal } => {
252            let result = match refusal {
253                SheetTargetRefusal::Shortcut => FindReplaceResult::RefusedShortcut,
254                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
255                    FindReplaceResult::RefusedNotASpreadsheet { mime_type }
256                }
257                SheetTargetRefusal::NoVisibleParents => FindReplaceResult::RefusedNoVisibleParents,
258            };
259            return FindReplaceOutcome {
260                spreadsheet_id: opts.spreadsheet_id.clone(),
261                file_name: Some(target.name),
262                resolved_folder_id: None,
263                result,
264            };
265        }
266        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
267            return FindReplaceOutcome {
268                spreadsheet_id: opts.spreadsheet_id.clone(),
269                file_name: Some(target.name),
270                resolved_folder_id: None,
271                result: FindReplaceResult::Failed { detail },
272            }
273        }
274        target_gate::TargetGateOutcome::Gated {
275            target,
276            decision,
277            resolved_folder_id,
278            requires_lease,
279        } => (target, decision, resolved_folder_id, requires_lease),
280    };
281    let gated = |result| FindReplaceOutcome {
282        spreadsheet_id: opts.spreadsheet_id.clone(),
283        file_name: Some(target_meta.name.clone()),
284        resolved_folder_id: resolved_folder_id.clone(),
285        result,
286    };
287    if decision.verdict == write_gate::Verdict::Deny {
288        return gated(FindReplaceResult::Blocked {
289            decided_by: decision.decided_by,
290        });
291    }
292    let api = SheetsApi::new(sheets);
293    let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
294        Ok(workbook) => workbook,
295        Err(err) => {
296            return gated(FindReplaceResult::Failed {
297                detail: format!("{err:#}"),
298            })
299        }
300    };
301    let (target, grid) = match resolve_target(opts, &workbook) {
302        Ok(value) => value,
303        Err(result) => return gated(result),
304    };
305    let Some(scope) = target.request_scope(grid) else {
306        // omni-dev: coverage ignore reason="request_scope only returns None for a Range target paired with no grid, but resolve_target's Range arm always returns Ok with Some(grid) alongside it; this arm exists only to unwrap the shared Option"
307        return gated(FindReplaceResult::Failed {
308            detail: "internal error: a range scope did not resolve a GridRange".to_string(),
309        });
310        // omni-dev: coverage end
311    };
312    let request = FindReplaceRequest {
313        find: opts.find.clone(),
314        replacement: opts.replacement.clone(),
315        match_case: opts.match_case,
316        match_entire_cell: opts.match_entire_cell,
317        search_by_regex: opts.search_by_regex,
318        include_formulas: opts.include_formulas,
319        scope,
320    };
321    if opts.dry_run {
322        return gated(FindReplaceResult::WouldChange {
323            target,
324            summary: preview_summary(opts),
325        });
326    }
327    let files_api = FilesApi::new(drive);
328    let leased = LeasedWrite {
329        log_prefix: "drive sheets find-replace",
330        operation: "sheets-find-replace",
331        ledger_path: &opts.ledger_path,
332        file_id: &opts.spreadsheet_id,
333    };
334    let lease_grant = match gate_optional_leased_write(
335        leased,
336        &files_api,
337        requires_lease,
338        opts.lease_token.as_deref(),
339    )
340    .await
341    {
342        Ok(grant) => grant,
343        Err(err) => return gated(err.into_result()),
344    };
345    let outcome = match conclude_native_leased_write(
346        leased,
347        &lease_grant,
348        &files_api,
349        api.batch_update(
350            &opts.spreadsheet_id,
351            vec![BatchUpdateRequestItem::FindReplace(request)],
352        )
353        .await,
354        |err| format!("{err:#}"),
355    )
356    .await
357    {
358        Ok(outcome) => outcome,
359        Err(err) => {
360            return gated(FindReplaceResult::Failed {
361                detail: format!("{err:#}"),
362            })
363        }
364    };
365    // Not idempotent (`NotIdempotent`): a second run replaces again, so
366    // `a` -> `aa` compounds.
367    let response = match outcome {
368        BatchUpdateOutcome::Applied(response) => response,
369        BatchUpdateOutcome::AppliedReplyUnreadable { detail } => {
370            return gated(FindReplaceResult::AppliedReplyUnreadable {
371                summary: format!("find/replace on {}", target.describe()),
372                target,
373                detail,
374            })
375        }
376    };
377    let counts = response
378        .replies
379        .into_iter()
380        .next()
381        .and_then(|reply| reply.find_replace)
382        .map(Into::into);
383    gated(FindReplaceResult::Changed { target, counts })
384}
385
386fn resolve_target(
387    opts: &FindReplaceOptions,
388    workbook: &Spreadsheet,
389) -> Result<(FindReplaceTarget, Option<GridRange>), FindReplaceResult> {
390    debug_assert!(validate_scope_syntax(opts).is_ok());
391    if opts.all_sheets {
392        if opts.sheet.is_some() {
393            // omni-dev: coverage ignore reason="validate_scope_syntax already refuses --all-sheets combined with --sheet before resolve_target is ever reached; this arm exists only as a defensive re-check"
394            return Err(FindReplaceResult::RefusedInvalidRequest {
395                detail: "--all-sheets cannot be combined with --sheet".to_string(),
396            });
397            // omni-dev: coverage end
398        }
399        return Ok((FindReplaceTarget::AllSheets, None));
400    }
401    if opts.whole_sheet {
402        if opts.range.is_some() {
403            // omni-dev: coverage ignore reason="validate_scope_syntax's scope_count check already guarantees --range is unset whenever --whole-sheet is set, before resolve_target is ever reached; this arm exists only as a defensive re-check"
404            return Err(FindReplaceResult::RefusedInvalidRequest {
405                detail: "--whole-sheet cannot be combined with --range".to_string(),
406            });
407            // omni-dev: coverage end
408        }
409        let Some(sheet) = opts.sheet.as_deref() else {
410            // omni-dev: coverage ignore reason="validate_scope_syntax already refuses --whole-sheet without --sheet before resolve_target is ever reached; this arm exists only as a defensive re-check"
411            return Err(FindReplaceResult::RefusedInvalidRequest {
412                detail: "--whole-sheet requires --sheet".to_string(),
413            });
414            // omni-dev: coverage end
415        };
416        let sheet_id = grid_range::find_sheet_id(workbook, sheet, |title, available| {
417            FindReplaceResult::RefusedSheetNotFound { title, available }
418        })?;
419        return Ok((
420            FindReplaceTarget::Sheet {
421                sheet: sheet.to_string(),
422                sheet_id,
423            },
424            None,
425        ));
426    }
427    let composed = a1::compose(opts.sheet.as_deref(), opts.range.as_deref()).map_err(|err| {
428        FindReplaceResult::RefusedInvalidRequest {
429            detail: err.to_string(),
430        }
431    })?;
432    let (_, grid) = grid_range::resolve_grid_range(
433        workbook,
434        &composed,
435        |detail| FindReplaceResult::RefusedInvalidRequest { detail },
436        |title, available| FindReplaceResult::RefusedSheetNotFound { title, available },
437    )?;
438    Ok((
439        FindReplaceTarget::Range {
440            range: composed,
441            sheet_id: grid.sheet_id,
442        },
443        Some(grid),
444    ))
445}
446
447fn validate_scope_syntax(opts: &FindReplaceOptions) -> Result<(), String> {
448    let scope_count = usize::from(opts.range.is_some())
449        + usize::from(opts.whole_sheet)
450        + usize::from(opts.all_sheets);
451    if scope_count != 1 {
452        return Err("pass exactly one scope: --range, --whole-sheet, or --all-sheets".to_string());
453    }
454    if opts.all_sheets && opts.sheet.is_some() {
455        return Err("--all-sheets cannot be combined with --sheet".to_string());
456    }
457    if opts.whole_sheet && opts.sheet.is_none() {
458        return Err("--whole-sheet requires --sheet".to_string());
459    }
460    Ok(())
461}
462
463fn preview_summary(opts: &FindReplaceOptions) -> String {
464    format!(
465        "find {:?}, replace with {:?} (match-case={}, match-entire-cell={}, search-by-regex={}, include-formulas={}); Sheets computes match counts when executed",
466        opts.find, opts.replacement, opts.match_case, opts.match_entire_cell, opts.search_by_regex, opts.include_formulas
467    )
468}
469
470/// The `(occurrences_changed, updated_cells, fields_changed)` a result logs.
471///
472/// An unreadable-reply result logs no counts (they were in the reply) but
473/// always logs its summary as `fields_changed`.
474fn logged_counts(result: &FindReplaceResult) -> (Option<i64>, Option<i64>, Option<String>) {
475    match result {
476        FindReplaceResult::Changed {
477            target,
478            counts: Some(counts),
479        } => (
480            Some(counts.occurrences_changed),
481            Some(counts.values_changed + counts.formulas_changed),
482            Some(format!(
483                "{}; rows_changed={}, sheets_changed={}",
484                target.describe(),
485                counts.rows_changed,
486                counts.sheets_changed
487            )),
488        ),
489        FindReplaceResult::Changed {
490            target,
491            counts: None,
492        } => (
493            None,
494            None,
495            Some(format!(
496                "{}; response counts unavailable",
497                target.describe()
498            )),
499        ),
500        FindReplaceResult::AppliedReplyUnreadable { summary, .. } => {
501            (None, None, Some(summary.clone()))
502        }
503        FindReplaceResult::WouldChange { target, .. } => (None, None, Some(target.describe())), // omni-dev: coverage ignore-line reason="record_attempt is only called when !opts.dry_run, and WouldChange is only ever returned when opts.dry_run is true, so this arm can never run"
504        _ => (None, None, None),
505    }
506}
507
508fn record_attempt(outcome: &FindReplaceOutcome, _opts: &FindReplaceOptions, duration: Duration) {
509    let decided_by = match &outcome.result {
510        FindReplaceResult::Blocked { decided_by } => decided_by.as_ref(),
511        _ => None,
512    };
513    let decided_by = write_gate::decided_by_log_fields(decided_by);
514    let (occurrences_changed, updated_cells, fields_changed) = logged_counts(&outcome.result);
515    let error = match &outcome.result {
516        FindReplaceResult::Failed { detail }
517        | FindReplaceResult::RefusedInvalidRequest { detail }
518        | FindReplaceResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
519        _ => None,
520    };
521    request_log::record_drive_mutation(DriveMutationOutcome {
522        operation: "sheets-find-replace",
523        file_id: outcome.spreadsheet_id.clone(),
524        file_name: outcome.file_name.clone().unwrap_or_default(),
525        status: outcome.result.log_status().to_string(),
526        resolved_folder_id: outcome.resolved_folder_id.clone(),
527        decided_by_folder_id: decided_by.folder_id,
528        decided_by_depth: decided_by.depth,
529        decided_by_file_id: decided_by.file_id,
530        occurrences_changed,
531        updated_cells,
532        fields_changed,
533        error,
534        duration,
535        ..Default::default()
536    });
537}
538
539/// Human-readable lines, free of request content except the safe scope.
540#[must_use]
541pub fn describe_lines(outcome: &FindReplaceOutcome) -> Vec<String> {
542    let book = outcome.file_name.as_deref().map_or_else(
543        || format!("'{}'", outcome.spreadsheet_id),
544        |name| format!("'{name}'"),
545    );
546    match &outcome.result {
547        FindReplaceResult::WouldChange { target, summary } => vec![format!("Would run find/replace in {} on {book}. {summary}", target.describe())],
548        FindReplaceResult::Changed { target, counts: Some(c) } => vec![format!("Applied find/replace in {} on {book}: values={}, formulas={}, rows={}, sheets={}, occurrences={}", target.describe(), c.values_changed, c.formulas_changed, c.rows_changed, c.sheets_changed, c.occurrences_changed)],
549        FindReplaceResult::Changed { target, counts: None } => vec![format!("Applied find/replace in {} on {book}; Sheets returned no change counts", target.describe())],
550        FindReplaceResult::RefusedInvalidRequest { detail } => vec![format!("Refused: {detail}")],
551        FindReplaceResult::RefusedNotASpreadsheet { mime_type } => vec![format!("Refused: {book} is not a Google Sheet (mimeType: {mime_type})")],
552        FindReplaceResult::RefusedShortcut => vec![format!("Refused: {book} is a shortcut; find-replace doesn't follow shortcuts")],
553        FindReplaceResult::RefusedNoVisibleParents => vec![format!("Refused: {book} has no visible parent folder")],
554        FindReplaceResult::RefusedSheetNotFound { title, available } => vec![grid_range::no_sheet_refusal(&book, title, available)],
555        FindReplaceResult::Blocked { decided_by } => vec![match decided_by { Some(rule) => format!("Blocked: find-replace on {book} refused by rule on {} {}{}", rule.kind_label(), rule.id(), rule.depth_suffix()), None => format!("Blocked: find-replace on {book} refused by default policy (no matching rule for sheets-write)") }],
556        FindReplaceResult::RefusedNoLease => LeaseGateRefusal::NoLease.describe_line(&outcome.spreadsheet_id, &book).into_iter().collect(),
557        FindReplaceResult::RefusedLeaseExpired => LeaseGateRefusal::Expired.describe_line(&outcome.spreadsheet_id, &book).into_iter().collect(),
558        FindReplaceResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.describe_line(&outcome.spreadsheet_id, &book).into_iter().collect(),
559        FindReplaceResult::RefusedLeaseStale => LeaseGateRefusal::Stale.describe_line(&outcome.spreadsheet_id, &book).into_iter().collect(),
560        FindReplaceResult::AppliedReplyUnreadable {
561            summary, detail, ..
562        } => vec![
563            applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
564            "  the replacement counts were in the unreadable reply; check the spreadsheet".to_string(),
565        ],
566        FindReplaceResult::Failed { detail } => vec![format!("Failed: {detail}")],
567    }
568}
569
570#[cfg(test)]
571#[allow(clippy::unwrap_used, clippy::expect_used)]
572mod tests {
573    use super::*;
574
575    fn options() -> FindReplaceOptions {
576        FindReplaceOptions {
577            spreadsheet_id: "sheet-1".to_string(),
578            find: "draft".to_string(),
579            replacement: "final".to_string(),
580            sheet: None,
581            range: Some("Q1!A1".to_string()),
582            whole_sheet: false,
583            all_sheets: false,
584            match_case: false,
585            match_entire_cell: false,
586            search_by_regex: false,
587            include_formulas: false,
588            dry_run: true,
589            lease_token: None,
590            ledger_path: PathBuf::from("/tmp/unused-ledger"),
591        }
592    }
593
594    #[test]
595    fn validate_scope_syntax_requires_one_unambiguous_scope() {
596        let opts = options();
597        assert!(validate_scope_syntax(&opts).is_ok());
598
599        let mut ambiguous = opts.clone();
600        ambiguous.all_sheets = true;
601        assert!(validate_scope_syntax(&ambiguous)
602            .unwrap_err()
603            .contains("exactly one scope"));
604
605        let mut whole_without_sheet = opts.clone();
606        whole_without_sheet.range = None;
607        whole_without_sheet.whole_sheet = true;
608        assert!(validate_scope_syntax(&whole_without_sheet)
609            .unwrap_err()
610            .contains("requires --sheet"));
611
612        let mut all_sheets_with_sheet = opts;
613        all_sheets_with_sheet.range = None;
614        all_sheets_with_sheet.all_sheets = true;
615        all_sheets_with_sheet.sheet = Some("Q1".to_string());
616        assert!(validate_scope_syntax(&all_sheets_with_sheet)
617            .unwrap_err()
618            .contains("--all-sheets cannot be combined with --sheet"));
619    }
620
621    #[test]
622    fn lease_refusals_map_to_their_find_replace_results() {
623        assert_eq!(
624            FindReplaceResult::from_no_lease(),
625            FindReplaceResult::RefusedNoLease
626        );
627        assert_eq!(
628            FindReplaceResult::from_lease_expired(),
629            FindReplaceResult::RefusedLeaseExpired
630        );
631        assert_eq!(
632            FindReplaceResult::from_lease_wrong_file(),
633            FindReplaceResult::RefusedLeaseWrongFile
634        );
635        assert_eq!(
636            FindReplaceResult::from_lease_stale(),
637            FindReplaceResult::RefusedLeaseStale
638        );
639        assert_eq!(
640            FindReplaceResult::from_lease_failed("ledger unavailable".into()),
641            FindReplaceResult::Failed {
642                detail: "ledger unavailable".into()
643            }
644        );
645    }
646
647    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
648    use crate::drive::sheets::client::SHEETS_API_URL;
649    use crate::drive::test_support::seed_lease;
650    use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
651    use crate::drive::write_gate::Verdict;
652    use crate::test_support::env::MapEnv;
653    use crate::utils::secret::Secret;
654    use std::collections::HashSet;
655
656    fn test_credentials() -> DriveCredentials {
657        DriveCredentials {
658            client_id: "client-1".to_string(),
659            client_secret: Secret::new("secret-1"),
660            refresh_token: Secret::new("refresh-1"),
661            scope: DriveGrantedScopes::READONLY,
662        }
663    }
664
665    /// Both clients against one wiremock server, sharing an OAuth session.
666    /// Mirrors `write.rs::tests::clients`.
667    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
668        wiremock::Mock::given(wiremock::matchers::method("POST"))
669            .and(wiremock::matchers::path("/token"))
670            .respond_with(
671                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
672                    "access_token": "test-token", "expires_in": 3600,
673                })),
674            )
675            .mount(server)
676            .await;
677        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
678        crate::drive::client::test_support::replace_session(
679            &mut drive,
680            &test_credentials(),
681            &format!("{}/token", server.uri()),
682        );
683        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
684        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
685        (drive, sheets)
686    }
687
688    /// `version: "1"` throughout — matches [`opts`]'s default seeded lease.
689    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
690        let parents: Vec<&str> = parents.to_vec();
691        wiremock::Mock::given(wiremock::matchers::method("GET"))
692            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
693            .respond_with(
694                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
695                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
696                    "version": "1",
697                })),
698            )
699    }
700
701    fn mount_folder(id: &str) -> wiremock::Mock {
702        mount_file(id, "application/vnd.google-apps.folder", &[])
703    }
704
705    /// A `spreadsheets.get` reply with two sheets: `Q1` (sheetId 0) and
706    /// `Q2` (sheetId 42).
707    fn mount_workbook() -> wiremock::Mock {
708        wiremock::Mock::given(wiremock::matchers::method("GET"))
709            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
710            .respond_with(
711                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
712                    "spreadsheetId": "sheet-1",
713                    "properties": {"title": "Budget"},
714                    "sheets": [
715                        {"properties": {
716                            "sheetId": 0, "title": "Q1", "index": 0,
717                            "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
718                        {"properties": {
719                            "sheetId": 42, "title": "Q2", "index": 1,
720                            "gridProperties": {"rowCount": 500, "columnCount": 10}}}
721                    ],
722                })),
723            )
724    }
725
726    fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
727        wiremock::Mock::given(wiremock::matchers::method("POST"))
728            .and(wiremock::matchers::path(
729                "/v4/spreadsheets/sheet-1:batchUpdate",
730            ))
731            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
732    }
733
734    fn allow_rule(folder: &str) -> FolderPermissionRule {
735        FolderPermissionRule {
736            folder_id: Some(folder.to_string()),
737            file_id: None,
738            recursive: true,
739            allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
740            deny: HashSet::default(),
741            require_lease: true,
742        }
743    }
744
745    fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
746        FolderPermissionRule {
747            require_lease: false,
748            ..allow_rule(folder)
749        }
750    }
751
752    /// Seeds a fresh, isolated ledger with a live lease for `"sheet-1"` at
753    /// version `"1"` (matching [`mount_file`]'s default) and returns
754    /// options for a `--range Q1!A1:B2` request against it. Mirrors
755    /// `write.rs::tests::opts`'s leaked-tempdir rationale.
756    fn opts(dry_run: bool) -> FindReplaceOptions {
757        let ledger_path = tempfile::tempdir()
758            .unwrap()
759            .keep()
760            .join("lease-ledger.jsonl");
761        let token = seed_lease(&ledger_path, "sheet-1", "1");
762        FindReplaceOptions {
763            spreadsheet_id: "sheet-1".to_string(),
764            find: "draft".to_string(),
765            replacement: "final".to_string(),
766            sheet: None,
767            range: Some("Q1!A1:B2".to_string()),
768            whole_sheet: false,
769            all_sheets: false,
770            match_case: false,
771            match_entire_cell: false,
772            search_by_regex: false,
773            include_formulas: false,
774            dry_run,
775            lease_token: Some(token),
776            ledger_path,
777        }
778    }
779
780    // ── refusals that must precede the gate and the network ────────────
781
782    #[tokio::test]
783    async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
784        let server = wiremock::MockServer::start().await;
785        let (drive, sheets) = clients(&server).await;
786        mount_file("sheet-1", "application/pdf", &["parent-1"])
787            .mount(&server)
788            .await;
789        // Deliberately no mock for parent-1 (the gate never runs) and none
790        // for any Sheets endpoint.
791        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
792        assert!(matches!(
793            outcome.result,
794            FindReplaceResult::RefusedNotASpreadsheet { .. }
795        ));
796        let text = describe_lines(&outcome).join("\n");
797        assert!(text.contains("is not a Google Sheet"), "{text}");
798    }
799
800    #[tokio::test]
801    async fn shortcut_is_refused_with_its_own_message_not_the_generic_one() {
802        let server = wiremock::MockServer::start().await;
803        let (drive, sheets) = clients(&server).await;
804        mount_file(
805            "sheet-1",
806            "application/vnd.google-apps.shortcut",
807            &["parent-1"],
808        )
809        .mount(&server)
810        .await;
811        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
812        assert!(matches!(outcome.result, FindReplaceResult::RefusedShortcut));
813        let text = describe_lines(&outcome).join("\n");
814        assert!(text.contains("is a shortcut"), "{text}");
815        assert!(!text.contains("is not a Google Sheet"), "{text}");
816    }
817
818    #[tokio::test]
819    async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_a_blocked_one() {
820        let server = wiremock::MockServer::start().await;
821        let (drive, sheets) = clients(&server).await;
822        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
823            .mount(&server)
824            .await;
825        let outcome = find_replace(&drive, &sheets, &opts(false), &[]).await;
826        assert!(matches!(
827            outcome.result,
828            FindReplaceResult::RefusedNoVisibleParents
829        ));
830    }
831
832    // ── scope validation, before any network call ───────────────────────
833
834    #[tokio::test]
835    async fn empty_find_is_refused_before_any_network_call() {
836        let server = wiremock::MockServer::start().await;
837        let (drive, sheets) = clients(&server).await;
838        // No mocks at all — the refusal must short-circuit before `files.get`.
839        let mut o = opts(false);
840        o.find = String::new();
841        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
842        let FindReplaceResult::RefusedInvalidRequest { detail } = &outcome.result else {
843            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
844        };
845        assert!(detail.contains("--find"), "{detail}");
846    }
847
848    #[tokio::test]
849    async fn ambiguous_scope_is_refused_before_any_network_call() {
850        let server = wiremock::MockServer::start().await;
851        let (drive, sheets) = clients(&server).await;
852        let mut o = opts(false);
853        o.all_sheets = true; // `--range` is also set by `opts`.
854        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
855        let FindReplaceResult::RefusedInvalidRequest { detail } = &outcome.result else {
856            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
857        };
858        assert!(detail.contains("exactly one scope"), "{detail}");
859    }
860
861    #[tokio::test]
862    async fn whole_sheet_without_sheet_is_refused_before_any_network_call() {
863        let server = wiremock::MockServer::start().await;
864        let (drive, sheets) = clients(&server).await;
865        let mut o = opts(false);
866        o.range = None;
867        o.whole_sheet = true;
868        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
869        let FindReplaceResult::RefusedInvalidRequest { detail } = &outcome.result else {
870            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
871        };
872        assert!(detail.contains("requires --sheet"), "{detail}");
873    }
874
875    #[tokio::test]
876    async fn a_conflicting_sheet_and_range_fails_before_any_batch_update() {
877        // Unlike the pure-syntax refusals above, `--range` carrying its own
878        // sheet prefix plus `--sheet` is only caught once composed via
879        // `a1::compose`, which runs after the gate and the workbook fetch
880        // have already succeeded — so this needs both mocks, but must still
881        // never reach `batchUpdate`.
882        let server = wiremock::MockServer::start().await;
883        let (drive, sheets) = clients(&server).await;
884        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
885            .mount(&server)
886            .await;
887        mount_folder("parent-1").mount(&server).await;
888        mount_workbook().mount(&server).await;
889        // No batchUpdate mock: the refusal must short-circuit the mutation.
890        let mut o = opts(false);
891        o.sheet = Some("Q1".to_string());
892        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
893        let FindReplaceResult::RefusedInvalidRequest { detail } = &outcome.result else {
894            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
895        };
896        assert!(detail.contains("already names a sheet"), "{detail}");
897    }
898
899    #[tokio::test]
900    async fn sheet_not_found_is_refused_with_available_titles() {
901        let server = wiremock::MockServer::start().await;
902        let (drive, sheets) = clients(&server).await;
903        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
904            .mount(&server)
905            .await;
906        mount_folder("parent-1").mount(&server).await;
907        mount_workbook().mount(&server).await;
908        let mut o = opts(false);
909        o.range = None;
910        o.sheet = Some("Nope".to_string());
911        o.whole_sheet = true;
912        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
913        let FindReplaceResult::RefusedSheetNotFound { title, available } = &outcome.result else {
914            panic!("expected RefusedSheetNotFound, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
915        };
916        assert_eq!(title, "Nope");
917        assert_eq!(available, &["Q1".to_string(), "Q2".to_string()]);
918    }
919
920    #[tokio::test]
921    async fn a_range_without_a_sheet_prefix_is_refused_as_invalid() {
922        let server = wiremock::MockServer::start().await;
923        let (drive, sheets) = clients(&server).await;
924        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
925            .mount(&server)
926            .await;
927        mount_folder("parent-1").mount(&server).await;
928        mount_workbook().mount(&server).await;
929        let mut o = opts(false);
930        o.range = Some("A1:B2".to_string()); // no `Sheet!` prefix, and no `--sheet` either.
931        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
932        let FindReplaceResult::RefusedInvalidRequest { detail } = &outcome.result else {
933            panic!("expected RefusedInvalidRequest, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
934        };
935        assert!(detail.contains("does not name a sheet"), "{detail}");
936    }
937
938    #[tokio::test]
939    async fn a_range_naming_an_unknown_sheet_is_refused_with_available_titles() {
940        let server = wiremock::MockServer::start().await;
941        let (drive, sheets) = clients(&server).await;
942        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
943            .mount(&server)
944            .await;
945        mount_folder("parent-1").mount(&server).await;
946        mount_workbook().mount(&server).await;
947        let mut o = opts(false);
948        o.range = Some("Nope!A1:B2".to_string());
949        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
950        let FindReplaceResult::RefusedSheetNotFound { title, available } = &outcome.result else {
951            panic!("expected RefusedSheetNotFound, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
952        };
953        assert_eq!(title, "Nope");
954        assert_eq!(available, &["Q1".to_string(), "Q2".to_string()]);
955    }
956
957    #[tokio::test]
958    async fn a_metadata_fetch_failure_surfaces_as_failed() {
959        let server = wiremock::MockServer::start().await;
960        let (drive, sheets) = clients(&server).await;
961        wiremock::Mock::given(wiremock::matchers::method("GET"))
962            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
963            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
964            .mount(&server)
965            .await;
966        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
967        assert!(matches!(outcome.result, FindReplaceResult::Failed { .. }));
968    }
969
970    #[tokio::test]
971    async fn a_gate_fetch_failure_surfaces_as_failed() {
972        let server = wiremock::MockServer::start().await;
973        let (drive, sheets) = clients(&server).await;
974        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
975            .mount(&server)
976            .await;
977        wiremock::Mock::given(wiremock::matchers::method("GET"))
978            .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
979            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
980            .mount(&server)
981            .await;
982        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
983        assert!(matches!(outcome.result, FindReplaceResult::Failed { .. }));
984    }
985
986    #[tokio::test]
987    async fn a_workbook_fetch_failure_surfaces_as_failed() {
988        let server = wiremock::MockServer::start().await;
989        let (drive, sheets) = clients(&server).await;
990        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
991            .mount(&server)
992            .await;
993        mount_folder("parent-1").mount(&server).await;
994        wiremock::Mock::given(wiremock::matchers::method("GET"))
995            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
996            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
997            .mount(&server)
998            .await;
999        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1000        assert!(matches!(outcome.result, FindReplaceResult::Failed { .. }));
1001    }
1002
1003    // ── the gate ───────────────────────────────────────────────────────
1004
1005    #[tokio::test]
1006    async fn denied_target_makes_zero_sheets_calls() {
1007        let server = wiremock::MockServer::start().await;
1008        let (drive, sheets) = clients(&server).await;
1009        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1010            .mount(&server)
1011            .await;
1012        mount_folder("parent-1").mount(&server).await;
1013        // No workbook or batchUpdate mock: either call would 404.
1014        let outcome = find_replace(&drive, &sheets, &opts(false), &[]).await;
1015        assert!(
1016            matches!(
1017                outcome.result,
1018                FindReplaceResult::Blocked { decided_by: None }
1019            ),
1020            "{:?}",
1021            outcome.result
1022        );
1023    }
1024
1025    #[tokio::test]
1026    async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
1027        let server = wiremock::MockServer::start().await;
1028        let (drive, sheets) = clients(&server).await;
1029        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1030            .mount(&server)
1031            .await;
1032        mount_folder("parent-1").mount(&server).await;
1033        let deny_rule = FolderPermissionRule {
1034            folder_id: Some("parent-1".to_string()),
1035            file_id: None,
1036            recursive: true,
1037            allow: HashSet::default(),
1038            deny: std::iter::once(DriveOperation::SheetsWrite).collect(),
1039            require_lease: true,
1040        };
1041        let outcome = find_replace(&drive, &sheets, &opts(false), &[deny_rule]).await;
1042        let text = describe_lines(&outcome).join("\n");
1043        assert!(
1044            text.contains("refused by rule on folder parent-1"),
1045            "{text}"
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn an_edit_rule_alone_does_not_permit_find_replace() {
1051        // The consequence of ADR-0083 §1, asserted end-to-end: an existing
1052        // `allow: ["edit"]` rule must not silently grant find/replace.
1053        let server = wiremock::MockServer::start().await;
1054        let (drive, sheets) = clients(&server).await;
1055        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1056            .mount(&server)
1057            .await;
1058        mount_folder("parent-1").mount(&server).await;
1059        let edit_only = FolderPermissionRule {
1060            folder_id: Some("parent-1".to_string()),
1061            file_id: None,
1062            recursive: true,
1063            allow: std::iter::once(DriveOperation::Edit).collect(),
1064            deny: HashSet::default(),
1065            require_lease: true,
1066        };
1067        let outcome = find_replace(&drive, &sheets, &opts(false), &[edit_only]).await;
1068        assert!(matches!(outcome.result, FindReplaceResult::Blocked { .. }));
1069    }
1070
1071    // ── dry run ────────────────────────────────────────────────────────
1072
1073    #[tokio::test]
1074    async fn dry_run_reports_scope_terms_and_modifiers_and_calls_no_batch_update() {
1075        // Regression coverage for the ADR-0083 §6 requirement that the
1076        // preview name the search and replacement terms, not only the
1077        // boolean modifiers.
1078        let server = wiremock::MockServer::start().await;
1079        let (drive, sheets) = clients(&server).await;
1080        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1081            .mount(&server)
1082            .await;
1083        mount_folder("parent-1").mount(&server).await;
1084        mount_workbook().expect(1).mount(&server).await;
1085        // No batchUpdate mock: a dry run must never send it.
1086        let mut o = opts(true);
1087        o.match_case = true;
1088        o.include_formulas = true;
1089        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1090        let FindReplaceResult::WouldChange { target, summary } = &outcome.result else {
1091            panic!("expected WouldChange, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
1092        };
1093        assert!(matches!(target, FindReplaceTarget::Range { .. }));
1094        assert!(summary.contains("\"draft\""), "{summary}");
1095        assert!(summary.contains("\"final\""), "{summary}");
1096        assert!(summary.contains("match-case=true"), "{summary}");
1097        assert!(summary.contains("include-formulas=true"), "{summary}");
1098        assert!(
1099            summary.contains("computes match counts when executed"),
1100            "{summary}"
1101        );
1102    }
1103
1104    #[tokio::test]
1105    async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
1106        let server = wiremock::MockServer::start().await;
1107        let (drive, sheets) = clients(&server).await;
1108        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1109            .expect(2)
1110            .mount(&server)
1111            .await;
1112        mount_folder("parent-1").expect(2).mount(&server).await;
1113
1114        let dry = find_replace(&drive, &sheets, &opts(true), &[]).await;
1115        let real = find_replace(&drive, &sheets, &opts(false), &[]).await;
1116        assert_eq!(dry.result, real.result);
1117    }
1118
1119    // ── successful mutations ───────────────────────────────────────────
1120
1121    #[tokio::test]
1122    async fn allowed_find_replace_sends_a_range_scope_request_and_reports_counts() {
1123        let server = wiremock::MockServer::start().await;
1124        let (drive, sheets) = clients(&server).await;
1125        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1126            .mount(&server)
1127            .await;
1128        mount_folder("parent-1").mount(&server).await;
1129        mount_workbook().mount(&server).await;
1130        wiremock::Mock::given(wiremock::matchers::method("POST"))
1131            .and(wiremock::matchers::path(
1132                "/v4/spreadsheets/sheet-1:batchUpdate",
1133            ))
1134            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1135                "requests": [{"findReplace": {
1136                    "find": "draft", "replacement": "final",
1137                    "matchCase": false, "matchEntireCell": false,
1138                    "searchByRegex": false, "includeFormulas": false,
1139                }}],
1140            })))
1141            .respond_with(
1142                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1143                    "replies": [{"findReplace": {
1144                        "valuesChanged": 2, "formulasChanged": 0,
1145                        "rowsChanged": 1, "sheetsChanged": 1, "occurrencesChanged": 3,
1146                    }}],
1147                })),
1148            )
1149            .expect(1)
1150            .mount(&server)
1151            .await;
1152
1153        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1154        let FindReplaceResult::Changed { target, counts } = &outcome.result else {
1155            panic!("expected Changed, got {:?}", outcome.result); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; this test always constructs that exact variant, so the branch never executes"
1156        };
1157        assert!(matches!(target, FindReplaceTarget::Range { .. }));
1158        let counts = counts.as_ref().expect("counts present");
1159        assert_eq!(counts.occurrences_changed, 3);
1160        assert_eq!(counts.values_changed, 2);
1161        let text = describe_lines(&outcome).join("\n");
1162        assert!(text.contains("occurrences=3"), "{text}");
1163    }
1164
1165    #[tokio::test]
1166    async fn allowed_find_replace_sends_a_sheet_scope_request_for_whole_sheet() {
1167        let server = wiremock::MockServer::start().await;
1168        let (drive, sheets) = clients(&server).await;
1169        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1170            .mount(&server)
1171            .await;
1172        mount_folder("parent-1").mount(&server).await;
1173        mount_workbook().mount(&server).await;
1174        wiremock::Mock::given(wiremock::matchers::method("POST"))
1175            .and(wiremock::matchers::path(
1176                "/v4/spreadsheets/sheet-1:batchUpdate",
1177            ))
1178            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1179                "requests": [{"findReplace": {"sheetId": 42}}],
1180            })))
1181            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1182            .expect(1)
1183            .mount(&server)
1184            .await;
1185
1186        let mut o = opts(false);
1187        o.range = None;
1188        o.sheet = Some("Q2".to_string());
1189        o.whole_sheet = true;
1190        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1191        assert!(matches!(outcome.result, FindReplaceResult::Changed { .. }));
1192    }
1193
1194    #[tokio::test]
1195    async fn allowed_find_replace_sends_an_all_sheets_scope_request() {
1196        let server = wiremock::MockServer::start().await;
1197        let (drive, sheets) = clients(&server).await;
1198        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1199            .mount(&server)
1200            .await;
1201        mount_folder("parent-1").mount(&server).await;
1202        mount_workbook().mount(&server).await;
1203        wiremock::Mock::given(wiremock::matchers::method("POST"))
1204            .and(wiremock::matchers::path(
1205                "/v4/spreadsheets/sheet-1:batchUpdate",
1206            ))
1207            .and(wiremock::matchers::body_partial_json(serde_json::json!({
1208                "requests": [{"findReplace": {"allSheets": true}}],
1209            })))
1210            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
1211            .expect(1)
1212            .mount(&server)
1213            .await;
1214
1215        let mut o = opts(false);
1216        o.range = None;
1217        o.all_sheets = true;
1218        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1219        assert!(matches!(outcome.result, FindReplaceResult::Changed { .. }));
1220    }
1221
1222    #[tokio::test]
1223    async fn a_response_omitting_the_find_replace_reply_reports_changed_with_no_counts() {
1224        // `BatchUpdateReply::find_replace` deliberately tolerates a missing
1225        // reply object — a successful `batchUpdate` must still report
1226        // success, just with unavailable counts, rather than `Failed`.
1227        let server = wiremock::MockServer::start().await;
1228        let (drive, sheets) = clients(&server).await;
1229        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1230            .mount(&server)
1231            .await;
1232        mount_folder("parent-1").mount(&server).await;
1233        mount_workbook().mount(&server).await;
1234        mount_batch_update(serde_json::json!({"replies": [{}]}))
1235            .mount(&server)
1236            .await;
1237
1238        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1239        assert_eq!(
1240            outcome.result,
1241            FindReplaceResult::Changed {
1242                target: FindReplaceTarget::Range {
1243                    range: "Q1!A1:B2".to_string(),
1244                    sheet_id: 0,
1245                },
1246                counts: None,
1247            }
1248        );
1249        let text = describe_lines(&outcome).join("\n");
1250        assert!(text.contains("no change counts"), "{text}");
1251    }
1252
1253    #[tokio::test]
1254    async fn a_batch_update_error_is_reported_as_failed() {
1255        let server = wiremock::MockServer::start().await;
1256        let (drive, sheets) = clients(&server).await;
1257        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1258            .mount(&server)
1259            .await;
1260        mount_folder("parent-1").mount(&server).await;
1261        mount_workbook().mount(&server).await;
1262        wiremock::Mock::given(wiremock::matchers::method("POST"))
1263            .and(wiremock::matchers::path(
1264                "/v4/spreadsheets/sheet-1:batchUpdate",
1265            ))
1266            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1267            .mount(&server)
1268            .await;
1269
1270        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1271        assert!(matches!(outcome.result, FindReplaceResult::Failed { .. }));
1272    }
1273
1274    #[tokio::test]
1275    async fn find_replace_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1276        let server = wiremock::MockServer::start().await;
1277        let (drive, sheets) = clients(&server).await;
1278        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1279            .mount(&server)
1280            .await;
1281        mount_folder("parent-1").mount(&server).await;
1282        mount_workbook().mount(&server).await;
1283        // 2xx, so the replacement WAS applied — but the body is not the reply shape.
1284        wiremock::Mock::given(wiremock::matchers::method("POST"))
1285            .and(wiremock::matchers::path(
1286                "/v4/spreadsheets/sheet-1:batchUpdate",
1287            ))
1288            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1289            .expect(1)
1290            .mount(&server)
1291            .await;
1292
1293        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1294        assert_eq!(
1295            outcome.result.log_status(),
1296            "applied-reply-unreadable",
1297            "{:?}",
1298            outcome.result
1299        );
1300        let lines = describe_lines(&outcome);
1301        assert!(
1302            lines[0].starts_with("Applied, but the reply could not be read"),
1303            "{lines:?}"
1304        );
1305        assert!(lines[0].contains("do not retry"), "{lines:?}");
1306    }
1307
1308    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1309
1310    #[tokio::test]
1311    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1312        let server = wiremock::MockServer::start().await;
1313        let (drive, sheets) = clients(&server).await;
1314        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1315            .mount(&server)
1316            .await;
1317        mount_folder("parent-1").mount(&server).await;
1318        mount_workbook().mount(&server).await;
1319        // No batchUpdate mock: a refusal must make zero mutating calls.
1320
1321        let mut o = opts(false);
1322        o.lease_token = None;
1323        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1324        assert_eq!(outcome.result, FindReplaceResult::RefusedNoLease);
1325    }
1326
1327    #[tokio::test]
1328    async fn refuses_an_unknown_lease_token() {
1329        let server = wiremock::MockServer::start().await;
1330        let (drive, sheets) = clients(&server).await;
1331        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1332            .mount(&server)
1333            .await;
1334        mount_folder("parent-1").mount(&server).await;
1335        mount_workbook().mount(&server).await;
1336
1337        let mut o = opts(false);
1338        o.lease_token = Some("bogus-token".to_string());
1339        // Never seeded — no ledger exists at this fresh path.
1340        o.ledger_path = tempfile::tempdir()
1341            .unwrap()
1342            .keep()
1343            .join("lease-ledger.jsonl");
1344        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1345        assert_eq!(outcome.result, FindReplaceResult::RefusedLeaseExpired);
1346    }
1347
1348    #[tokio::test]
1349    async fn refuses_a_lease_bound_to_a_different_file() {
1350        let server = wiremock::MockServer::start().await;
1351        let (drive, sheets) = clients(&server).await;
1352        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1353            .mount(&server)
1354            .await;
1355        mount_folder("parent-1").mount(&server).await;
1356        mount_workbook().mount(&server).await;
1357
1358        let ledger_path = tempfile::tempdir()
1359            .unwrap()
1360            .keep()
1361            .join("lease-ledger.jsonl");
1362        let token = seed_lease(&ledger_path, "other-sheet", "1");
1363        let mut o = opts(false);
1364        o.lease_token = Some(token);
1365        o.ledger_path = ledger_path;
1366        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1367        assert_eq!(outcome.result, FindReplaceResult::RefusedLeaseWrongFile);
1368    }
1369
1370    #[tokio::test]
1371    async fn refuses_a_stale_lease() {
1372        let server = wiremock::MockServer::start().await;
1373        let (drive, sheets) = clients(&server).await;
1374        // Live version "1" (`mount_file`'s default) but the lease was
1375        // acquired at "0" — the file has moved since.
1376        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1377            .mount(&server)
1378            .await;
1379        mount_folder("parent-1").mount(&server).await;
1380        mount_workbook().mount(&server).await;
1381
1382        let ledger_path = tempfile::tempdir()
1383            .unwrap()
1384            .keep()
1385            .join("lease-ledger.jsonl");
1386        let token = seed_lease(&ledger_path, "sheet-1", "0");
1387        let mut o = opts(false);
1388        o.lease_token = Some(token);
1389        o.ledger_path = ledger_path;
1390        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
1391        assert_eq!(outcome.result, FindReplaceResult::RefusedLeaseStale);
1392    }
1393
1394    #[tokio::test]
1395    async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
1396        let server = wiremock::MockServer::start().await;
1397        let (drive, sheets) = clients(&server).await;
1398        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1399            .mount(&server)
1400            .await;
1401        mount_folder("parent-1").mount(&server).await;
1402        mount_workbook().mount(&server).await;
1403        mount_batch_update(serde_json::json!({}))
1404            .expect(1)
1405            .mount(&server)
1406            .await;
1407
1408        let mut o = opts(false);
1409        o.lease_token = None;
1410        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1411        assert!(matches!(outcome.result, FindReplaceResult::Changed { .. }));
1412    }
1413
1414    #[tokio::test]
1415    async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
1416        // ADR-0080 §13: `require_lease: false` relaxes the *requirement*,
1417        // not the *meaning* — a token volunteered anyway is checked exactly
1418        // like a required one, including staleness.
1419        let server = wiremock::MockServer::start().await;
1420        let (drive, sheets) = clients(&server).await;
1421        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1422            .mount(&server)
1423            .await;
1424        mount_folder("parent-1").mount(&server).await;
1425        mount_workbook().mount(&server).await;
1426        // No batchUpdate mock: a refusal must make zero mutating calls.
1427
1428        let ledger_path = tempfile::tempdir()
1429            .unwrap()
1430            .keep()
1431            .join("lease-ledger.jsonl");
1432        let token = seed_lease(&ledger_path, "sheet-1", "0");
1433        let mut o = opts(false);
1434        o.lease_token = Some(token);
1435        o.ledger_path = ledger_path;
1436        let outcome = find_replace(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
1437        assert_eq!(outcome.result, FindReplaceResult::RefusedLeaseStale);
1438    }
1439
1440    // ── the write's own audit trail (ADR-0080 §11) ─────────────────────
1441
1442    #[tokio::test]
1443    async fn a_leased_find_replace_concludes_its_audit_pair_with_allowed() {
1444        let server = wiremock::MockServer::start().await;
1445        let (drive, sheets) = clients(&server).await;
1446        mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
1447            .mount(&server)
1448            .await;
1449        mount_folder("parent-1").mount(&server).await;
1450        mount_workbook().mount(&server).await;
1451        mount_batch_update(serde_json::json!({}))
1452            .mount(&server)
1453            .await;
1454        let dir = tempfile::tempdir().unwrap();
1455        let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
1456
1457        let outcome = find_replace(&drive, &sheets, &opts(false), &[allow_rule("parent-1")]).await;
1458        assert!(matches!(outcome.result, FindReplaceResult::Changed { .. }));
1459
1460        let records = audit.records();
1461        assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
1462        // The verb, not the engine — the same `["drive", <log_operation>]`
1463        // this write's `drivemutation` record carries.
1464        assert_eq!(records[0].command, ["drive", "sheets-find-replace"]);
1465    }
1466
1467    // ── describing an outcome / metadata ────────────────────────────────
1468
1469    /// One of every [`FindReplaceResult`] variant.
1470    ///
1471    /// The `match` below is exhaustive and wildcard-free on purpose: adding
1472    /// a variant breaks this build, which is what forces the new arm
1473    /// through `every_describe_arm_renders_a_single_line`.
1474    fn every_find_replace_result() -> Vec<FindReplaceResult> {
1475        let all = vec![
1476            FindReplaceResult::WouldChange {
1477                target: FindReplaceTarget::AllSheets,
1478                summary: "find \"a\", replace with \"b\"".to_string(),
1479            },
1480            FindReplaceResult::Changed {
1481                target: FindReplaceTarget::Range {
1482                    range: "A1:B2".to_string(),
1483                    sheet_id: 0,
1484                },
1485                counts: Some(FindReplaceCounts {
1486                    values_changed: 1,
1487                    formulas_changed: 0,
1488                    rows_changed: 1,
1489                    sheets_changed: 1,
1490                    occurrences_changed: 1,
1491                }),
1492            },
1493            FindReplaceResult::Changed {
1494                target: FindReplaceTarget::Sheet {
1495                    sheet: "Q1".to_string(),
1496                    sheet_id: 0,
1497                },
1498                counts: None,
1499            },
1500            FindReplaceResult::RefusedInvalidRequest {
1501                detail: "bad".to_string(),
1502            },
1503            FindReplaceResult::RefusedNotASpreadsheet {
1504                mime_type: "application/pdf".to_string(),
1505            },
1506            FindReplaceResult::RefusedShortcut,
1507            FindReplaceResult::RefusedNoVisibleParents,
1508            FindReplaceResult::RefusedSheetNotFound {
1509                title: "Nope".to_string(),
1510                available: vec!["Q1".to_string()],
1511            },
1512            FindReplaceResult::Blocked { decided_by: None },
1513            FindReplaceResult::Blocked {
1514                decided_by: Some(DecidingRule::Folder {
1515                    folder_id: "folder-1".to_string(),
1516                    depth: 2,
1517                }),
1518            },
1519            FindReplaceResult::Blocked {
1520                decided_by: Some(DecidingRule::File {
1521                    file_id: "sheet-1".to_string(),
1522                }),
1523            },
1524            FindReplaceResult::RefusedNoLease,
1525            FindReplaceResult::RefusedLeaseExpired,
1526            FindReplaceResult::RefusedLeaseWrongFile,
1527            FindReplaceResult::RefusedLeaseStale,
1528            FindReplaceResult::AppliedReplyUnreadable {
1529                target: FindReplaceTarget::AllSheets,
1530                summary: "find/replace on all sheets".to_string(),
1531                detail: "bad reply".to_string(),
1532            },
1533            FindReplaceResult::Failed {
1534                detail: "boom".to_string(),
1535            },
1536        ];
1537        for result in &all {
1538            match result {
1539                FindReplaceResult::WouldChange { .. }
1540                | FindReplaceResult::Changed { .. }
1541                | FindReplaceResult::RefusedInvalidRequest { .. }
1542                | FindReplaceResult::RefusedNotASpreadsheet { .. }
1543                | FindReplaceResult::RefusedShortcut
1544                | FindReplaceResult::RefusedNoVisibleParents
1545                | FindReplaceResult::RefusedSheetNotFound { .. }
1546                | FindReplaceResult::Blocked { .. }
1547                | FindReplaceResult::RefusedNoLease
1548                | FindReplaceResult::RefusedLeaseExpired
1549                | FindReplaceResult::RefusedLeaseWrongFile
1550                | FindReplaceResult::RefusedLeaseStale
1551                | FindReplaceResult::AppliedReplyUnreadable { .. }
1552                | FindReplaceResult::Failed { .. } => (),
1553            }
1554        }
1555        all
1556    }
1557
1558    #[test]
1559    fn every_describe_arm_renders_a_single_line() {
1560        for result in every_find_replace_result() {
1561            let outcome = FindReplaceOutcome {
1562                spreadsheet_id: "sheet-1".to_string(),
1563                file_name: Some("Quarterly Plan".to_string()),
1564                resolved_folder_id: None,
1565                result,
1566            };
1567            let lines = describe_lines(&outcome);
1568            // The unreadable-reply result adds one hint line for the counts.
1569            let expected = if matches!(
1570                outcome.result,
1571                FindReplaceResult::AppliedReplyUnreadable { .. }
1572            ) {
1573                2
1574            } else {
1575                1
1576            };
1577            assert_eq!(
1578                lines.len(),
1579                expected,
1580                "describe_lines emitted {} lines for {:?}: {lines:?}",
1581                lines.len(), // omni-dev: coverage ignore-line reason="assert_eq!'s message args are only evaluated on failure, and this test always passes"
1582                outcome.result
1583            );
1584            assert!(
1585                !lines[0].chars().any(char::is_control),
1586                "describe_lines emitted a control character for {:?}: {lines:?}",
1587                outcome.result
1588            );
1589        }
1590    }
1591
1592    #[test]
1593    fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
1594        let outcome = FindReplaceOutcome {
1595            spreadsheet_id: "sheet-1".to_string(),
1596            file_name: None,
1597            resolved_folder_id: None,
1598            result: FindReplaceResult::RefusedShortcut,
1599        };
1600        let lines = describe_lines(&outcome);
1601        assert_eq!(lines.len(), 1);
1602        assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
1603    }
1604
1605    #[test]
1606    fn log_status_covers_every_variant() {
1607        for result in every_find_replace_result() {
1608            assert!(!result.log_status().is_empty());
1609        }
1610        assert_eq!(
1611            FindReplaceResult::Blocked { decided_by: None }.log_status(),
1612            "blocked"
1613        );
1614        assert_eq!(
1615            FindReplaceResult::Changed {
1616                target: FindReplaceTarget::AllSheets,
1617                counts: None
1618            }
1619            .log_status(),
1620            "changed"
1621        );
1622        assert_eq!(
1623            FindReplaceResult::RefusedLeaseStale.log_status(),
1624            "refused-lease-stale"
1625        );
1626        assert_eq!(
1627            FindReplaceResult::AppliedReplyUnreadable {
1628                target: FindReplaceTarget::AllSheets,
1629                summary: String::new(),
1630                detail: String::new()
1631            }
1632            .log_status(),
1633            "applied-reply-unreadable"
1634        );
1635    }
1636
1637    #[test]
1638    fn describe_lines_renders_applied_reply_unreadable_with_a_counts_hint() {
1639        let outcome = FindReplaceOutcome {
1640            spreadsheet_id: "sheet-1".to_string(),
1641            file_name: Some("Budget".to_string()),
1642            resolved_folder_id: None,
1643            result: FindReplaceResult::AppliedReplyUnreadable {
1644                target: FindReplaceTarget::Sheet {
1645                    sheet: "Q1".to_string(),
1646                    sheet_id: 0,
1647                },
1648                summary: "find/replace on sheet 'Q1'".to_string(),
1649                detail: "bad reply".to_string(),
1650            },
1651        };
1652        assert_eq!(
1653            describe_lines(&outcome),
1654            [
1655                "Applied, but the reply could not be read: find/replace on sheet 'Q1' in \
1656                 'Budget' — do not retry; check the spreadsheet first (bad reply)",
1657                "  the replacement counts were in the unreadable reply; check the spreadsheet",
1658            ]
1659        );
1660    }
1661
1662    #[test]
1663    fn an_unreadable_reply_logs_its_summary_and_no_reply_derived_counts() {
1664        let result = FindReplaceResult::AppliedReplyUnreadable {
1665            target: FindReplaceTarget::AllSheets,
1666            summary: "find/replace on all sheets".to_string(),
1667            detail: "bad reply".to_string(),
1668        };
1669        assert_eq!(
1670            logged_counts(&result),
1671            (None, None, Some("find/replace on all sheets".to_string()))
1672        );
1673    }
1674
1675    #[test]
1676    fn an_unreadable_reply_serializes_its_target_and_status() {
1677        let result = FindReplaceResult::AppliedReplyUnreadable {
1678            target: FindReplaceTarget::Range {
1679                range: "A1:B2".to_string(),
1680                sheet_id: 7,
1681            },
1682            summary: "find/replace on range A1:B2".to_string(),
1683            detail: "bad reply".to_string(),
1684        };
1685        let json = serde_json::to_value(&result).unwrap();
1686        assert_eq!(json["status"], "applied-reply-unreadable");
1687        assert_eq!(json["target"]["sheet_id"], 7);
1688        assert_eq!(json["target"]["range"], "A1:B2");
1689    }
1690
1691    #[test]
1692    fn write_jsonl_emits_one_line_of_json() {
1693        let outcome = FindReplaceOutcome {
1694            spreadsheet_id: "sheet-1".to_string(),
1695            file_name: Some("Budget".to_string()),
1696            resolved_folder_id: Some("parent-1".to_string()),
1697            result: FindReplaceResult::Changed {
1698                target: FindReplaceTarget::AllSheets,
1699                counts: Some(FindReplaceCounts {
1700                    values_changed: 1,
1701                    formulas_changed: 0,
1702                    rows_changed: 1,
1703                    sheets_changed: 1,
1704                    occurrences_changed: 1,
1705                }),
1706            },
1707        };
1708        let mut buf = Vec::new();
1709        outcome.write_jsonl(&mut buf).unwrap();
1710        let text = String::from_utf8(buf).unwrap();
1711        assert_eq!(text.matches('\n').count(), 1);
1712        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
1713        assert_eq!(parsed["result"]["status"], "changed");
1714    }
1715
1716    #[test]
1717    fn gate_denies_sheets_write_by_default() {
1718        let decision =
1719            write_gate::resolve(&["folder".to_string()], DriveOperation::SheetsWrite, &[]);
1720        assert_eq!(decision.verdict, Verdict::Deny);
1721    }
1722}