1#![allow(missing_docs)] use std::path::PathBuf;
38use std::time::{Duration, Instant};
39
40use serde::Serialize;
41
42use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
43use crate::drive::client::DriveClient;
44use crate::drive::files_api::FilesApi;
45use crate::drive::lease::check::{
46 conclude_native_leased_write, gate_optional_leased_write, recovery_note, FromLeaseRefusal,
47 LeaseGateRefusal, LeasedWrite,
48};
49use crate::drive::lease::ledger::LeaseBackup;
50use crate::drive::sheets::api::SheetsApi;
51use crate::drive::sheets::client::SheetsClient;
52use crate::drive::sheets::types::{
53 BatchUpdateRequestItem, DeleteDuplicatesRequest, Dimension, DimensionRange, GridRange,
54};
55use crate::drive::sheets::{a1, grid_range, target_gate};
56use crate::drive::types::SheetTargetRefusal;
57use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
58use crate::request_log::{self, DriveMutationOutcome};
59
60const LOG_OPERATION: &str = "sheets-delete-duplicates";
61
62const EQUALITY_RULE: &str = "the API keeps the first instance of each duplicate and removes the \
66 rest; duplicates need not be adjacent, rows differing only in letter \
67 case, formatting or formulas still count as duplicates, and rows \
68 hidden by a filter are removed along with visible ones";
69
70const BLANK_ROW_CAVEAT: &str = "blank rows between data rows duplicate one another, so every such \
75 blank row after the first is removed; blank rows after the last \
76 data row are left alone";
77const RANGE_ONLY_CAVEAT: &str = "only cells inside the selected range are removed and shifted up; \
78 columns outside it stay in place, so a range narrower than the \
79 sheet can misalign records";
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct DeleteDuplicatesOptions {
83 pub spreadsheet_id: String,
84 pub sheet: Option<String>,
85 pub range: Option<String>,
86 pub comparison_columns: Vec<i64>,
89 pub dry_run: bool,
90 pub lease_token: Option<String>,
91 pub ledger_path: PathBuf,
92}
93
94#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
95#[serde(tag = "status", rename_all = "kebab-case")]
96pub enum DeleteDuplicatesResult {
97 WouldRemove {
98 range: String,
99 comparison_columns: Vec<i64>,
101 content_outside_range_untouched: bool,
103 },
104 Removed {
105 range: String,
106 comparison_columns: Vec<i64>,
107 content_outside_range_untouched: bool,
109 duplicates_removed_count: Option<i64>,
113 #[serde(skip_serializing_if = "Option::is_none")]
117 backup: Option<Box<LeaseBackup>>,
118 },
119 RefusedInvalidRequest {
120 detail: String,
121 },
122 RefusedNotASpreadsheet {
123 mime_type: String,
124 },
125 RefusedShortcut,
126 RefusedNoVisibleParents,
127 RefusedSheetNotFound {
128 title: String,
129 available: Vec<String>,
130 },
131 Blocked {
132 decided_by: Option<DecidingRule>,
133 },
134 RefusedNoLease,
135 RefusedLeaseExpired,
136 RefusedLeaseWrongFile,
137 RefusedLeaseStale,
138 Failed {
139 detail: String,
140 },
141}
142
143impl FromLeaseRefusal for DeleteDuplicatesResult {
144 fn from_no_lease() -> Self {
145 Self::RefusedNoLease
146 }
147 fn from_lease_expired() -> Self {
148 Self::RefusedLeaseExpired
149 }
150 fn from_lease_wrong_file() -> Self {
151 Self::RefusedLeaseWrongFile
152 }
153 fn from_lease_stale() -> Self {
154 Self::RefusedLeaseStale
155 }
156 fn from_lease_failed(detail: String) -> Self {
157 Self::Failed { detail }
158 }
159}
160
161impl DeleteDuplicatesResult {
162 fn log_status(&self) -> &'static str {
163 match self {
164 Self::WouldRemove { .. } => "would-remove",
165 Self::Removed { .. } => "removed",
166 Self::RefusedInvalidRequest { .. } => "refused-invalid-request",
167 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
168 Self::RefusedShortcut => "refused-shortcut",
169 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
170 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
171 Self::Blocked { .. } => "blocked",
172 Self::RefusedNoLease => "refused-no-lease",
173 Self::RefusedLeaseExpired => "refused-lease-expired",
174 Self::RefusedLeaseWrongFile => "refused-lease-wrong-file",
175 Self::RefusedLeaseStale => "refused-lease-stale",
176 Self::Failed { .. } => "failed",
177 }
178 }
179}
180
181#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
182pub struct DeleteDuplicatesOutcome {
183 pub spreadsheet_id: String,
184 pub file_name: Option<String>,
185 pub resolved_folder_id: Option<String>,
186 pub result: DeleteDuplicatesResult,
187}
188
189impl JsonlSerialize for DeleteDuplicatesOutcome {
190 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
191 write_scalar_jsonl(self, out)
192 }
193}
194
195pub async fn delete_duplicates(
196 drive: &DriveClient,
197 sheets: &SheetsClient,
198 opts: &DeleteDuplicatesOptions,
199 rules: &[FolderPermissionRule],
200) -> DeleteDuplicatesOutcome {
201 let started = Instant::now();
202 let outcome = delete_duplicates_inner(drive, sheets, opts, rules).await;
203 if !opts.dry_run {
204 record_attempt(&outcome, started.elapsed());
205 }
206 outcome
207}
208
209async fn delete_duplicates_inner(
210 drive: &DriveClient,
211 sheets: &SheetsClient,
212 opts: &DeleteDuplicatesOptions,
213 rules: &[FolderPermissionRule],
214) -> DeleteDuplicatesOutcome {
215 let bare = |result| DeleteDuplicatesOutcome {
216 spreadsheet_id: opts.spreadsheet_id.clone(),
217 file_name: None,
218 resolved_folder_id: None,
219 result,
220 };
221 let composed = match a1::compose(opts.sheet.as_deref(), opts.range.as_deref()) {
222 Ok(range) => range,
223 Err(err) => {
224 return bare(DeleteDuplicatesResult::RefusedInvalidRequest {
225 detail: err.to_string(),
226 })
227 }
228 };
229 if let Err(detail) = validate_comparison_columns(&opts.comparison_columns) {
230 return bare(DeleteDuplicatesResult::RefusedInvalidRequest { detail });
231 }
232 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
233 drive,
234 &opts.spreadsheet_id,
235 DriveOperation::SheetsDelete,
236 rules,
237 )
238 .await
239 {
240 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
241 return bare(DeleteDuplicatesResult::Failed { detail })
242 }
243 target_gate::TargetGateOutcome::Refused { target, refusal } => {
244 let result = match refusal {
245 SheetTargetRefusal::Shortcut => DeleteDuplicatesResult::RefusedShortcut,
246 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
247 DeleteDuplicatesResult::RefusedNotASpreadsheet { mime_type }
248 }
249 SheetTargetRefusal::NoVisibleParents => {
250 DeleteDuplicatesResult::RefusedNoVisibleParents
251 }
252 };
253 return DeleteDuplicatesOutcome {
254 spreadsheet_id: opts.spreadsheet_id.clone(),
255 file_name: Some(target.name),
256 resolved_folder_id: None,
257 result,
258 };
259 }
260 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
261 return DeleteDuplicatesOutcome {
262 spreadsheet_id: opts.spreadsheet_id.clone(),
263 file_name: Some(target.name),
264 resolved_folder_id: None,
265 result: DeleteDuplicatesResult::Failed { detail },
266 }
267 }
268 target_gate::TargetGateOutcome::Gated {
269 target,
270 decision,
271 resolved_folder_id,
272 requires_lease,
273 } => (target, decision, resolved_folder_id, requires_lease),
274 };
275 let gated = |result| DeleteDuplicatesOutcome {
276 spreadsheet_id: opts.spreadsheet_id.clone(),
277 file_name: Some(target.name.clone()),
278 resolved_folder_id: resolved_folder_id.clone(),
279 result,
280 };
281 if decision.verdict == write_gate::Verdict::Deny {
282 return gated(DeleteDuplicatesResult::Blocked {
283 decided_by: decision.decided_by,
284 });
285 }
286 let api = SheetsApi::new(sheets);
287 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
288 Ok(book) => book,
289 Err(err) => {
290 return gated(DeleteDuplicatesResult::Failed {
291 detail: format!("{err:#}"),
292 })
293 }
294 };
295 let (_, grid) = match grid_range::resolve_grid_range(
296 &workbook,
297 &composed,
298 |detail| DeleteDuplicatesResult::RefusedInvalidRequest { detail },
299 |title, available| DeleteDuplicatesResult::RefusedSheetNotFound { title, available },
300 ) {
301 Ok(value) => value,
302 Err(result) => return gated(result),
303 };
304 if !grid_range::is_bounded(&grid) {
309 return gated(DeleteDuplicatesResult::RefusedInvalidRequest {
310 detail: format!(
311 "{composed} is open-ended; delete-duplicates needs a fully bounded range (e.g. \
312 A1:D100) to avoid automatically spanning every allocated row"
313 ),
314 });
315 }
316 if let Err(detail) = columns_within_range(&opts.comparison_columns, &grid) {
317 return gated(DeleteDuplicatesResult::RefusedInvalidRequest { detail });
318 }
319
320 let request = BatchUpdateRequestItem::DeleteDuplicates(DeleteDuplicatesRequest {
323 range: grid,
324 comparison_columns: opts
325 .comparison_columns
326 .iter()
327 .map(|&index| DimensionRange {
328 sheet_id: grid.sheet_id,
329 dimension: Dimension::Columns,
330 start_index: index,
331 end_index: index + 1,
332 })
333 .collect(),
334 });
335
336 if opts.dry_run {
337 return gated(DeleteDuplicatesResult::WouldRemove {
338 range: composed,
339 comparison_columns: opts.comparison_columns.clone(),
340 content_outside_range_untouched: true,
341 });
342 }
343
344 let leased = LeasedWrite {
345 log_prefix: "drive sheets delete-duplicates",
346 operation: LOG_OPERATION,
347 ledger_path: &opts.ledger_path,
348 file_id: &opts.spreadsheet_id,
349 };
350 let files_api = FilesApi::new(drive);
351 let lease_grant = match gate_optional_leased_write(
352 leased,
353 &files_api,
354 requires_lease,
355 opts.lease_token.as_deref(),
356 )
357 .await
358 {
359 Ok(grant) => grant,
360 Err(err) => return gated(err.into_result()),
361 };
362 let result = match conclude_native_leased_write(
363 leased,
364 &lease_grant,
365 &files_api,
366 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
367 |err| format!("{err:#}"),
368 )
369 .await
370 {
371 Ok(response) => DeleteDuplicatesResult::Removed {
372 range: composed,
373 comparison_columns: opts.comparison_columns.clone(),
374 content_outside_range_untouched: true,
375 duplicates_removed_count: response
376 .replies
377 .into_iter()
378 .next()
379 .and_then(|reply| reply.delete_duplicates)
380 .map(|reply| reply.duplicates_removed_count),
381 backup: lease_grant
382 .as_ref()
383 .map(|grant| Box::new(grant.backup.clone())),
384 },
385 Err(err) => DeleteDuplicatesResult::Failed {
386 detail: format!("{err:#}"),
387 },
388 };
389 drop(lease_grant);
390 gated(result)
391}
392
393fn validate_comparison_columns(columns: &[i64]) -> Result<(), String> {
397 if let Some(negative) = columns.iter().find(|&&index| index < 0) {
398 return Err(format!(
399 "--comparison-column {negative} must be a non-negative, zero-based column index"
400 ));
401 }
402 let mut seen = Vec::with_capacity(columns.len());
403 for &index in columns {
404 if seen.contains(&index) {
405 return Err(format!(
406 "--comparison-column {index} is given more than once"
407 ));
408 }
409 seen.push(index);
410 }
411 Ok(())
412}
413
414fn columns_within_range(columns: &[i64], grid: &GridRange) -> Result<(), String> {
417 columns.iter().try_for_each(|&index| {
418 grid_range::check_column_in_range(index, grid).map_err(|e| {
419 format!(
420 "comparison column {index} is outside the selected range's columns \
421 {}..{} (zero-based, end exclusive)",
422 e.start,
423 e.end.unwrap_or(0)
424 )
425 })
426 })
427}
428
429fn record_attempt(outcome: &DeleteDuplicatesOutcome, duration: Duration) {
430 let decided_by = match &outcome.result {
431 DeleteDuplicatesResult::Blocked { decided_by } => decided_by.as_ref(),
432 _ => None,
433 };
434 let decided_by = write_gate::decided_by_log_fields(decided_by);
435 let fields_changed = match &outcome.result {
436 DeleteDuplicatesResult::Removed {
437 range,
438 comparison_columns,
439 duplicates_removed_count,
440 ..
441 } => Some(match duplicates_removed_count {
442 Some(count) => format!(
443 "removed {count} duplicate row(s) from {range} comparing {}",
444 render_columns(comparison_columns)
445 ),
446 None => format!(
447 "removed duplicate rows from {range} comparing {}; the API reported no count",
448 render_columns(comparison_columns)
449 ),
450 }),
451 _ => None,
452 };
453 let error = match &outcome.result {
454 DeleteDuplicatesResult::RefusedInvalidRequest { detail }
455 | DeleteDuplicatesResult::Failed { detail } => Some(detail.clone()),
456 _ => None,
457 };
458 request_log::record_drive_mutation(DriveMutationOutcome {
459 operation: LOG_OPERATION,
460 file_id: outcome.spreadsheet_id.clone(),
461 file_name: outcome.file_name.clone().unwrap_or_default(),
462 status: outcome.result.log_status().to_string(),
463 resolved_folder_id: outcome.resolved_folder_id.clone(),
464 decided_by_folder_id: decided_by.folder_id,
465 decided_by_depth: decided_by.depth,
466 decided_by_file_id: decided_by.file_id,
467 fields_changed,
468 error,
469 duration,
470 ..Default::default()
471 });
472}
473
474pub fn describe_lines(outcome: &DeleteDuplicatesOutcome) -> Vec<String> {
475 let book = outcome.file_name.as_deref().map_or_else(
476 || format!("'{}'", outcome.spreadsheet_id),
477 |name| format!("'{name}'"),
478 );
479 match &outcome.result {
480 DeleteDuplicatesResult::WouldRemove {
481 range,
482 comparison_columns,
483 ..
484 } => vec![
485 format!("Warning: {BLANK_ROW_CAVEAT}"),
486 format!("Warning: {RANGE_ONLY_CAVEAT}"),
487 format!(
488 "Would remove duplicate rows from {range} in {book}, comparing {}",
489 render_columns(comparison_columns)
490 ),
491 format!(" {EQUALITY_RULE}"),
492 " which rows would be removed is decided by the API and cannot be previewed"
493 .to_string(),
494 ],
495 DeleteDuplicatesResult::Removed {
496 range,
497 comparison_columns,
498 duplicates_removed_count,
499 backup,
500 ..
501 } => vec![
502 format!("Warning: {RANGE_ONLY_CAVEAT}"),
503 match duplicates_removed_count {
504 Some(count) => format!(
505 "Removed {count} duplicate row(s) from {range} in {book}, comparing {}",
506 render_columns(comparison_columns)
507 ),
508 None => format!(
509 "Removed duplicate rows from {range} in {book}, comparing {}; the API \
510 reported no count",
511 render_columns(comparison_columns)
512 ),
513 },
514 format!(" {EQUALITY_RULE}"),
515 format!(" {}", recovery_note(backup.as_deref(), false)),
516 ],
517 DeleteDuplicatesResult::RefusedInvalidRequest { detail } => {
518 vec![format!("Refused: {detail}")]
519 }
520 DeleteDuplicatesResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
521 "Refused: {book} is not a Google Sheet (mimeType: {mime_type})"
522 )],
523 DeleteDuplicatesResult::RefusedShortcut => vec![format!(
524 "Refused: {book} is a shortcut; delete-duplicates doesn't follow shortcuts"
525 )],
526 DeleteDuplicatesResult::RefusedNoVisibleParents => {
527 vec![format!("Refused: {book} has no visible parent folder")]
528 }
529 DeleteDuplicatesResult::RefusedSheetNotFound { title, available } => {
530 vec![grid_range::no_sheet_refusal(&book, title, available)]
531 }
532 DeleteDuplicatesResult::Blocked { .. } => vec![format!(
533 "Blocked: delete-duplicates on {book} requires an allowing sheets-delete rule"
534 )],
535 DeleteDuplicatesResult::RefusedNoLease => LeaseGateRefusal::NoLease
536 .describe_line(&outcome.spreadsheet_id, &book)
537 .into_iter()
538 .collect(),
539 DeleteDuplicatesResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
540 .describe_line(&outcome.spreadsheet_id, &book)
541 .into_iter()
542 .collect(),
543 DeleteDuplicatesResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
544 .describe_line(&outcome.spreadsheet_id, &book)
545 .into_iter()
546 .collect(),
547 DeleteDuplicatesResult::RefusedLeaseStale => LeaseGateRefusal::Stale
548 .describe_line(&outcome.spreadsheet_id, &book)
549 .into_iter()
550 .collect(),
551 DeleteDuplicatesResult::Failed { detail } => vec![format!("Failed: {detail}")],
552 }
553}
554
555fn render_columns(columns: &[i64]) -> String {
558 if columns.is_empty() {
559 return "every column in the range".to_string();
560 }
561 format!(
562 "column(s) {}",
563 columns
564 .iter()
565 .map(i64::to_string)
566 .collect::<Vec<_>>()
567 .join(", ")
568 )
569}
570
571#[cfg(test)]
572#[allow(clippy::unwrap_used)]
573mod tests {
574 use super::*;
575 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
576 use crate::drive::lease::ledger::LeaseBackup;
577 use crate::drive::sheets::client::SHEETS_API_URL;
578 use crate::drive::test_support::{seed_lease, seed_lease_with_backup};
579 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
580 use crate::test_support::env::MapEnv;
581 use crate::utils::secret::Secret;
582 use std::collections::HashSet;
583
584 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
585 let credentials = DriveCredentials {
586 client_id: "client-1".into(),
587 client_secret: Secret::new("secret-1"),
588 refresh_token: Secret::new("refresh-1"),
589 scope: DriveGrantedScopes::READONLY,
590 };
591 wiremock::Mock::given(wiremock::matchers::method("POST"))
592 .and(wiremock::matchers::path("/token"))
593 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
594 serde_json::json!({"access_token": "test-token", "expires_in": 3600}),
595 ))
596 .mount(server)
597 .await;
598 let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
599 crate::drive::client::test_support::replace_session(
600 &mut drive,
601 &credentials,
602 &format!("{}/token", server.uri()),
603 );
604 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
605 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
606 (drive, sheets)
607 }
608
609 async fn mount_metadata(server: &wiremock::MockServer) {
610 wiremock::Mock::given(wiremock::matchers::method("GET"))
611 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
612 .respond_with(
613 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
614 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
615 "parents": ["parent-1"], "version": "1"
616 })),
617 )
618 .mount(server)
619 .await;
620 wiremock::Mock::given(wiremock::matchers::method("GET"))
621 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
622 .respond_with(
623 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
624 "id": "parent-1", "name": "parent-1",
625 "mimeType": "application/vnd.google-apps.folder", "parents": []
626 })),
627 )
628 .mount(server)
629 .await;
630 wiremock::Mock::given(wiremock::matchers::method("GET"))
631 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
632 .respond_with(
633 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
634 "spreadsheetId": "sheet-1",
635 "sheets": [{"properties": {
636 "sheetId": 0, "title": "Q1",
637 "gridProperties": {"rowCount": 100, "columnCount": 6}
638 }}]
639 })),
640 )
641 .mount(server)
642 .await;
643 }
644
645 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
646 wiremock::Mock::given(wiremock::matchers::method("POST"))
647 .and(wiremock::matchers::path(
648 "/v4/spreadsheets/sheet-1:batchUpdate",
649 ))
650 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
651 }
652
653 fn options(dry_run: bool) -> DeleteDuplicatesOptions {
654 DeleteDuplicatesOptions {
655 spreadsheet_id: "sheet-1".into(),
656 sheet: Some("Q1".into()),
657 range: Some("A2:C10".into()),
658 comparison_columns: Vec::new(),
659 dry_run,
660 lease_token: None,
661 ledger_path: PathBuf::from("/tmp/unused-delete-duplicates-ledger"),
662 }
663 }
664
665 fn rule() -> FolderPermissionRule {
666 FolderPermissionRule {
667 folder_id: Some("parent-1".into()),
668 file_id: None,
669 recursive: true,
670 allow: std::iter::once(DriveOperation::SheetsDelete).collect(),
671 deny: HashSet::default(),
672 require_lease: false,
673 }
674 }
675
676 fn rule_allowing(operation: DriveOperation) -> FolderPermissionRule {
677 FolderPermissionRule {
678 allow: std::iter::once(operation).collect(),
679 ..rule()
680 }
681 }
682
683 fn rule_requiring_lease() -> FolderPermissionRule {
684 FolderPermissionRule {
685 require_lease: true,
686 ..rule()
687 }
688 }
689
690 #[test]
693 fn comparison_columns_refuse_negatives_and_repeats() {
694 assert!(validate_comparison_columns(&[0, 2]).is_ok());
695 assert!(validate_comparison_columns(&[]).is_ok());
696 assert!(validate_comparison_columns(&[-1])
697 .unwrap_err()
698 .contains("non-negative"));
699 assert!(validate_comparison_columns(&[1, 1])
700 .unwrap_err()
701 .contains("more than once"));
702 }
703
704 #[test]
705 fn comparison_columns_must_lie_inside_the_range() {
706 let grid = GridRange {
707 sheet_id: 0,
708 start_row_index: Some(1),
709 end_row_index: Some(10),
710 start_column_index: Some(2),
711 end_column_index: Some(5),
712 };
713 assert!(columns_within_range(&[2, 4], &grid).is_ok());
714 assert!(columns_within_range(&[1], &grid)
716 .unwrap_err()
717 .contains("columns 2..5"));
718 assert!(columns_within_range(&[5], &grid).is_err());
719 }
720
721 #[test]
722 fn comparison_columns_within_an_unbounded_range_are_never_rejected() {
723 let whole_rows = GridRange {
726 sheet_id: 0,
727 start_row_index: Some(1),
728 end_row_index: Some(10),
729 start_column_index: Some(2),
730 end_column_index: None,
731 };
732 assert!(columns_within_range(&[2, 700], &whole_rows).is_ok());
733 }
734
735 #[test]
738 fn render_columns_names_the_api_default_rather_than_an_empty_list() {
739 assert_eq!(render_columns(&[]), "every column in the range");
740 assert_eq!(render_columns(&[0, 2]), "column(s) 0, 2");
741 }
742
743 #[test]
744 fn the_preview_states_the_servers_rule_and_never_claims_a_row_list() {
745 let outcome = DeleteDuplicatesOutcome {
746 spreadsheet_id: "sheet-1".into(),
747 file_name: Some("Budget".into()),
748 resolved_folder_id: None,
749 result: DeleteDuplicatesResult::WouldRemove {
750 range: "'Q1'!A2:C10".into(),
751 comparison_columns: vec![0],
752 content_outside_range_untouched: true,
753 },
754 };
755 let lines = describe_lines(&outcome).join("\n");
756 assert!(lines.contains("keeps the first instance"), "{lines}");
757 assert!(lines.contains("hidden by a filter"), "{lines}");
758 assert!(lines.contains("cannot be previewed"), "{lines}");
759 assert!(lines.contains("blank rows"), "{lines}");
760 assert!(lines.contains("can misalign records"), "{lines}");
761 }
762
763 #[test]
766 fn the_real_run_names_the_lease_backup_or_says_there_was_none() {
767 let with_backup = DeleteDuplicatesOutcome {
768 spreadsheet_id: "sheet-1".into(),
769 file_name: Some("Budget".into()),
770 resolved_folder_id: None,
771 result: DeleteDuplicatesResult::Removed {
772 range: "'Q1'!A2:C10".into(),
773 comparison_columns: Vec::new(),
774 content_outside_range_untouched: true,
775 duplicates_removed_count: Some(2),
776 backup: Some(Box::new(LeaseBackup::DriveCopy {
777 file_id: "copy-9".into(),
778 })),
779 },
780 };
781 let lines = describe_lines(&with_backup).join("\n");
782 assert!(lines.contains("Drive copy copy-9"), "{lines}");
783 assert!(lines.contains("drive lease restore"), "{lines}");
784 assert!(lines.contains("can misalign records"), "{lines}");
785
786 let without = DeleteDuplicatesOutcome {
787 result: DeleteDuplicatesResult::Removed {
788 range: "'Q1'!A2:C10".into(),
789 comparison_columns: Vec::new(),
790 content_outside_range_untouched: true,
791 duplicates_removed_count: Some(2),
792 backup: None,
793 },
794 ..with_backup
795 };
796 let lines = describe_lines(&without).join("\n");
797 assert!(lines.contains("no lease backup was taken"), "{lines}");
798 assert!(!lines.contains("Drive copy"), "{lines}");
799 }
800
801 #[test]
802 fn lease_refusals_map_to_their_delete_duplicates_results() {
803 assert_eq!(
804 DeleteDuplicatesResult::from_no_lease(),
805 DeleteDuplicatesResult::RefusedNoLease
806 );
807 assert_eq!(
808 DeleteDuplicatesResult::from_lease_expired(),
809 DeleteDuplicatesResult::RefusedLeaseExpired
810 );
811 assert_eq!(
812 DeleteDuplicatesResult::from_lease_wrong_file(),
813 DeleteDuplicatesResult::RefusedLeaseWrongFile
814 );
815 assert_eq!(
816 DeleteDuplicatesResult::from_lease_stale(),
817 DeleteDuplicatesResult::RefusedLeaseStale
818 );
819 assert_eq!(
820 DeleteDuplicatesResult::from_lease_failed("boom".into()),
821 DeleteDuplicatesResult::Failed {
822 detail: "boom".into()
823 }
824 );
825 }
826
827 fn every_result() -> Vec<DeleteDuplicatesResult> {
828 vec![
829 DeleteDuplicatesResult::WouldRemove {
830 range: "'Q1'!A2:C10".into(),
831 comparison_columns: vec![0],
832 content_outside_range_untouched: true,
833 },
834 DeleteDuplicatesResult::Removed {
835 range: "'Q1'!A2:C10".into(),
836 comparison_columns: Vec::new(),
837 content_outside_range_untouched: true,
838 duplicates_removed_count: Some(2),
839 backup: None,
840 },
841 DeleteDuplicatesResult::Removed {
842 range: "'Q1'!A2:C10".into(),
843 comparison_columns: vec![1],
844 content_outside_range_untouched: true,
845 duplicates_removed_count: None,
846 backup: None,
847 },
848 DeleteDuplicatesResult::RefusedInvalidRequest {
849 detail: "bad".into(),
850 },
851 DeleteDuplicatesResult::RefusedNotASpreadsheet {
852 mime_type: "application/pdf".into(),
853 },
854 DeleteDuplicatesResult::RefusedShortcut,
855 DeleteDuplicatesResult::RefusedNoVisibleParents,
856 DeleteDuplicatesResult::RefusedSheetNotFound {
857 title: "Nope".into(),
858 available: vec!["Q1".into()],
859 },
860 DeleteDuplicatesResult::Blocked { decided_by: None },
861 DeleteDuplicatesResult::RefusedNoLease,
862 DeleteDuplicatesResult::RefusedLeaseExpired,
863 DeleteDuplicatesResult::RefusedLeaseWrongFile,
864 DeleteDuplicatesResult::RefusedLeaseStale,
865 DeleteDuplicatesResult::Failed {
866 detail: "boom".into(),
867 },
868 ]
869 }
870
871 #[test]
872 fn every_result_renders_and_carries_a_distinct_log_status() {
873 let mut statuses = HashSet::new();
874 for result in every_result() {
875 let outcome = DeleteDuplicatesOutcome {
876 spreadsheet_id: "sheet-1".into(),
877 file_name: Some("Budget".into()),
878 resolved_folder_id: None,
879 result,
880 };
881 assert!(
882 !describe_lines(&outcome).is_empty(),
883 "{:?} rendered nothing",
884 outcome.result
885 );
886 statuses.insert(outcome.result.log_status());
887 }
888 assert_eq!(statuses.len(), 13);
890 }
891
892 #[test]
893 fn describe_lines_falls_back_to_the_spreadsheet_id_with_no_file_name() {
894 let outcome = DeleteDuplicatesOutcome {
895 spreadsheet_id: "sheet-1".into(),
896 file_name: None,
897 resolved_folder_id: None,
898 result: DeleteDuplicatesResult::RefusedShortcut,
899 };
900 assert!(describe_lines(&outcome).join("\n").contains("'sheet-1'"));
901 }
902
903 #[test]
904 fn write_jsonl_emits_one_line_of_json() {
905 let outcome = DeleteDuplicatesOutcome {
906 spreadsheet_id: "sheet-1".into(),
907 file_name: Some("Budget".into()),
908 resolved_folder_id: None,
909 result: DeleteDuplicatesResult::Removed {
910 range: "'Q1'!A2:C10".into(),
911 comparison_columns: vec![0],
912 content_outside_range_untouched: true,
913 duplicates_removed_count: Some(2),
914 backup: None,
915 },
916 };
917 let mut buf = Vec::new();
918 outcome.write_jsonl(&mut buf).unwrap();
919 let text = String::from_utf8(buf).unwrap();
920 assert_eq!(text.lines().count(), 1);
921 assert!(text.contains("\"duplicates_removed_count\":2"), "{text}");
922 assert!(!text.contains("backup"), "{text}");
924 }
925
926 #[tokio::test]
929 async fn gate_denies_sheets_delete_by_default() {
930 let server = wiremock::MockServer::start().await;
931 let (drive, sheets) = clients(&server).await;
932 mount_metadata(&server).await;
933 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
934 assert!(
935 matches!(outcome.result, DeleteDuplicatesResult::Blocked { .. }),
936 "{outcome:?}"
937 );
938 }
939
940 #[tokio::test]
944 async fn a_sheets_write_grant_alone_does_not_permit_delete_duplicates() {
945 let server = wiremock::MockServer::start().await;
946 let (drive, sheets) = clients(&server).await;
947 mount_metadata(&server).await;
948 let outcome = delete_duplicates(
949 &drive,
950 &sheets,
951 &options(true),
952 &[rule_allowing(DriveOperation::SheetsWrite)],
953 )
954 .await;
955 assert!(
956 matches!(outcome.result, DeleteDuplicatesResult::Blocked { .. }),
957 "{outcome:?}"
958 );
959 }
960
961 #[tokio::test]
962 async fn a_sheets_structure_grant_alone_does_not_permit_delete_duplicates() {
963 let server = wiremock::MockServer::start().await;
964 let (drive, sheets) = clients(&server).await;
965 mount_metadata(&server).await;
966 let outcome = delete_duplicates(
967 &drive,
968 &sheets,
969 &options(true),
970 &[rule_allowing(DriveOperation::SheetsStructure)],
971 )
972 .await;
973 assert!(
974 matches!(outcome.result, DeleteDuplicatesResult::Blocked { .. }),
975 "{outcome:?}"
976 );
977 }
978
979 #[tokio::test]
982 async fn a_metadata_fetch_failure_surfaces_as_failed_without_a_file_name() {
983 let server = wiremock::MockServer::start().await;
984 let (drive, sheets) = clients(&server).await;
985 wiremock::Mock::given(wiremock::matchers::method("GET"))
986 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
987 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
988 .mount(&server)
989 .await;
990
991 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
992
993 assert!(matches!(
994 outcome.result,
995 DeleteDuplicatesResult::Failed { .. }
996 ));
997 assert!(outcome.file_name.is_none());
998 }
999
1000 #[tokio::test]
1001 async fn a_shortcut_target_is_refused_without_following_it() {
1002 let server = wiremock::MockServer::start().await;
1003 let (drive, sheets) = clients(&server).await;
1004 wiremock::Mock::given(wiremock::matchers::method("GET"))
1005 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1006 .respond_with(
1007 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1008 "id": "sheet-1", "name": "Budget",
1009 "mimeType": "application/vnd.google-apps.shortcut",
1010 "parents": ["parent-1"], "version": "1"
1011 })),
1012 )
1013 .mount(&server)
1014 .await;
1015
1016 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
1017
1018 assert_eq!(outcome.result, DeleteDuplicatesResult::RefusedShortcut);
1019 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1020 }
1021
1022 #[tokio::test]
1023 async fn a_non_spreadsheet_target_is_refused_with_its_mime_type() {
1024 let server = wiremock::MockServer::start().await;
1025 let (drive, sheets) = clients(&server).await;
1026 wiremock::Mock::given(wiremock::matchers::method("GET"))
1027 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1028 .respond_with(
1029 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1030 "id": "sheet-1", "name": "Budget",
1031 "mimeType": "application/vnd.google-apps.document",
1032 "parents": ["parent-1"], "version": "1"
1033 })),
1034 )
1035 .mount(&server)
1036 .await;
1037
1038 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
1039
1040 assert_eq!(
1041 outcome.result,
1042 DeleteDuplicatesResult::RefusedNotASpreadsheet {
1043 mime_type: "application/vnd.google-apps.document".into(),
1044 }
1045 );
1046 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1047 }
1048
1049 #[tokio::test]
1050 async fn a_target_with_no_visible_parents_is_refused() {
1051 let server = wiremock::MockServer::start().await;
1052 let (drive, sheets) = clients(&server).await;
1053 wiremock::Mock::given(wiremock::matchers::method("GET"))
1054 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1055 .respond_with(
1056 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1057 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1058 "parents": [], "version": "1"
1059 })),
1060 )
1061 .mount(&server)
1062 .await;
1063
1064 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
1065
1066 assert_eq!(
1067 outcome.result,
1068 DeleteDuplicatesResult::RefusedNoVisibleParents
1069 );
1070 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1071 }
1072
1073 #[tokio::test]
1074 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed_with_the_file_name() {
1075 let server = wiremock::MockServer::start().await;
1076 let (drive, sheets) = clients(&server).await;
1077 wiremock::Mock::given(wiremock::matchers::method("GET"))
1078 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1079 .respond_with(
1080 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1081 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1082 "parents": ["parent-1"], "version": "1"
1083 })),
1084 )
1085 .mount(&server)
1086 .await;
1087 wiremock::Mock::given(wiremock::matchers::method("GET"))
1088 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1089 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1090 .mount(&server)
1091 .await;
1092
1093 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[]).await;
1094
1095 assert!(matches!(
1096 outcome.result,
1097 DeleteDuplicatesResult::Failed { .. }
1098 ));
1099 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1100 assert!(outcome.resolved_folder_id.is_none());
1101 }
1102
1103 #[tokio::test]
1104 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
1105 let server = wiremock::MockServer::start().await;
1106 let (drive, sheets) = clients(&server).await;
1107 wiremock::Mock::given(wiremock::matchers::method("GET"))
1108 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1109 .respond_with(
1110 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1111 "id": "sheet-1", "name": "Budget", "mimeType": GOOGLE_SHEET_MIME_TYPE,
1112 "parents": ["parent-1"], "version": "1"
1113 })),
1114 )
1115 .mount(&server)
1116 .await;
1117 wiremock::Mock::given(wiremock::matchers::method("GET"))
1118 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
1119 .respond_with(
1120 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1121 "id": "parent-1", "name": "parent-1",
1122 "mimeType": "application/vnd.google-apps.folder", "parents": []
1123 })),
1124 )
1125 .mount(&server)
1126 .await;
1127 wiremock::Mock::given(wiremock::matchers::method("GET"))
1128 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1129 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1130 .mount(&server)
1131 .await;
1132
1133 let outcome = delete_duplicates(&drive, &sheets, &options(true), &[rule()]).await;
1134
1135 assert!(matches!(
1136 outcome.result,
1137 DeleteDuplicatesResult::Failed { .. }
1138 ));
1139 assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
1140 }
1141
1142 #[tokio::test]
1145 async fn dry_run_reads_no_values_and_never_mutates() {
1146 let server = wiremock::MockServer::start().await;
1147 let (drive, sheets) = clients(&server).await;
1148 mount_metadata(&server).await;
1149 let mut opts = options(true);
1150 opts.comparison_columns = vec![0, 2];
1151 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1152 let json = serde_json::to_value(&outcome).unwrap();
1153 assert_eq!(json["result"]["content_outside_range_untouched"], true);
1154 assert_eq!(
1155 outcome.result,
1156 DeleteDuplicatesResult::WouldRemove {
1157 range: "'Q1'!A2:C10".into(),
1158 comparison_columns: vec![0, 2],
1159 content_outside_range_untouched: true,
1160 }
1161 );
1162 let requests = server.received_requests().await.unwrap();
1163 assert!(
1164 !requests.iter().any(|r| r.url.path().contains("/values/")),
1165 "the server-decided preview read values"
1166 );
1167 assert!(!requests
1168 .iter()
1169 .any(|r| r.url.path().ends_with(":batchUpdate")));
1170 }
1171
1172 #[tokio::test]
1173 async fn real_run_sends_one_delete_duplicates_request_with_column_ranges() {
1174 let server = wiremock::MockServer::start().await;
1175 let (drive, sheets) = clients(&server).await;
1176 mount_metadata(&server).await;
1177 mount_batch_update(serde_json::json!({
1178 "replies": [{"deleteDuplicates": {"duplicatesRemovedCount": 4}}]
1179 }))
1180 .mount(&server)
1181 .await;
1182 let mut opts = options(false);
1183 opts.comparison_columns = vec![0, 2];
1184 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1185 assert!(
1186 matches!(
1187 outcome.result,
1188 DeleteDuplicatesResult::Removed {
1189 duplicates_removed_count: Some(4),
1190 ..
1191 }
1192 ),
1193 "{outcome:?}"
1194 );
1195
1196 let requests = server.received_requests().await.unwrap();
1197 let batch = requests
1198 .iter()
1199 .find(|r| r.url.path().ends_with(":batchUpdate"))
1200 .unwrap();
1201 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1202 let request = &body["requests"][0]["deleteDuplicates"];
1203 assert_eq!(
1204 request["range"],
1205 serde_json::json!({
1206 "sheetId": 0, "startRowIndex": 1, "endRowIndex": 10,
1207 "startColumnIndex": 0, "endColumnIndex": 3
1208 })
1209 );
1210 assert_eq!(
1211 request["comparisonColumns"],
1212 serde_json::json!([
1213 {"sheetId": 0, "dimension": "COLUMNS", "startIndex": 0, "endIndex": 1},
1214 {"sheetId": 0, "dimension": "COLUMNS", "startIndex": 2, "endIndex": 3}
1215 ])
1216 );
1217 }
1218
1219 #[tokio::test]
1222 async fn no_comparison_columns_omits_the_field_entirely() {
1223 let server = wiremock::MockServer::start().await;
1224 let (drive, sheets) = clients(&server).await;
1225 mount_metadata(&server).await;
1226 mount_batch_update(serde_json::json!({"replies": [{}]}))
1227 .mount(&server)
1228 .await;
1229 let _ = delete_duplicates(&drive, &sheets, &options(false), &[rule()]).await;
1230 let requests = server.received_requests().await.unwrap();
1231 let batch = requests
1232 .iter()
1233 .find(|r| r.url.path().ends_with(":batchUpdate"))
1234 .unwrap();
1235 let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
1236 assert!(body["requests"][0]["deleteDuplicates"]
1237 .get("comparisonColumns")
1238 .is_none());
1239 }
1240
1241 #[tokio::test]
1242 async fn a_reply_without_a_delete_duplicates_object_reports_removed_with_no_count() {
1243 let server = wiremock::MockServer::start().await;
1244 let (drive, sheets) = clients(&server).await;
1245 mount_metadata(&server).await;
1246 mount_batch_update(serde_json::json!({"replies": [{}]}))
1247 .mount(&server)
1248 .await;
1249 let outcome = delete_duplicates(&drive, &sheets, &options(false), &[rule()]).await;
1250 assert!(
1251 matches!(
1252 outcome.result,
1253 DeleteDuplicatesResult::Removed {
1254 duplicates_removed_count: None,
1255 ..
1256 }
1257 ),
1258 "{outcome:?}"
1259 );
1260 assert!(describe_lines(&outcome)
1261 .join("\n")
1262 .contains("reported no count"));
1263 }
1264
1265 #[tokio::test]
1268 async fn an_open_ended_range_is_refused_to_avoid_automatically_spanning_the_sheet() {
1269 let server = wiremock::MockServer::start().await;
1270 let (drive, sheets) = clients(&server).await;
1271 mount_metadata(&server).await;
1272 let mut opts = options(true);
1273 opts.range = Some("A:C".into());
1274 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1275 let DeleteDuplicatesResult::RefusedInvalidRequest { detail } = &outcome.result else {
1276 panic!("expected a refusal"); };
1278 assert!(detail.contains("open-ended"), "{detail}");
1279 assert!(
1280 detail.contains("automatically spanning every allocated row"),
1281 "{detail}"
1282 );
1283 }
1284
1285 #[tokio::test]
1286 async fn a_comparison_column_outside_the_range_is_refused_before_mutating() {
1287 let server = wiremock::MockServer::start().await;
1288 let (drive, sheets) = clients(&server).await;
1289 mount_metadata(&server).await;
1290 let mut opts = options(false);
1291 opts.comparison_columns = vec![4];
1292 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1293 assert!(
1294 matches!(
1295 outcome.result,
1296 DeleteDuplicatesResult::RefusedInvalidRequest { .. }
1297 ),
1298 "{outcome:?}"
1299 );
1300 assert!(!server
1301 .received_requests()
1302 .await
1303 .unwrap()
1304 .iter()
1305 .any(|r| r.url.path().ends_with(":batchUpdate")));
1306 }
1307
1308 #[tokio::test]
1309 async fn a_repeated_comparison_column_is_refused_before_any_network_call() {
1310 let server = wiremock::MockServer::start().await;
1311 let (drive, sheets) = clients(&server).await;
1312 let mut opts = options(true);
1313 opts.comparison_columns = vec![1, 1];
1314 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1315 assert!(matches!(
1316 outcome.result,
1317 DeleteDuplicatesResult::RefusedInvalidRequest { .. }
1318 ));
1319 assert!(server.received_requests().await.unwrap().is_empty());
1320 }
1321
1322 #[tokio::test]
1323 async fn an_unknown_sheet_is_refused_with_the_available_titles() {
1324 let server = wiremock::MockServer::start().await;
1325 let (drive, sheets) = clients(&server).await;
1326 mount_metadata(&server).await;
1327 let mut opts = options(true);
1328 opts.sheet = Some("Nope".into());
1329 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule()]).await;
1330 assert_eq!(
1331 outcome.result,
1332 DeleteDuplicatesResult::RefusedSheetNotFound {
1333 title: "Nope".into(),
1334 available: vec!["Q1".into()],
1335 }
1336 );
1337 }
1338
1339 #[tokio::test]
1340 async fn a_batch_update_failure_surfaces_as_failed() {
1341 let server = wiremock::MockServer::start().await;
1342 let (drive, sheets) = clients(&server).await;
1343 mount_metadata(&server).await;
1344 wiremock::Mock::given(wiremock::matchers::method("POST"))
1345 .and(wiremock::matchers::path(
1346 "/v4/spreadsheets/sheet-1:batchUpdate",
1347 ))
1348 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
1349 .mount(&server)
1350 .await;
1351 let outcome = delete_duplicates(&drive, &sheets, &options(false), &[rule()]).await;
1352 assert!(
1353 matches!(outcome.result, DeleteDuplicatesResult::Failed { .. }),
1354 "{outcome:?}"
1355 );
1356 }
1357
1358 #[tokio::test]
1361 async fn refuses_without_a_lease_when_the_rule_requires_one() {
1362 let server = wiremock::MockServer::start().await;
1363 let (drive, sheets) = clients(&server).await;
1364 mount_metadata(&server).await;
1365 let outcome =
1367 delete_duplicates(&drive, &sheets, &options(false), &[rule_requiring_lease()]).await;
1368 assert_eq!(outcome.result, DeleteDuplicatesResult::RefusedNoLease);
1369 }
1370
1371 #[tokio::test]
1372 async fn refuses_a_lease_bound_to_a_different_file() {
1373 let server = wiremock::MockServer::start().await;
1374 let (drive, sheets) = clients(&server).await;
1375 mount_metadata(&server).await;
1376 let dir = tempfile::tempdir().unwrap();
1377 let ledger_path = dir.path().join("leases.json");
1378 let token = seed_lease(&ledger_path, "other-file", "1");
1379 let mut opts = options(false);
1380 opts.lease_token = Some(token);
1381 opts.ledger_path = ledger_path;
1382 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1383 assert_eq!(
1384 outcome.result,
1385 DeleteDuplicatesResult::RefusedLeaseWrongFile
1386 );
1387 }
1388
1389 #[tokio::test]
1392 async fn a_leased_run_reports_the_ledgers_own_backup() {
1393 let server = wiremock::MockServer::start().await;
1394 let (drive, sheets) = clients(&server).await;
1395 mount_metadata(&server).await;
1396 mount_batch_update(serde_json::json!({
1397 "replies": [{"deleteDuplicates": {"duplicatesRemovedCount": 1}}]
1398 }))
1399 .mount(&server)
1400 .await;
1401 let dir = tempfile::tempdir().unwrap();
1402 let ledger_path = dir.path().join("leases.json");
1403 let token = seed_lease_with_backup(
1404 &ledger_path,
1405 "sheet-1",
1406 "1",
1407 LeaseBackup::DriveCopy {
1408 file_id: "copy-42".into(),
1409 },
1410 );
1411 let mut opts = options(false);
1412 opts.lease_token = Some(token);
1413 opts.ledger_path = ledger_path;
1414 let outcome = delete_duplicates(&drive, &sheets, &opts, &[rule_requiring_lease()]).await;
1415 assert!(
1416 matches!(
1417 &outcome.result,
1418 DeleteDuplicatesResult::Removed { backup: Some(backup), .. }
1419 if **backup == LeaseBackup::DriveCopy { file_id: "copy-42".into() }
1420 ),
1421 "{outcome:?}"
1422 );
1423 assert!(describe_lines(&outcome).join("\n").contains("copy-42"));
1424 }
1425
1426 #[tokio::test]
1430 async fn an_unleased_run_never_claims_a_backup_exists() {
1431 let server = wiremock::MockServer::start().await;
1432 let (drive, sheets) = clients(&server).await;
1433 mount_metadata(&server).await;
1434 mount_batch_update(serde_json::json!({"replies": [{}]}))
1435 .mount(&server)
1436 .await;
1437 let outcome = delete_duplicates(&drive, &sheets, &options(false), &[rule()]).await;
1438 let lines = describe_lines(&outcome).join("\n");
1439 assert!(lines.contains("no lease backup was taken"), "{lines}");
1440 assert!(
1441 lines.contains("version history is the only recovery path"),
1442 "{lines}"
1443 );
1444 }
1445}