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