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::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#[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#[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#[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#[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#[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
191pub 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 return gated(FindReplaceResult::Failed {
294 detail: "internal error: a range scope did not resolve a GridRange".to_string(),
295 });
296 };
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 return Err(FindReplaceResult::RefusedInvalidRequest {
369 detail: "--all-sheets cannot be combined with --sheet".to_string(),
370 });
371 }
373 return Ok((FindReplaceTarget::AllSheets, None));
374 }
375 if opts.whole_sheet {
376 if opts.range.is_some() {
377 return Err(FindReplaceResult::RefusedInvalidRequest {
379 detail: "--whole-sheet cannot be combined with --range".to_string(),
380 });
381 }
383 let Some(sheet) = opts.sheet.as_deref() else {
384 return Err(FindReplaceResult::RefusedInvalidRequest {
386 detail: "--whole-sheet requires --sheet".to_string(),
387 });
388 };
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())), _ => (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#[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 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 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 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 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 #[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 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 #[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 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); };
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; 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); };
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); };
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 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 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); };
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); };
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()); 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); };
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); };
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 #[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 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 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 #[tokio::test]
1030 async fn dry_run_reports_scope_terms_and_modifiers_and_calls_no_batch_update() {
1031 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 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); };
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 #[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); };
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 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 #[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 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 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 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 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 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 #[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 assert_eq!(records[0].command, ["drive", "sheets-find-replace"]);
1387 }
1388
1389 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(), 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}