1#![allow(missing_docs)] use 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#[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#[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#[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#[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 AppliedReplyUnreadable {
140 target: FindReplaceTarget,
141 summary: String,
143 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#[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
205pub 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 return gated(FindReplaceResult::Failed {
308 detail: "internal error: a range scope did not resolve a GridRange".to_string(),
309 });
310 };
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 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 return Err(FindReplaceResult::RefusedInvalidRequest {
395 detail: "--all-sheets cannot be combined with --sheet".to_string(),
396 });
397 }
399 return Ok((FindReplaceTarget::AllSheets, None));
400 }
401 if opts.whole_sheet {
402 if opts.range.is_some() {
403 return Err(FindReplaceResult::RefusedInvalidRequest {
405 detail: "--whole-sheet cannot be combined with --range".to_string(),
406 });
407 }
409 let Some(sheet) = opts.sheet.as_deref() else {
410 return Err(FindReplaceResult::RefusedInvalidRequest {
412 detail: "--whole-sheet requires --sheet".to_string(),
413 });
414 };
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
470fn 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())), _ => (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#[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 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 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 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 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 #[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 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 #[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 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); };
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; 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); };
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); };
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 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 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); };
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); };
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()); 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); };
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); };
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 #[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 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 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 #[tokio::test]
1074 async fn dry_run_reports_scope_terms_and_modifiers_and_calls_no_batch_update() {
1075 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 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); };
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 #[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); };
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 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 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 #[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 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 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 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 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 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 #[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 assert_eq!(records[0].command, ["drive", "sheets-find-replace"]);
1465 }
1466
1467 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 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(), 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}