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