Skip to main content

omni_dev/drive/sheets/
named_range.rs

1//! Named ranges via `spreadsheets.batchUpdate` (issue #1796,
2//! [ADR-0081](../../../docs/adrs/adr-0081.md) §2).
3//!
4//! Gated by `DriveOperation::SheetsStructure`, like every other additive
5//! Sheets verb — a named range is a label over a region, and adding,
6//! renaming/re-pointing, or removing one never touches a cell's stored
7//! value or formula text. `delete-named-range` is the one verb with a
8//! visible consequence: every cell formula referencing the removed name
9//! starts evaluating to `#REF!` (conditional-formatting rules,
10//! data-validation custom formulas and chart source references can also
11//! name a named range, but are outside the grid-cell scan below). ADR-0081
12//! §2 mitigates this the way ADR-0078 §8 mitigates `merge-cells`'s data
13//! loss (`format.rs`'s `discarded_cells`): before deleting, in both
14//! `--dry-run` and the real run, this module scans the workbook's cell
15//! formulas for the name and reports the count and A1 locations of every
16//! reference — **never** the formula text or a cell's value.
17//!
18//! Three mutating verbs (`add-named-range`/`update-named-range`/
19//! `delete-named-range`) plus one read (`list-named-ranges`, ungated like
20//! `list-protections`). Shape mirrors `protection.rs`: compose a target,
21//! resolve it against a freshly-fetched workbook, gate, dry-run, mutate,
22//! log. The formula scan reuses `SheetsApi::batch_get_every_sheet`'s
23//! chunked `values.batchGet` (shared with `read.rs::read_whole_workbook`,
24//! which needs the same "chunk titles, fetch, resolve each result's own
25//! echoed title" skeleton for a different purpose).
26//!
27//! **`update-named-range`/`delete-named-range` resolve their target by a
28//! case-insensitive exact name match** against the workbook's current
29//! named ranges — the name, not the server-assigned id, is the one stable
30//! handle a CLI user would actually type (the id is discoverable only via
31//! `list-named-ranges`). Case-insensitive to match the formula scan's own
32//! case-insensitive resolution of a name reference (see
33//! [`scan_referencing_formulas`]).
34//!
35//! Sheets enforces unique names on `addNamedRange` but **not** on
36//! `updateNamedRange` (issue #1932, live-verified): renaming a named range
37//! to another's name is not rejected server-side, so a workbook can end up
38//! with two ranges sharing a name despite `addNamedRange`'s own behavior
39//! suggesting otherwise. Like `protection.rs`'s range-based lookup, name
40//! resolution here does need an "ambiguous" branch after all —
41//! [`find_existing_named_range`] refuses rather than picks the first match
42//! when more than one range shares a name, and [`check_new_name_available`]
43//! refuses a rename that would create that state in the first place.
44//!
45//! **`--id` is the escape hatch for that ambiguity** (issue #1975,
46//! follow-up to #1932/#1974): the `--name` refusal above lists the matching
47//! ids but, before this, gave no way to act on one — the Sheets UI was the
48//! only workaround. `--id`/`--name` are mutually exclusive and exactly one
49//! is required (a clap `ArgGroup` on both CLI commands); [`find_existing_named_range_by_id`]
50//! looks up by exact `named_range_id` match (not case-insensitive — it's a
51//! server-assigned opaque id, not a user-typed name) and refuses with
52//! [`NamedRangeResult::RefusedIdNotFound`] rather than
53//! [`NamedRangeResult::RefusedNotFound`] when nothing matches, so the error
54//! message can point back at `list-named-ranges` without implying a `--name`
55//! typo. An id match is always unique (`named_range_id` is server-assigned),
56//! so `--id` has no ambiguous-match branch to speak of.
57
58use std::path::PathBuf;
59use std::time::{Duration, Instant};
60
61use serde::Serialize;
62
63use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
64use crate::drive::client::DriveClient;
65use crate::drive::files_api::FilesApi;
66use crate::drive::lease::check::{
67    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
68    LeasedWrite,
69};
70use crate::drive::sheets::a1;
71use crate::drive::sheets::api::{
72    applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
73};
74use crate::drive::sheets::client::SheetsClient;
75use crate::drive::sheets::grid_range;
76use crate::drive::sheets::target_gate;
77use crate::drive::sheets::types::{
78    AddNamedRangeRequest, BatchUpdateRequestItem, BatchUpdateResponse, DeleteNamedRangeRequest,
79    GridRange, NamedRange, Spreadsheet, UpdateNamedRangeRequest,
80};
81use crate::drive::types::SheetTargetRefusal;
82use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
83use crate::request_log::{self, DriveMutationOutcome};
84
85/// Which mutation to perform.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum NamedRangeVerb {
88    /// Add a new named range (or, with `whole_sheet`, one covering an
89    /// entire sheet).
90    AddNamedRange {
91        /// The name to create. Must be unique workbook-wide — a duplicate
92        /// is left to the API's own error rather than pre-checked here.
93        name: String,
94        /// A sheet title, supplying a prefix for a bare `range`. Also the
95        /// target sheet directly when `whole_sheet` is set.
96        sheet: Option<String>,
97        /// An explicit A1 range, which may carry its own `Sheet!` prefix.
98        /// Mutually exclusive with `whole_sheet`.
99        range: Option<String>,
100        /// Cover the entire sheet named by `sheet`, rather than a range
101        /// within it.
102        whole_sheet: bool,
103    },
104    /// Change an existing named range's name and/or the range it covers.
105    UpdateNamedRange {
106        /// The existing name to change, by exact match. Mutually exclusive
107        /// with `id`; exactly one of the two is set.
108        name: Option<String>,
109        /// The existing named range's id, by exact match (from
110        /// `list-named-ranges`). Mutually exclusive with `name`; exactly
111        /// one of the two is set.
112        id: Option<String>,
113        /// The new name, when renaming.
114        new_name: Option<String>,
115        /// A sheet title for the new range, supplying a prefix for a bare
116        /// `range`. Also the target sheet directly when `whole_sheet` is
117        /// set. Omitting `sheet`, `range` and `whole_sheet` entirely keeps
118        /// the existing range unchanged — this verb may be a rename only.
119        sheet: Option<String>,
120        /// An explicit new A1 range, which may carry its own `Sheet!`
121        /// prefix. Mutually exclusive with `whole_sheet`.
122        range: Option<String>,
123        /// Re-point at the entire sheet named by `sheet`, rather than a
124        /// range within it.
125        whole_sheet: bool,
126    },
127    /// Remove a named range.
128    DeleteNamedRange {
129        /// The existing name to remove, by exact match. Mutually exclusive
130        /// with `id`; exactly one of the two is set.
131        name: Option<String>,
132        /// The existing named range's id, by exact match (from
133        /// `list-named-ranges`). Mutually exclusive with `name`; exactly
134        /// one of the two is set.
135        id: Option<String>,
136    },
137}
138
139impl NamedRangeVerb {
140    const fn log_operation(&self) -> &'static str {
141        match self {
142            Self::AddNamedRange { .. } => "sheets-add-named-range",
143            Self::UpdateNamedRange { .. } => "sheets-update-named-range",
144            Self::DeleteNamedRange { .. } => "sheets-delete-named-range",
145        }
146    }
147
148    const fn label(&self) -> &'static str {
149        match self {
150            Self::AddNamedRange { .. } => "add-named-range",
151            Self::UpdateNamedRange { .. } => "update-named-range",
152            Self::DeleteNamedRange { .. } => "delete-named-range",
153        }
154    }
155}
156
157/// Per-call options.
158#[derive(Debug, Clone)]
159pub struct NamedRangeOptions {
160    /// Spreadsheet id.
161    pub spreadsheet_id: String,
162    /// Which mutation to perform.
163    pub verb: NamedRangeVerb,
164    /// Classify and describe only; never call `batchUpdate`.
165    pub dry_run: bool,
166    /// The lease token presented via `--lease`. Checked only when the
167    /// deciding rule requires one
168    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
169    /// `None` is only ever valid when it does not.
170    pub lease_token: Option<String>,
171    /// Path to the lease ledger the token is checked against. Production
172    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
173    /// result; tests pass a path under a `tempdir`.
174    pub ledger_path: PathBuf,
175}
176
177/// What happened (or, under `--dry-run`, would happen).
178#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
179#[serde(tag = "status", rename_all = "kebab-case")]
180pub enum NamedRangeResult {
181    /// `--dry-run`, and the gate would allow it.
182    WouldChange {
183        /// A human-readable summary of the effect.
184        summary: String,
185        /// `delete-named-range` only: the A1 locations of every cell
186        /// formula referencing the name being removed. Empty for every
187        /// other verb.
188        #[serde(skip_serializing_if = "Vec::is_empty")]
189        referencing_formulas: Vec<String>,
190    },
191    /// The target is not a Google Sheet.
192    RefusedNotASpreadsheet {
193        /// The target's actual MIME type.
194        mime_type: String,
195    },
196    /// The target is a shortcut, which we do not follow.
197    RefusedShortcut,
198    /// The target has no parents this account can see.
199    RefusedNoVisibleParents,
200    /// The named sheet does not exist in this workbook.
201    RefusedSheetNotFound {
202        /// The title that was not found.
203        title: String,
204        /// The titles that do exist.
205        available: Vec<String>,
206    },
207    /// The `--sheet`/`--range`/`--whole-sheet` combination was invalid, or
208    /// `update-named-range` named nothing to change.
209    RefusedInvalidRange {
210        /// What was wrong and why.
211        detail: String,
212    },
213    /// `update-named-range`/`delete-named-range` found no named range with
214    /// this exact name.
215    RefusedNotFound {
216        /// The name that was searched for.
217        name: String,
218    },
219    /// `update-named-range`/`delete-named-range`'s `--id` matched no named
220    /// range.
221    RefusedIdNotFound {
222        /// The id that was searched for.
223        id: String,
224    },
225    /// `update-named-range`/`delete-named-range`'s `--name` matched more
226    /// than one named range (issue #1932: `updateNamedRange` does not
227    /// enforce unique names the way `addNamedRange` does) — refused rather
228    /// than guessing which was meant.
229    RefusedAmbiguousName {
230        /// The name that was searched for.
231        name: String,
232        /// The matching ids — pass one of these to `--id` (issue #1975) to
233        /// act on a specific range instead of retrying by name.
234        candidates: Vec<String>,
235    },
236    /// `update-named-range --new-name` matches another, different named
237    /// range (case-insensitively) — refused pre-emptively (issue #1932)
238    /// since the API allows it, producing two ranges with the same name.
239    RefusedDuplicateName {
240        /// The requested new name.
241        new_name: String,
242        /// Every other named range's id that already has this name — more
243        /// than one if the workbook already had duplicates under it.
244        existing_ids: Vec<String>,
245    },
246    /// The folder write-permission gate refused it.
247    Blocked {
248        /// The rule that decided the refusal, if any.
249        decided_by: Option<DecidingRule>,
250    },
251    /// No `--lease` was presented, and the deciding rule requires one
252    /// (ADR-0080 §9).
253    RefusedNoLease,
254    /// The presented lease has expired, or was never a token this ledger
255    /// knows about.
256    RefusedLeaseExpired,
257    /// The presented lease is bound to a different file id.
258    RefusedLeaseWrongFile,
259    /// The file has moved since the lease's recorded `version` — the
260    /// staleness check (ADR-0080 §6).
261    RefusedLeaseStale,
262    /// The mutation succeeded.
263    Changed {
264        /// Same summary as [`Self::WouldChange`].
265        summary: String,
266        /// The named range's stable id — server-assigned for
267        /// `add-named-range`, otherwise the one resolved against.
268        named_range_id: Option<String>,
269        /// Same preview as [`Self::WouldChange`], read before the delete
270        /// took effect.
271        #[serde(skip_serializing_if = "Vec::is_empty")]
272        referencing_formulas: Vec<String>,
273    },
274    /// The API answered 2xx — the mutation **was applied** — but its reply
275    /// could not be read (issue #2021). Distinct from [`Self::Failed`]: a
276    /// retry here would fail on the now-taken name or act on a range that has
277    /// already changed. The server-assigned id of an added named range was in
278    /// that reply, so it is unknown.
279    AppliedReplyUnreadable {
280        /// Same summary as [`Self::WouldChange`].
281        summary: String,
282        /// The named range id resolved before the call — `Some` for
283        /// `update-named-range`/`delete-named-range`, `None` for
284        /// `add-named-range`. Never the server-assigned id of an added range
285        /// (that was in the reply).
286        named_range_id: Option<String>,
287        /// Same as [`Self::Changed`]'s.
288        #[serde(skip_serializing_if = "Vec::is_empty")]
289        referencing_formulas: Vec<String>,
290        /// Why the reply could not be read.
291        detail: String,
292    },
293    /// An API or validation error.
294    Failed {
295        /// A human-readable summary of what failed.
296        detail: String,
297    },
298}
299
300impl FromLeaseRefusal for NamedRangeResult {
301    fn from_no_lease() -> Self {
302        Self::RefusedNoLease
303    }
304    fn from_lease_expired() -> Self {
305        Self::RefusedLeaseExpired
306    }
307    fn from_lease_wrong_file() -> Self {
308        Self::RefusedLeaseWrongFile
309    }
310    fn from_lease_stale() -> Self {
311        Self::RefusedLeaseStale
312    }
313    fn from_lease_failed(detail: String) -> Self {
314        Self::Failed { detail }
315    }
316}
317
318impl NamedRangeResult {
319    fn log_status(&self) -> &'static str {
320        match self {
321            Self::WouldChange { .. } => "would-change",
322            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
323            Self::RefusedShortcut => "refused-shortcut",
324            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
325            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
326            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
327            Self::RefusedNotFound { .. } => "refused-not-found",
328            Self::RefusedIdNotFound { .. } => "refused-id-not-found",
329            Self::RefusedAmbiguousName { .. } => "refused-ambiguous-name",
330            Self::RefusedDuplicateName { .. } => "refused-duplicate-name",
331            Self::Blocked { .. } => "blocked",
332            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
333            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
334            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
335            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
336            Self::Changed { .. } => "changed",
337            Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
338            Self::Failed { .. } => "failed",
339        }
340    }
341}
342
343/// The full outcome of one attempt.
344#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
345pub struct NamedRangeOutcome {
346    /// The spreadsheet acted on.
347    pub spreadsheet_id: String,
348    /// Its Drive file name, when the metadata fetch got that far.
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub file_name: Option<String>,
351    /// The folder the gate evaluated against.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub resolved_folder_id: Option<String>,
354    /// Which mutation was attempted. Not serialised.
355    #[serde(skip)]
356    pub verb: NamedRangeVerb,
357    /// What happened.
358    pub result: NamedRangeResult,
359}
360
361impl JsonlSerialize for NamedRangeOutcome {
362    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
363        write_scalar_jsonl(self, out)
364    }
365}
366
367/// Runs one named-range mutation, logging every attempt that isn't a dry
368/// run.
369pub async fn named_range(
370    drive: &DriveClient,
371    sheets: &SheetsClient,
372    opts: &NamedRangeOptions,
373    rules: &[FolderPermissionRule],
374) -> NamedRangeOutcome {
375    let started = Instant::now();
376    let outcome = named_range_inner(drive, sheets, opts, rules).await;
377    if !opts.dry_run {
378        record_attempt(&outcome, opts, started.elapsed());
379    }
380    outcome
381}
382
383async fn named_range_inner(
384    drive: &DriveClient,
385    sheets: &SheetsClient,
386    opts: &NamedRangeOptions,
387    rules: &[FolderPermissionRule],
388) -> NamedRangeOutcome {
389    let bare = |result| NamedRangeOutcome {
390        spreadsheet_id: opts.spreadsheet_id.clone(),
391        file_name: None,
392        resolved_folder_id: None,
393        verb: opts.verb.clone(),
394        result,
395    };
396
397    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
398        drive,
399        &opts.spreadsheet_id,
400        DriveOperation::SheetsStructure,
401        rules,
402    )
403    .await
404    {
405        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
406            return bare(NamedRangeResult::Failed { detail })
407        }
408        target_gate::TargetGateOutcome::Refused { target, refusal } => {
409            let result = match refusal {
410                SheetTargetRefusal::Shortcut => NamedRangeResult::RefusedShortcut,
411                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
412                    NamedRangeResult::RefusedNotASpreadsheet { mime_type }
413                }
414                SheetTargetRefusal::NoVisibleParents => NamedRangeResult::RefusedNoVisibleParents,
415            };
416            return NamedRangeOutcome {
417                spreadsheet_id: opts.spreadsheet_id.clone(),
418                file_name: Some(target.name),
419                resolved_folder_id: None,
420                verb: opts.verb.clone(),
421                result,
422            };
423        }
424        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
425            return NamedRangeOutcome {
426                spreadsheet_id: opts.spreadsheet_id.clone(),
427                file_name: Some(target.name),
428                resolved_folder_id: None,
429                verb: opts.verb.clone(),
430                result: NamedRangeResult::Failed { detail },
431            };
432        }
433        target_gate::TargetGateOutcome::Gated {
434            target,
435            decision,
436            resolved_folder_id,
437            requires_lease,
438        } => (target, decision, resolved_folder_id, requires_lease),
439    };
440
441    let gated = |result| NamedRangeOutcome {
442        spreadsheet_id: opts.spreadsheet_id.clone(),
443        file_name: Some(target.name.clone()),
444        resolved_folder_id: resolved_folder_id.clone(),
445        verb: opts.verb.clone(),
446        result,
447    };
448
449    if decision.verdict == write_gate::Verdict::Deny {
450        return gated(NamedRangeResult::Blocked {
451            decided_by: decision.decided_by,
452        });
453    }
454
455    let api = SheetsApi::new(sheets);
456    let workbook = match api
457        .get_spreadsheet_with_named_ranges(&opts.spreadsheet_id)
458        .await
459    {
460        Ok(workbook) => workbook,
461        Err(err) => {
462            return gated(NamedRangeResult::Failed {
463                detail: format!("{err:#}"),
464            })
465        }
466    };
467
468    // `update-named-range`/`delete-named-range` resolve their target by
469    // exact name or id match against the workbook's current named ranges —
470    // done once, here, before the dry-run check, so a preview refuses a
471    // nonexistent target exactly like a real attempt would.
472    let existing = match &opts.verb {
473        NamedRangeVerb::AddNamedRange { .. } => None,
474        NamedRangeVerb::UpdateNamedRange { name, id, .. }
475        | NamedRangeVerb::DeleteNamedRange { name, id } => {
476            match find_existing_named_range_target(&workbook, name.as_deref(), id.as_deref()) {
477                Ok(existing) => Some(existing),
478                Err(result) => return gated(result),
479            }
480        }
481    };
482
483    if let NamedRangeVerb::UpdateNamedRange {
484        new_name: Some(new_name),
485        ..
486    } = &opts.verb
487    {
488        let Some(existing) = existing else {
489            unreachable!("existing is resolved for UpdateNamedRange above") // omni-dev: coverage ignore-line reason="existing is always Some for UpdateNamedRange: find_existing_named_range above either returns it or refuses and returns early"
490        };
491        if let Err(result) =
492            check_new_name_available(&workbook, existing.named_range_id.as_deref(), new_name)
493        {
494            return gated(result);
495        }
496    }
497
498    let new_grid = match &opts.verb {
499        NamedRangeVerb::AddNamedRange {
500            sheet,
501            range,
502            whole_sheet,
503            ..
504        } => match resolve_grid(&workbook, sheet.as_deref(), range.as_deref(), *whole_sheet) {
505            Ok(grid) => Some(grid),
506            Err(result) => return gated(result),
507        },
508        NamedRangeVerb::UpdateNamedRange {
509            sheet,
510            range,
511            whole_sheet,
512            ..
513        } => {
514            match resolve_optional_grid(&workbook, sheet.as_deref(), range.as_deref(), *whole_sheet)
515            {
516                Ok(grid) => grid,
517                Err(result) => return gated(result),
518            }
519        }
520        NamedRangeVerb::DeleteNamedRange { .. } => None,
521    };
522
523    if let NamedRangeVerb::UpdateNamedRange { new_name, .. } = &opts.verb {
524        if new_name.is_none() && new_grid.is_none() {
525            return gated(NamedRangeResult::RefusedInvalidRange {
526                detail: "update-named-range needs --new-name and/or a new range \
527                         (--range/--sheet/--whole-sheet); nothing to change"
528                    .to_string(),
529            });
530        }
531    }
532
533    // `delete-named-range` only (ADR-0081 §2): scan the workbook's formulas
534    // for the name being removed — unconditionally, before the dry-run
535    // check, so `--dry-run` and the real run report identically. Scans by
536    // `existing.name`, the resolved named range's canonical name, rather
537    // than the verb's own `name` field — the latter is `None` when the
538    // target was selected by `--id`.
539    let referencing_formulas = if let NamedRangeVerb::DeleteNamedRange { .. } = &opts.verb {
540        let Some(existing) = existing else {
541            unreachable!("existing is resolved for DeleteNamedRange above") // omni-dev: coverage ignore-line reason="existing is always Some for DeleteNamedRange: find_existing_named_range_target above either returns it or refuses and returns early"
542        };
543        match scan_referencing_formulas(&api, &opts.spreadsheet_id, &workbook, &existing.name).await
544        {
545            Ok(locations) => locations,
546            Err(detail) => return gated(NamedRangeResult::Failed { detail }),
547        }
548    } else {
549        Vec::new()
550    };
551
552    let summary = describe_effect(&opts.verb);
553
554    if opts.dry_run {
555        return gated(NamedRangeResult::WouldChange {
556            summary,
557            referencing_formulas,
558        });
559    }
560
561    let (request, existing_id) = match &opts.verb {
562        NamedRangeVerb::AddNamedRange { name, .. } => {
563            let Some(grid) = new_grid else {
564                unreachable!("new_grid is resolved for AddNamedRange above") // omni-dev: coverage ignore-line reason="new_grid is always Some for AddNamedRange: resolve_grid above either returns it or refuses and returns early"
565            };
566            (
567                BatchUpdateRequestItem::AddNamedRange(AddNamedRangeRequest {
568                    named_range: NamedRange {
569                        named_range_id: None,
570                        name: name.clone(),
571                        range: grid,
572                    },
573                }),
574                None,
575            )
576        }
577        NamedRangeVerb::UpdateNamedRange { new_name, .. } => {
578            let Some(existing) = existing else {
579                unreachable!("existing is resolved for UpdateNamedRange above") // omni-dev: coverage ignore-line reason="existing is always Some for UpdateNamedRange: find_existing_named_range above either returns it or refuses and returns early"
580            };
581            (
582                BatchUpdateRequestItem::UpdateNamedRange(build_update(
583                    existing, new_name, new_grid,
584                )),
585                existing.named_range_id.clone(),
586            )
587        }
588        NamedRangeVerb::DeleteNamedRange { .. } => {
589            let Some(existing) = existing else {
590                unreachable!("existing is resolved for DeleteNamedRange above") // omni-dev: coverage ignore-line reason="existing is always Some for DeleteNamedRange: find_existing_named_range above either returns it or refuses and returns early"
591            };
592            let id = existing.named_range_id.clone();
593            (
594                BatchUpdateRequestItem::DeleteNamedRange(DeleteNamedRangeRequest {
595                    named_range_id: id.clone().unwrap_or_default(),
596                }),
597                id,
598            )
599        }
600    };
601
602    // The lease check (ADR-0080 §9) sits here: after the permission gate
603    // and the `--dry-run` branch, before the mutating call — see
604    // `protection.rs::protection_inner`'s doc comment for the full
605    // reasoning, shared verbatim by every leased engine.
606    let files_api = FilesApi::new(drive);
607    let leased = LeasedWrite {
608        log_prefix: "drive sheets named-range",
609        operation: opts.verb.log_operation(),
610        ledger_path: &opts.ledger_path,
611        file_id: &opts.spreadsheet_id,
612    };
613    let lease_grant = match gate_optional_leased_write(
614        leased,
615        &files_api,
616        requires_lease,
617        opts.lease_token.as_deref(),
618    )
619    .await
620    {
621        Ok(grant) => grant,
622        Err(err) => return gated(err.into_result()),
623    };
624
625    let result = match conclude_native_leased_write(
626        leased,
627        &lease_grant,
628        &files_api,
629        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
630        |err| format!("{err:#}"),
631    )
632    .await
633    {
634        Ok(BatchUpdateOutcome::Applied(response)) => {
635            let named_range_id = added_named_range_id(&response).or(existing_id);
636            NamedRangeResult::Changed {
637                summary,
638                named_range_id,
639                referencing_formulas,
640            }
641        }
642        // NotIdempotent: a second run fails on the now-taken name or acts on an
643        // already-changed range.
644        Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
645            NamedRangeResult::AppliedReplyUnreadable {
646                summary,
647                named_range_id: existing_id,
648                referencing_formulas,
649                detail,
650            }
651        }
652        Err(err) => NamedRangeResult::Failed {
653            detail: format!("{err:#}"),
654        },
655    };
656    drop(lease_grant);
657    gated(result)
658}
659
660/// Composes and resolves `--sheet`/`--range`/`--whole-sheet` into a
661/// concrete [`GridRange`] — mandatory, exactly like `protection.rs`'s
662/// `compose_target`/`resolve_grid` pair for `protect-range`. Used by
663/// `add-named-range`, where a target is always required.
664fn resolve_grid(
665    workbook: &Spreadsheet,
666    sheet: Option<&str>,
667    range: Option<&str>,
668    whole_sheet: bool,
669) -> Result<GridRange, NamedRangeResult> {
670    if whole_sheet {
671        if range.is_some() {
672            return Err(NamedRangeResult::RefusedInvalidRange {
673                detail: "--whole-sheet and --range are mutually exclusive".to_string(),
674            });
675        }
676        let Some(sheet) = sheet else {
677            return Err(NamedRangeResult::RefusedInvalidRange {
678                detail: "--whole-sheet needs --sheet to name the sheet".to_string(),
679            });
680        };
681        let sheet_id = find_sheet_id(workbook, sheet)?;
682        return Ok(GridRange {
683            sheet_id,
684            ..Default::default()
685        });
686    }
687    let composed =
688        a1::compose(sheet, range).map_err(|err| NamedRangeResult::RefusedInvalidRange {
689            detail: err.to_string(),
690        })?;
691    let (_, grid) = grid_range::resolve_grid_range(
692        workbook,
693        &composed,
694        |detail| NamedRangeResult::RefusedInvalidRange { detail },
695        |title, available| NamedRangeResult::RefusedSheetNotFound { title, available },
696    )?;
697    Ok(grid)
698}
699
700/// `update-named-range`'s range resolution: like [`resolve_grid`], except
701/// omitting `--sheet`, `--range` and `--whole-sheet` entirely means "keep
702/// the existing range" (`Ok(None)`) rather than an error, since the verb
703/// may be a rename only.
704fn resolve_optional_grid(
705    workbook: &Spreadsheet,
706    sheet: Option<&str>,
707    range: Option<&str>,
708    whole_sheet: bool,
709) -> Result<Option<GridRange>, NamedRangeResult> {
710    if !whole_sheet && sheet.is_none() && range.is_none() {
711        return Ok(None);
712    }
713    resolve_grid(workbook, sheet, range, whole_sheet).map(Some)
714}
715
716fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, NamedRangeResult> {
717    grid_range::find_sheet_id(workbook, title, |title, available| {
718        NamedRangeResult::RefusedSheetNotFound { title, available }
719    })
720}
721
722/// Finds the one existing named range with a case-insensitive exact `name`
723/// match — `update-named-range`/`delete-named-range`'s `--name` selector.
724/// Refuses rather than guesses on zero or multiple matches, like
725/// `protection.rs::find_existing_protection`. See
726/// [`find_existing_named_range_by_id`] for the `--id` selector, which a
727/// user reaches for when this refuses as ambiguous.
728///
729/// Sheets enforces unique names on `addNamedRange` but not on
730/// `updateNamedRange` (issue #1932): renaming one named range to another's
731/// name is not rejected server-side, so two ranges can end up sharing a
732/// name despite the "unique workbook-wide" assumption `addNamedRange`'s own
733/// behavior suggests. `check_new_name_available` refuses the rename that
734/// would create this; this function's `many` branch is the other half —
735/// refusing to guess which of two already-duplicate ranges `--name` meant,
736/// for a workbook that reached that state before this crate could refuse
737/// the rename (or via another client entirely).
738///
739/// Case-insensitive to match [`scan_referencing_formulas`]'s own
740/// case-insensitive resolution of a reference to this same name — a
741/// case-sensitive lookup here would let `--name foo` report "not found"
742/// for a range actually stored as `Foo`, even though a formula spelling it
743/// `foo` resolves to it fine.
744fn find_existing_named_range<'a>(
745    workbook: &'a Spreadsheet,
746    name: &str,
747) -> Result<&'a NamedRange, NamedRangeResult> {
748    let matches: Vec<&NamedRange> = workbook
749        .named_ranges
750        .iter()
751        .filter(|nr| nr.name.eq_ignore_ascii_case(name))
752        .collect();
753    match matches.as_slice() {
754        [one] => Ok(one),
755        [] => Err(NamedRangeResult::RefusedNotFound {
756            name: name.to_string(),
757        }),
758        many => Err(NamedRangeResult::RefusedAmbiguousName {
759            name: name.to_string(),
760            candidates: many
761                .iter()
762                .filter_map(|nr| nr.named_range_id.clone())
763                .collect(),
764        }),
765    }
766}
767
768/// Finds the one existing named range with an exact `named_range_id` match
769/// — `update-named-range`/`delete-named-range`'s `--id` selector (issue
770/// #1975), the escape hatch for [`find_existing_named_range`]'s
771/// `RefusedAmbiguousName` refusal, which lists matching ids but gives no way
772/// to act on one otherwise. Case-sensitive, unlike name resolution: a
773/// server-assigned opaque id is never something a user would type in a
774/// different case. An id match is always unique, so unlike
775/// [`find_existing_named_range`] there is no "ambiguous" branch — only
776/// found-or-not-found.
777fn find_existing_named_range_by_id<'a>(
778    workbook: &'a Spreadsheet,
779    id: &str,
780) -> Result<&'a NamedRange, NamedRangeResult> {
781    workbook
782        .named_ranges
783        .iter()
784        .find(|nr| nr.named_range_id.as_deref() == Some(id))
785        .ok_or_else(|| NamedRangeResult::RefusedIdNotFound { id: id.to_string() })
786}
787
788/// Resolves `update-named-range`/`delete-named-range`'s target by whichever
789/// of `name`/`id` the CLI's `ArgGroup` set — mutually exclusive, exactly one
790/// required. A direct library caller passing neither (impossible through
791/// the CLI) is refused rather than panicking.
792fn find_existing_named_range_target<'a>(
793    workbook: &'a Spreadsheet,
794    name: Option<&str>,
795    id: Option<&str>,
796) -> Result<&'a NamedRange, NamedRangeResult> {
797    if let Some(id) = id {
798        find_existing_named_range_by_id(workbook, id)
799    } else if let Some(name) = name {
800        find_existing_named_range(workbook, name)
801    } else {
802        Err(NamedRangeResult::RefusedInvalidRange {
803            detail: "one of --name/--id is required".to_string(),
804        })
805    }
806}
807
808/// Refuses `update-named-range --new-name <new_name>` when another named
809/// range — not the one being renamed, identified by `own_id` — already
810/// has that name, case-insensitively (issue #1932). The API accepts this
811/// silently, producing two named ranges with the same name, which breaks
812/// the case-insensitive-exact-match lookup every other verb in this module
813/// relies on. Lists every colliding id, not just the first, the same way
814/// [`find_existing_named_range`]'s `RefusedAmbiguousName` does — a workbook
815/// can already have more than one range sharing `new_name` from before
816/// this check existed, or from another client.
817///
818/// Best-effort, not airtight: like every other check in this module, this
819/// reads a workbook fetched once at the top of [`named_range_inner`] and is
820/// not re-verified immediately before the mutating `batchUpdate` call. A
821/// rule with `require_lease: false` (ADR-0080 §13) skips the lease's own
822/// re-fetch-and-compare-`version` step, so two concurrent renames — or one
823/// racing an edit from another client — can each pass this check against
824/// their own snapshot and still collide. This closes the common case (a
825/// single CLI invocation, which is what issue #1932 reported); closing the
826/// race too would need re-reading the workbook after the lease is held,
827/// which no verb in this module does today.
828fn check_new_name_available(
829    workbook: &Spreadsheet,
830    own_id: Option<&str>,
831    new_name: &str,
832) -> Result<(), NamedRangeResult> {
833    let collisions: Vec<String> = workbook
834        .named_ranges
835        .iter()
836        .filter(|nr| {
837            nr.name.eq_ignore_ascii_case(new_name) && nr.named_range_id.as_deref() != own_id
838        })
839        .filter_map(|nr| nr.named_range_id.clone())
840        .collect();
841    if collisions.is_empty() {
842        Ok(())
843    } else {
844        Err(NamedRangeResult::RefusedDuplicateName {
845            new_name: new_name.to_string(),
846            existing_ids: collisions,
847        })
848    }
849}
850
851/// Builds the `updateNamedRange` request and its `fields` mask from what
852/// actually changed, mirroring `protection.rs::build_update`'s incremental-
853/// mask pattern. `named_range`/`range` are always sent in full (unlike
854/// `ProtectedRangeUpdate`'s `Option` fields) since [`NamedRange`] has no
855/// field this request must exclude — the `fields` mask alone controls what
856/// the server applies, so re-sending an unchanged value is harmless.
857fn build_update(
858    existing: &NamedRange,
859    new_name: &Option<String>,
860    new_grid: Option<GridRange>,
861) -> UpdateNamedRangeRequest {
862    let mut fields = Vec::new();
863    let mut update = existing.clone();
864    if let Some(new_name) = new_name {
865        update.name.clone_from(new_name);
866        fields.push("name");
867    }
868    if let Some(grid) = new_grid {
869        update.range = grid;
870        fields.push("range");
871    }
872    UpdateNamedRangeRequest {
873        named_range: update,
874        fields: fields.join(","),
875    }
876}
877
878fn added_named_range_id(response: &BatchUpdateResponse) -> Option<String> {
879    response
880        .replies
881        .iter()
882        .find_map(|reply| reply.add_named_range.as_ref())
883        .and_then(|added| added.named_range.as_ref())
884        .and_then(|nr| nr.named_range_id.clone())
885}
886
887/// Scans every sheet's formulas for a bare reference to `name`, returning
888/// the A1 location of each match — **never** the formula text or a cell's
889/// value (ADR-0081 §2's data-exposure line). Word-boundary matched and
890/// case-insensitive, so a name like `Foo` doesn't match inside `Foobar`,
891/// and a formula spelling it `FOO` is still caught — matching Sheets' own
892/// case-insensitive resolution of a named-range reference, and erring
893/// toward over- rather than under-reporting for a safety preview.
894async fn scan_referencing_formulas(
895    api: &SheetsApi<'_>,
896    spreadsheet_id: &str,
897    workbook: &Spreadsheet,
898    name: &str,
899) -> Result<Vec<String>, String> {
900    let titles = workbook.sheet_titles();
901    if titles.is_empty() {
902        return Ok(Vec::new());
903    }
904    let pattern = format!(r"(?i)\b{}\b", regex::escape(name));
905    let re = regex::Regex::new(&pattern).map_err(|err| format!("{err:#}"))?;
906
907    let mut locations = Vec::new();
908    let results = api
909        .batch_get_every_sheet(spreadsheet_id, &titles, ValueRenderOption::Formula)
910        .await
911        .map_err(|err| format!("{err:#}"))?;
912    for (title, value_range) in results {
913        let Some(title) = title else {
914            continue;
915        };
916        for (row_idx, row) in value_range.values.iter().enumerate() {
917            for (col_idx, cell) in row.iter().enumerate() {
918                let Some(formula) = cell.as_str() else {
919                    continue;
920                };
921                // Only a formula (leading `=`) can reference a named
922                // range — a plain text cell whose content merely
923                // contains the name is not a reference.
924                if !formula.starts_with('=') || !re.is_match(formula) {
925                    continue;
926                }
927                let address = format!(
928                    "{}{}",
929                    grid_range::column_index_to_letters(col_idx as i64),
930                    row_idx + 1
931                );
932                let location = a1::compose(Some(&title), Some(&address))
933                    .unwrap_or_else(|_| format!("{title}!{address}"));
934                locations.push(location);
935            }
936        }
937    }
938    Ok(locations)
939}
940
941fn describe_effect(verb: &NamedRangeVerb) -> String {
942    match verb {
943        NamedRangeVerb::AddNamedRange { name, .. } => format!("add named range '{name}'"),
944        NamedRangeVerb::UpdateNamedRange {
945            name, id, new_name, ..
946        } => {
947            let target = target_descriptor(name.as_deref(), id.as_deref());
948            match new_name {
949                Some(new_name) => format!("rename named range {target} to '{new_name}'"),
950                None => format!("update named range {target}"),
951            }
952        }
953        NamedRangeVerb::DeleteNamedRange { name, id } => format!(
954            "delete named range {}",
955            target_descriptor(name.as_deref(), id.as_deref())
956        ),
957    }
958}
959
960/// Renders `update-named-range`/`delete-named-range`'s resolved target for
961/// a human-readable summary — whichever of `--name`/`--id` was set. `(None,
962/// None)` is unreachable via the CLI (the `ArgGroup` requires exactly one)
963/// but rendered rather than panicking, matching
964/// [`find_existing_named_range_target`]'s own refuse-don't-panic stance.
965fn target_descriptor(name: Option<&str>, id: Option<&str>) -> String {
966    match (name, id) {
967        (Some(name), _) => format!("'{name}'"),
968        (None, Some(id)) => format!("(id '{id}')"),
969        (None, None) => "?".to_string(),
970    }
971}
972
973fn record_attempt(outcome: &NamedRangeOutcome, opts: &NamedRangeOptions, duration: Duration) {
974    request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
975}
976
977/// The request-log record for one attempt. Split from [`record_attempt`] so
978/// the logged fields are unit-testable.
979fn mutation_record(
980    outcome: &NamedRangeOutcome,
981    opts: &NamedRangeOptions,
982    duration: Duration,
983) -> DriveMutationOutcome {
984    let error = match &outcome.result {
985        NamedRangeResult::Failed { detail }
986        | NamedRangeResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
987        _ => None,
988    };
989    let decided_by = match &outcome.result {
990        NamedRangeResult::Blocked { decided_by } => decided_by.as_ref(),
991        _ => None,
992    };
993    let decided_by = write_gate::decided_by_log_fields(decided_by);
994    let (named_range_id, referencing_formula_locations) = match &outcome.result {
995        NamedRangeResult::Changed {
996            named_range_id,
997            referencing_formulas,
998            ..
999        }
1000        | NamedRangeResult::AppliedReplyUnreadable {
1001            named_range_id,
1002            referencing_formulas,
1003            ..
1004        } => (named_range_id.clone(), referencing_formulas.clone()),
1005        _ => (None, Vec::new()),
1006    };
1007    let fields_changed = match &outcome.result {
1008        NamedRangeResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1009        _ => None,
1010    };
1011
1012    DriveMutationOutcome {
1013        operation: opts.verb.log_operation(),
1014        file_id: outcome.spreadsheet_id.clone(),
1015        file_name: outcome.file_name.clone().unwrap_or_default(),
1016        status: outcome.result.log_status().to_string(),
1017        resolved_folder_id: outcome.resolved_folder_id.clone(),
1018        decided_by_folder_id: decided_by.folder_id,
1019        decided_by_depth: decided_by.depth,
1020        decided_by_file_id: decided_by.file_id,
1021        named_range_id,
1022        referencing_formula_locations,
1023        fields_changed,
1024        error,
1025        duration,
1026        ..Default::default()
1027    }
1028}
1029
1030/// Renders an outcome as human-readable text.
1031#[must_use]
1032pub fn describe(outcome: &NamedRangeOutcome) -> String {
1033    describe_lines(outcome).join("\n")
1034}
1035
1036/// Renders an outcome as its individual lines, none of which contains a
1037/// newline.
1038#[must_use]
1039pub fn describe_lines(outcome: &NamedRangeOutcome) -> Vec<String> {
1040    let verb = &outcome.verb;
1041    let book = outcome.file_name.as_deref().map_or_else(
1042        || format!("'{}'", outcome.spreadsheet_id),
1043        |n| format!("'{n}'"),
1044    );
1045    match &outcome.result {
1046        NamedRangeResult::WouldChange {
1047            summary,
1048            referencing_formulas,
1049        } => {
1050            let mut lines = vec![format!("Would {summary} in {book}")];
1051            lines.extend(referencing_formula_lines(referencing_formulas));
1052            lines
1053        }
1054        NamedRangeResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1055            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
1056             `drive sheets {}` only works on spreadsheets",
1057            verb.label()
1058        )],
1059        NamedRangeResult::RefusedShortcut => vec![format!(
1060            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1061            verb.label()
1062        )],
1063        NamedRangeResult::RefusedNoVisibleParents => vec![format!(
1064            "Refused: {book} has no parent folder visible to this account, so no folder \
1065             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
1066             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
1067        )],
1068        NamedRangeResult::RefusedSheetNotFound { title, available } => {
1069            vec![grid_range::no_sheet_refusal(&book, title, available)]
1070        }
1071        NamedRangeResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
1072        NamedRangeResult::RefusedNotFound { name } => vec![format!(
1073            "Refused: {book} has no named range '{name}'; run \
1074             `drive sheets list-named-ranges` to see what exists"
1075        )],
1076        NamedRangeResult::RefusedIdNotFound { id } => vec![format!(
1077            "Refused: {book} has no named range with id '{id}'; run \
1078             `drive sheets list-named-ranges` to see what exists"
1079        )],
1080        NamedRangeResult::RefusedAmbiguousName { name, candidates } => vec![format!(
1081            "Refused: more than one named range in {book} is named '{name}' (ids: {}); \
1082             this is ambiguous and nothing was changed. Retry with `--id` naming one \
1083             of those ids, or rename/delete all but one in the Sheets UI \
1084             (Data > Named ranges)",
1085            candidates.join(", ")
1086        )],
1087        NamedRangeResult::RefusedDuplicateName {
1088            new_name,
1089            existing_ids,
1090        } => {
1091            let ids = if existing_ids.is_empty() {
1092                String::new()
1093            } else {
1094                format!(" (ids: {})", existing_ids.join(", "))
1095            };
1096            vec![format!(
1097                "Refused: {book} already has a named range called '{new_name}'{ids}; \
1098                 rename would create a duplicate"
1099            )]
1100        }
1101        NamedRangeResult::Blocked { decided_by } => vec![match decided_by {
1102            Some(rule) => format!(
1103                "Blocked: {} on {book} refused by rule on {} {}{}",
1104                verb.label(),
1105                rule.kind_label(),
1106                rule.id(),
1107                rule.depth_suffix()
1108            ),
1109            None => format!(
1110                "Blocked: {} on {book} refused by default policy (no matching rule for \
1111                 sheets-structure)",
1112                verb.label()
1113            ),
1114        }],
1115        NamedRangeResult::RefusedNoLease => LeaseGateRefusal::NoLease
1116            .describe_line(&outcome.spreadsheet_id, &book)
1117            .into_iter()
1118            .collect(),
1119        NamedRangeResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1120            .describe_line(&outcome.spreadsheet_id, &book)
1121            .into_iter()
1122            .collect(),
1123        NamedRangeResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1124            .describe_line(&outcome.spreadsheet_id, &book)
1125            .into_iter()
1126            .collect(),
1127        NamedRangeResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1128            .describe_line(&outcome.spreadsheet_id, &book)
1129            .into_iter()
1130            .collect(),
1131        NamedRangeResult::Changed {
1132            summary,
1133            named_range_id,
1134            referencing_formulas,
1135        } => {
1136            let id = named_range_id
1137                .as_deref()
1138                .map_or_else(String::new, |id| format!(" (id {id})"));
1139            let mut lines = vec![format!("Applied: {summary}{id} in {book}")];
1140            lines.extend(referencing_formula_lines(referencing_formulas));
1141            lines
1142        }
1143        NamedRangeResult::AppliedReplyUnreadable {
1144            summary,
1145            named_range_id,
1146            referencing_formulas,
1147            detail,
1148        } => {
1149            let id = named_range_id
1150                .as_deref()
1151                .map_or_else(String::new, |id| format!(" (id {id})"));
1152            let mut lines = vec![applied_reply_unreadable_line(
1153                &format!("{summary}{id}"),
1154                &book,
1155                RetryHint::NotIdempotent,
1156                detail,
1157            )];
1158            if matches!(verb, NamedRangeVerb::AddNamedRange { .. }) {
1159                lines.push(
1160                    "  the new named range's id was in the unreadable reply; run \
1161                     `list-named-ranges` to find it"
1162                        .to_string(),
1163                );
1164            }
1165            // The preview `Changed` prints, read before the delete took effect.
1166            lines.extend(referencing_formula_lines(referencing_formulas));
1167            lines
1168        }
1169        NamedRangeResult::Failed { detail } => vec![format!("Failed: {detail}")],
1170    }
1171}
1172
1173/// Renders the referencing-formula preview (ADR-0081 §2) as one
1174/// human-readable line: the count sentence, then the rendered locations.
1175/// Empty when nothing references the name, and for every verb but
1176/// `delete-named-range`. ADR-0081 §2 requires the preview to be
1177/// *mandatory*, not exhaustive-in-text, so eliding the rendered line past
1178/// [`grid_range::RENDERED_LOCATION_LIMIT`] is consistent — the structured
1179/// outcome keeps the full list.
1180fn referencing_formula_lines(referencing_formulas: &[String]) -> Vec<String> {
1181    if referencing_formulas.is_empty() {
1182        return Vec::new();
1183    }
1184    vec![format!(
1185        "{} cell formula(s) reference this name and will start evaluating to #REF! once \
1186         it's removed (conditional formatting, data validation and chart references are not \
1187         scanned): {}",
1188        referencing_formulas.len(),
1189        grid_range::render_locations(referencing_formulas)
1190    )]
1191}
1192
1193#[cfg(test)]
1194#[allow(clippy::unwrap_used, clippy::expect_used)]
1195mod tests {
1196    use super::*;
1197    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1198    use crate::drive::sheets::client::SHEETS_API_URL;
1199    use crate::drive::sheets::types::Sheet;
1200    use crate::drive::test_support::seed_lease;
1201    use crate::test_support::env::MapEnv;
1202    use crate::utils::secret::Secret;
1203    use std::collections::HashSet;
1204
1205    fn grid(sheet_id: i64) -> GridRange {
1206        GridRange {
1207            sheet_id,
1208            start_row_index: Some(0),
1209            end_row_index: Some(5),
1210            start_column_index: Some(0),
1211            end_column_index: Some(1),
1212        }
1213    }
1214
1215    fn named(id: &str, name: &str, range: GridRange) -> NamedRange {
1216        NamedRange {
1217            named_range_id: Some(id.to_string()),
1218            name: name.to_string(),
1219            range,
1220        }
1221    }
1222
1223    fn workbook_with(named_ranges: Vec<NamedRange>) -> Spreadsheet {
1224        Spreadsheet {
1225            sheets: vec![Sheet::default()],
1226            named_ranges,
1227            ..Default::default()
1228        }
1229    }
1230
1231    #[test]
1232    fn find_existing_named_range_matches_by_exact_name() {
1233        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1234        let found = find_existing_named_range(&workbook, "Foo").unwrap();
1235        assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1236    }
1237
1238    #[test]
1239    fn find_existing_named_range_refuses_when_none_matches() {
1240        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1241        let err = find_existing_named_range(&workbook, "Bar").unwrap_err();
1242        assert!(matches!(err, NamedRangeResult::RefusedNotFound { name } if name == "Bar"));
1243    }
1244
1245    #[test]
1246    fn find_existing_named_range_matches_case_insensitively() {
1247        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1248        let found = find_existing_named_range(&workbook, "foo").unwrap();
1249        assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1250    }
1251
1252    #[test]
1253    fn find_existing_named_range_refuses_as_ambiguous_when_two_share_a_name() {
1254        // issue #1932: updateNamedRange doesn't enforce unique names, so a
1255        // workbook can reach this state despite addNamedRange refusing it.
1256        let workbook = workbook_with(vec![
1257            named("id-1", "AllS1", grid(0)),
1258            named("id-2", "AllS1", grid(1)),
1259        ]);
1260        let err = find_existing_named_range(&workbook, "AllS1").unwrap_err();
1261        match err {
1262            NamedRangeResult::RefusedAmbiguousName {
1263                name,
1264                mut candidates,
1265            } => {
1266                assert_eq!(name, "AllS1");
1267                candidates.sort();
1268                assert_eq!(candidates, ["id-1", "id-2"]);
1269            }
1270            other => panic!("expected RefusedAmbiguousName, got {other:?}"), // omni-dev: coverage ignore-line reason="find_existing_named_range always returns RefusedAmbiguousName here; this test's mounted workbook always has two matching names"
1271        }
1272    }
1273
1274    #[test]
1275    fn find_existing_named_range_refuses_as_ambiguous_case_insensitively() {
1276        let workbook = workbook_with(vec![
1277            named("id-1", "Units", grid(0)),
1278            named("id-2", "units", grid(1)),
1279        ]);
1280        let err = find_existing_named_range(&workbook, "UNITS").unwrap_err();
1281        assert!(matches!(err, NamedRangeResult::RefusedAmbiguousName { .. }));
1282    }
1283
1284    // ── the `--id` selector (issue #1975) ───────────────────────────────
1285
1286    #[test]
1287    fn find_existing_named_range_by_id_matches_by_exact_id() {
1288        let workbook = workbook_with(vec![
1289            named("id-1", "AllS1", grid(0)),
1290            named("id-2", "AllS1", grid(1)),
1291        ]);
1292        // Two ranges share a name — exactly the ambiguous case `--id` is
1293        // the escape hatch for. An exact id match still resolves uniquely.
1294        let found = find_existing_named_range_by_id(&workbook, "id-2").unwrap();
1295        assert_eq!(found.named_range_id.as_deref(), Some("id-2"));
1296    }
1297
1298    #[test]
1299    fn find_existing_named_range_by_id_refuses_when_none_matches() {
1300        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1301        let err = find_existing_named_range_by_id(&workbook, "id-99").unwrap_err();
1302        assert!(matches!(err, NamedRangeResult::RefusedIdNotFound { id } if id == "id-99"));
1303    }
1304
1305    #[test]
1306    fn find_existing_named_range_by_id_is_case_sensitive() {
1307        // Unlike name resolution, an id is a server-assigned opaque token,
1308        // not something a user might type in a different case.
1309        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1310        let err = find_existing_named_range_by_id(&workbook, "ID-1").unwrap_err();
1311        assert!(matches!(err, NamedRangeResult::RefusedIdNotFound { id } if id == "ID-1"));
1312    }
1313
1314    #[test]
1315    fn find_existing_named_range_target_prefers_id_when_both_are_set() {
1316        // Unreachable via the CLI (the `ArgGroup` requires exactly one),
1317        // but the library function itself must still resolve deterministically.
1318        let workbook = workbook_with(vec![
1319            named("id-1", "Foo", grid(0)),
1320            named("id-2", "Bar", grid(1)),
1321        ]);
1322        let found = find_existing_named_range_target(&workbook, Some("Bar"), Some("id-1")).unwrap();
1323        assert_eq!(found.named_range_id.as_deref(), Some("id-1"));
1324    }
1325
1326    #[test]
1327    fn find_existing_named_range_target_refuses_when_neither_is_set() {
1328        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1329        let err = find_existing_named_range_target(&workbook, None, None).unwrap_err();
1330        assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1331    }
1332
1333    #[test]
1334    fn check_new_name_available_allows_renaming_to_its_own_current_name() {
1335        let workbook = workbook_with(vec![named("id-1", "Foo", grid(0))]);
1336        assert!(check_new_name_available(&workbook, Some("id-1"), "Foo").is_ok());
1337        // Case changes to the same range are also fine — still "own".
1338        assert!(check_new_name_available(&workbook, Some("id-1"), "foo").is_ok());
1339    }
1340
1341    #[test]
1342    fn check_new_name_available_refuses_a_case_insensitive_collision() {
1343        let workbook = workbook_with(vec![
1344            named("id-1", "Qty", grid(0)),
1345            named("id-2", "AllS1", grid(1)),
1346        ]);
1347        let err = check_new_name_available(&workbook, Some("id-1"), "allS1").unwrap_err();
1348        match err {
1349            NamedRangeResult::RefusedDuplicateName {
1350                new_name,
1351                existing_ids,
1352            } => {
1353                assert_eq!(new_name, "allS1");
1354                assert_eq!(existing_ids, ["id-2"]);
1355            }
1356            other => panic!("expected RefusedDuplicateName, got {other:?}"), // omni-dev: coverage ignore-line reason="check_new_name_available always returns RefusedDuplicateName here; this test's new_name always collides with a different named range"
1357        }
1358    }
1359
1360    #[test]
1361    fn check_new_name_available_lists_every_colliding_id_when_the_workbook_already_has_duplicates()
1362    {
1363        // issue #1932 review: a collision check that only reported the
1364        // first match would give an incomplete picture of a workbook that
1365        // already has duplicates from before this check existed.
1366        let workbook = workbook_with(vec![
1367            named("id-1", "Qty", grid(0)),
1368            named("id-2", "AllS1", grid(1)),
1369            named("id-3", "AllS1", grid(2)),
1370        ]);
1371        let err = check_new_name_available(&workbook, Some("id-1"), "AllS1").unwrap_err();
1372        match err {
1373            NamedRangeResult::RefusedDuplicateName {
1374                new_name,
1375                mut existing_ids,
1376            } => {
1377                assert_eq!(new_name, "AllS1");
1378                existing_ids.sort();
1379                assert_eq!(existing_ids, ["id-2", "id-3"]);
1380            }
1381            other => panic!("expected RefusedDuplicateName, got {other:?}"), // omni-dev: coverage ignore-line reason="check_new_name_available always returns RefusedDuplicateName here; this test's new_name always collides with two different named ranges"
1382        }
1383    }
1384
1385    #[test]
1386    fn check_new_name_available_allows_a_genuinely_new_name() {
1387        let workbook = workbook_with(vec![named("id-1", "Qty", grid(0))]);
1388        assert!(check_new_name_available(&workbook, Some("id-1"), "Units").is_ok());
1389    }
1390
1391    #[test]
1392    fn resolve_grid_rejects_whole_sheet_with_range() {
1393        let workbook = workbook_with(Vec::new());
1394        let err = resolve_grid(&workbook, Some("Q1"), Some("A1"), true).unwrap_err();
1395        assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1396    }
1397
1398    #[test]
1399    fn resolve_grid_rejects_whole_sheet_without_a_sheet() {
1400        let workbook = workbook_with(Vec::new());
1401        let err = resolve_grid(&workbook, None, None, true).unwrap_err();
1402        assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
1403    }
1404
1405    #[test]
1406    fn resolve_optional_grid_is_none_when_nothing_is_given() {
1407        let workbook = workbook_with(Vec::new());
1408        assert_eq!(
1409            resolve_optional_grid(&workbook, None, None, false).unwrap(),
1410            None
1411        );
1412    }
1413
1414    #[test]
1415    fn build_update_only_sets_fields_that_changed() {
1416        let existing = named("id-1", "Foo", grid(0));
1417        let request = build_update(&existing, &None, None);
1418        assert_eq!(request.fields, "");
1419        assert_eq!(request.named_range.name, "Foo");
1420
1421        let request = build_update(&existing, &Some("Bar".to_string()), None);
1422        assert_eq!(request.fields, "name");
1423        assert_eq!(request.named_range.name, "Bar");
1424
1425        let new_grid = grid(1);
1426        let request = build_update(&existing, &None, Some(new_grid));
1427        assert_eq!(request.fields, "range");
1428        assert_eq!(request.named_range.range, new_grid);
1429    }
1430
1431    // ── the word-boundary/case-insensitivity scan ──────────────────────
1432
1433    #[test]
1434    fn regex_matches_the_name_but_not_a_longer_identifier_containing_it() {
1435        let re = regex::Regex::new(&format!(r"(?i)\b{}\b", regex::escape("Foo"))).unwrap();
1436        assert!(re.is_match("=SUM(Foo)"));
1437        assert!(re.is_match("=SUM(foo)"));
1438        assert!(!re.is_match("=SUM(Foobar)"));
1439    }
1440
1441    fn test_credentials() -> DriveCredentials {
1442        DriveCredentials {
1443            client_id: "client-1".to_string(),
1444            client_secret: Secret::new("secret-1"),
1445            refresh_token: Secret::new("refresh-1"),
1446            scope: DriveGrantedScopes::READONLY,
1447        }
1448    }
1449
1450    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1451        wiremock::Mock::given(wiremock::matchers::method("POST"))
1452            .and(wiremock::matchers::path("/token"))
1453            .respond_with(
1454                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1455                    "access_token": "test-token", "expires_in": 3600,
1456                })),
1457            )
1458            .mount(server)
1459            .await;
1460        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1461        crate::drive::client::test_support::replace_session(
1462            &mut drive,
1463            &test_credentials(),
1464            &format!("{}/token", server.uri()),
1465        );
1466        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1467        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1468        (drive, sheets)
1469    }
1470
1471    /// `version: "1"` throughout — matches [`leased_opts_for`]'s default
1472    /// seeded lease.
1473    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1474        let parents: Vec<&str> = parents.to_vec();
1475        wiremock::Mock::given(wiremock::matchers::method("GET"))
1476            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1477            .respond_with(
1478                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1479                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1480                    "version": "1",
1481                })),
1482            )
1483    }
1484
1485    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1486        let ledger_path = tempfile::tempdir()
1487            .unwrap()
1488            .keep()
1489            .join("lease-ledger.jsonl");
1490        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1491        (Some(token), ledger_path)
1492    }
1493
1494    fn mount_folder(id: &str) -> wiremock::Mock {
1495        mount_file(id, "application/vnd.google-apps.folder", &[])
1496    }
1497
1498    fn allow_rule(folder: &str) -> FolderPermissionRule {
1499        FolderPermissionRule {
1500            folder_id: Some(folder.to_string()),
1501            file_id: None,
1502            recursive: true,
1503            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1504            deny: HashSet::default(),
1505            require_lease: true,
1506        }
1507    }
1508
1509    fn mount_workbook(named_ranges: serde_json::Value) -> wiremock::Mock {
1510        wiremock::Mock::given(wiremock::matchers::method("GET"))
1511            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1512            .respond_with(
1513                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1514                    "spreadsheetId": "sheet-1",
1515                    "properties": {"title": "Budget"},
1516                    "sheets": [
1517                        {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
1518                        {"properties": {"sheetId": 1, "title": "Q2", "index": 1}},
1519                    ],
1520                    "namedRanges": named_ranges,
1521                })),
1522            )
1523    }
1524
1525    /// Mounts a single `values:batchGet` response carrying every named
1526    /// sheet's data in one body — the shape the real API actually returns
1527    /// for one chunk (every test workbook here has 2 sheets, well under
1528    /// `MAX_RANGES_PER_BATCH`, so there is always exactly one request).
1529    /// Deliberately **not** one mock per sheet matched by query param:
1530    /// `wiremock`'s `query_param` matcher is satisfied by any request that
1531    /// *contains* the given pair, and a combined request naming every
1532    /// sheet satisfies every per-sheet mock at once, so only the
1533    /// first-registered one would ever actually respond — silently never
1534    /// exercising the others.
1535    fn mount_batch_get(sheets: &[(&str, serde_json::Value)]) -> wiremock::Mock {
1536        let value_ranges: Vec<serde_json::Value> = sheets
1537            .iter()
1538            .map(|(sheet, values)| {
1539                serde_json::json!({"range": format!("{sheet}!A1:Z1000"), "values": values})
1540            })
1541            .collect();
1542        wiremock::Mock::given(wiremock::matchers::method("GET"))
1543            .and(wiremock::matchers::path(
1544                "/v4/spreadsheets/sheet-1/values:batchGet",
1545            ))
1546            .respond_with(
1547                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1548                    "valueRanges": value_ranges
1549                })),
1550            )
1551    }
1552
1553    #[tokio::test]
1554    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1555        let server = wiremock::MockServer::start().await;
1556        let (drive, sheets) = clients(&server).await;
1557        mount_file(
1558            "sheet-1",
1559            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1560            &["folder-1"],
1561        )
1562        .mount(&server)
1563        .await;
1564        mount_folder("folder-1").mount(&server).await;
1565        let rules: Vec<FolderPermissionRule> = Vec::new();
1566        let opts = NamedRangeOptions {
1567            spreadsheet_id: "sheet-1".to_string(),
1568            verb: NamedRangeVerb::AddNamedRange {
1569                name: "Foo".to_string(),
1570                sheet: Some("Q1".to_string()),
1571                range: Some("A1:A5".to_string()),
1572                whole_sheet: false,
1573            },
1574            dry_run: false,
1575            lease_token: None,
1576            ledger_path: std::path::PathBuf::new(),
1577        };
1578        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1579        assert!(matches!(outcome.result, NamedRangeResult::Blocked { .. }));
1580    }
1581
1582    #[tokio::test]
1583    async fn add_named_range_sends_an_add_named_range_request() {
1584        let server = wiremock::MockServer::start().await;
1585        let (drive, sheets) = clients(&server).await;
1586        mount_file(
1587            "sheet-1",
1588            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1589            &["folder-1"],
1590        )
1591        .mount(&server)
1592        .await;
1593        mount_folder("folder-1").mount(&server).await;
1594        mount_workbook(serde_json::json!([])).mount(&server).await;
1595        wiremock::Mock::given(wiremock::matchers::method("POST"))
1596            .and(wiremock::matchers::path(
1597                "/v4/spreadsheets/sheet-1:batchUpdate",
1598            ))
1599            .respond_with(
1600                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1601                    "replies": [{"addNamedRange": {"namedRange": {"namedRangeId": "id-99"}}}]
1602                })),
1603            )
1604            .mount(&server)
1605            .await;
1606        let rules = vec![allow_rule("folder-1")];
1607        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1608        let opts = NamedRangeOptions {
1609            spreadsheet_id: "sheet-1".to_string(),
1610            verb: NamedRangeVerb::AddNamedRange {
1611                name: "Foo".to_string(),
1612                sheet: Some("Q1".to_string()),
1613                range: Some("A1:A5".to_string()),
1614                whole_sheet: false,
1615            },
1616            dry_run: false,
1617            lease_token,
1618            ledger_path,
1619        };
1620        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1621        match outcome.result {
1622            NamedRangeResult::Changed { named_range_id, .. } => {
1623                assert_eq!(named_range_id.as_deref(), Some("id-99"));
1624            }
1625            other => panic!("expected Changed, got {other:?}"),
1626        }
1627    }
1628
1629    #[tokio::test]
1630    async fn add_named_range_with_an_unreadable_reply_reports_applied_reply_unreadable() {
1631        let server = wiremock::MockServer::start().await;
1632        let (drive, sheets) = clients(&server).await;
1633        mount_file(
1634            "sheet-1",
1635            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1636            &["folder-1"],
1637        )
1638        .mount(&server)
1639        .await;
1640        mount_folder("folder-1").mount(&server).await;
1641        mount_workbook(serde_json::json!([])).mount(&server).await;
1642        // 2xx, so the named range WAS added — but the body is not the reply shape.
1643        wiremock::Mock::given(wiremock::matchers::method("POST"))
1644            .and(wiremock::matchers::path(
1645                "/v4/spreadsheets/sheet-1:batchUpdate",
1646            ))
1647            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
1648            .expect(1)
1649            .mount(&server)
1650            .await;
1651        let rules = vec![allow_rule("folder-1")];
1652        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1653        let opts = NamedRangeOptions {
1654            spreadsheet_id: "sheet-1".to_string(),
1655            verb: NamedRangeVerb::AddNamedRange {
1656                name: "Foo".to_string(),
1657                sheet: Some("Q1".to_string()),
1658                range: Some("A1:A5".to_string()),
1659                whole_sheet: false,
1660            },
1661            dry_run: false,
1662            lease_token,
1663            ledger_path,
1664        };
1665        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1666        assert_eq!(
1667            outcome.result.log_status(),
1668            "applied-reply-unreadable",
1669            "{:?}",
1670            outcome.result
1671        );
1672        assert!(
1673            describe(&outcome).starts_with("Applied, but the reply could not be read"),
1674            "{}",
1675            describe(&outcome)
1676        );
1677    }
1678
1679    #[tokio::test]
1680    async fn update_named_range_renames_and_re_ranges() {
1681        let server = wiremock::MockServer::start().await;
1682        let (drive, sheets) = clients(&server).await;
1683        mount_file(
1684            "sheet-1",
1685            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1686            &["folder-1"],
1687        )
1688        .mount(&server)
1689        .await;
1690        mount_folder("folder-1").mount(&server).await;
1691        mount_workbook(serde_json::json!([{
1692            "namedRangeId": "id-1",
1693            "name": "Foo",
1694            "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1695                      "startColumnIndex": 0, "endColumnIndex": 1},
1696        }]))
1697        .mount(&server)
1698        .await;
1699        wiremock::Mock::given(wiremock::matchers::method("POST"))
1700            .and(wiremock::matchers::path(
1701                "/v4/spreadsheets/sheet-1:batchUpdate",
1702            ))
1703            .respond_with(
1704                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1705                    "replies": [{}]
1706                })),
1707            )
1708            .mount(&server)
1709            .await;
1710        let rules = vec![allow_rule("folder-1")];
1711        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1712        let opts = NamedRangeOptions {
1713            spreadsheet_id: "sheet-1".to_string(),
1714            verb: NamedRangeVerb::UpdateNamedRange {
1715                name: Some("Foo".to_string()),
1716                id: None,
1717                new_name: Some("Bar".to_string()),
1718                sheet: Some("Q2".to_string()),
1719                range: None,
1720                whole_sheet: true,
1721            },
1722            dry_run: false,
1723            lease_token,
1724            ledger_path,
1725        };
1726        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1727        match outcome.result {
1728            NamedRangeResult::Changed { named_range_id, .. } => {
1729                assert_eq!(named_range_id.as_deref(), Some("id-1"));
1730            }
1731            other => panic!("expected Changed, got {other:?}"),
1732        }
1733    }
1734
1735    #[tokio::test]
1736    async fn update_named_range_refuses_when_no_name_matches() {
1737        let server = wiremock::MockServer::start().await;
1738        let (drive, sheets) = clients(&server).await;
1739        mount_file(
1740            "sheet-1",
1741            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1742            &["folder-1"],
1743        )
1744        .mount(&server)
1745        .await;
1746        mount_folder("folder-1").mount(&server).await;
1747        mount_workbook(serde_json::json!([])).mount(&server).await;
1748        let rules = vec![allow_rule("folder-1")];
1749        let opts = NamedRangeOptions {
1750            spreadsheet_id: "sheet-1".to_string(),
1751            verb: NamedRangeVerb::UpdateNamedRange {
1752                name: Some("Foo".to_string()),
1753                id: None,
1754                new_name: Some("Bar".to_string()),
1755                sheet: None,
1756                range: None,
1757                whole_sheet: false,
1758            },
1759            dry_run: false,
1760            lease_token: None,
1761            ledger_path: std::path::PathBuf::new(),
1762        };
1763        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1764        assert!(matches!(
1765            outcome.result,
1766            NamedRangeResult::RefusedNotFound { .. }
1767        ));
1768    }
1769
1770    #[tokio::test]
1771    async fn update_named_range_by_id_refuses_when_no_id_matches() {
1772        let server = wiremock::MockServer::start().await;
1773        let (drive, sheets) = clients(&server).await;
1774        mount_file(
1775            "sheet-1",
1776            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1777            &["folder-1"],
1778        )
1779        .mount(&server)
1780        .await;
1781        mount_folder("folder-1").mount(&server).await;
1782        mount_workbook(serde_json::json!([])).mount(&server).await;
1783        let rules = vec![allow_rule("folder-1")];
1784        let opts = NamedRangeOptions {
1785            spreadsheet_id: "sheet-1".to_string(),
1786            verb: NamedRangeVerb::UpdateNamedRange {
1787                name: None,
1788                id: Some("id-99".to_string()),
1789                new_name: Some("Bar".to_string()),
1790                sheet: None,
1791                range: None,
1792                whole_sheet: false,
1793            },
1794            dry_run: false,
1795            lease_token: None,
1796            ledger_path: std::path::PathBuf::new(),
1797        };
1798        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1799        assert!(matches!(
1800            outcome.result,
1801            NamedRangeResult::RefusedIdNotFound { id } if id == "id-99"
1802        ));
1803    }
1804
1805    #[tokio::test]
1806    async fn update_named_range_refuses_a_new_name_that_collides_with_another_range() {
1807        // issue #1932's exact repro: `AllS1` and `Qty` both defined,
1808        // `update-named-range --name qty --new-name AllS1` must be refused
1809        // rather than producing two ranges named `AllS1`. No batchUpdate
1810        // mock is registered — a call to it would fail the test.
1811        let server = wiremock::MockServer::start().await;
1812        let (drive, sheets) = clients(&server).await;
1813        mount_file(
1814            "sheet-1",
1815            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1816            &["folder-1"],
1817        )
1818        .mount(&server)
1819        .await;
1820        mount_folder("folder-1").mount(&server).await;
1821        mount_workbook(serde_json::json!([
1822            {
1823                "namedRangeId": "id-1",
1824                "name": "AllS1",
1825                "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1826                          "startColumnIndex": 0, "endColumnIndex": 1},
1827            },
1828            {
1829                "namedRangeId": "id-2",
1830                "name": "Qty",
1831                "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
1832                          "startColumnIndex": 0, "endColumnIndex": 1},
1833            },
1834        ]))
1835        .mount(&server)
1836        .await;
1837        let rules = vec![allow_rule("folder-1")];
1838        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1839        let opts = NamedRangeOptions {
1840            spreadsheet_id: "sheet-1".to_string(),
1841            verb: NamedRangeVerb::UpdateNamedRange {
1842                name: Some("qty".to_string()),
1843                id: None,
1844                new_name: Some("AllS1".to_string()),
1845                sheet: None,
1846                range: None,
1847                whole_sheet: false,
1848            },
1849            dry_run: false,
1850            lease_token,
1851            ledger_path,
1852        };
1853        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1854        match outcome.result {
1855            NamedRangeResult::RefusedDuplicateName {
1856                new_name,
1857                existing_ids,
1858            } => {
1859                assert_eq!(new_name, "AllS1");
1860                assert_eq!(existing_ids, ["id-1"]);
1861            }
1862            other => panic!("expected RefusedDuplicateName, got {other:?}"),
1863        }
1864    }
1865
1866    #[tokio::test]
1867    async fn update_named_range_allows_renaming_to_its_own_current_name() {
1868        // A same-name "rename" (e.g. re-pointing the range while also
1869        // passing --new-name equal to the current name) is not a
1870        // collision with a *different* range.
1871        let server = wiremock::MockServer::start().await;
1872        let (drive, sheets) = clients(&server).await;
1873        mount_file(
1874            "sheet-1",
1875            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1876            &["folder-1"],
1877        )
1878        .mount(&server)
1879        .await;
1880        mount_folder("folder-1").mount(&server).await;
1881        mount_workbook(serde_json::json!([{
1882            "namedRangeId": "id-1",
1883            "name": "Foo",
1884            "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1885                      "startColumnIndex": 0, "endColumnIndex": 1},
1886        }]))
1887        .mount(&server)
1888        .await;
1889        wiremock::Mock::given(wiremock::matchers::method("POST"))
1890            .and(wiremock::matchers::path(
1891                "/v4/spreadsheets/sheet-1:batchUpdate",
1892            ))
1893            .respond_with(
1894                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1895                    "replies": [{}]
1896                })),
1897            )
1898            .mount(&server)
1899            .await;
1900        let rules = vec![allow_rule("folder-1")];
1901        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1902        let opts = NamedRangeOptions {
1903            spreadsheet_id: "sheet-1".to_string(),
1904            verb: NamedRangeVerb::UpdateNamedRange {
1905                name: Some("Foo".to_string()),
1906                id: None,
1907                new_name: Some("foo".to_string()),
1908                sheet: Some("Q2".to_string()),
1909                range: None,
1910                whole_sheet: true,
1911            },
1912            dry_run: false,
1913            lease_token,
1914            ledger_path,
1915        };
1916        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1917        assert!(
1918            matches!(outcome.result, NamedRangeResult::Changed { .. }),
1919            "{:?}",
1920            outcome.result
1921        );
1922    }
1923
1924    #[tokio::test]
1925    async fn update_and_delete_named_range_refuse_as_ambiguous_when_name_matches_two_ranges() {
1926        let verbs = [
1927            NamedRangeVerb::UpdateNamedRange {
1928                name: Some("AllS1".to_string()),
1929                id: None,
1930                new_name: Some("Totals".to_string()),
1931                sheet: None,
1932                range: None,
1933                whole_sheet: false,
1934            },
1935            NamedRangeVerb::DeleteNamedRange {
1936                name: Some("AllS1".to_string()),
1937                id: None,
1938            },
1939        ];
1940        for verb in verbs {
1941            let server = wiremock::MockServer::start().await;
1942            let (drive, sheets) = clients(&server).await;
1943            mount_file(
1944                "sheet-1",
1945                crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1946                &["folder-1"],
1947            )
1948            .mount(&server)
1949            .await;
1950            mount_folder("folder-1").mount(&server).await;
1951            mount_workbook(serde_json::json!([
1952                {
1953                    "namedRangeId": "id-1",
1954                    "name": "AllS1",
1955                    "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
1956                              "startColumnIndex": 0, "endColumnIndex": 1},
1957                },
1958                {
1959                    "namedRangeId": "id-2",
1960                    "name": "AllS1",
1961                    "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
1962                              "startColumnIndex": 0, "endColumnIndex": 1},
1963                },
1964            ]))
1965            .mount(&server)
1966            .await;
1967            let rules = vec![allow_rule("folder-1")];
1968            let opts = NamedRangeOptions {
1969                spreadsheet_id: "sheet-1".to_string(),
1970                verb,
1971                dry_run: false,
1972                lease_token: None,
1973                ledger_path: std::path::PathBuf::new(),
1974            };
1975            // No batchUpdate mock is mounted, so reaching the API would
1976            // surface as Failed rather than the refusal asserted here.
1977            let outcome = named_range(&drive, &sheets, &opts, &rules).await;
1978            assert!(
1979                matches!(
1980                    outcome.result,
1981                    NamedRangeResult::RefusedAmbiguousName { .. }
1982                ),
1983                "{:?}",
1984                outcome.result
1985            );
1986        }
1987    }
1988
1989    /// The actual escape hatch (issue #1975): two named ranges share a
1990    /// name, so `--name` alone is refused as ambiguous (the test above),
1991    /// but `--id` resolves the same workbook state unambiguously and the
1992    /// mutation actually goes through.
1993    #[tokio::test]
1994    async fn delete_named_range_by_id_succeeds_when_name_would_be_ambiguous() {
1995        let server = wiremock::MockServer::start().await;
1996        let (drive, sheets) = clients(&server).await;
1997        mount_file(
1998            "sheet-1",
1999            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2000            &["folder-1"],
2001        )
2002        .mount(&server)
2003        .await;
2004        mount_folder("folder-1").mount(&server).await;
2005        mount_workbook(serde_json::json!([
2006            {
2007                "namedRangeId": "id-1",
2008                "name": "AllS1",
2009                "range": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 5,
2010                          "startColumnIndex": 0, "endColumnIndex": 1},
2011            },
2012            {
2013                "namedRangeId": "id-2",
2014                "name": "AllS1",
2015                "range": {"sheetId": 0, "startRowIndex": 5, "endRowIndex": 10,
2016                          "startColumnIndex": 0, "endColumnIndex": 1},
2017            },
2018        ]))
2019        .mount(&server)
2020        .await;
2021        mount_batch_get(&[("Q1", serde_json::json!([])), ("Q2", serde_json::json!([]))])
2022            .mount(&server)
2023            .await;
2024        wiremock::Mock::given(wiremock::matchers::method("POST"))
2025            .and(wiremock::matchers::path(
2026                "/v4/spreadsheets/sheet-1:batchUpdate",
2027            ))
2028            .respond_with(
2029                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2030                    "replies": [{}]
2031                })),
2032            )
2033            .mount(&server)
2034            .await;
2035        let rules = vec![allow_rule("folder-1")];
2036        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2037        let opts = NamedRangeOptions {
2038            spreadsheet_id: "sheet-1".to_string(),
2039            verb: NamedRangeVerb::DeleteNamedRange {
2040                name: None,
2041                id: Some("id-2".to_string()),
2042            },
2043            dry_run: false,
2044            lease_token,
2045            ledger_path,
2046        };
2047        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2048        match outcome.result {
2049            NamedRangeResult::Changed { named_range_id, .. } => {
2050                assert_eq!(named_range_id.as_deref(), Some("id-2"));
2051            }
2052            other => panic!("expected Changed, got {other:?}"),
2053        }
2054    }
2055
2056    #[tokio::test]
2057    async fn delete_named_range_dry_run_reports_referencing_formulas_without_a_decoy_match() {
2058        let server = wiremock::MockServer::start().await;
2059        let (drive, sheets) = clients(&server).await;
2060        mount_file(
2061            "sheet-1",
2062            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2063            &["folder-1"],
2064        )
2065        .mount(&server)
2066        .await;
2067        mount_folder("folder-1").mount(&server).await;
2068        mount_workbook(serde_json::json!([{
2069            "namedRangeId": "id-1",
2070            "name": "Foo",
2071            "range": {"sheetId": 0},
2072        }]))
2073        .mount(&server)
2074        .await;
2075        // A true reference on Q1, and a decoy on Q2 that must NOT match:
2076        // "Foobar" contains "Foo" but is a distinct identifier. Both
2077        // sheets' data ride the one combined batchGet response.
2078        mount_batch_get(&[
2079            (
2080                "Q1",
2081                serde_json::json!([["=SUM(Foo)", "plain text with Foo in it"]]),
2082            ),
2083            ("Q2", serde_json::json!([["=Foobar+1"]])),
2084        ])
2085        .mount(&server)
2086        .await;
2087        let rules = vec![allow_rule("folder-1")];
2088        let opts = NamedRangeOptions {
2089            spreadsheet_id: "sheet-1".to_string(),
2090            verb: NamedRangeVerb::DeleteNamedRange {
2091                name: Some("Foo".to_string()),
2092                id: None,
2093            },
2094            dry_run: true,
2095            lease_token: None,
2096            ledger_path: std::path::PathBuf::new(),
2097        };
2098        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2099        match outcome.result {
2100            NamedRangeResult::WouldChange {
2101                referencing_formulas,
2102                ..
2103            } => {
2104                assert_eq!(referencing_formulas, vec!["'Q1'!A1".to_string()]);
2105            }
2106            other => panic!("expected WouldChange, got {other:?}"),
2107        }
2108    }
2109
2110    #[tokio::test]
2111    async fn delete_named_range_with_zero_references_reports_an_empty_preview() {
2112        let server = wiremock::MockServer::start().await;
2113        let (drive, sheets) = clients(&server).await;
2114        mount_file(
2115            "sheet-1",
2116            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2117            &["folder-1"],
2118        )
2119        .mount(&server)
2120        .await;
2121        mount_folder("folder-1").mount(&server).await;
2122        mount_workbook(serde_json::json!([{
2123            "namedRangeId": "id-1",
2124            "name": "Foo",
2125            "range": {"sheetId": 0},
2126        }]))
2127        .mount(&server)
2128        .await;
2129        mount_batch_get(&[("Q1", serde_json::json!([])), ("Q2", serde_json::json!([]))])
2130            .mount(&server)
2131            .await;
2132        let rules = vec![allow_rule("folder-1")];
2133        let opts = NamedRangeOptions {
2134            spreadsheet_id: "sheet-1".to_string(),
2135            verb: NamedRangeVerb::DeleteNamedRange {
2136                name: Some("Foo".to_string()),
2137                id: None,
2138            },
2139            dry_run: true,
2140            lease_token: None,
2141            ledger_path: std::path::PathBuf::new(),
2142        };
2143        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2144        match outcome.result {
2145            NamedRangeResult::WouldChange {
2146                referencing_formulas,
2147                ..
2148            } => assert!(referencing_formulas.is_empty()),
2149            other => panic!("expected WouldChange, got {other:?}"),
2150        }
2151    }
2152
2153    #[tokio::test]
2154    async fn delete_named_range_real_run_sends_the_same_preview_as_dry_run() {
2155        let server = wiremock::MockServer::start().await;
2156        let (drive, sheets) = clients(&server).await;
2157        mount_file(
2158            "sheet-1",
2159            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2160            &["folder-1"],
2161        )
2162        .mount(&server)
2163        .await;
2164        mount_folder("folder-1").mount(&server).await;
2165        mount_workbook(serde_json::json!([{
2166            "namedRangeId": "id-1",
2167            "name": "Foo",
2168            "range": {"sheetId": 0},
2169        }]))
2170        .mount(&server)
2171        .await;
2172        mount_batch_get(&[
2173            ("Q1", serde_json::json!([["=SUM(Foo)"]])),
2174            ("Q2", serde_json::json!([])),
2175        ])
2176        .mount(&server)
2177        .await;
2178        wiremock::Mock::given(wiremock::matchers::method("POST"))
2179            .and(wiremock::matchers::path(
2180                "/v4/spreadsheets/sheet-1:batchUpdate",
2181            ))
2182            .respond_with(
2183                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2184                    "replies": [{}]
2185                })),
2186            )
2187            .mount(&server)
2188            .await;
2189        let rules = vec![allow_rule("folder-1")];
2190        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
2191        let opts = NamedRangeOptions {
2192            spreadsheet_id: "sheet-1".to_string(),
2193            verb: NamedRangeVerb::DeleteNamedRange {
2194                name: Some("Foo".to_string()),
2195                id: None,
2196            },
2197            dry_run: false,
2198            lease_token,
2199            ledger_path,
2200        };
2201        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2202        match outcome.result {
2203            NamedRangeResult::Changed {
2204                named_range_id,
2205                referencing_formulas,
2206                ..
2207            } => {
2208                assert_eq!(named_range_id.as_deref(), Some("id-1"));
2209                assert_eq!(referencing_formulas, vec!["'Q1'!A1".to_string()]);
2210            }
2211            other => panic!("expected Changed, got {other:?}"),
2212        }
2213    }
2214
2215    // ── describe/describe_lines/describe_effect (pure) ─────────────────
2216
2217    fn outcome_with(
2218        verb: NamedRangeVerb,
2219        file_name: Option<&str>,
2220        result: NamedRangeResult,
2221    ) -> NamedRangeOutcome {
2222        NamedRangeOutcome {
2223            spreadsheet_id: "sheet-1".to_string(),
2224            file_name: file_name.map(str::to_string),
2225            resolved_folder_id: None,
2226            verb,
2227            result,
2228        }
2229    }
2230
2231    fn add_verb() -> NamedRangeVerb {
2232        NamedRangeVerb::AddNamedRange {
2233            name: "Foo".to_string(),
2234            sheet: Some("Q1".to_string()),
2235            range: Some("A1:A5".to_string()),
2236            whole_sheet: false,
2237        }
2238    }
2239
2240    fn update_verb() -> NamedRangeVerb {
2241        NamedRangeVerb::UpdateNamedRange {
2242            name: Some("Foo".to_string()),
2243            id: None,
2244            new_name: Some("Bar".to_string()),
2245            sheet: None,
2246            range: None,
2247            whole_sheet: false,
2248        }
2249    }
2250
2251    fn delete_verb() -> NamedRangeVerb {
2252        NamedRangeVerb::DeleteNamedRange {
2253            name: Some("Foo".to_string()),
2254            id: None,
2255        }
2256    }
2257
2258    #[test]
2259    fn describe_lines_renders_would_change_with_and_without_referencing_formulas() {
2260        let none = outcome_with(
2261            add_verb(),
2262            Some("Budget"),
2263            NamedRangeResult::WouldChange {
2264                summary: "add named range 'Foo'".to_string(),
2265                referencing_formulas: Vec::new(),
2266            },
2267        );
2268        assert_eq!(describe(&none), "Would add named range 'Foo' in 'Budget'");
2269
2270        let some = outcome_with(
2271            delete_verb(),
2272            Some("Budget"),
2273            NamedRangeResult::WouldChange {
2274                summary: "delete named range 'Foo'".to_string(),
2275                referencing_formulas: vec!["'Q1'!A1".to_string(), "'Q2'!B2".to_string()],
2276            },
2277        );
2278        let text = describe(&some);
2279        assert!(
2280            text.contains("2 cell formula(s) reference this name"),
2281            "{text}"
2282        );
2283        assert!(
2284            text.contains(
2285                "this name and will start evaluating to #REF! once it's removed \
2286                (conditional formatting, data validation and chart references are not \
2287                scanned): 'Q1'!A1, 'Q2'!B2"
2288            ),
2289            "{text}"
2290        );
2291    }
2292
2293    #[test]
2294    fn referencing_formula_lines_elides_past_the_render_limit_but_keeps_the_true_count() {
2295        let formulas: Vec<String> = (1..=60).map(|row| format!("'Q1'!A{row}")).collect();
2296        let some = outcome_with(
2297            delete_verb(),
2298            Some("Budget"),
2299            NamedRangeResult::WouldChange {
2300                summary: "delete named range 'Foo'".to_string(),
2301                referencing_formulas: formulas.clone(),
2302            },
2303        );
2304        let text = describe(&some);
2305        assert!(
2306            text.contains("60 cell formula(s) reference this name"),
2307            "{text}"
2308        );
2309        assert!(text.contains("… and 10 more"), "{text}");
2310        assert_eq!(formulas.len(), 60);
2311    }
2312
2313    #[test]
2314    fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2315        let out = outcome_with(
2316            add_verb(),
2317            None,
2318            NamedRangeResult::RefusedNotASpreadsheet {
2319                mime_type: "text/plain".to_string(),
2320            },
2321        );
2322        let text = describe(&out);
2323        assert!(text.contains("'sheet-1'"), "{text}");
2324        assert!(text.contains("add-named-range"), "{text}");
2325        assert!(text.contains("text/plain"), "{text}");
2326    }
2327
2328    #[test]
2329    fn describe_lines_renders_shortcut() {
2330        let out = outcome_with(
2331            delete_verb(),
2332            Some("Budget"),
2333            NamedRangeResult::RefusedShortcut,
2334        );
2335        let text = describe(&out);
2336        assert!(text.contains("shortcut"), "{text}");
2337        assert!(text.contains("delete-named-range"), "{text}");
2338    }
2339
2340    #[test]
2341    fn describe_lines_renders_no_visible_parents() {
2342        let out = outcome_with(
2343            add_verb(),
2344            Some("Budget"),
2345            NamedRangeResult::RefusedNoVisibleParents,
2346        );
2347        assert!(describe(&out).contains("sheets-structure"));
2348    }
2349
2350    #[test]
2351    fn describe_lines_renders_ambiguous_name_with_candidates() {
2352        let out = outcome_with(
2353            update_verb(),
2354            Some("Budget"),
2355            NamedRangeResult::RefusedAmbiguousName {
2356                name: "AllS1".to_string(),
2357                candidates: vec!["id-1".to_string(), "id-2".to_string()],
2358            },
2359        );
2360        let text = describe(&out);
2361        assert!(text.contains("AllS1"), "{text}");
2362        assert!(text.contains("id-1"), "{text}");
2363        assert!(text.contains("id-2"), "{text}");
2364        assert!(text.contains("ambiguous"), "{text}");
2365        assert!(text.contains("Data > Named ranges"), "{text}");
2366    }
2367
2368    #[test]
2369    fn describe_lines_renders_duplicate_name_with_one_and_several_ids() {
2370        let with_id = outcome_with(
2371            update_verb(),
2372            Some("Budget"),
2373            NamedRangeResult::RefusedDuplicateName {
2374                new_name: "AllS1".to_string(),
2375                existing_ids: vec!["id-1".to_string()],
2376            },
2377        );
2378        let text = describe(&with_id);
2379        assert!(text.contains("AllS1"), "{text}");
2380        assert!(text.contains("id-1"), "{text}");
2381
2382        let with_several = outcome_with(
2383            update_verb(),
2384            Some("Budget"),
2385            NamedRangeResult::RefusedDuplicateName {
2386                new_name: "AllS1".to_string(),
2387                existing_ids: vec!["id-1".to_string(), "id-2".to_string()],
2388            },
2389        );
2390        let text = describe(&with_several);
2391        assert!(text.contains("id-1"), "{text}");
2392        assert!(text.contains("id-2"), "{text}");
2393
2394        let without_id = outcome_with(
2395            update_verb(),
2396            Some("Budget"),
2397            NamedRangeResult::RefusedDuplicateName {
2398                new_name: "AllS1".to_string(),
2399                existing_ids: Vec::new(),
2400            },
2401        );
2402        let text = describe(&without_id);
2403        assert!(text.contains("AllS1"), "{text}");
2404        assert!(!text.contains("(ids"), "{text}");
2405    }
2406
2407    #[test]
2408    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
2409        let none = outcome_with(
2410            add_verb(),
2411            Some("Budget"),
2412            NamedRangeResult::RefusedSheetNotFound {
2413                title: "Q2".to_string(),
2414                available: Vec::new(),
2415            },
2416        );
2417        assert!(describe(&none).contains("Available: none"));
2418
2419        let some = outcome_with(
2420            add_verb(),
2421            Some("Budget"),
2422            NamedRangeResult::RefusedSheetNotFound {
2423                title: "Q2".to_string(),
2424                available: vec!["Q1".to_string(), "Q3".to_string()],
2425            },
2426        );
2427        assert!(describe(&some).contains("'Q1', 'Q3'"));
2428    }
2429
2430    #[test]
2431    fn describe_lines_renders_invalid_range_and_not_found() {
2432        let invalid = outcome_with(
2433            add_verb(),
2434            Some("Budget"),
2435            NamedRangeResult::RefusedInvalidRange {
2436                detail: "bad range".to_string(),
2437            },
2438        );
2439        assert_eq!(describe(&invalid), "Refused: bad range");
2440
2441        let not_found = outcome_with(
2442            update_verb(),
2443            Some("Budget"),
2444            NamedRangeResult::RefusedNotFound {
2445                name: "Foo".to_string(),
2446            },
2447        );
2448        let text = describe(&not_found);
2449        assert!(text.contains("no named range 'Foo'"), "{text}");
2450        assert!(text.contains("list-named-ranges"), "{text}");
2451
2452        let id_not_found = outcome_with(
2453            update_verb(),
2454            Some("Budget"),
2455            NamedRangeResult::RefusedIdNotFound {
2456                id: "id-99".to_string(),
2457            },
2458        );
2459        let text = describe(&id_not_found);
2460        assert!(text.contains("no named range with id 'id-99'"), "{text}");
2461        assert!(text.contains("list-named-ranges"), "{text}");
2462    }
2463
2464    #[test]
2465    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2466        let folder_rule = outcome_with(
2467            update_verb(),
2468            Some("Budget"),
2469            NamedRangeResult::Blocked {
2470                decided_by: Some(DecidingRule::Folder {
2471                    folder_id: "folder-1".to_string(),
2472                    depth: 2,
2473                }),
2474            },
2475        );
2476        let text = describe(&folder_rule);
2477        assert!(text.contains("update-named-range"), "{text}");
2478        assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
2479
2480        let default_policy = outcome_with(
2481            delete_verb(),
2482            Some("Budget"),
2483            NamedRangeResult::Blocked { decided_by: None },
2484        );
2485        assert!(describe(&default_policy).contains("default policy"));
2486    }
2487
2488    #[test]
2489    fn describe_lines_renders_every_lease_refusal() {
2490        for (result, needle) in [
2491            (
2492                NamedRangeResult::RefusedNoLease,
2493                "requires a Drive write lease",
2494            ),
2495            (
2496                NamedRangeResult::RefusedLeaseExpired,
2497                "expired, released, or unknown",
2498            ),
2499            (
2500                NamedRangeResult::RefusedLeaseWrongFile,
2501                "acquired for a different file",
2502            ),
2503            (
2504                NamedRangeResult::RefusedLeaseStale,
2505                "changed since the lease was acquired",
2506            ),
2507        ] {
2508            let out = outcome_with(add_verb(), Some("Budget"), result);
2509            let text = describe(&out);
2510            assert!(text.contains(needle), "{text}");
2511            assert!(text.contains("drive lease acquire sheet-1"), "{text}");
2512        }
2513    }
2514
2515    #[test]
2516    fn describe_lines_renders_changed_with_and_without_an_id_and_formulas() {
2517        let with_id = outcome_with(
2518            add_verb(),
2519            Some("Budget"),
2520            NamedRangeResult::Changed {
2521                summary: "add named range 'Foo'".to_string(),
2522                named_range_id: Some("id-7".to_string()),
2523                referencing_formulas: Vec::new(),
2524            },
2525        );
2526        assert!(describe(&with_id).contains("(id id-7)"));
2527
2528        let without_id = outcome_with(
2529            delete_verb(),
2530            Some("Budget"),
2531            NamedRangeResult::Changed {
2532                summary: "delete named range 'Foo'".to_string(),
2533                named_range_id: None,
2534                referencing_formulas: vec!["'Q1'!A1".to_string()],
2535            },
2536        );
2537        let text = describe(&without_id);
2538        assert!(!text.contains("(id"), "{text}");
2539        assert!(text.contains("1 cell formula(s)"), "{text}");
2540        assert!(text.contains("scanned): 'Q1'!A1"), "{text}");
2541    }
2542
2543    #[test]
2544    fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
2545        let added = outcome_with(
2546            add_verb(),
2547            Some("Budget"),
2548            NamedRangeResult::AppliedReplyUnreadable {
2549                summary: "add named range 'Foo'".to_string(),
2550                named_range_id: None,
2551                referencing_formulas: Vec::new(),
2552                detail: "bad reply".to_string(),
2553            },
2554        );
2555        assert_eq!(
2556            describe_lines(&added),
2557            vec![
2558                "Applied, but the reply could not be read: add named range 'Foo' in 'Budget' — \
2559                 do not retry; check the spreadsheet first (bad reply)"
2560                    .to_string(),
2561                "  the new named range's id was in the unreadable reply; run \
2562                 `list-named-ranges` to find it"
2563                    .to_string(),
2564            ]
2565        );
2566
2567        let deleted = outcome_with(
2568            delete_verb(),
2569            Some("Budget"),
2570            NamedRangeResult::AppliedReplyUnreadable {
2571                summary: "delete named range 'Foo'".to_string(),
2572                named_range_id: Some("id-7".to_string()),
2573                referencing_formulas: vec!["'Q1'!A1".to_string()],
2574                detail: "bad reply".to_string(),
2575            },
2576        );
2577        // The resolved id and the pre-delete formula preview print exactly as
2578        // `Changed` prints them; there is no add-only hint line.
2579        assert_eq!(
2580            describe_lines(&deleted),
2581            vec![
2582                "Applied, but the reply could not be read: delete named range 'Foo' (id id-7) \
2583                 in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
2584                    .to_string(),
2585                "1 cell formula(s) reference this name and will start evaluating to #REF! \
2586                 once it's removed (conditional formatting, data validation and chart \
2587                 references are not scanned): 'Q1'!A1"
2588                    .to_string(),
2589            ]
2590        );
2591    }
2592
2593    #[test]
2594    fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
2595        let opts = NamedRangeOptions {
2596            spreadsheet_id: "sheet-1".to_string(),
2597            verb: delete_verb(),
2598            dry_run: false,
2599            lease_token: None,
2600            ledger_path: std::path::PathBuf::new(),
2601        };
2602        let record = |result| {
2603            let out = outcome_with(delete_verb(), Some("Budget"), result);
2604            mutation_record(&out, &opts, Duration::ZERO)
2605        };
2606        let changed = record(NamedRangeResult::Changed {
2607            summary: "delete named range 'Foo'".to_string(),
2608            named_range_id: Some("id-7".to_string()),
2609            referencing_formulas: vec!["'Q1'!A1".to_string()],
2610        });
2611        let unreadable = record(NamedRangeResult::AppliedReplyUnreadable {
2612            summary: "delete named range 'Foo'".to_string(),
2613            named_range_id: Some("id-7".to_string()),
2614            referencing_formulas: vec!["'Q1'!A1".to_string()],
2615            detail: "bad reply".to_string(),
2616        });
2617        assert_eq!(unreadable.named_range_id.as_deref(), Some("id-7"));
2618        assert_eq!(unreadable.named_range_id, changed.named_range_id);
2619        assert_eq!(
2620            unreadable.referencing_formula_locations,
2621            changed.referencing_formula_locations
2622        );
2623        assert_eq!(
2624            unreadable.referencing_formula_locations,
2625            vec!["'Q1'!A1".to_string()]
2626        );
2627        assert_eq!(unreadable.status, "applied-reply-unreadable");
2628        assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
2629        assert_eq!(
2630            unreadable.fields_changed.as_deref(),
2631            Some("delete named range 'Foo'")
2632        );
2633    }
2634
2635    #[test]
2636    fn describe_lines_renders_failed() {
2637        let out = outcome_with(
2638            add_verb(),
2639            Some("Budget"),
2640            NamedRangeResult::Failed {
2641                detail: "boom".to_string(),
2642            },
2643        );
2644        assert_eq!(describe(&out), "Failed: boom");
2645    }
2646
2647    #[test]
2648    fn describe_effect_update_without_a_new_name_says_update_not_rename() {
2649        let verb = NamedRangeVerb::UpdateNamedRange {
2650            name: Some("Foo".to_string()),
2651            id: None,
2652            new_name: None,
2653            sheet: Some("Q1".to_string()),
2654            range: Some("A1:A5".to_string()),
2655            whole_sheet: false,
2656        };
2657        assert_eq!(describe_effect(&verb), "update named range 'Foo'");
2658    }
2659
2660    #[test]
2661    fn describe_effect_uses_the_id_target_descriptor_when_selected_by_id() {
2662        let update = NamedRangeVerb::UpdateNamedRange {
2663            name: None,
2664            id: Some("id-1".to_string()),
2665            new_name: Some("Bar".to_string()),
2666            sheet: None,
2667            range: None,
2668            whole_sheet: false,
2669        };
2670        assert_eq!(
2671            describe_effect(&update),
2672            "rename named range (id 'id-1') to 'Bar'"
2673        );
2674
2675        let delete = NamedRangeVerb::DeleteNamedRange {
2676            name: None,
2677            id: Some("id-1".to_string()),
2678        };
2679        assert_eq!(describe_effect(&delete), "delete named range (id 'id-1')");
2680    }
2681
2682    // ── resolve_grid/resolve_optional_grid error branches ──────────────
2683
2684    #[test]
2685    fn resolve_grid_rejects_a_bare_range_with_no_sheet_prefix() {
2686        let workbook = workbook_with(Vec::new());
2687        let err = resolve_grid(&workbook, None, Some("A1:B2"), false).unwrap_err();
2688        assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
2689    }
2690
2691    #[test]
2692    fn resolve_grid_rejects_neither_sheet_nor_range() {
2693        let workbook = workbook_with(Vec::new());
2694        let err = resolve_grid(&workbook, None, None, false).unwrap_err();
2695        assert!(matches!(err, NamedRangeResult::RefusedInvalidRange { .. }));
2696    }
2697
2698    #[test]
2699    fn resolve_grid_reports_sheet_not_found_for_a_prefixed_range() {
2700        let workbook = workbook_with(Vec::new());
2701        let err = resolve_grid(&workbook, Some("Nope"), Some("A1:B2"), false).unwrap_err();
2702        assert!(
2703            matches!(err, NamedRangeResult::RefusedSheetNotFound { title, .. } if title == "Nope")
2704        );
2705    }
2706
2707    #[test]
2708    fn resolve_grid_reports_sheet_not_found_for_whole_sheet() {
2709        let workbook = workbook_with(Vec::new());
2710        let err = resolve_grid(&workbook, Some("Nope"), None, true).unwrap_err();
2711        assert!(
2712            matches!(err, NamedRangeResult::RefusedSheetNotFound { title, .. } if title == "Nope")
2713        );
2714    }
2715
2716    // ── scan_referencing_formulas edge cases ───────────────────────────
2717
2718    #[tokio::test]
2719    async fn scan_referencing_formulas_with_no_sheets_makes_no_api_call() {
2720        let server = wiremock::MockServer::start().await;
2721        let (_drive, sheets) = clients(&server).await;
2722        let api = SheetsApi::new(&sheets);
2723        let workbook = Spreadsheet::default();
2724        let locations = scan_referencing_formulas(&api, "sheet-1", &workbook, "Foo")
2725            .await
2726            .unwrap();
2727        assert!(locations.is_empty());
2728    }
2729
2730    #[tokio::test]
2731    async fn scan_referencing_formulas_skips_untitled_ranges_and_non_string_cells() {
2732        let server = wiremock::MockServer::start().await;
2733        let (_drive, sheets) = clients(&server).await;
2734        wiremock::Mock::given(wiremock::matchers::method("GET"))
2735            .and(wiremock::matchers::path(
2736                "/v4/spreadsheets/sheet-1/values:batchGet",
2737            ))
2738            .respond_with(
2739                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2740                    "valueRanges": [
2741                        // No sheet prefix at all: `sheet_title_of` resolves
2742                        // to `None` and this range must be skipped rather
2743                        // than panicking on the missing title.
2744                        {"range": "A1:Z1000", "values": [["=SUM(Foo)"]]},
2745                        {"range": "Q1!A1:Z1000", "values": [[42, "=SUM(Foo)"]]},
2746                    ]
2747                })),
2748            )
2749            .mount(&server)
2750            .await;
2751        let api = SheetsApi::new(&sheets);
2752        let workbook = workbook_with(Vec::new());
2753        let locations = scan_referencing_formulas(&api, "sheet-1", &workbook, "Foo")
2754            .await
2755            .unwrap();
2756        assert_eq!(locations, vec!["'Q1'!B1".to_string()]);
2757    }
2758
2759    // ── target-gate refusals reached through named_range() ─────────────
2760
2761    #[tokio::test]
2762    async fn a_metadata_fetch_failure_surfaces_as_failed() {
2763        let server = wiremock::MockServer::start().await;
2764        let (drive, sheets) = clients(&server).await;
2765        wiremock::Mock::given(wiremock::matchers::method("GET"))
2766            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2767            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2768            .mount(&server)
2769            .await;
2770        let rules: Vec<FolderPermissionRule> = Vec::new();
2771        let opts = NamedRangeOptions {
2772            spreadsheet_id: "sheet-1".to_string(),
2773            verb: add_verb(),
2774            dry_run: false,
2775            lease_token: None,
2776            ledger_path: std::path::PathBuf::new(),
2777        };
2778        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2779        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2780    }
2781
2782    #[tokio::test]
2783    async fn a_shortcut_target_is_refused() {
2784        let server = wiremock::MockServer::start().await;
2785        let (drive, sheets) = clients(&server).await;
2786        mount_file(
2787            "sheet-1",
2788            "application/vnd.google-apps.shortcut",
2789            &["folder-1"],
2790        )
2791        .mount(&server)
2792        .await;
2793        let rules = vec![allow_rule("folder-1")];
2794        let opts = NamedRangeOptions {
2795            spreadsheet_id: "sheet-1".to_string(),
2796            verb: add_verb(),
2797            dry_run: false,
2798            lease_token: None,
2799            ledger_path: std::path::PathBuf::new(),
2800        };
2801        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2802        assert!(matches!(outcome.result, NamedRangeResult::RefusedShortcut));
2803    }
2804
2805    #[tokio::test]
2806    async fn a_non_spreadsheet_target_is_refused() {
2807        let server = wiremock::MockServer::start().await;
2808        let (drive, sheets) = clients(&server).await;
2809        mount_file(
2810            "sheet-1",
2811            "application/vnd.google-apps.document",
2812            &["folder-1"],
2813        )
2814        .mount(&server)
2815        .await;
2816        let rules = vec![allow_rule("folder-1")];
2817        let opts = NamedRangeOptions {
2818            spreadsheet_id: "sheet-1".to_string(),
2819            verb: add_verb(),
2820            dry_run: false,
2821            lease_token: None,
2822            ledger_path: std::path::PathBuf::new(),
2823        };
2824        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2825        assert!(matches!(
2826            outcome.result,
2827            NamedRangeResult::RefusedNotASpreadsheet { .. }
2828        ));
2829    }
2830
2831    #[tokio::test]
2832    async fn a_target_with_no_visible_parents_is_refused() {
2833        let server = wiremock::MockServer::start().await;
2834        let (drive, sheets) = clients(&server).await;
2835        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2836            .mount(&server)
2837            .await;
2838        let rules: Vec<FolderPermissionRule> = Vec::new();
2839        let opts = NamedRangeOptions {
2840            spreadsheet_id: "sheet-1".to_string(),
2841            verb: add_verb(),
2842            dry_run: false,
2843            lease_token: None,
2844            ledger_path: std::path::PathBuf::new(),
2845        };
2846        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2847        assert!(matches!(
2848            outcome.result,
2849            NamedRangeResult::RefusedNoVisibleParents
2850        ));
2851    }
2852
2853    #[tokio::test]
2854    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2855        let server = wiremock::MockServer::start().await;
2856        let (drive, sheets) = clients(&server).await;
2857        mount_file(
2858            "sheet-1",
2859            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2860            &["folder-1"],
2861        )
2862        .mount(&server)
2863        .await;
2864        wiremock::Mock::given(wiremock::matchers::method("GET"))
2865            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2866            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2867            .mount(&server)
2868            .await;
2869        let rules = vec![allow_rule("folder-1")];
2870        let opts = NamedRangeOptions {
2871            spreadsheet_id: "sheet-1".to_string(),
2872            verb: add_verb(),
2873            dry_run: false,
2874            lease_token: None,
2875            ledger_path: std::path::PathBuf::new(),
2876        };
2877        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2878        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2879    }
2880
2881    #[tokio::test]
2882    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
2883        let server = wiremock::MockServer::start().await;
2884        let (drive, sheets) = clients(&server).await;
2885        mount_file(
2886            "sheet-1",
2887            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2888            &["folder-1"],
2889        )
2890        .mount(&server)
2891        .await;
2892        mount_folder("folder-1").mount(&server).await;
2893        wiremock::Mock::given(wiremock::matchers::method("GET"))
2894            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2895            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2896            .mount(&server)
2897            .await;
2898        let rules = vec![allow_rule("folder-1")];
2899        let opts = NamedRangeOptions {
2900            spreadsheet_id: "sheet-1".to_string(),
2901            verb: add_verb(),
2902            dry_run: false,
2903            lease_token: None,
2904            ledger_path: std::path::PathBuf::new(),
2905        };
2906        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2907        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
2908    }
2909
2910    // ── the Drive write lease (ADR-0080 §9) ────────────────────────────
2911
2912    #[tokio::test]
2913    async fn refuses_without_a_lease_when_the_rule_requires_one() {
2914        let server = wiremock::MockServer::start().await;
2915        let (drive, sheets) = clients(&server).await;
2916        mount_file(
2917            "sheet-1",
2918            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2919            &["folder-1"],
2920        )
2921        .mount(&server)
2922        .await;
2923        mount_folder("folder-1").mount(&server).await;
2924        mount_workbook(serde_json::json!([])).mount(&server).await;
2925        let rules = vec![allow_rule("folder-1")];
2926        let opts = NamedRangeOptions {
2927            spreadsheet_id: "sheet-1".to_string(),
2928            verb: add_verb(),
2929            dry_run: false,
2930            lease_token: None,
2931            ledger_path: std::path::PathBuf::new(),
2932        };
2933        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2934        assert!(matches!(outcome.result, NamedRangeResult::RefusedNoLease));
2935    }
2936
2937    #[tokio::test]
2938    async fn refuses_an_unknown_lease_token() {
2939        let server = wiremock::MockServer::start().await;
2940        let (drive, sheets) = clients(&server).await;
2941        mount_file(
2942            "sheet-1",
2943            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2944            &["folder-1"],
2945        )
2946        .mount(&server)
2947        .await;
2948        mount_folder("folder-1").mount(&server).await;
2949        mount_workbook(serde_json::json!([])).mount(&server).await;
2950        let rules = vec![allow_rule("folder-1")];
2951        let ledger_path = tempfile::tempdir()
2952            .unwrap()
2953            .keep()
2954            .join("lease-ledger.jsonl");
2955        let opts = NamedRangeOptions {
2956            spreadsheet_id: "sheet-1".to_string(),
2957            verb: add_verb(),
2958            dry_run: false,
2959            lease_token: Some("bogus-token".to_string()),
2960            ledger_path,
2961        };
2962        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2963        assert!(matches!(
2964            outcome.result,
2965            NamedRangeResult::RefusedLeaseExpired
2966        ));
2967    }
2968
2969    #[tokio::test]
2970    async fn refuses_a_lease_bound_to_a_different_file() {
2971        let server = wiremock::MockServer::start().await;
2972        let (drive, sheets) = clients(&server).await;
2973        mount_file(
2974            "sheet-1",
2975            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2976            &["folder-1"],
2977        )
2978        .mount(&server)
2979        .await;
2980        mount_folder("folder-1").mount(&server).await;
2981        mount_workbook(serde_json::json!([])).mount(&server).await;
2982        let rules = vec![allow_rule("folder-1")];
2983        let ledger_path = tempfile::tempdir()
2984            .unwrap()
2985            .keep()
2986            .join("lease-ledger.jsonl");
2987        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
2988        let opts = NamedRangeOptions {
2989            spreadsheet_id: "sheet-1".to_string(),
2990            verb: add_verb(),
2991            dry_run: false,
2992            lease_token: Some(token),
2993            ledger_path,
2994        };
2995        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
2996        assert!(matches!(
2997            outcome.result,
2998            NamedRangeResult::RefusedLeaseWrongFile
2999        ));
3000    }
3001
3002    #[tokio::test]
3003    async fn refuses_a_stale_lease_when_the_file_has_moved() {
3004        let server = wiremock::MockServer::start().await;
3005        let (drive, sheets) = clients(&server).await;
3006        mount_file(
3007            "sheet-1",
3008            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3009            &["folder-1"],
3010        )
3011        .mount(&server)
3012        .await;
3013        mount_folder("folder-1").mount(&server).await;
3014        mount_workbook(serde_json::json!([])).mount(&server).await;
3015        let rules = vec![allow_rule("folder-1")];
3016        let ledger_path = tempfile::tempdir()
3017            .unwrap()
3018            .keep()
3019            .join("lease-ledger.jsonl");
3020        let token = seed_lease(&ledger_path, "sheet-1", "0");
3021        let opts = NamedRangeOptions {
3022            spreadsheet_id: "sheet-1".to_string(),
3023            verb: add_verb(),
3024            dry_run: false,
3025            lease_token: Some(token),
3026            ledger_path,
3027        };
3028        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3029        assert!(matches!(
3030            outcome.result,
3031            NamedRangeResult::RefusedLeaseStale
3032        ));
3033    }
3034
3035    // ── update-named-range: nothing-to-change and re-pointing errors ───
3036
3037    #[tokio::test]
3038    async fn update_named_range_refuses_when_nothing_is_named_to_change() {
3039        let server = wiremock::MockServer::start().await;
3040        let (drive, sheets) = clients(&server).await;
3041        mount_file(
3042            "sheet-1",
3043            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3044            &["folder-1"],
3045        )
3046        .mount(&server)
3047        .await;
3048        mount_folder("folder-1").mount(&server).await;
3049        mount_workbook(serde_json::json!([{
3050            "namedRangeId": "id-1",
3051            "name": "Foo",
3052            "range": {"sheetId": 0},
3053        }]))
3054        .mount(&server)
3055        .await;
3056        let rules = vec![allow_rule("folder-1")];
3057        let opts = NamedRangeOptions {
3058            spreadsheet_id: "sheet-1".to_string(),
3059            verb: NamedRangeVerb::UpdateNamedRange {
3060                name: Some("Foo".to_string()),
3061                id: None,
3062                new_name: None,
3063                sheet: None,
3064                range: None,
3065                whole_sheet: false,
3066            },
3067            dry_run: false,
3068            lease_token: None,
3069            ledger_path: std::path::PathBuf::new(),
3070        };
3071        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3072        assert!(matches!(
3073            outcome.result,
3074            NamedRangeResult::RefusedInvalidRange { .. }
3075        ));
3076    }
3077
3078    #[tokio::test]
3079    async fn update_named_range_reports_sheet_not_found_when_re_pointing() {
3080        let server = wiremock::MockServer::start().await;
3081        let (drive, sheets) = clients(&server).await;
3082        mount_file(
3083            "sheet-1",
3084            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3085            &["folder-1"],
3086        )
3087        .mount(&server)
3088        .await;
3089        mount_folder("folder-1").mount(&server).await;
3090        mount_workbook(serde_json::json!([{
3091            "namedRangeId": "id-1",
3092            "name": "Foo",
3093            "range": {"sheetId": 0},
3094        }]))
3095        .mount(&server)
3096        .await;
3097        let rules = vec![allow_rule("folder-1")];
3098        let opts = NamedRangeOptions {
3099            spreadsheet_id: "sheet-1".to_string(),
3100            verb: NamedRangeVerb::UpdateNamedRange {
3101                name: Some("Foo".to_string()),
3102                id: None,
3103                new_name: None,
3104                sheet: Some("Missing".to_string()),
3105                range: None,
3106                whole_sheet: true,
3107            },
3108            dry_run: false,
3109            lease_token: None,
3110            ledger_path: std::path::PathBuf::new(),
3111        };
3112        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3113        assert!(matches!(
3114            outcome.result,
3115            NamedRangeResult::RefusedSheetNotFound { .. }
3116        ));
3117    }
3118
3119    // ── a real batchUpdate rejection surfaces as Failed ────────────────
3120
3121    #[tokio::test]
3122    async fn a_batch_update_failure_surfaces_as_failed() {
3123        let server = wiremock::MockServer::start().await;
3124        let (drive, sheets) = clients(&server).await;
3125        mount_file(
3126            "sheet-1",
3127            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3128            &["folder-1"],
3129        )
3130        .mount(&server)
3131        .await;
3132        mount_folder("folder-1").mount(&server).await;
3133        mount_workbook(serde_json::json!([])).mount(&server).await;
3134        wiremock::Mock::given(wiremock::matchers::method("POST"))
3135            .and(wiremock::matchers::path(
3136                "/v4/spreadsheets/sheet-1:batchUpdate",
3137            ))
3138            .respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
3139            .mount(&server)
3140            .await;
3141        let rules = vec![allow_rule("folder-1")];
3142        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3143        let opts = NamedRangeOptions {
3144            spreadsheet_id: "sheet-1".to_string(),
3145            verb: add_verb(),
3146            dry_run: false,
3147            lease_token,
3148            ledger_path,
3149        };
3150        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3151        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3152    }
3153
3154    #[tokio::test]
3155    async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
3156        // The gate's own resolve step succeeds off the first `files.get`,
3157        // but the fresh re-fetch feeding the staleness check (ADR-0080 §6)
3158        // fails — the change must report `Failed` and never reach
3159        // `batchUpdate`.
3160        let server = wiremock::MockServer::start().await;
3161        let (drive, sheets) = clients(&server).await;
3162        mount_file(
3163            "sheet-1",
3164            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3165            &["folder-1"],
3166        )
3167        .up_to_n_times(1)
3168        .with_priority(1)
3169        .mount(&server)
3170        .await;
3171        wiremock::Mock::given(wiremock::matchers::method("GET"))
3172            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
3173            .respond_with(wiremock::ResponseTemplate::new(500))
3174            .with_priority(2)
3175            .mount(&server)
3176            .await;
3177        mount_folder("folder-1").mount(&server).await;
3178        mount_workbook(serde_json::json!([])).mount(&server).await;
3179        wiremock::Mock::given(wiremock::matchers::method("POST"))
3180            .and(wiremock::matchers::path(
3181                "/v4/spreadsheets/sheet-1:batchUpdate",
3182            ))
3183            .respond_with(wiremock::ResponseTemplate::new(200))
3184            .expect(0)
3185            .mount(&server)
3186            .await;
3187        let rules = vec![allow_rule("folder-1")];
3188        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3189        let opts = NamedRangeOptions {
3190            spreadsheet_id: "sheet-1".to_string(),
3191            verb: add_verb(),
3192            dry_run: false,
3193            lease_token,
3194            ledger_path,
3195        };
3196        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3197        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3198    }
3199
3200    // ── AddNamedRange's own resolve_grid failure, reached through
3201    // named_range() rather than by calling resolve_grid directly ────────
3202
3203    #[tokio::test]
3204    async fn add_named_range_reports_sheet_not_found_for_an_unknown_sheet() {
3205        let server = wiremock::MockServer::start().await;
3206        let (drive, sheets) = clients(&server).await;
3207        mount_file(
3208            "sheet-1",
3209            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3210            &["folder-1"],
3211        )
3212        .mount(&server)
3213        .await;
3214        mount_folder("folder-1").mount(&server).await;
3215        mount_workbook(serde_json::json!([])).mount(&server).await;
3216        let rules = vec![allow_rule("folder-1")];
3217        let opts = NamedRangeOptions {
3218            spreadsheet_id: "sheet-1".to_string(),
3219            verb: NamedRangeVerb::AddNamedRange {
3220                name: "Foo".to_string(),
3221                sheet: Some("Missing".to_string()),
3222                range: None,
3223                whole_sheet: true,
3224            },
3225            dry_run: false,
3226            lease_token: None,
3227            ledger_path: std::path::PathBuf::new(),
3228        };
3229        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3230        assert!(matches!(
3231            outcome.result,
3232            NamedRangeResult::RefusedSheetNotFound { .. }
3233        ));
3234    }
3235
3236    // ── delete-named-range's formula scan failing surfaces as Failed ────
3237
3238    #[tokio::test]
3239    async fn a_referencing_formula_scan_failure_surfaces_as_failed() {
3240        let server = wiremock::MockServer::start().await;
3241        let (drive, sheets) = clients(&server).await;
3242        mount_file(
3243            "sheet-1",
3244            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3245            &["folder-1"],
3246        )
3247        .mount(&server)
3248        .await;
3249        mount_folder("folder-1").mount(&server).await;
3250        mount_workbook(serde_json::json!([{
3251            "namedRangeId": "id-1",
3252            "name": "Foo",
3253            "range": {"sheetId": 0},
3254        }]))
3255        .mount(&server)
3256        .await;
3257        wiremock::Mock::given(wiremock::matchers::method("GET"))
3258            .and(wiremock::matchers::path(
3259                "/v4/spreadsheets/sheet-1/values:batchGet",
3260            ))
3261            .respond_with(wiremock::ResponseTemplate::new(500))
3262            .mount(&server)
3263            .await;
3264        let rules = vec![allow_rule("folder-1")];
3265        let opts = NamedRangeOptions {
3266            spreadsheet_id: "sheet-1".to_string(),
3267            verb: NamedRangeVerb::DeleteNamedRange {
3268                name: Some("Foo".to_string()),
3269                id: None,
3270            },
3271            dry_run: true,
3272            lease_token: None,
3273            ledger_path: std::path::PathBuf::new(),
3274        };
3275        let outcome = named_range(&drive, &sheets, &opts, &rules).await;
3276        assert!(matches!(outcome.result, NamedRangeResult::Failed { .. }));
3277    }
3278
3279    // ── write_jsonl / log_status ─────────────────────────────────────────
3280
3281    #[test]
3282    fn write_jsonl_emits_one_line_of_json() {
3283        let outcome = outcome_with(
3284            add_verb(),
3285            Some("Budget"),
3286            NamedRangeResult::Changed {
3287                summary: "add named range 'Foo'".to_string(),
3288                named_range_id: Some("id-1".to_string()),
3289                referencing_formulas: Vec::new(),
3290            },
3291        );
3292        let mut buf = Vec::new();
3293        outcome.write_jsonl(&mut buf).unwrap();
3294        let text = String::from_utf8(buf).unwrap();
3295        assert_eq!(text.matches('\n').count(), 1);
3296        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3297        assert_eq!(parsed["result"]["status"], "changed");
3298    }
3299
3300    #[test]
3301    fn named_range_result_log_status_names_every_variant() {
3302        assert_eq!(
3303            NamedRangeResult::WouldChange {
3304                summary: String::new(),
3305                referencing_formulas: Vec::new(),
3306            }
3307            .log_status(),
3308            "would-change"
3309        );
3310        assert_eq!(
3311            NamedRangeResult::RefusedNotASpreadsheet {
3312                mime_type: String::new(),
3313            }
3314            .log_status(),
3315            "refused-not-a-spreadsheet"
3316        );
3317        assert_eq!(
3318            NamedRangeResult::RefusedShortcut.log_status(),
3319            "refused-shortcut"
3320        );
3321        assert_eq!(
3322            NamedRangeResult::RefusedNoVisibleParents.log_status(),
3323            "refused-no-visible-parents"
3324        );
3325        assert_eq!(
3326            NamedRangeResult::RefusedSheetNotFound {
3327                title: String::new(),
3328                available: Vec::new(),
3329            }
3330            .log_status(),
3331            "refused-sheet-not-found"
3332        );
3333        assert_eq!(
3334            NamedRangeResult::RefusedInvalidRange {
3335                detail: String::new(),
3336            }
3337            .log_status(),
3338            "refused-invalid-range"
3339        );
3340        assert_eq!(
3341            NamedRangeResult::RefusedNotFound {
3342                name: String::new(),
3343            }
3344            .log_status(),
3345            "refused-not-found"
3346        );
3347        assert_eq!(
3348            NamedRangeResult::RefusedAmbiguousName {
3349                name: String::new(),
3350                candidates: Vec::new(),
3351            }
3352            .log_status(),
3353            "refused-ambiguous-name"
3354        );
3355        assert_eq!(
3356            NamedRangeResult::RefusedDuplicateName {
3357                new_name: String::new(),
3358                existing_ids: Vec::new(),
3359            }
3360            .log_status(),
3361            "refused-duplicate-name"
3362        );
3363        assert_eq!(
3364            NamedRangeResult::AppliedReplyUnreadable {
3365                summary: String::new(),
3366                named_range_id: None,
3367                referencing_formulas: Vec::new(),
3368                detail: String::new(),
3369            }
3370            .log_status(),
3371            "applied-reply-unreadable"
3372        );
3373        assert_eq!(
3374            NamedRangeResult::Failed {
3375                detail: String::new(),
3376            }
3377            .log_status(),
3378            "failed"
3379        );
3380    }
3381}