Skip to main content

omni_dev/drive/sheets/
dimension_group.rs

1//! Dimension groups — row/column outlining, the collapsible +/- grouping
2//! bar — via `spreadsheets.batchUpdate` (issue #1833,
3//! [ADR-0084](../../../docs/adrs/adr-0084-dimension-groups.md)).
4//!
5//! Gated by [`DriveOperation::SheetsStructure`] — every mutating verb here
6//! reaches the same operation as `add-banding`/`update-banding`/
7//! `delete-banding` (`banding.rs`, ADR-0082): a group is presentation
8//! applied to a dimension span, and removing one destroys no data.
9//!
10//! **No client-side depth cap**, despite issue #1833's own scope note
11//! proposing one. `addDimensionGroup`'s request carries only a `range` —
12//! the server derives the new group's `depth` from how that range
13//! overlaps existing groups on the same axis (a superset increments an
14//! existing group's depth and gives the new group that shallower depth; a
15//! subset creates a new, deeper one; a partial overlap *widens the
16//! existing group to the union of the two spans* and creates a new,
17//! deeper one over their intersection — so an add can move an existing
18//! group's edges, which `--dry-run` cannot foresee; see the Sheets API
19//! reference for `AddDimensionGroupRequest`). Predicting that outcome
20//! client-side would mean re-implementing those rules, and no maximum
21//! depth is documented anywhere in the API reference to validate against
22//! in the first place — Sheets stays the authority on how deep a nesting
23//! may get, the same stance every other span-based verb in this crate
24//! takes on the *count* of rows/columns it may act on. Only the span
25//! itself (`--start`/`--end` within the sheet's current extent) is
26//! validated here, matching `format.rs`'s `auto-resize-dimension`.
27//!
28//! A group carries no server-assigned id: it is addressed by `(range,
29//! depth)`, both of which `updateDimensionGroup`'s wire request already
30//! requires. `update-dimension-group` resolves its target by exact `range`
31//! match, disambiguated by an optional `--depth` when more than one group
32//! shares that exact range (the API creates exactly this when a group is
33//! added over a range equal to an existing one) — the same ambiguous-match
34//! shape `protection.rs::find_existing_protection` uses for a protected
35//! range, which likewise carries no id of its own.
36//!
37//! `delete-dimension-group` requires an **exact** range match among the
38//! groups `list-dimension-groups` would show; the API's own partial-range
39//! delete (a `range` that only partially overlaps a group decrements its
40//! depth rather than removing anything, per the API reference's own
41//! example) is not exposed — a documented cut, not a silent gap, the same
42//! "never promise a change the real run then surprises you with" stance
43//! `banding.rs`'s id-only addressing takes for a different reason.
44//! `deleteDimensionGroup`'s wire request carries only a `range`, no
45//! `depth`, so an exact-range match that resolves to more than one group
46//! (different depths) is not refused as ambiguous — the request itself has
47//! no way to name one over the other, and Sheets decides. The outcome
48//! reports the depth acted on only when the match was unambiguous.
49//!
50//! Shape mirrors `banding.rs`: resolve a target, gate, dry-run, mutate,
51//! log — with no A1 composition step, since every verb here addresses a
52//! dimension span (`--sheet`/`--dimension`/`--start`/`--end`, 1-based
53//! inclusive) rather than an A1 range.
54
55use std::path::PathBuf;
56use std::time::{Duration, Instant};
57
58use serde::Serialize;
59
60use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
61use crate::drive::client::DriveClient;
62use crate::drive::files_api::FilesApi;
63use crate::drive::lease::check::{
64    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
65    LeasedWrite,
66};
67use crate::drive::sheets::api::SheetsApi;
68use crate::drive::sheets::client::SheetsClient;
69use crate::drive::sheets::grid_range;
70use crate::drive::sheets::target_gate;
71use crate::drive::sheets::types::{
72    AddDimensionGroupRequest, BatchUpdateRequestItem, DeleteDimensionGroupRequest, Dimension,
73    DimensionGroup, DimensionRange, Sheet, Spreadsheet, UpdateDimensionGroupRequest,
74};
75use crate::drive::types::SheetTargetRefusal;
76use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
77use crate::request_log::{self, DriveMutationOutcome};
78
79/// Which mutation to perform.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum DimensionGroupVerb {
82    /// Add a new outline group over a row or column span.
83    AddDimensionGroup {
84        /// Title of the sheet to modify.
85        sheet: String,
86        /// Rows or columns.
87        dimension: Dimension,
88        /// 1-based first row/column, inclusive.
89        start: i64,
90        /// 1-based last row/column, inclusive.
91        end: i64,
92    },
93    /// Change an existing group's `collapsed` state.
94    UpdateDimensionGroup {
95        /// Title of the sheet the group is on.
96        sheet: String,
97        /// Rows or columns.
98        dimension: Dimension,
99        /// 1-based first row/column, inclusive — identifies the group
100        /// together with `end`.
101        start: i64,
102        /// 1-based last row/column, inclusive.
103        end: i64,
104        /// Disambiguates which group at that exact span to update, when
105        /// more than one exists at different depths. `None` requires the
106        /// span to resolve to exactly one group.
107        depth: Option<i64>,
108        /// The new `collapsed` value.
109        collapsed: bool,
110    },
111    /// Remove an outline group.
112    DeleteDimensionGroup {
113        /// Title of the sheet the group is on.
114        sheet: String,
115        /// Rows or columns.
116        dimension: Dimension,
117        /// 1-based first row/column, inclusive — identifies the group
118        /// together with `end`.
119        start: i64,
120        /// 1-based last row/column, inclusive.
121        end: i64,
122    },
123}
124
125impl DimensionGroupVerb {
126    const fn log_operation(&self) -> &'static str {
127        match self {
128            Self::AddDimensionGroup { .. } => "sheets-add-dimension-group",
129            Self::UpdateDimensionGroup { .. } => "sheets-update-dimension-group",
130            Self::DeleteDimensionGroup { .. } => "sheets-delete-dimension-group",
131        }
132    }
133
134    const fn label(&self) -> &'static str {
135        match self {
136            Self::AddDimensionGroup { .. } => "add-dimension-group",
137            Self::UpdateDimensionGroup { .. } => "update-dimension-group",
138            Self::DeleteDimensionGroup { .. } => "delete-dimension-group",
139        }
140    }
141
142    /// The sheet, axis and 1-based inclusive span every verb carries.
143    const fn sheet_dimension_span(&self) -> (&str, Dimension, i64, i64) {
144        match self {
145            Self::AddDimensionGroup {
146                sheet,
147                dimension,
148                start,
149                end,
150            }
151            | Self::UpdateDimensionGroup {
152                sheet,
153                dimension,
154                start,
155                end,
156                ..
157            }
158            | Self::DeleteDimensionGroup {
159                sheet,
160                dimension,
161                start,
162                end,
163            } => (sheet.as_str(), *dimension, *start, *end),
164        }
165    }
166}
167
168/// Per-call options.
169#[derive(Debug, Clone)]
170pub struct DimensionGroupOptions {
171    /// Spreadsheet id.
172    pub spreadsheet_id: String,
173    /// Which mutation to perform.
174    pub verb: DimensionGroupVerb,
175    /// Classify and describe only; never call `batchUpdate`.
176    pub dry_run: bool,
177    /// The lease token presented via `--lease`. Checked only when the
178    /// deciding rule requires one
179    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
180    /// `None` is only ever valid when it does not.
181    pub lease_token: Option<String>,
182    /// Path to the lease ledger the token is checked against. Production
183    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
184    /// result; tests pass a path under a `tempdir`.
185    pub ledger_path: PathBuf,
186}
187
188/// What happened (or, under `--dry-run`, would happen).
189#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
190#[serde(tag = "status", rename_all = "kebab-case")]
191pub enum DimensionGroupResult {
192    /// `--dry-run`, and the gate would allow it.
193    WouldChange {
194        /// A human-readable summary of the effect.
195        summary: String,
196    },
197    /// The target is not a Google Sheet.
198    RefusedNotASpreadsheet {
199        /// The target's actual MIME type.
200        mime_type: String,
201    },
202    /// The target is a shortcut, which we do not follow.
203    RefusedShortcut,
204    /// The target has no parents this account can see.
205    RefusedNoVisibleParents,
206    /// The named sheet does not exist in this workbook.
207    RefusedSheetNotFound {
208        /// The title that was not found.
209        title: String,
210        /// The titles that do exist.
211        available: Vec<String>,
212    },
213    /// The verb's own `--start`/`--end` were invalid, or past the sheet's
214    /// current extent.
215    RefusedInvalidRange {
216        /// What was wrong and why.
217        detail: String,
218    },
219    /// `update-dimension-group`/`delete-dimension-group` named a span with
220    /// no dimension group over it.
221    RefusedDimensionGroupNotFound {
222        /// What was searched for and why nothing matched.
223        detail: String,
224    },
225    /// `update-dimension-group` named a span with more than one group at
226    /// different depths, and no `--depth` was given to pick one.
227    RefusedAmbiguousDimensionGroup {
228        /// The depths of every group found at that span.
229        depths: Vec<i64>,
230    },
231    /// The folder write-permission gate refused it.
232    Blocked {
233        /// The rule that decided the refusal, if any.
234        decided_by: Option<DecidingRule>,
235    },
236    /// No `--lease` was presented, and the deciding rule requires one
237    /// (ADR-0080 §9).
238    RefusedNoLease,
239    /// The presented lease has expired, or was never a token this ledger
240    /// knows about.
241    RefusedLeaseExpired,
242    /// The presented lease is bound to a different file id.
243    RefusedLeaseWrongFile,
244    /// The file has moved since the lease's recorded `version` — the
245    /// staleness check (ADR-0080 §6).
246    RefusedLeaseStale,
247    /// The mutation succeeded.
248    Changed {
249        /// Same summary as [`Self::WouldChange`].
250        summary: String,
251        /// The depth of the group acted on, when the match was
252        /// unambiguous. `None` for `add-dimension-group` (server-derived,
253        /// unknowable from this reply) and for a `delete-dimension-group`
254        /// whose span matched more than one group.
255        depth: Option<i64>,
256    },
257    /// An API or validation error.
258    Failed {
259        /// A human-readable summary of what failed.
260        detail: String,
261    },
262}
263
264impl FromLeaseRefusal for DimensionGroupResult {
265    fn from_no_lease() -> Self {
266        Self::RefusedNoLease
267    }
268    fn from_lease_expired() -> Self {
269        Self::RefusedLeaseExpired
270    }
271    fn from_lease_wrong_file() -> Self {
272        Self::RefusedLeaseWrongFile
273    }
274    fn from_lease_stale() -> Self {
275        Self::RefusedLeaseStale
276    }
277    fn from_lease_failed(detail: String) -> Self {
278        Self::Failed { detail }
279    }
280}
281
282impl DimensionGroupResult {
283    fn log_status(&self) -> &'static str {
284        match self {
285            Self::WouldChange { .. } => "would-change",
286            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
287            Self::RefusedShortcut => "refused-shortcut",
288            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
289            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
290            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
291            Self::RefusedDimensionGroupNotFound { .. } => "refused-dimension-group-not-found",
292            Self::RefusedAmbiguousDimensionGroup { .. } => "refused-ambiguous-dimension-group",
293            Self::Blocked { .. } => "blocked",
294            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
295            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
296            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
297            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
298            Self::Changed { .. } => "changed",
299            Self::Failed { .. } => "failed",
300        }
301    }
302}
303
304/// The full outcome of one attempt.
305#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
306pub struct DimensionGroupOutcome {
307    /// The spreadsheet acted on.
308    pub spreadsheet_id: String,
309    /// Its Drive file name, when the metadata fetch got that far.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub file_name: Option<String>,
312    /// The folder the gate evaluated against.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub resolved_folder_id: Option<String>,
315    /// The sheet the group is (or would be) on, once known.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub sheet_id: Option<i64>,
318    /// The span the request log records, e.g. `"ROWS 5:7"` — 1-based
319    /// inclusive, once known.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub dimension_range_label: Option<String>,
322    /// Which mutation was attempted. Not serialised.
323    #[serde(skip)]
324    pub verb: DimensionGroupVerb,
325    /// What happened.
326    pub result: DimensionGroupResult,
327}
328
329impl JsonlSerialize for DimensionGroupOutcome {
330    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
331        write_scalar_jsonl(self, out)
332    }
333}
334
335/// Runs one dimension-group mutation, logging every attempt that isn't a
336/// dry run.
337pub async fn dimension_group(
338    drive: &DriveClient,
339    sheets: &SheetsClient,
340    opts: &DimensionGroupOptions,
341    rules: &[FolderPermissionRule],
342) -> DimensionGroupOutcome {
343    let started = Instant::now();
344    let outcome = dimension_group_inner(drive, sheets, opts, rules).await;
345    if !opts.dry_run {
346        record_attempt(&outcome, opts, started.elapsed());
347    }
348    outcome
349}
350
351async fn dimension_group_inner(
352    drive: &DriveClient,
353    sheets: &SheetsClient,
354    opts: &DimensionGroupOptions,
355    rules: &[FolderPermissionRule],
356) -> DimensionGroupOutcome {
357    let bare = |result| DimensionGroupOutcome {
358        spreadsheet_id: opts.spreadsheet_id.clone(),
359        file_name: None,
360        resolved_folder_id: None,
361        sheet_id: None,
362        dimension_range_label: None,
363        verb: opts.verb.clone(),
364        result,
365    };
366
367    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
368        drive,
369        &opts.spreadsheet_id,
370        DriveOperation::SheetsStructure,
371        rules,
372    )
373    .await
374    {
375        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
376            return bare(DimensionGroupResult::Failed { detail })
377        }
378        target_gate::TargetGateOutcome::Refused { target, refusal } => {
379            let result = match refusal {
380                SheetTargetRefusal::Shortcut => DimensionGroupResult::RefusedShortcut,
381                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
382                    DimensionGroupResult::RefusedNotASpreadsheet { mime_type }
383                }
384                SheetTargetRefusal::NoVisibleParents => {
385                    DimensionGroupResult::RefusedNoVisibleParents
386                }
387            };
388            return DimensionGroupOutcome {
389                spreadsheet_id: opts.spreadsheet_id.clone(),
390                file_name: Some(target.name),
391                resolved_folder_id: None,
392                sheet_id: None,
393                dimension_range_label: None,
394                verb: opts.verb.clone(),
395                result,
396            };
397        }
398        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
399            return DimensionGroupOutcome {
400                spreadsheet_id: opts.spreadsheet_id.clone(),
401                file_name: Some(target.name),
402                resolved_folder_id: None,
403                sheet_id: None,
404                dimension_range_label: None,
405                verb: opts.verb.clone(),
406                result: DimensionGroupResult::Failed { detail },
407            };
408        }
409        target_gate::TargetGateOutcome::Gated {
410            target,
411            decision,
412            resolved_folder_id,
413            requires_lease,
414        } => (target, decision, resolved_folder_id, requires_lease),
415    };
416
417    let pre_gated = |result| DimensionGroupOutcome {
418        spreadsheet_id: opts.spreadsheet_id.clone(),
419        file_name: Some(target.name.clone()),
420        resolved_folder_id: resolved_folder_id.clone(),
421        sheet_id: None,
422        dimension_range_label: None,
423        verb: opts.verb.clone(),
424        result,
425    };
426
427    if decision.verdict == write_gate::Verdict::Deny {
428        return pre_gated(DimensionGroupResult::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_dimension_groups(&opts.spreadsheet_id)
436        .await
437    {
438        Ok(workbook) => workbook,
439        Err(err) => {
440            return pre_gated(DimensionGroupResult::Failed {
441                detail: format!("{err:#}"),
442            })
443        }
444    };
445
446    let (sheet_title, dimension, start, end) = opts.verb.sheet_dimension_span();
447    let sheet_id = match grid_range::find_sheet_id(&workbook, sheet_title, |title, available| {
448        DimensionGroupResult::RefusedSheetNotFound { title, available }
449    }) {
450        Ok(sheet_id) => sheet_id,
451        Err(result) => return pre_gated(result),
452    };
453    let Some(sheet) = find_sheet(&workbook, sheet_id) else {
454        unreachable!("find_sheet_id resolved this id from this same workbook") // omni-dev: coverage ignore-line reason="find_sheet_id only ever returns an id it read out of this same workbook's sheets, so a lookup by that id in the same workbook always succeeds; this else-arm exists only to unwrap the shared Option"
455    };
456
457    let range = match validate_span(dimension, start, end, sheet, sheet_id) {
458        Ok(range) => range,
459        Err(detail) => return pre_gated(DimensionGroupResult::RefusedInvalidRange { detail }),
460    };
461    let range_label = dimension_range_label(&range);
462
463    let gated = |sheet_id, result| DimensionGroupOutcome {
464        spreadsheet_id: opts.spreadsheet_id.clone(),
465        file_name: Some(target.name.clone()),
466        resolved_folder_id: resolved_folder_id.clone(),
467        sheet_id: Some(sheet_id),
468        dimension_range_label: Some(range_label.clone()),
469        verb: opts.verb.clone(),
470        result,
471    };
472
473    let existing = match &opts.verb {
474        DimensionGroupVerb::AddDimensionGroup { .. } => ExistingGroups::None,
475        DimensionGroupVerb::UpdateDimensionGroup { depth, .. } => {
476            match resolve_for_update(candidates(sheet, dimension, &range), *depth) {
477                Ok(group) => ExistingGroups::One(group),
478                Err(result) => return gated(sheet_id, result),
479            }
480        }
481        DimensionGroupVerb::DeleteDimensionGroup { .. } => {
482            match resolve_for_delete(candidates(sheet, dimension, &range)) {
483                Ok(depth) => ExistingGroups::MaybeOne(depth),
484                Err(result) => return gated(sheet_id, result),
485            }
486        }
487    };
488
489    let summary = describe_effect(&opts.verb, &range_label);
490
491    if opts.dry_run {
492        return gated(sheet_id, DimensionGroupResult::WouldChange { summary });
493    }
494
495    let (request, depth) = build_request(&opts.verb, range, existing);
496
497    let files_api = FilesApi::new(drive);
498    let leased = LeasedWrite {
499        log_prefix: "drive sheets dimension_group",
500        operation: opts.verb.log_operation(),
501        ledger_path: &opts.ledger_path,
502        file_id: &opts.spreadsheet_id,
503    };
504    let lease_grant = match gate_optional_leased_write(
505        leased,
506        &files_api,
507        requires_lease,
508        opts.lease_token.as_deref(),
509    )
510    .await
511    {
512        Ok(grant) => grant,
513        Err(err) => return gated(sheet_id, err.into_result()),
514    };
515
516    let result = match conclude_native_leased_write(
517        leased,
518        &lease_grant,
519        &files_api,
520        api.batch_update(&opts.spreadsheet_id, vec![request]).await,
521        |err| format!("{err:#}"),
522    )
523    .await
524    {
525        Ok(_response) => DimensionGroupResult::Changed { summary, depth },
526        Err(err) => DimensionGroupResult::Failed {
527            detail: format!("{err:#}"),
528        },
529    };
530    drop(lease_grant);
531    gated(sheet_id, result)
532}
533
534/// What was resolved for an existing group, if this verb needs one.
535/// `add-dimension-group` needs none; `update-dimension-group` always
536/// resolves exactly one (or has already returned its refusal);
537/// `delete-dimension-group` resolves a depth only when the match was
538/// unambiguous.
539enum ExistingGroups<'a> {
540    None,
541    One(&'a DimensionGroup),
542    MaybeOne(Option<i64>),
543}
544
545/// Finds the `Sheet` with this id in an already-fetched workbook. Never
546/// `None` when `sheet_id` came from [`grid_range::find_sheet_id`] against
547/// the same `workbook`.
548fn find_sheet(workbook: &Spreadsheet, sheet_id: i64) -> Option<&Sheet> {
549    workbook
550        .sheets
551        .iter()
552        .find(|s| s.sheet_id() == Some(sheet_id))
553}
554
555/// Every group on `sheet`'s `dimension` axis whose range exactly matches
556/// `range` — the candidates `update`/`delete` disambiguate among.
557fn candidates<'a>(
558    sheet: &'a Sheet,
559    dimension: Dimension,
560    range: &DimensionRange,
561) -> Vec<&'a DimensionGroup> {
562    let groups = match dimension {
563        Dimension::Rows => &sheet.row_groups,
564        Dimension::Columns => &sheet.column_groups,
565    };
566    groups
567        .iter()
568        .filter(|group| &group.range == range)
569        .collect()
570}
571
572/// The `UpdateDimensionGroup` half of resolving `candidates`: exactly one
573/// match is required, `--depth` disambiguating when more than one exists.
574fn resolve_for_update(
575    candidates: Vec<&DimensionGroup>,
576    depth: Option<i64>,
577) -> Result<&DimensionGroup, DimensionGroupResult> {
578    if let Some(depth) = depth {
579        return candidates
580            .into_iter()
581            .find(|group| group.depth == depth)
582            .ok_or_else(|| DimensionGroupResult::RefusedDimensionGroupNotFound {
583                detail: format!("no dimension group over that span at depth {depth}"),
584            });
585    }
586    match candidates.len() {
587        0 => Err(DimensionGroupResult::RefusedDimensionGroupNotFound {
588            detail: "no dimension group exists over that span; run \
589                     `drive sheets list-dimension-groups` to see what exists"
590                .to_string(),
591        }),
592        1 => Ok(candidates[0]),
593        _ => Err(DimensionGroupResult::RefusedAmbiguousDimensionGroup {
594            depths: candidates.iter().map(|group| group.depth).collect(),
595        }),
596    }
597}
598
599/// The `DeleteDimensionGroup` half of resolving `candidates`: any match
600/// count but zero is accepted, since `deleteDimensionGroup`'s wire request
601/// carries only a `range` — it has no way to name one depth over another,
602/// and Sheets decides among an ambiguous match. Reports a depth only when
603/// the match was unambiguous.
604fn resolve_for_delete(
605    candidates: Vec<&DimensionGroup>,
606) -> Result<Option<i64>, DimensionGroupResult> {
607    match candidates.len() {
608        0 => Err(DimensionGroupResult::RefusedDimensionGroupNotFound {
609            detail: "no dimension group exists over that span; run \
610                     `drive sheets list-dimension-groups` to see what exists"
611                .to_string(),
612        }),
613        1 => Ok(Some(candidates[0].depth)),
614        _ => Ok(None),
615    }
616}
617
618/// Converts the verb's `--start`/`--end` into the API's zero-based
619/// half-open [`DimensionRange`], validating the span against the sheet's
620/// current extent when the workbook reports one. The single conversion
621/// site, on purpose — the same "inlining this at a call site is how the
622/// obvious off-by-one gets in" reasoning `structure.rs::dimension_range`
623/// documents for its own, near-identical conversion.
624fn validate_span(
625    dimension: Dimension,
626    start: i64,
627    end: i64,
628    sheet: &Sheet,
629    sheet_id: i64,
630) -> Result<DimensionRange, String> {
631    if start < 1 {
632        return Err(format!("--start must be at least 1, got {start}"));
633    }
634    if end < start {
635        return Err(format!(
636            "--end must be >= --start (got --start {start} --end {end})"
637        ));
638    }
639    let current = sheet
640        .properties
641        .as_ref()
642        .and_then(|props| props.grid_properties.as_ref())
643        .and_then(|grid| match dimension {
644            Dimension::Rows => grid.row_count,
645            Dimension::Columns => grid.column_count,
646        });
647    if let Some(current) = current {
648        if end > current {
649            return Err(format!(
650                "--end {end} is past the end of the sheet, which has {current} {noun}(s)",
651                noun = dimension.noun(),
652            ));
653        }
654    }
655    Ok(DimensionRange {
656        sheet_id,
657        dimension,
658        start_index: start - 1,
659        end_index: end,
660    })
661}
662
663/// Renders a [`DimensionRange`] as the request log's `dimension_range`
664/// context value, e.g. `"ROWS 5:7"` — 1-based inclusive, through the same
665/// [`Dimension::span_label`] `structure.rs`'s insert/delete-dimension
666/// verbs use, so the two writers of one log key cannot drift. The only
667/// thing this adds is the zero-based-half-open → one-based-inclusive step.
668fn dimension_range_label(range: &DimensionRange) -> String {
669    range
670        .dimension
671        .span_label(range.start_index + 1, range.end_index)
672}
673
674/// Builds the request for `verb`. Placed after the `--dry-run` branch in
675/// `dimension_group_inner` deliberately — see `banding.rs::banding_inner`'s
676/// comment just before its own call site for the full `#1688` ordering
677/// reasoning, shared verbatim by every leased engine.
678fn build_request(
679    verb: &DimensionGroupVerb,
680    range: DimensionRange,
681    existing: ExistingGroups<'_>,
682) -> (BatchUpdateRequestItem, Option<i64>) {
683    match verb {
684        DimensionGroupVerb::AddDimensionGroup { .. } => (
685            BatchUpdateRequestItem::AddDimensionGroup(AddDimensionGroupRequest { range }),
686            None,
687        ),
688        DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. } => {
689            let ExistingGroups::One(existing) = existing else {
690                // omni-dev: coverage ignore reason="resolve_for_update returns Ok only with exactly one group, or the caller has already returned RefusedDimensionGroupNotFound/RefusedAmbiguousDimensionGroup; this else-arm exists only to unwrap the shared enum"
691                unreachable!("existing is resolved for UpdateDimensionGroup above")
692                // omni-dev: coverage end
693            };
694            let dimension_group = DimensionGroup {
695                range,
696                depth: existing.depth,
697                collapsed: *collapsed,
698            };
699            (
700                BatchUpdateRequestItem::UpdateDimensionGroup(UpdateDimensionGroupRequest {
701                    dimension_group,
702                    fields: "collapsed".to_string(),
703                }),
704                Some(existing.depth),
705            )
706        }
707        DimensionGroupVerb::DeleteDimensionGroup { .. } => {
708            let ExistingGroups::MaybeOne(depth) = existing else {
709                // omni-dev: coverage ignore reason="resolve_for_delete returns Ok only as MaybeOne, or the caller has already returned RefusedDimensionGroupNotFound; this else-arm exists only to unwrap the shared enum"
710                unreachable!("existing is resolved for DeleteDimensionGroup above")
711                // omni-dev: coverage end
712            };
713            (
714                BatchUpdateRequestItem::DeleteDimensionGroup(DeleteDimensionGroupRequest { range }),
715                depth,
716            )
717        }
718    }
719}
720
721fn describe_effect(verb: &DimensionGroupVerb, range_label: &str) -> String {
722    match verb {
723        DimensionGroupVerb::AddDimensionGroup { dimension, .. } => {
724            format!("add a {} group over {range_label}", dimension.noun())
725        }
726        DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. } => {
727            format!("set collapsed={collapsed} on the group over {range_label}")
728        }
729        DimensionGroupVerb::DeleteDimensionGroup { .. } => {
730            format!("delete the group over {range_label}")
731        }
732    }
733}
734
735fn record_attempt(
736    outcome: &DimensionGroupOutcome,
737    opts: &DimensionGroupOptions,
738    duration: Duration,
739) {
740    let error = match &outcome.result {
741        DimensionGroupResult::Failed { detail } => Some(detail.clone()),
742        _ => None,
743    };
744    let decided_by = match &outcome.result {
745        DimensionGroupResult::Blocked { decided_by } => decided_by.as_ref(),
746        _ => None,
747    };
748    let decided_by = write_gate::decided_by_log_fields(decided_by);
749    let fields_changed = match (&opts.verb, &outcome.result) {
750        (
751            DimensionGroupVerb::UpdateDimensionGroup { collapsed, .. },
752            DimensionGroupResult::Changed { .. },
753        ) => Some(format!("collapsed={collapsed}")),
754        _ => None,
755    };
756
757    request_log::record_drive_mutation(DriveMutationOutcome {
758        operation: opts.verb.log_operation(),
759        file_id: outcome.spreadsheet_id.clone(),
760        file_name: outcome.file_name.clone().unwrap_or_default(),
761        status: outcome.result.log_status().to_string(),
762        resolved_folder_id: outcome.resolved_folder_id.clone(),
763        decided_by_folder_id: decided_by.folder_id,
764        decided_by_depth: decided_by.depth,
765        decided_by_file_id: decided_by.file_id,
766        sheet_id: outcome.sheet_id,
767        dimension_range: outcome.dimension_range_label.clone(),
768        fields_changed,
769        error,
770        duration,
771        ..Default::default()
772    });
773}
774
775/// Renders an outcome as human-readable text.
776#[must_use]
777pub fn describe(outcome: &DimensionGroupOutcome) -> String {
778    describe_lines(outcome).join("\n")
779}
780
781/// Renders an outcome as its individual lines, none of which contains a
782/// newline.
783#[must_use]
784pub fn describe_lines(outcome: &DimensionGroupOutcome) -> Vec<String> {
785    let verb = &outcome.verb;
786    let book = outcome.file_name.as_deref().map_or_else(
787        || format!("'{}'", outcome.spreadsheet_id),
788        |n| format!("'{n}'"),
789    );
790    match &outcome.result {
791        DimensionGroupResult::WouldChange { summary } => {
792            vec![format!("Would {summary} in {book}")]
793        }
794        DimensionGroupResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
795            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
796             `drive sheets {}` only works on spreadsheets",
797            verb.label()
798        )],
799        DimensionGroupResult::RefusedShortcut => vec![format!(
800            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
801            verb.label()
802        )],
803        DimensionGroupResult::RefusedNoVisibleParents => vec![format!(
804            "Refused: {book} has no parent folder visible to this account, so no folder \
805             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
806             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
807        )],
808        DimensionGroupResult::RefusedSheetNotFound { title, available } => {
809            vec![grid_range::no_sheet_refusal(&book, title, available)]
810        }
811        DimensionGroupResult::RefusedInvalidRange { detail }
812        | DimensionGroupResult::RefusedDimensionGroupNotFound { detail } => {
813            vec![format!("Refused: {detail}")]
814        }
815        DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths } => {
816            let depths = depths
817                .iter()
818                .map(i64::to_string)
819                .collect::<Vec<_>>()
820                .join(", ");
821            vec![format!(
822                "Refused: more than one dimension group exists over that span, at depths \
823                 {depths}; pass --depth to pick one"
824            )]
825        }
826        DimensionGroupResult::Blocked { decided_by } => vec![match decided_by {
827            Some(rule) => format!(
828                "Blocked: {} on {book} refused by rule on {} {}{}",
829                verb.label(),
830                rule.kind_label(),
831                rule.id(),
832                rule.depth_suffix()
833            ),
834            None => format!(
835                "Blocked: {} on {book} refused by default policy (no matching rule for \
836                 sheets-structure)",
837                verb.label()
838            ),
839        }],
840        DimensionGroupResult::RefusedNoLease => LeaseGateRefusal::NoLease
841            .describe_line(&outcome.spreadsheet_id, &book)
842            .into_iter()
843            .collect(),
844        DimensionGroupResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
845            .describe_line(&outcome.spreadsheet_id, &book)
846            .into_iter()
847            .collect(),
848        DimensionGroupResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
849            .describe_line(&outcome.spreadsheet_id, &book)
850            .into_iter()
851            .collect(),
852        DimensionGroupResult::RefusedLeaseStale => LeaseGateRefusal::Stale
853            .describe_line(&outcome.spreadsheet_id, &book)
854            .into_iter()
855            .collect(),
856        DimensionGroupResult::Changed { summary, depth } => {
857            let id = depth.map_or_else(String::new, |depth| format!(" (depth {depth})"));
858            vec![format!("Applied: {summary}{id} in {book}")]
859        }
860        DimensionGroupResult::Failed { detail } => vec![format!("Failed: {detail}")],
861    }
862}
863
864#[cfg(test)]
865#[allow(clippy::unwrap_used, clippy::expect_used)]
866mod tests {
867    use super::*;
868    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
869    use crate::drive::sheets::client::SHEETS_API_URL;
870    use crate::drive::sheets::types::{GridProperties, SheetProperties};
871    use crate::drive::test_support::seed_lease;
872    use crate::test_support::env::MapEnv;
873    use crate::utils::secret::Secret;
874    use std::collections::HashSet;
875
876    // ── pure helpers ─────────────────────────────────────────────────────
877
878    #[test]
879    fn log_operation_and_label_are_distinct_for_every_verb() {
880        let verbs = [add_verb(), update_verb(), delete_verb()];
881        let mut operations: Vec<&str> = verbs
882            .iter()
883            .map(DimensionGroupVerb::log_operation)
884            .collect();
885        let mut labels: Vec<&str> = verbs.iter().map(DimensionGroupVerb::label).collect();
886        operations.sort_unstable();
887        operations.dedup();
888        labels.sort_unstable();
889        labels.dedup();
890        assert_eq!(operations.len(), verbs.len());
891        assert_eq!(labels.len(), verbs.len());
892    }
893
894    #[test]
895    fn dimension_group_result_log_status_names_every_variant() {
896        assert_eq!(
897            DimensionGroupResult::WouldChange {
898                summary: String::new()
899            }
900            .log_status(),
901            "would-change"
902        );
903        assert_eq!(
904            DimensionGroupResult::RefusedNotASpreadsheet {
905                mime_type: String::new()
906            }
907            .log_status(),
908            "refused-not-a-spreadsheet"
909        );
910        assert_eq!(
911            DimensionGroupResult::RefusedShortcut.log_status(),
912            "refused-shortcut"
913        );
914        assert_eq!(
915            DimensionGroupResult::RefusedNoVisibleParents.log_status(),
916            "refused-no-visible-parents"
917        );
918        assert_eq!(
919            DimensionGroupResult::RefusedSheetNotFound {
920                title: String::new(),
921                available: Vec::new(),
922            }
923            .log_status(),
924            "refused-sheet-not-found"
925        );
926        assert_eq!(
927            DimensionGroupResult::RefusedInvalidRange {
928                detail: String::new()
929            }
930            .log_status(),
931            "refused-invalid-range"
932        );
933        assert_eq!(
934            DimensionGroupResult::RefusedDimensionGroupNotFound {
935                detail: String::new()
936            }
937            .log_status(),
938            "refused-dimension-group-not-found"
939        );
940        assert_eq!(
941            DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths: Vec::new() }
942                .log_status(),
943            "refused-ambiguous-dimension-group"
944        );
945        assert_eq!(
946            DimensionGroupResult::Blocked { decided_by: None }.log_status(),
947            "blocked"
948        );
949        assert_eq!(
950            DimensionGroupResult::RefusedNoLease.log_status(),
951            LeaseGateRefusal::NoLease.log_status()
952        );
953        assert_eq!(
954            DimensionGroupResult::RefusedLeaseExpired.log_status(),
955            LeaseGateRefusal::Expired.log_status()
956        );
957        assert_eq!(
958            DimensionGroupResult::RefusedLeaseWrongFile.log_status(),
959            LeaseGateRefusal::WrongFile.log_status()
960        );
961        assert_eq!(
962            DimensionGroupResult::RefusedLeaseStale.log_status(),
963            LeaseGateRefusal::Stale.log_status()
964        );
965        assert_eq!(
966            DimensionGroupResult::Changed {
967                summary: String::new(),
968                depth: None,
969            }
970            .log_status(),
971            "changed"
972        );
973        assert_eq!(
974            DimensionGroupResult::Failed {
975                detail: String::new()
976            }
977            .log_status(),
978            "failed"
979        );
980    }
981
982    fn range(
983        sheet_id: i64,
984        dimension: Dimension,
985        start_index: i64,
986        end_index: i64,
987    ) -> DimensionRange {
988        DimensionRange {
989            sheet_id,
990            dimension,
991            start_index,
992            end_index,
993        }
994    }
995
996    fn group(
997        sheet_id: i64,
998        start_index: i64,
999        end_index: i64,
1000        depth: i64,
1001        collapsed: bool,
1002    ) -> DimensionGroup {
1003        DimensionGroup {
1004            range: range(sheet_id, Dimension::Rows, start_index, end_index),
1005            depth,
1006            collapsed,
1007        }
1008    }
1009
1010    fn sheet_with_row_groups(sheet_id: i64, groups: Vec<DimensionGroup>) -> Sheet {
1011        Sheet {
1012            properties: Some(SheetProperties {
1013                sheet_id: Some(sheet_id),
1014                title: "Q1".to_string(),
1015                index: Some(0),
1016                hidden: None,
1017                grid_properties: Some(GridProperties {
1018                    row_count: Some(1000),
1019                    column_count: Some(26),
1020                    frozen_row_count: None,
1021                    frozen_column_count: None,
1022                    hide_gridlines: None,
1023                }),
1024                right_to_left: None,
1025            }),
1026            row_groups: groups,
1027            ..Default::default()
1028        }
1029    }
1030
1031    #[test]
1032    fn validate_span_rejects_start_below_one() {
1033        let sheet = sheet_with_row_groups(0, Vec::new());
1034        let err = validate_span(Dimension::Rows, 0, 5, &sheet, 0).unwrap_err();
1035        assert!(err.contains("--start"), "{err}");
1036    }
1037
1038    #[test]
1039    fn validate_span_rejects_end_before_start() {
1040        let sheet = sheet_with_row_groups(0, Vec::new());
1041        let err = validate_span(Dimension::Rows, 5, 3, &sheet, 0).unwrap_err();
1042        assert!(err.contains("--end"), "{err}");
1043    }
1044
1045    #[test]
1046    fn validate_span_rejects_end_past_the_sheet() {
1047        let sheet = sheet_with_row_groups(0, Vec::new());
1048        let err = validate_span(Dimension::Rows, 998, 1001, &sheet, 0).unwrap_err();
1049        assert!(err.contains("past the end"), "{err}");
1050    }
1051
1052    #[test]
1053    fn validate_span_allows_end_equal_to_the_sheet_count() {
1054        let sheet = sheet_with_row_groups(0, Vec::new());
1055        let result = validate_span(Dimension::Rows, 995, 1000, &sheet, 0).unwrap();
1056        assert_eq!(result, range(0, Dimension::Rows, 994, 1000));
1057    }
1058
1059    #[test]
1060    fn validate_span_checks_the_columns_extent_too() {
1061        let sheet = sheet_with_row_groups(0, Vec::new());
1062        let err = validate_span(Dimension::Columns, 20, 30, &sheet, 0).unwrap_err();
1063        assert!(err.contains("past the end") && err.contains("26"), "{err}");
1064
1065        let result = validate_span(Dimension::Columns, 20, 26, &sheet, 0).unwrap();
1066        assert_eq!(result, range(0, Dimension::Columns, 19, 26));
1067    }
1068
1069    #[test]
1070    fn validate_span_allows_any_span_when_the_sheet_extent_is_unknown() {
1071        let sheet = Sheet {
1072            properties: Some(SheetProperties {
1073                sheet_id: Some(0),
1074                title: "Q1".to_string(),
1075                index: Some(0),
1076                hidden: None,
1077                grid_properties: None,
1078                right_to_left: None,
1079            }),
1080            ..Default::default()
1081        };
1082        let result = validate_span(Dimension::Rows, 1, 1_000_000, &sheet, 0).unwrap();
1083        assert_eq!(result, range(0, Dimension::Rows, 0, 1_000_000));
1084    }
1085
1086    #[test]
1087    fn validate_span_converts_one_based_inclusive_to_zero_based_half_open() {
1088        let sheet = sheet_with_row_groups(0, Vec::new());
1089        let result = validate_span(Dimension::Rows, 5, 7, &sheet, 0).unwrap();
1090        assert_eq!(result, range(0, Dimension::Rows, 4, 7));
1091    }
1092
1093    #[test]
1094    fn dimension_range_label_is_one_based_inclusive() {
1095        assert_eq!(
1096            dimension_range_label(&range(0, Dimension::Rows, 4, 7)),
1097            "ROWS 5:7"
1098        );
1099        assert_eq!(
1100            dimension_range_label(&range(0, Dimension::Columns, 0, 3)),
1101            "COLUMNS 1:3"
1102        );
1103    }
1104
1105    #[test]
1106    fn candidates_filters_by_exact_range_on_the_named_axis() {
1107        let target = range(0, Dimension::Rows, 4, 9);
1108        let sheet = sheet_with_row_groups(
1109            0,
1110            vec![
1111                group(0, 4, 9, 1, false),
1112                group(0, 4, 9, 2, true),
1113                group(0, 10, 20, 1, false),
1114            ],
1115        );
1116        let found = candidates(&sheet, Dimension::Rows, &target);
1117        assert_eq!(found.len(), 2);
1118        assert!(found.iter().all(|g| g.range == target));
1119    }
1120
1121    #[test]
1122    fn candidates_reads_the_column_groups_for_the_column_axis() {
1123        let target = range(0, Dimension::Columns, 0, 3);
1124        let mut sheet = sheet_with_row_groups(0, Vec::new());
1125        sheet.column_groups = vec![DimensionGroup {
1126            range: target.clone(),
1127            depth: 1,
1128            collapsed: false,
1129        }];
1130        let found = candidates(&sheet, Dimension::Columns, &target);
1131        assert_eq!(found.len(), 1);
1132    }
1133
1134    #[test]
1135    fn resolve_for_update_refuses_not_found() {
1136        let err = resolve_for_update(Vec::new(), None).unwrap_err();
1137        assert!(matches!(
1138            err,
1139            DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1140        ));
1141    }
1142
1143    #[test]
1144    fn resolve_for_update_resolves_the_single_match() {
1145        let g = group(0, 4, 9, 1, false);
1146        let found = resolve_for_update(vec![&g], None).unwrap();
1147        assert_eq!(found.depth, 1);
1148    }
1149
1150    #[test]
1151    fn resolve_for_update_refuses_ambiguous_without_depth() {
1152        let shallow = group(0, 4, 9, 1, false);
1153        let deep = group(0, 4, 9, 2, true);
1154        let err = resolve_for_update(vec![&shallow, &deep], None).unwrap_err();
1155        let DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths } = err else {
1156            panic!("expected RefusedAmbiguousDimensionGroup"); // omni-dev: coverage ignore-line reason="guards this test's assumption; resolve_for_update always returns RefusedAmbiguousDimensionGroup for more than one candidate with no depth given"
1157        };
1158        assert_eq!(depths, vec![1, 2]);
1159    }
1160
1161    #[test]
1162    fn resolve_for_update_disambiguates_by_depth() {
1163        let shallow = group(0, 4, 9, 1, false);
1164        let deep = group(0, 4, 9, 2, true);
1165        let found = resolve_for_update(vec![&shallow, &deep], Some(2)).unwrap();
1166        assert!(found.collapsed);
1167        assert_eq!(found.depth, 2);
1168    }
1169
1170    #[test]
1171    fn resolve_for_update_refuses_a_depth_that_does_not_exist() {
1172        let shallow = group(0, 4, 9, 1, false);
1173        let err = resolve_for_update(vec![&shallow], Some(9)).unwrap_err();
1174        assert!(matches!(
1175            err,
1176            DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1177        ));
1178    }
1179
1180    #[test]
1181    fn resolve_for_delete_refuses_not_found() {
1182        let err = resolve_for_delete(Vec::new()).unwrap_err();
1183        assert!(matches!(
1184            err,
1185            DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1186        ));
1187    }
1188
1189    #[test]
1190    fn resolve_for_delete_reports_the_depth_when_unambiguous() {
1191        let g = group(0, 4, 9, 2, false);
1192        let depth = resolve_for_delete(vec![&g]).unwrap();
1193        assert_eq!(depth, Some(2));
1194    }
1195
1196    #[test]
1197    fn resolve_for_delete_accepts_an_ambiguous_match_reporting_no_depth() {
1198        let shallow = group(0, 4, 9, 1, false);
1199        let deep = group(0, 4, 9, 2, true);
1200        let depth = resolve_for_delete(vec![&shallow, &deep]).unwrap();
1201        assert_eq!(depth, None);
1202    }
1203
1204    fn add_verb() -> DimensionGroupVerb {
1205        DimensionGroupVerb::AddDimensionGroup {
1206            sheet: "Q1".to_string(),
1207            dimension: Dimension::Rows,
1208            start: 5,
1209            end: 9,
1210        }
1211    }
1212
1213    fn update_verb() -> DimensionGroupVerb {
1214        DimensionGroupVerb::UpdateDimensionGroup {
1215            sheet: "Q1".to_string(),
1216            dimension: Dimension::Rows,
1217            start: 5,
1218            end: 9,
1219            depth: None,
1220            collapsed: true,
1221        }
1222    }
1223
1224    fn delete_verb() -> DimensionGroupVerb {
1225        DimensionGroupVerb::DeleteDimensionGroup {
1226            sheet: "Q1".to_string(),
1227            dimension: Dimension::Rows,
1228            start: 5,
1229            end: 9,
1230        }
1231    }
1232
1233    #[test]
1234    fn build_request_add_dimension_group_carries_the_range() {
1235        let target = range(0, Dimension::Rows, 4, 9);
1236        let (request, depth) = build_request(&add_verb(), target.clone(), ExistingGroups::None);
1237        assert_eq!(depth, None);
1238        let BatchUpdateRequestItem::AddDimensionGroup(add) = request else {
1239            panic!("expected AddDimensionGroup"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns AddDimensionGroup for a DimensionGroupVerb::AddDimensionGroup verb"
1240        };
1241        assert_eq!(add.range, target);
1242    }
1243
1244    #[test]
1245    fn build_request_update_dimension_group_sets_collapsed_and_names_the_field_mask() {
1246        let target = range(0, Dimension::Rows, 4, 9);
1247        let existing = group(0, 4, 9, 3, false);
1248        let verb = DimensionGroupVerb::UpdateDimensionGroup {
1249            sheet: "Q1".to_string(),
1250            dimension: Dimension::Rows,
1251            start: 5,
1252            end: 9,
1253            depth: Some(3),
1254            collapsed: true,
1255        };
1256        let (request, depth) = build_request(&verb, target.clone(), ExistingGroups::One(&existing));
1257        assert_eq!(depth, Some(3));
1258        let BatchUpdateRequestItem::UpdateDimensionGroup(update) = request else {
1259            panic!("expected UpdateDimensionGroup"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns UpdateDimensionGroup for a DimensionGroupVerb::UpdateDimensionGroup verb"
1260        };
1261        assert_eq!(update.fields, "collapsed");
1262        assert_eq!(update.dimension_group.depth, 3);
1263        assert!(update.dimension_group.collapsed);
1264        assert_eq!(update.dimension_group.range, target);
1265    }
1266
1267    #[test]
1268    fn build_request_delete_dimension_group_carries_the_range_and_resolved_depth() {
1269        let target = range(0, Dimension::Rows, 4, 9);
1270        let (request, depth) = build_request(
1271            &delete_verb(),
1272            target.clone(),
1273            ExistingGroups::MaybeOne(Some(2)),
1274        );
1275        assert_eq!(depth, Some(2));
1276        let BatchUpdateRequestItem::DeleteDimensionGroup(delete) = request else {
1277            panic!("expected DeleteDimensionGroup"); // omni-dev: coverage ignore-line reason="guards this test's assumption; build_request always returns DeleteDimensionGroup for a DimensionGroupVerb::DeleteDimensionGroup verb"
1278        };
1279        assert_eq!(delete.range, target);
1280    }
1281
1282    #[test]
1283    fn describe_effect_names_the_axis_and_span() {
1284        let label = "ROWS 5:9";
1285        assert_eq!(
1286            describe_effect(&add_verb(), label),
1287            "add a row group over ROWS 5:9"
1288        );
1289        assert_eq!(
1290            describe_effect(&update_verb(), label),
1291            "set collapsed=true on the group over ROWS 5:9"
1292        );
1293        assert_eq!(
1294            describe_effect(&delete_verb(), label),
1295            "delete the group over ROWS 5:9"
1296        );
1297    }
1298
1299    // ── end-to-end (wiremock) ────────────────────────────────────────────
1300
1301    fn test_credentials() -> DriveCredentials {
1302        DriveCredentials {
1303            client_id: "client-1".to_string(),
1304            client_secret: Secret::new("secret-1"),
1305            refresh_token: Secret::new("refresh-1"),
1306            scope: DriveGrantedScopes::READONLY,
1307        }
1308    }
1309
1310    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1311        wiremock::Mock::given(wiremock::matchers::method("POST"))
1312            .and(wiremock::matchers::path("/token"))
1313            .respond_with(
1314                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1315                    "access_token": "test-token", "expires_in": 3600,
1316                })),
1317            )
1318            .mount(server)
1319            .await;
1320        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1321        crate::drive::client::test_support::replace_session(
1322            &mut drive,
1323            &test_credentials(),
1324            &format!("{}/token", server.uri()),
1325        );
1326        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1327        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1328        (drive, sheets)
1329    }
1330
1331    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1332        let parents: Vec<&str> = parents.to_vec();
1333        wiremock::Mock::given(wiremock::matchers::method("GET"))
1334            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1335            .respond_with(
1336                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1337                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1338                    "version": "1",
1339                })),
1340            )
1341    }
1342
1343    fn mount_folder(id: &str) -> wiremock::Mock {
1344        mount_file(id, "application/vnd.google-apps.folder", &[])
1345    }
1346
1347    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
1348        let ledger_path = tempfile::tempdir()
1349            .unwrap()
1350            .keep()
1351            .join("lease-ledger.jsonl");
1352        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
1353        (Some(token), ledger_path)
1354    }
1355
1356    fn allow_rule(folder: &str) -> FolderPermissionRule {
1357        FolderPermissionRule {
1358            folder_id: Some(folder.to_string()),
1359            file_id: None,
1360            recursive: true,
1361            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
1362            deny: HashSet::default(),
1363            require_lease: true,
1364        }
1365    }
1366
1367    fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
1368        wiremock::Mock::given(wiremock::matchers::method("GET"))
1369            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1370            .respond_with(
1371                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1372                    "spreadsheetId": "sheet-1",
1373                    "properties": {"title": "Budget"},
1374                    "sheets": sheets,
1375                })),
1376            )
1377    }
1378
1379    fn base_opts(verb: DimensionGroupVerb, dry_run: bool) -> DimensionGroupOptions {
1380        DimensionGroupOptions {
1381            spreadsheet_id: "sheet-1".to_string(),
1382            verb,
1383            dry_run,
1384            lease_token: None,
1385            ledger_path: std::path::PathBuf::new(),
1386        }
1387    }
1388
1389    #[tokio::test]
1390    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
1391        let server = wiremock::MockServer::start().await;
1392        let (drive, sheets) = clients(&server).await;
1393        mount_file(
1394            "sheet-1",
1395            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1396            &["folder-1"],
1397        )
1398        .mount(&server)
1399        .await;
1400        mount_folder("folder-1").mount(&server).await;
1401        let rules: Vec<FolderPermissionRule> = Vec::new();
1402        let opts = base_opts(add_verb(), false);
1403        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1404        assert!(matches!(
1405            outcome.result,
1406            DimensionGroupResult::Blocked { .. }
1407        ));
1408    }
1409
1410    #[tokio::test]
1411    async fn add_dimension_group_dry_run_reports_would_change_and_makes_no_batch_update_call() {
1412        let server = wiremock::MockServer::start().await;
1413        let (drive, sheets) = clients(&server).await;
1414        mount_file(
1415            "sheet-1",
1416            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1417            &["folder-1"],
1418        )
1419        .mount(&server)
1420        .await;
1421        mount_folder("folder-1").mount(&server).await;
1422        mount_workbook(serde_json::json!([
1423            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1424                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1425        ]))
1426        .mount(&server)
1427        .await;
1428        let rules = vec![allow_rule("folder-1")];
1429        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), true), &rules).await;
1430        assert!(matches!(
1431            outcome.result,
1432            DimensionGroupResult::WouldChange { .. }
1433        ));
1434    }
1435
1436    #[tokio::test]
1437    async fn add_dimension_group_sends_an_add_dimension_group_request() {
1438        let server = wiremock::MockServer::start().await;
1439        let (drive, sheets) = clients(&server).await;
1440        mount_file(
1441            "sheet-1",
1442            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1443            &["folder-1"],
1444        )
1445        .mount(&server)
1446        .await;
1447        mount_folder("folder-1").mount(&server).await;
1448        mount_workbook(serde_json::json!([
1449            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1450                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1451        ]))
1452        .mount(&server)
1453        .await;
1454        wiremock::Mock::given(wiremock::matchers::method("POST"))
1455            .and(wiremock::matchers::path(
1456                "/v4/spreadsheets/sheet-1:batchUpdate",
1457            ))
1458            .and(wiremock::matchers::body_json(serde_json::json!({
1459                "requests": [{
1460                    "addDimensionGroup": {
1461                        "range": {
1462                            "sheetId": 0,
1463                            "dimension": "ROWS",
1464                            "startIndex": 4,
1465                            "endIndex": 9,
1466                        }
1467                    }
1468                }]
1469            })))
1470            .respond_with(
1471                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1472                    "replies": [{}]
1473                })),
1474            )
1475            .mount(&server)
1476            .await;
1477        let rules = vec![allow_rule("folder-1")];
1478        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1479        let mut opts = base_opts(add_verb(), false);
1480        opts.lease_token = lease_token;
1481        opts.ledger_path = ledger_path;
1482        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1483        assert_eq!(
1484            outcome.result,
1485            DimensionGroupResult::Changed {
1486                summary: "add a row group over ROWS 5:9".to_string(),
1487                depth: None,
1488            }
1489        );
1490        assert_eq!(outcome.sheet_id, Some(0));
1491        assert_eq!(outcome.dimension_range_label, Some("ROWS 5:9".to_string()));
1492    }
1493
1494    #[tokio::test]
1495    async fn update_dimension_group_refuses_when_no_group_exists() {
1496        let server = wiremock::MockServer::start().await;
1497        let (drive, sheets) = clients(&server).await;
1498        mount_file(
1499            "sheet-1",
1500            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1501            &["folder-1"],
1502        )
1503        .mount(&server)
1504        .await;
1505        mount_folder("folder-1").mount(&server).await;
1506        mount_workbook(serde_json::json!([
1507            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1508                "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1509                "rowGroups": []},
1510        ]))
1511        .mount(&server)
1512        .await;
1513        let rules = vec![allow_rule("folder-1")];
1514        let outcome =
1515            dimension_group(&drive, &sheets, &base_opts(update_verb(), false), &rules).await;
1516        assert!(matches!(
1517            outcome.result,
1518            DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1519        ));
1520    }
1521
1522    #[tokio::test]
1523    async fn update_dimension_group_refuses_an_ambiguous_match() {
1524        let server = wiremock::MockServer::start().await;
1525        let (drive, sheets) = clients(&server).await;
1526        mount_file(
1527            "sheet-1",
1528            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1529            &["folder-1"],
1530        )
1531        .mount(&server)
1532        .await;
1533        mount_folder("folder-1").mount(&server).await;
1534        mount_workbook(serde_json::json!([
1535            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1536                "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1537                "rowGroups": [
1538                    {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1539                    {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 2, "collapsed": true},
1540                ]},
1541        ]))
1542        .mount(&server)
1543        .await;
1544        let rules = vec![allow_rule("folder-1")];
1545        let outcome =
1546            dimension_group(&drive, &sheets, &base_opts(update_verb(), false), &rules).await;
1547        assert!(matches!(
1548            outcome.result,
1549            DimensionGroupResult::RefusedAmbiguousDimensionGroup { .. }
1550        ));
1551    }
1552
1553    #[tokio::test]
1554    async fn update_dimension_group_disambiguated_by_depth_sends_the_update() {
1555        let server = wiremock::MockServer::start().await;
1556        let (drive, sheets) = clients(&server).await;
1557        mount_file(
1558            "sheet-1",
1559            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1560            &["folder-1"],
1561        )
1562        .mount(&server)
1563        .await;
1564        mount_folder("folder-1").mount(&server).await;
1565        mount_workbook(serde_json::json!([
1566            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1567                "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1568                "rowGroups": [
1569                    {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1570                    {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 2, "collapsed": true},
1571                ]},
1572        ]))
1573        .mount(&server)
1574        .await;
1575        wiremock::Mock::given(wiremock::matchers::method("POST"))
1576            .and(wiremock::matchers::path(
1577                "/v4/spreadsheets/sheet-1:batchUpdate",
1578            ))
1579            .and(wiremock::matchers::body_json(serde_json::json!({
1580                "requests": [{
1581                    "updateDimensionGroup": {
1582                        "dimensionGroup": {
1583                            "range": {
1584                                "sheetId": 0,
1585                                "dimension": "ROWS",
1586                                "startIndex": 4,
1587                                "endIndex": 9,
1588                            },
1589                            "depth": 2,
1590                            "collapsed": false,
1591                        },
1592                        "fields": "collapsed",
1593                    }
1594                }]
1595            })))
1596            .respond_with(
1597                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1598                    "replies": [{}]
1599                })),
1600            )
1601            .mount(&server)
1602            .await;
1603        let rules = vec![allow_rule("folder-1")];
1604        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1605        let verb = DimensionGroupVerb::UpdateDimensionGroup {
1606            sheet: "Q1".to_string(),
1607            dimension: Dimension::Rows,
1608            start: 5,
1609            end: 9,
1610            depth: Some(2),
1611            collapsed: false,
1612        };
1613        let mut opts = base_opts(verb, false);
1614        opts.lease_token = lease_token;
1615        opts.ledger_path = ledger_path;
1616        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1617        assert_eq!(
1618            outcome.result,
1619            DimensionGroupResult::Changed {
1620                summary: "set collapsed=false on the group over ROWS 5:9".to_string(),
1621                depth: Some(2),
1622            }
1623        );
1624    }
1625
1626    #[tokio::test]
1627    async fn delete_dimension_group_sends_a_delete_dimension_group_request() {
1628        let server = wiremock::MockServer::start().await;
1629        let (drive, sheets) = clients(&server).await;
1630        mount_file(
1631            "sheet-1",
1632            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1633            &["folder-1"],
1634        )
1635        .mount(&server)
1636        .await;
1637        mount_folder("folder-1").mount(&server).await;
1638        mount_workbook(serde_json::json!([
1639            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1640                "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1641                "rowGroups": [
1642                    {"range": {"sheetId": 0, "dimension": "ROWS", "startIndex": 4, "endIndex": 9}, "depth": 1},
1643                ]},
1644        ]))
1645        .mount(&server)
1646        .await;
1647        wiremock::Mock::given(wiremock::matchers::method("POST"))
1648            .and(wiremock::matchers::path(
1649                "/v4/spreadsheets/sheet-1:batchUpdate",
1650            ))
1651            .and(wiremock::matchers::body_json(serde_json::json!({
1652                "requests": [{
1653                    "deleteDimensionGroup": {
1654                        "range": {
1655                            "sheetId": 0,
1656                            "dimension": "ROWS",
1657                            "startIndex": 4,
1658                            "endIndex": 9,
1659                        }
1660                    }
1661                }]
1662            })))
1663            .respond_with(
1664                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1665                    "replies": [{}]
1666                })),
1667            )
1668            .mount(&server)
1669            .await;
1670        let rules = vec![allow_rule("folder-1")];
1671        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1672        let mut opts = base_opts(delete_verb(), false);
1673        opts.lease_token = lease_token;
1674        opts.ledger_path = ledger_path;
1675        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1676        assert_eq!(
1677            outcome.result,
1678            DimensionGroupResult::Changed {
1679                summary: "delete the group over ROWS 5:9".to_string(),
1680                depth: Some(1),
1681            }
1682        );
1683    }
1684
1685    #[tokio::test]
1686    async fn delete_dimension_group_refuses_when_no_group_exists() {
1687        let server = wiremock::MockServer::start().await;
1688        let (drive, sheets) = clients(&server).await;
1689        mount_file(
1690            "sheet-1",
1691            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1692            &["folder-1"],
1693        )
1694        .mount(&server)
1695        .await;
1696        mount_folder("folder-1").mount(&server).await;
1697        mount_workbook(serde_json::json!([
1698            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1699                "gridProperties": {"rowCount": 1000, "columnCount": 26}},
1700                "rowGroups": []},
1701        ]))
1702        .mount(&server)
1703        .await;
1704        let rules = vec![allow_rule("folder-1")];
1705        let outcome =
1706            dimension_group(&drive, &sheets, &base_opts(delete_verb(), false), &rules).await;
1707        assert!(matches!(
1708            outcome.result,
1709            DimensionGroupResult::RefusedDimensionGroupNotFound { .. }
1710        ));
1711    }
1712
1713    #[tokio::test]
1714    async fn add_dimension_group_reports_sheet_not_found_for_an_unknown_sheet() {
1715        let server = wiremock::MockServer::start().await;
1716        let (drive, sheets) = clients(&server).await;
1717        mount_file(
1718            "sheet-1",
1719            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1720            &["folder-1"],
1721        )
1722        .mount(&server)
1723        .await;
1724        mount_folder("folder-1").mount(&server).await;
1725        mount_workbook(serde_json::json!([
1726            {"properties": {"sheetId": 0, "title": "Other", "index": 0}},
1727        ]))
1728        .mount(&server)
1729        .await;
1730        let rules = vec![allow_rule("folder-1")];
1731        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1732        let DimensionGroupResult::RefusedSheetNotFound { title, available } = outcome.result else {
1733            panic!("expected RefusedSheetNotFound"); // omni-dev: coverage ignore-line reason="guards this test's assumption; the mounted workbook has no sheet titled Q1"
1734        };
1735        assert_eq!(title, "Q1");
1736        assert_eq!(available, vec!["Other".to_string()]);
1737    }
1738
1739    #[tokio::test]
1740    async fn add_dimension_group_reports_invalid_range_for_a_span_past_the_sheet() {
1741        let server = wiremock::MockServer::start().await;
1742        let (drive, sheets) = clients(&server).await;
1743        mount_file(
1744            "sheet-1",
1745            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1746            &["folder-1"],
1747        )
1748        .mount(&server)
1749        .await;
1750        mount_folder("folder-1").mount(&server).await;
1751        mount_workbook(serde_json::json!([
1752            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1753                "gridProperties": {"rowCount": 10, "columnCount": 26}}},
1754        ]))
1755        .mount(&server)
1756        .await;
1757        let rules = vec![allow_rule("folder-1")];
1758        let verb = DimensionGroupVerb::AddDimensionGroup {
1759            sheet: "Q1".to_string(),
1760            dimension: Dimension::Rows,
1761            start: 5,
1762            end: 50,
1763        };
1764        let outcome = dimension_group(&drive, &sheets, &base_opts(verb, false), &rules).await;
1765        assert!(matches!(
1766            outcome.result,
1767            DimensionGroupResult::RefusedInvalidRange { .. }
1768        ));
1769    }
1770
1771    #[tokio::test]
1772    async fn a_metadata_fetch_failure_surfaces_as_failed() {
1773        let server = wiremock::MockServer::start().await;
1774        let (drive, sheets) = clients(&server).await;
1775        wiremock::Mock::given(wiremock::matchers::method("GET"))
1776            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
1777            .respond_with(wiremock::ResponseTemplate::new(500))
1778            .mount(&server)
1779            .await;
1780        let rules: Vec<FolderPermissionRule> = Vec::new();
1781        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1782        assert!(matches!(
1783            outcome.result,
1784            DimensionGroupResult::Failed { .. }
1785        ));
1786    }
1787
1788    #[tokio::test]
1789    async fn a_shortcut_target_is_refused() {
1790        let server = wiremock::MockServer::start().await;
1791        let (drive, sheets) = clients(&server).await;
1792        mount_file("sheet-1", "application/vnd.google-apps.shortcut", &[])
1793            .mount(&server)
1794            .await;
1795        let rules: Vec<FolderPermissionRule> = Vec::new();
1796        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1797        assert!(matches!(
1798            outcome.result,
1799            DimensionGroupResult::RefusedShortcut
1800        ));
1801    }
1802
1803    #[tokio::test]
1804    async fn a_non_spreadsheet_target_is_refused() {
1805        let server = wiremock::MockServer::start().await;
1806        let (drive, sheets) = clients(&server).await;
1807        mount_file("sheet-1", "application/vnd.google-apps.document", &[])
1808            .mount(&server)
1809            .await;
1810        let rules: Vec<FolderPermissionRule> = Vec::new();
1811        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1812        assert!(matches!(
1813            outcome.result,
1814            DimensionGroupResult::RefusedNotASpreadsheet { .. }
1815        ));
1816    }
1817
1818    #[tokio::test]
1819    async fn a_target_with_no_visible_parents_is_refused() {
1820        let server = wiremock::MockServer::start().await;
1821        let (drive, sheets) = clients(&server).await;
1822        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
1823            .mount(&server)
1824            .await;
1825        let rules: Vec<FolderPermissionRule> = Vec::new();
1826        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1827        assert!(matches!(
1828            outcome.result,
1829            DimensionGroupResult::RefusedNoVisibleParents
1830        ));
1831    }
1832
1833    #[tokio::test]
1834    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
1835        let server = wiremock::MockServer::start().await;
1836        let (drive, sheets) = clients(&server).await;
1837        mount_file(
1838            "sheet-1",
1839            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1840            &["folder-1"],
1841        )
1842        .mount(&server)
1843        .await;
1844        wiremock::Mock::given(wiremock::matchers::method("GET"))
1845            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
1846            .respond_with(wiremock::ResponseTemplate::new(500))
1847            .mount(&server)
1848            .await;
1849        let rules: Vec<FolderPermissionRule> = Vec::new();
1850        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1851        assert!(matches!(
1852            outcome.result,
1853            DimensionGroupResult::Failed { .. }
1854        ));
1855    }
1856
1857    #[tokio::test]
1858    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
1859        let server = wiremock::MockServer::start().await;
1860        let (drive, sheets) = clients(&server).await;
1861        mount_file(
1862            "sheet-1",
1863            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1864            &["folder-1"],
1865        )
1866        .mount(&server)
1867        .await;
1868        mount_folder("folder-1").mount(&server).await;
1869        wiremock::Mock::given(wiremock::matchers::method("GET"))
1870            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1871            .respond_with(wiremock::ResponseTemplate::new(500))
1872            .mount(&server)
1873            .await;
1874        let rules = vec![allow_rule("folder-1")];
1875        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1876        assert!(matches!(
1877            outcome.result,
1878            DimensionGroupResult::Failed { .. }
1879        ));
1880    }
1881
1882    #[tokio::test]
1883    async fn a_batch_update_failure_surfaces_as_failed() {
1884        let server = wiremock::MockServer::start().await;
1885        let (drive, sheets) = clients(&server).await;
1886        mount_file(
1887            "sheet-1",
1888            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1889            &["folder-1"],
1890        )
1891        .mount(&server)
1892        .await;
1893        mount_folder("folder-1").mount(&server).await;
1894        mount_workbook(serde_json::json!([
1895            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1896                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1897        ]))
1898        .mount(&server)
1899        .await;
1900        wiremock::Mock::given(wiremock::matchers::method("POST"))
1901            .and(wiremock::matchers::path(
1902                "/v4/spreadsheets/sheet-1:batchUpdate",
1903            ))
1904            .respond_with(wiremock::ResponseTemplate::new(500))
1905            .mount(&server)
1906            .await;
1907        let rules = vec![allow_rule("folder-1")];
1908        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
1909        let mut opts = base_opts(add_verb(), false);
1910        opts.lease_token = lease_token;
1911        opts.ledger_path = ledger_path;
1912        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1913        assert!(matches!(
1914            outcome.result,
1915            DimensionGroupResult::Failed { .. }
1916        ));
1917    }
1918
1919    #[tokio::test]
1920    async fn refuses_without_a_lease_when_the_rule_requires_one() {
1921        let server = wiremock::MockServer::start().await;
1922        let (drive, sheets) = clients(&server).await;
1923        mount_file(
1924            "sheet-1",
1925            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1926            &["folder-1"],
1927        )
1928        .mount(&server)
1929        .await;
1930        mount_folder("folder-1").mount(&server).await;
1931        mount_workbook(serde_json::json!([
1932            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1933                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1934        ]))
1935        .mount(&server)
1936        .await;
1937        let rules = vec![allow_rule("folder-1")];
1938        let outcome = dimension_group(&drive, &sheets, &base_opts(add_verb(), false), &rules).await;
1939        assert!(matches!(
1940            outcome.result,
1941            DimensionGroupResult::RefusedNoLease
1942        ));
1943    }
1944
1945    #[tokio::test]
1946    async fn refuses_an_unknown_lease_token() {
1947        let server = wiremock::MockServer::start().await;
1948        let (drive, sheets) = clients(&server).await;
1949        mount_file(
1950            "sheet-1",
1951            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1952            &["folder-1"],
1953        )
1954        .mount(&server)
1955        .await;
1956        mount_folder("folder-1").mount(&server).await;
1957        mount_workbook(serde_json::json!([
1958            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1959                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1960        ]))
1961        .mount(&server)
1962        .await;
1963        let rules = vec![allow_rule("folder-1")];
1964        let ledger_path = tempfile::tempdir()
1965            .unwrap()
1966            .keep()
1967            .join("lease-ledger.jsonl");
1968        let mut opts = base_opts(add_verb(), false);
1969        opts.lease_token = Some("not-a-real-token".to_string());
1970        opts.ledger_path = ledger_path;
1971        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
1972        assert!(matches!(
1973            outcome.result,
1974            DimensionGroupResult::RefusedLeaseExpired
1975        ));
1976    }
1977
1978    #[tokio::test]
1979    async fn refuses_a_lease_bound_to_a_different_file() {
1980        let server = wiremock::MockServer::start().await;
1981        let (drive, sheets) = clients(&server).await;
1982        mount_file(
1983            "sheet-1",
1984            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1985            &["folder-1"],
1986        )
1987        .mount(&server)
1988        .await;
1989        mount_folder("folder-1").mount(&server).await;
1990        mount_workbook(serde_json::json!([
1991            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1992                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1993        ]))
1994        .mount(&server)
1995        .await;
1996        let rules = vec![allow_rule("folder-1")];
1997        let (lease_token, ledger_path) = leased_opts_for("a-different-sheet");
1998        let mut opts = base_opts(add_verb(), false);
1999        opts.lease_token = lease_token;
2000        opts.ledger_path = ledger_path;
2001        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2002        assert!(matches!(
2003            outcome.result,
2004            DimensionGroupResult::RefusedLeaseWrongFile
2005        ));
2006    }
2007
2008    #[tokio::test]
2009    async fn refuses_a_stale_lease_when_the_file_has_moved() {
2010        let server = wiremock::MockServer::start().await;
2011        let (drive, sheets) = clients(&server).await;
2012        mount_file(
2013            "sheet-1",
2014            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2015            &["folder-1"],
2016        )
2017        .mount(&server)
2018        .await;
2019        mount_folder("folder-1").mount(&server).await;
2020        mount_workbook(serde_json::json!([
2021            {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2022                "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
2023        ]))
2024        .mount(&server)
2025        .await;
2026        let rules = vec![allow_rule("folder-1")];
2027        let ledger_path = tempfile::tempdir()
2028            .unwrap()
2029            .keep()
2030            .join("lease-ledger.jsonl");
2031        let token = seed_lease(&ledger_path, "sheet-1", "0");
2032        let mut opts = base_opts(add_verb(), false);
2033        opts.lease_token = Some(token);
2034        opts.ledger_path = ledger_path;
2035        let outcome = dimension_group(&drive, &sheets, &opts, &rules).await;
2036        assert!(matches!(
2037            outcome.result,
2038            DimensionGroupResult::RefusedLeaseStale
2039        ));
2040    }
2041
2042    // ── describe_lines ───────────────────────────────────────────────────
2043
2044    fn outcome_with(
2045        verb: DimensionGroupVerb,
2046        file_name: Option<&str>,
2047        result: DimensionGroupResult,
2048    ) -> DimensionGroupOutcome {
2049        DimensionGroupOutcome {
2050            spreadsheet_id: "sheet-1".to_string(),
2051            file_name: file_name.map(str::to_string),
2052            resolved_folder_id: None,
2053            sheet_id: None,
2054            dimension_range_label: None,
2055            verb,
2056            result,
2057        }
2058    }
2059
2060    #[test]
2061    fn describe_lines_renders_would_change() {
2062        let out = outcome_with(
2063            add_verb(),
2064            Some("Budget"),
2065            DimensionGroupResult::WouldChange {
2066                summary: "add a row group over ROWS 5:9".to_string(),
2067            },
2068        );
2069        assert_eq!(
2070            describe(&out),
2071            "Would add a row group over ROWS 5:9 in 'Budget'"
2072        );
2073    }
2074
2075    #[test]
2076    fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
2077        let out = outcome_with(
2078            add_verb(),
2079            None,
2080            DimensionGroupResult::RefusedNotASpreadsheet {
2081                mime_type: "application/vnd.google-apps.document".to_string(),
2082            },
2083        );
2084        let text = describe(&out);
2085        assert!(text.contains("'sheet-1'"), "{text}");
2086        assert!(text.contains("not a Google Sheet"), "{text}");
2087    }
2088
2089    #[test]
2090    fn describe_lines_renders_shortcut() {
2091        let out = outcome_with(
2092            add_verb(),
2093            Some("Budget"),
2094            DimensionGroupResult::RefusedShortcut,
2095        );
2096        assert!(describe(&out).contains("shortcut"));
2097    }
2098
2099    #[test]
2100    fn describe_lines_renders_no_visible_parents() {
2101        let out = outcome_with(
2102            add_verb(),
2103            Some("Budget"),
2104            DimensionGroupResult::RefusedNoVisibleParents,
2105        );
2106        assert!(describe(&out).contains("sheets-structure"));
2107    }
2108
2109    #[test]
2110    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
2111        let with_titles = outcome_with(
2112            add_verb(),
2113            Some("Budget"),
2114            DimensionGroupResult::RefusedSheetNotFound {
2115                title: "Q1".to_string(),
2116                available: vec!["Q2".to_string()],
2117            },
2118        );
2119        assert!(describe(&with_titles).contains("'Q2'"));
2120
2121        let without_titles = outcome_with(
2122            add_verb(),
2123            Some("Budget"),
2124            DimensionGroupResult::RefusedSheetNotFound {
2125                title: "Q1".to_string(),
2126                available: Vec::new(),
2127            },
2128        );
2129        assert!(describe(&without_titles).contains("Available: none"));
2130    }
2131
2132    #[test]
2133    fn describe_lines_renders_invalid_range_and_dimension_group_not_found() {
2134        let invalid = outcome_with(
2135            add_verb(),
2136            Some("Budget"),
2137            DimensionGroupResult::RefusedInvalidRange {
2138                detail: "--start must be at least 1, got 0".to_string(),
2139            },
2140        );
2141        assert_eq!(
2142            describe(&invalid),
2143            "Refused: --start must be at least 1, got 0"
2144        );
2145
2146        let not_found = outcome_with(
2147            update_verb(),
2148            Some("Budget"),
2149            DimensionGroupResult::RefusedDimensionGroupNotFound {
2150                detail: "no dimension group exists over that span".to_string(),
2151            },
2152        );
2153        assert_eq!(
2154            describe(&not_found),
2155            "Refused: no dimension group exists over that span"
2156        );
2157    }
2158
2159    #[test]
2160    fn describe_lines_renders_ambiguous() {
2161        let out = outcome_with(
2162            update_verb(),
2163            Some("Budget"),
2164            DimensionGroupResult::RefusedAmbiguousDimensionGroup { depths: vec![0, 1] },
2165        );
2166        let text = describe(&out);
2167        assert!(text.contains("0, 1"), "{text}");
2168        assert!(text.contains("--depth"), "{text}");
2169    }
2170
2171    #[test]
2172    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
2173        let without_rule = outcome_with(
2174            add_verb(),
2175            Some("Budget"),
2176            DimensionGroupResult::Blocked { decided_by: None },
2177        );
2178        assert!(describe(&without_rule).contains("default policy"));
2179
2180        let with_folder_rule = outcome_with(
2181            update_verb(),
2182            Some("Budget"),
2183            DimensionGroupResult::Blocked {
2184                decided_by: Some(DecidingRule::Folder {
2185                    folder_id: "folder-1".to_string(),
2186                    depth: 2,
2187                }),
2188            },
2189        );
2190        let text = describe(&with_folder_rule);
2191        assert!(text.contains("update-dimension-group"), "{text}");
2192        assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
2193    }
2194
2195    #[test]
2196    fn describe_lines_renders_every_lease_refusal() {
2197        for result in [
2198            DimensionGroupResult::RefusedNoLease,
2199            DimensionGroupResult::RefusedLeaseExpired,
2200            DimensionGroupResult::RefusedLeaseWrongFile,
2201            DimensionGroupResult::RefusedLeaseStale,
2202        ] {
2203            let out = outcome_with(add_verb(), Some("Budget"), result);
2204            assert!(!describe(&out).is_empty());
2205        }
2206    }
2207
2208    #[test]
2209    fn describe_lines_renders_changed_with_and_without_a_depth() {
2210        let with_depth = outcome_with(
2211            update_verb(),
2212            Some("Budget"),
2213            DimensionGroupResult::Changed {
2214                summary: "set collapsed=true on the group over ROWS 5:9".to_string(),
2215                depth: Some(1),
2216            },
2217        );
2218        assert!(describe(&with_depth).contains("(depth 1)"));
2219
2220        let without_depth = outcome_with(
2221            add_verb(),
2222            Some("Budget"),
2223            DimensionGroupResult::Changed {
2224                summary: "add a row group over ROWS 5:9".to_string(),
2225                depth: None,
2226            },
2227        );
2228        let text = describe(&without_depth);
2229        assert!(!text.contains("(depth"), "{text}");
2230    }
2231
2232    #[test]
2233    fn describe_lines_renders_failed() {
2234        let out = outcome_with(
2235            add_verb(),
2236            Some("Budget"),
2237            DimensionGroupResult::Failed {
2238                detail: "boom".to_string(),
2239            },
2240        );
2241        assert_eq!(describe(&out), "Failed: boom");
2242    }
2243
2244    #[test]
2245    fn from_lease_failed_maps_to_the_failed_variant() {
2246        assert_eq!(
2247            <DimensionGroupResult as FromLeaseRefusal>::from_lease_failed("boom".to_string()),
2248            DimensionGroupResult::Failed {
2249                detail: "boom".to_string()
2250            }
2251        );
2252    }
2253
2254    #[test]
2255    fn write_jsonl_emits_one_line_of_json() {
2256        let outcome = outcome_with(
2257            add_verb(),
2258            Some("Budget"),
2259            DimensionGroupResult::Changed {
2260                summary: "add a row group over ROWS 5:9".to_string(),
2261                depth: None,
2262            },
2263        );
2264        let mut buf = Vec::new();
2265        outcome.write_jsonl(&mut buf).unwrap();
2266        let text = String::from_utf8(buf).unwrap();
2267        assert_eq!(text.matches('\n').count(), 1);
2268        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
2269        assert_eq!(parsed["result"]["status"], "changed");
2270    }
2271}