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