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