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::{
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
64/// The caveat both the preview and the real run carry, stating the rule
65/// the *server* applies — §2's requirement, since the caller never names
66/// the rows this verb removes.
67const 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
72/// The hazard blank rows create. Blank rows between data rows duplicate one
73/// another, so every such row after the first is removed. Live, blank rows
74/// after the last data row were left alone (`A1:C10` holding three data
75/// rows removed none of its seven trailing blanks).
76const 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    /// Absolute, zero-based sheet column indexes to compare, in the order
89    /// given. Empty means the API compares every column in the range.
90    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        /// The compared columns, or empty for "every column in the range".
102        comparison_columns: Vec<i64>,
103        /// The API leaves content outside the selected rectangle in place.
104        content_outside_range_untouched: bool,
105    },
106    Removed {
107        range: String,
108        comparison_columns: Vec<i64>,
109        /// The API leaves content outside the selected rectangle in place.
110        content_outside_range_untouched: bool,
111        /// The server's own count. `None` when the reply carried no
112        /// `deleteDuplicates` object — `find-replace`'s precedent for a
113        /// response that succeeded but reported nothing.
114        duplicates_removed_count: Option<i64>,
115        /// Where the lease that authorised this write backed the file up,
116        /// so the recovery message names a real copy rather than assuming
117        /// one exists. `None` under a `require_lease: false` rule.
118        #[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    /// The API answered 2xx — the rows **were removed** — but its reply could
141    /// not be read (issue #2021). Distinct from [`Self::Failed`]. The
142    /// server's `duplicatesRemovedCount` was in that reply, so it is unknown.
143    AppliedReplyUnreadable {
144        range: String,
145        comparison_columns: Vec<i64>,
146        /// The API leaves content outside the selected rectangle in place.
147        content_outside_range_untouched: bool,
148        /// What was applied, in the same terms as a `Removed` line.
149        summary: String,
150        /// Why the reply could not be read.
151        detail: String,
152        /// Where the lease that authorised this write backed the file up;
153        /// the recovery note names it exactly as for [`Self::Removed`].
154        #[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    // Bounded-only, `sort-range`'s rule. An open-ended dedupe would reach
325    // every allocated row of the sheet. A bounded range can still hold
326    // blank rows between data rows, so the preview warns about that
327    // separately.
328    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    // Built before the gate, not after — #1688/#1742's ordering invariant,
341    // shared verbatim by every leased engine.
342    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        // Not idempotent (`RetryHint::NotIdempotent`): a re-run compares the
406        // already-deduplicated rows, so it can remove further rows.
407        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
430/// Rejects the column indexes that are wrong on their own terms, before
431/// any network call. Bounds against the *range* need the resolved grid, so
432/// they are checked separately by [`columns_within_range`].
433fn 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
451/// Every compared column must lie inside the selected range — the same
452/// absolute, zero-based convention as [`grid_range::check_column_in_range`].
453fn 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
466/// The `fields_changed` summary a result logs. An unreadable-reply result
467/// always logs its summary, though not the count that was in the reply.
468fn 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
613/// Renders the compared columns for a message, naming the API's default
614/// explicitly rather than printing an empty list.
615fn 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    // ── comparison-column validation (pure) ─────────────────────────────
749
750    #[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        // Below the start and at the exclusive end are both outside.
773        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        // `grid_range::check_column_in_range`, not `is_bounded`, now handles
782        // an open-ended `end_column_index` — the latent bug #1989 describes.
783        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    // ── rendering (pure) ────────────────────────────────────────────────
794
795    #[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    /// ADR-0077 §5's recovery tail, from the lifted shared `recovery_note`
822    /// — both arms, since the `None` one must never claim a backup exists.
823    #[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        // 15 results, 14 statuses: the two `Removed`s share one.
948        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        // The absent backup is omitted, not rendered as null.
1037        assert!(!text.contains("backup"), "{text}");
1038    }
1039
1040    // ── the gate ────────────────────────────────────────────────────────
1041
1042    /// A real (non-dry) run records its attempt, so a denied one must reach
1043    /// the request log's `Blocked` arm too.
1044    #[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    /// ADR-0083 §2's whole point, as an executable claim: this verb needs
1070    /// the stronger grant, so `sheets-write` — which every other verb in
1071    /// the tranche runs on — must not open it.
1072    #[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    // ── target-gate failures and refusals ───────────────────────────────
1109
1110    #[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    // ── the two paths ───────────────────────────────────────────────────
1272
1273    #[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    /// The API's "analyze all columns" default is an *absent* field, not
1349    /// an empty array.
1350    #[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    // ── refusals ────────────────────────────────────────────────────────
1395
1396    #[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"); // 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"
1406        };
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        // 2xx, so the rows WERE removed — but the body is not the reply shape.
1493        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    // ── the Drive write lease (ADR-0080 §9) ─────────────────────────────
1516
1517    #[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        // No batchUpdate mock: a refusal must make zero mutating calls.
1523        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    /// The end-to-end shape of the recovery message: the backup the engine
1547    /// reports must be the one the ledger actually recorded.
1548    #[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    /// A `require_lease: false` rule reaches the write with no lease and
1584    /// therefore no backup, so the message must fall back to "version
1585    /// history is the only recovery path" rather than invent a copy.
1586    #[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}