Skip to main content

omni_dev/drive/sheets/
delete_duplicates.rs

1//! `delete-duplicates` — remove rows within a range that duplicate an
2//! earlier row (issue #1844).
3//!
4//! [ADR-0083](../../../docs/adrs/adr-0083.md) §2 places this under
5//! `DriveOperation::SheetsDelete`, alone in this tranche: cells within the
6//! selected range are removed and the survivors shift up, which is
7//! `delete-range`'s shape, not `clear`'s. Content outside the range
8//! stays in place, so a narrow selection can misalign records. It is also
9//! the **first `sheets-delete` verb outside `structure.rs`**, which is
10//! why `recovery_note` was lifted to `lease::check` rather than copied here.
11//!
12//! Two things make it unlike every other deletion this crate performs:
13//!
14//! - **The server chooses the rows.** Every other `sheets-delete` verb
15//!   removes cells the caller named by address. Here the API applies its
16//!   own equality rule — case-, formatting- and formula-insensitive, first
17//!   instance kept, duplicates need not be adjacent — and removes whatever
18//!   it finds, filter-hidden rows included. §2 accepts that as within what
19//!   a `sheets-delete` grant consents to, and requires the preview and the
20//!   real run to state the rule rather than leave it implicit.
21//! - **`--dry-run` lists no rows at all.** §6 puts this verb in the
22//!   server-decided tier: reproducing the equality rule locally would mean
23//!   guessing which rendering of each value the server compares, and a
24//!   wrong guess here costs a *row*, not a match. So the preview names the
25//!   range and the compared columns and states the rule, "rather than a
26//!   row list it cannot vouch for".
27//!
28//! There is deliberately no `--whole-sheet` scope, unlike
29//! `trim_whitespace.rs`: a fully blank row *between* data rows duplicates
30//! every other such row, so a whole-sheet dedupe would collapse every
31//! intentional gap in the tab to one. A live run left blank rows *after*
32//! the last data row alone, so the hazard is interior gaps rather than the
33//! trailing empty region; the preview calls it out either way.
34
35#![allow(missing_docs)] // The CLI-facing outcome models are self-describing in JSON.
36
37use 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
62/// The caveat both the preview and the real run carry, stating the rule
63/// the *server* applies — §2's requirement, since the caller never names
64/// the rows this verb removes.
65const 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
70/// The hazard blank rows create. Blank rows between data rows duplicate one
71/// another, so every such row after the first is removed. Live, blank rows
72/// after the last data row were left alone (`A1:C10` holding three data
73/// rows removed none of its seven trailing blanks).
74const 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    /// Absolute, zero-based sheet column indexes to compare, in the order
87    /// given. Empty means the API compares every column in the range.
88    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        /// The compared columns, or empty for "every column in the range".
100        comparison_columns: Vec<i64>,
101        /// The API leaves content outside the selected rectangle in place.
102        content_outside_range_untouched: bool,
103    },
104    Removed {
105        range: String,
106        comparison_columns: Vec<i64>,
107        /// The API leaves content outside the selected rectangle in place.
108        content_outside_range_untouched: bool,
109        /// The server's own count. `None` when the reply carried no
110        /// `deleteDuplicates` object — `find-replace`'s precedent for a
111        /// response that succeeded but reported nothing.
112        duplicates_removed_count: Option<i64>,
113        /// Where the lease that authorised this write backed the file up,
114        /// so the recovery message names a real copy rather than assuming
115        /// one exists. `None` under a `require_lease: false` rule.
116        #[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    // Bounded-only, `sort-range`'s rule. An open-ended dedupe would reach
305    // every allocated row of the sheet. A bounded range can still hold
306    // blank rows between data rows, so the preview warns about that
307    // separately.
308    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    // Built before the gate, not after — #1688/#1742's ordering invariant,
321    // shared verbatim by every leased engine.
322    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
393/// Rejects the column indexes that are wrong on their own terms, before
394/// any network call. Bounds against the *range* need the resolved grid, so
395/// they are checked separately by [`columns_within_range`].
396fn 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
414/// Every compared column must lie inside the selected range — the same
415/// absolute, zero-based convention as [`grid_range::check_column_in_range`].
416fn 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
555/// Renders the compared columns for a message, naming the API's default
556/// explicitly rather than printing an empty list.
557fn 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    // ── comparison-column validation (pure) ─────────────────────────────
691
692    #[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        // Below the start and at the exclusive end are both outside.
715        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        // `grid_range::check_column_in_range`, not `is_bounded`, now handles
724        // an open-ended `end_column_index` — the latent bug #1989 describes.
725        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    // ── rendering (pure) ────────────────────────────────────────────────
736
737    #[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    /// ADR-0077 §5's recovery tail, from the lifted shared `recovery_note`
764    /// — both arms, since the `None` one must never claim a backup exists.
765    #[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        // 14 results, 13 statuses: the two `Removed`s share one.
889        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        // The absent backup is omitted, not rendered as null.
923        assert!(!text.contains("backup"), "{text}");
924    }
925
926    // ── the gate ────────────────────────────────────────────────────────
927
928    #[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    /// ADR-0083 §2's whole point, as an executable claim: this verb needs
941    /// the stronger grant, so `sheets-write` — which every other verb in
942    /// the tranche runs on — must not open it.
943    #[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    // ── target-gate failures and refusals ───────────────────────────────
980
981    #[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    // ── the two paths ───────────────────────────────────────────────────
1143
1144    #[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    /// The API's "analyze all columns" default is an *absent* field, not
1220    /// an empty array.
1221    #[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    // ── refusals ────────────────────────────────────────────────────────
1266
1267    #[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"); // omni-dev: coverage ignore-line reason="this let-else panic only runs if the match failed to bind the expected variant; an open-ended range always refuses here"
1277        };
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    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1359
1360    #[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        // No batchUpdate mock: a refusal must make zero mutating calls.
1366        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    /// The end-to-end shape of the recovery message: the backup the engine
1390    /// reports must be the one the ledger actually recorded.
1391    #[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    /// A `require_lease: false` rule reaches the write with no lease and
1427    /// therefore no backup, so the message must fall back to "version
1428    /// history is the only recovery path" rather than invent a copy.
1429    #[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}