Skip to main content

omni_dev/drive/sheets/
embedded_object.rs

1//! Charts and slicers via `spreadsheets.batchUpdate` (issue #1797,
2//! [ADR-0081](../../../docs/adrs/adr-0081.md) §3).
3//!
4//! Extended by issue #1837 with `move-chart`/`move-slicer`
5//! (`updateEmbeddedObjectPosition`) and `update-chart-border`
6//! (`updateEmbeddedObjectBorder`).
7//!
8//! One module for both, per the issue's own framing: a slicer is an
9//! embedded object exactly like a chart, and both are removed by the same
10//! `deleteEmbeddedObject` request, addressed by `objectId` alone with no
11//! discriminator naming which kind it is.
12//!
13//! **Gate.** Every mutating verb here — including both deletes — reaches
14//! `DriveOperation::SheetsStructure`. ADR-0081 §3 settles this the same way
15//! `unmerge-cells`/`clear-data-validation` were settled in ADR-0078: a
16//! chart or slicer is a property of the *sheet*, not the sheet's grid
17//! *data*, one level further out than a merge or a validation rule but the
18//! same kind of thing. The unrecoverability is real, so `delete-chart`/
19//! `delete-slicer` read back and report the object's spec — type, title,
20//! anchor position — before ever sending the request, in both `--dry-run`
21//! and the real run's `drivemutation` record (the `discarded_cells`
22//! treatment applied to an object instead of a value). `list-charts`/
23//! `list-slicers` are read-only and ungated, like `list-protections`.
24//!
25//! **Chart subset (v1).** `basicChart` restricted to `chartType` ∈
26//! {COLUMN, BAR, LINE, AREA, SCATTER} and `pieChart` — the issue's own
27//! chosen first cut. Every other chart type (bubble, candlestick, org,
28//! histogram, waterfall, treemap, scorecard, data-source) is a documented
29//! cut, not a silent gap.
30//!
31//! **`update-chart`'s crux: no field mask.** `updateChartSpec` replaces a
32//! chart's entire `ChartSpec`, unlike every other `update*` request in this
33//! crate. `merge_chart_spec` fetches the existing spec, refuses it outright
34//! if it isn't one of the two supported kinds (rather than silently
35//! discarding a histogram's configuration), refuses a basic↔pie switch
36//! (the two unions carry domain/series too differently to convert), and
37//! otherwise applies only the flags the caller actually set — preserving
38//! everything else, including every field this crate doesn't model, via
39//! each spec type's `#[serde(flatten)] extra` map. `update-slicer` is the
40//! opposite case: `updateSlicerSpec` *does* take a field mask, so it only
41//! ever names the fields actually set.
42//!
43//! **`move-chart`/`move-slicer`'s crux: the field mask is rooted at
44//! `overlayPosition`, not `newPosition`.** `updateEmbeddedObjectPosition`'s
45//! own rule is that "the root `newPosition.overlayPosition` is implied and
46//! should not be specified" — the one request in this file whose mask isn't
47//! rooted at the request's own payload field (cf. `update-slicer`'s mask,
48//! rooted at `spec`). `overlay_position_update` builds both the position and
49//! the mask together so the two can't drift apart; a resize-only move (no
50//! `--anchor`) still carries the object's *current* anchor forward on the
51//! wire, since `OverlayPosition.anchor_cell` is a required field there, even
52//! though the mask never names it. A chart on its own sheet has no overlay
53//! position to carry forward, so moving it onto a grid requires `--anchor`;
54//! `--new-sheet` sends no `fields` at all.
55//!
56//! **`update-chart-border` is colour-only.** `EmbeddedObjectBorder` models
57//! `colorStyle` alone — no style, no width — so `--clear` sends an empty
58//! border (`border: {}`) with the same `"colorStyle"` mask a set does.
59//!
60//! **Confirmed live (issue #1929).** A chart that never had its border
61//! touched reads back with no `border` field at all. But `--clear` does
62//! *not* return a chart to that state: it reads back with `border` present
63//! and an all-omitted `colorStyle.rgbColor` — proto3 dropping every
64//! zero-valued channel. Sending an explicit `--color 000000` (pure black)
65//! reads back **byte-for-byte identical** to a clear: `{"colorStyle":
66//! {"rgbColor": {}}}` either way. There is no second field to break the
67//! tie, so "cleared" and "explicit black" are genuinely indistinguishable
68//! from a read alone — [`describe_border_color`] reports that ambiguity
69//! rather than guessing. Whether the Sheets UI actually paints a visible
70//! black border after `--clear` was not checked (this crate cannot inspect
71//! the rendered UI); what's confirmed is only the wire shape.
72//!
73//! **Documented cuts, matching this feature's general stance:**
74//! `COMBO`/`STEPPED_AREA` basic charts (need a per-series `type` this crate
75//! doesn't model), and condition-based slicer filter criteria
76//! (`FilterCriteria` supports `hiddenValues` only, the same cut `filter.rs`
77//! makes).
78//!
79//! Shape mirrors `filter.rs`: compose/validate, resolve against a
80//! freshly-fetched workbook, gate, dry-run, mutate, log.
81
82use std::collections::BTreeMap;
83use std::path::PathBuf;
84use std::time::{Duration, Instant};
85
86use serde::Serialize;
87
88use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
89use crate::drive::client::DriveClient;
90use crate::drive::files_api::FilesApi;
91use crate::drive::lease::check::{
92    conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
93    LeasedWrite,
94};
95use crate::drive::sheets::a1;
96use crate::drive::sheets::api::SheetsApi;
97use crate::drive::sheets::client::SheetsClient;
98use crate::drive::sheets::format::parse_hex_color;
99use crate::drive::sheets::grid_range;
100use crate::drive::sheets::target_gate;
101use crate::drive::sheets::types::{
102    AddChartRequest, AddSlicerRequest, BasicChartAxis, BasicChartDomain, BasicChartSeries,
103    BasicChartSpec, BatchUpdateRequestItem, BatchUpdateResponse, ChartData, ChartSourceRange,
104    ChartSpec, Color, ColorStyle, DeleteEmbeddedObjectRequest, EmbeddedChart, EmbeddedObjectBorder,
105    EmbeddedObjectPosition, FilterCriteria, GridCoordinate, GridRange, OverlayPosition,
106    PieChartSpec, Sheet, Slicer, SlicerSpec, Spreadsheet, UpdateChartSpecRequest,
107    UpdateEmbeddedObjectBorderRequest, UpdateEmbeddedObjectPositionRequest,
108    UpdateSlicerSpecRequest,
109};
110use crate::drive::types::SheetTargetRefusal;
111use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
112use crate::request_log::{self, DriveMutationOutcome};
113
114/// The five `basicChart` `chartType` values this crate builds or accepts an
115/// update onto. `COMBO` and `STEPPED_AREA` are valid wire values Sheets
116/// itself accepts, but neither is offered — see the module docs.
117const SUPPORTED_BASIC_CHART_TYPES: &[&str] = &["COLUMN", "BAR", "LINE", "AREA", "SCATTER"];
118
119/// Which mutation to perform.
120#[derive(Debug, Clone, PartialEq)]
121pub enum EmbeddedObjectVerb {
122    /// Add a chart.
123    AddChart {
124        /// `column`/`bar`/`line`/`area`/`scatter`/`pie`.
125        chart_type: String,
126        /// The domain (category/x-axis) range.
127        domain: String,
128        /// The data series ranges — exactly one for a pie chart, one or
129        /// more for a basic chart.
130        series: Vec<String>,
131        /// Sheet title, supplying the prefix for `domain`/`series`/
132        /// `anchor` when they don't carry their own.
133        sheet: Option<String>,
134        /// The chart's title.
135        title: Option<String>,
136        /// The chart's subtitle.
137        subtitle: Option<String>,
138        /// `bottom`/`top`/`left`/`right`/`none`.
139        legend: Option<String>,
140        /// `none`/`stacked`/`percent` — basic charts only.
141        stacked: Option<String>,
142        /// Leading rows/columns of the source range that are headers —
143        /// basic charts only.
144        header_count: Option<i64>,
145        /// The horizontal (`BOTTOM_AXIS`) axis title — basic charts only.
146        horizontal_axis_title: Option<String>,
147        /// The vertical (`LEFT_AXIS`) axis title — basic charts only.
148        vertical_axis_title: Option<String>,
149        /// `0.0`-`1.0` center-hole radius — pie charts only.
150        pie_hole: Option<f64>,
151        /// The anchor cell, when not `--new-sheet`.
152        anchor: Option<String>,
153        /// Additional horizontal offset from the anchor cell, in pixels.
154        offset_x: Option<i64>,
155        /// Additional vertical offset from the anchor cell, in pixels.
156        offset_y: Option<i64>,
157        /// The chart's width in pixels.
158        width: Option<i64>,
159        /// The chart's height in pixels.
160        height: Option<i64>,
161        /// Put the chart on a brand-new sheet of its own, instead of
162        /// anchoring it to an existing one. Mutually exclusive with
163        /// `anchor`/`offset_x`/`offset_y`/`width`/`height`.
164        new_sheet: bool,
165    },
166    /// Replace an existing chart's spec.
167    UpdateChart {
168        /// Which chart to update, discovered via `list-charts`.
169        chart_id: i64,
170        /// Change the chart's type, when set. Refused if it would switch
171        /// between a basic chart and a pie chart.
172        chart_type: Option<String>,
173        /// Replace the domain range, when set (requires `series` too).
174        domain: Option<String>,
175        /// Replace every series wholesale, when non-empty.
176        series: Vec<String>,
177        /// Sheet title, supplying the prefix for `domain`/`series` when
178        /// they don't carry their own.
179        sheet: Option<String>,
180        /// Change the title, when set.
181        title: Option<String>,
182        /// Change the subtitle, when set.
183        subtitle: Option<String>,
184        /// Change the legend position, when set.
185        legend: Option<String>,
186        /// Change the stacking mode, when set — basic charts only.
187        stacked: Option<String>,
188        /// Change the header row/column count, when set — basic charts
189        /// only.
190        header_count: Option<i64>,
191        /// Change the horizontal axis title, when set — basic charts only.
192        horizontal_axis_title: Option<String>,
193        /// Change the vertical axis title, when set — basic charts only.
194        vertical_axis_title: Option<String>,
195        /// Change the pie-hole radius, when set — pie charts only.
196        pie_hole: Option<f64>,
197    },
198    /// Remove a chart.
199    DeleteChart {
200        /// Which chart to remove, discovered via `list-charts`.
201        chart_id: i64,
202    },
203    /// Add a slicer.
204    AddSlicer {
205        /// Sheet title, supplying the prefix for `range`/`anchor` when
206        /// they don't carry their own.
207        sheet: Option<String>,
208        /// The range the slicer filters.
209        range: String,
210        /// The absolute 0-based sheet column (`0` = column A) the filter
211        /// criteria apply to — not an offset within `range`. Must fall
212        /// inside `range`'s columns.
213        column: i64,
214        /// Values to hide in `column`.
215        hide_values: Vec<String>,
216        /// A human-readable name for the slicer.
217        title: Option<String>,
218        /// Whether the slicer also filters pivot tables built from
219        /// `range`.
220        apply_to_pivot_tables: Option<bool>,
221        /// The anchor cell.
222        anchor: String,
223        /// Additional horizontal offset from the anchor cell, in pixels.
224        offset_x: Option<i64>,
225        /// Additional vertical offset from the anchor cell, in pixels.
226        offset_y: Option<i64>,
227        /// The slicer's width in pixels.
228        width: Option<i64>,
229        /// The slicer's height in pixels.
230        height: Option<i64>,
231    },
232    /// Change an existing slicer's range, filter column/criteria, title,
233    /// or pivot-table linkage.
234    UpdateSlicer {
235        /// Which slicer to change, discovered via `list-slicers`.
236        slicer_id: i64,
237        /// Sheet title, supplying the prefix for `range` when it doesn't
238        /// carry its own.
239        sheet: Option<String>,
240        /// Replace the filtered range, when set.
241        range: Option<String>,
242        /// Replace the filtered column, when set — an absolute 0-based
243        /// sheet column (`0` = column A), which must fall inside the
244        /// slicer's (new or existing) range.
245        column: Option<i64>,
246        /// Replace the hidden-value criteria, when non-empty.
247        hide_values: Vec<String>,
248        /// Reset the filter criteria to empty.
249        clear_criteria: bool,
250        /// Change the title, when set.
251        title: Option<String>,
252        /// Change the pivot-table linkage, when set.
253        apply_to_pivot_tables: Option<bool>,
254    },
255    /// Remove a slicer.
256    DeleteSlicer {
257        /// Which slicer to remove, discovered via `list-slicers`.
258        slicer_id: i64,
259    },
260    /// Move and/or resize an existing chart (issue #1837).
261    MoveChart {
262        /// Which chart to move, discovered via `list-charts`.
263        chart_id: i64,
264        /// Sheet title, supplying the prefix for `anchor` when it doesn't
265        /// carry its own.
266        sheet: Option<String>,
267        /// The new anchor cell, when the chart stays (or becomes) an
268        /// overlay. Required for a chart on its own sheet, which has no
269        /// existing anchor to carry forward.
270        anchor: Option<String>,
271        /// Additional horizontal offset from the anchor cell, in pixels.
272        offset_x: Option<i64>,
273        /// Additional vertical offset from the anchor cell, in pixels.
274        offset_y: Option<i64>,
275        /// The chart's new width in pixels.
276        width: Option<i64>,
277        /// The chart's new height in pixels.
278        height: Option<i64>,
279        /// Move the chart onto a brand-new sheet of its own. Mutually
280        /// exclusive with every other field but `chart_id`.
281        new_sheet: bool,
282    },
283    /// Move and/or resize an existing slicer (issue #1837). No own-sheet
284    /// placement — a slicer can only ever be an overlay.
285    MoveSlicer {
286        /// Which slicer to move, discovered via `list-slicers`.
287        slicer_id: i64,
288        /// Sheet title, supplying the prefix for `anchor` when it doesn't
289        /// carry its own.
290        sheet: Option<String>,
291        /// The new anchor cell, when set.
292        anchor: Option<String>,
293        /// Additional horizontal offset from the anchor cell, in pixels.
294        offset_x: Option<i64>,
295        /// Additional vertical offset from the anchor cell, in pixels.
296        offset_y: Option<i64>,
297        /// The slicer's new width in pixels.
298        width: Option<i64>,
299        /// The slicer's new height in pixels.
300        height: Option<i64>,
301    },
302    /// Set or clear a chart's border colour (issue #1837).
303    UpdateChartBorder {
304        /// Which chart to update, discovered via `list-charts`.
305        chart_id: i64,
306        /// The new border colour, as `#RRGGBB`. Mutually exclusive with
307        /// `clear`.
308        color: Option<String>,
309        /// Remove the chart's border entirely. Mutually exclusive with
310        /// `color`.
311        clear: bool,
312    },
313}
314
315impl EmbeddedObjectVerb {
316    const fn log_operation(&self) -> &'static str {
317        match self {
318            Self::AddChart { .. } => "sheets-add-chart",
319            Self::UpdateChart { .. } => "sheets-update-chart",
320            Self::DeleteChart { .. } => "sheets-delete-chart",
321            Self::AddSlicer { .. } => "sheets-add-slicer",
322            Self::UpdateSlicer { .. } => "sheets-update-slicer",
323            Self::DeleteSlicer { .. } => "sheets-delete-slicer",
324            Self::MoveChart { .. } => "sheets-move-chart",
325            Self::MoveSlicer { .. } => "sheets-move-slicer",
326            Self::UpdateChartBorder { .. } => "sheets-update-chart-border",
327        }
328    }
329
330    const fn label(&self) -> &'static str {
331        match self {
332            Self::AddChart { .. } => "add-chart",
333            Self::UpdateChart { .. } => "update-chart",
334            Self::DeleteChart { .. } => "delete-chart",
335            Self::AddSlicer { .. } => "add-slicer",
336            Self::UpdateSlicer { .. } => "update-slicer",
337            Self::DeleteSlicer { .. } => "delete-slicer",
338            Self::MoveChart { .. } => "move-chart",
339            Self::MoveSlicer { .. } => "move-slicer",
340            Self::UpdateChartBorder { .. } => "update-chart-border",
341        }
342    }
343}
344
345/// Per-call options.
346#[derive(Debug, Clone)]
347pub struct EmbeddedObjectOptions {
348    /// Spreadsheet id.
349    pub spreadsheet_id: String,
350    /// Which mutation to perform.
351    pub verb: EmbeddedObjectVerb,
352    /// Classify and describe only; never call `batchUpdate`.
353    pub dry_run: bool,
354    /// The lease token presented via `--lease`. Checked only when the
355    /// deciding rule requires one
356    /// ([`write_gate::decided_rule_requires_lease`], ADR-0080 §1/§9);
357    /// `None` is only ever valid when it does not.
358    pub lease_token: Option<String>,
359    /// Path to the lease ledger the token is checked against. Production
360    /// callers pass `crate::drive::lease::ledger::ledger_path`'s own
361    /// result; tests pass a path under a `tempdir`.
362    pub ledger_path: PathBuf,
363}
364
365/// A chart or slicer's identifying details, as reported in a preview or a
366/// completed result — the "type, title or id, anchor position" ADR-0081 §3
367/// requires before an unrecoverable delete.
368#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
369pub struct EmbeddedObjectSummary {
370    /// The object's stable id.
371    pub object_id: i64,
372    /// `"chart"` or `"slicer"`.
373    pub kind: String,
374    /// The chart type (`"COLUMN"`, `"PIE"`, …). `None` for a slicer.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub chart_type: Option<String>,
377    /// The object's title, if it has one.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub title: Option<String>,
380    /// A human-readable rendering of where the object is anchored.
381    pub position: String,
382    /// The chart's existing border colour, as `#RRGGBB`. `None` for a
383    /// slicer (no border field on the wire) or a chart with no border set.
384    /// Lets `update-chart-border`'s preview report the colour about to be
385    /// replaced or cleared (issue #1837).
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub border: Option<String>,
388}
389
390/// What happened (or, under `--dry-run`, would happen).
391#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
392#[serde(tag = "status", rename_all = "kebab-case")]
393pub enum EmbeddedObjectResult {
394    /// `--dry-run`, and the gate would allow it. `object` is populated for
395    /// `update-chart`/`update-slicer` (the pre-change state) and for
396    /// `delete-chart`/`delete-slicer` (the ADR-0081 §3 preview).
397    WouldChange {
398        /// A human-readable summary of the effect.
399        summary: String,
400        /// The object as it stands now, for an update or a delete.
401        #[serde(skip_serializing_if = "Option::is_none")]
402        object: Option<Box<EmbeddedObjectSummary>>,
403    },
404    /// The target is not a Google Sheet.
405    RefusedNotASpreadsheet {
406        /// The target's actual MIME type.
407        mime_type: String,
408    },
409    /// The target is a shortcut, which we do not follow.
410    RefusedShortcut,
411    /// The target has no parents this account can see.
412    RefusedNoVisibleParents,
413    /// The named sheet does not exist in this workbook.
414    RefusedSheetNotFound {
415        /// The title that was not found.
416        title: String,
417        /// The titles that do exist.
418        available: Vec<String>,
419    },
420    /// The verb's own arguments were invalid — a bad range/anchor, an
421    /// unsupported flag combination, or nothing to change.
422    RefusedInvalidRange {
423        /// What was wrong and why.
424        detail: String,
425    },
426    /// `update-chart`/`delete-chart` named a `--chart-id`, or
427    /// `update-slicer`/`delete-slicer` a `--slicer-id`, that does not exist
428    /// in this workbook.
429    RefusedObjectNotFound {
430        /// The id that was not found.
431        object_id: i64,
432    },
433    /// A chart-only verb (`update-chart`/`delete-chart`/`move-chart`/
434    /// `update-chart-border`) named an id that exists but belongs to a
435    /// slicer, or a slicer-only verb (`update-slicer`/`delete-slicer`/
436    /// `move-slicer`) named one that belongs to a chart. Distinct from
437    /// [`Self::RefusedObjectNotFound`] — the id is real, just the wrong
438    /// kind of object — so the message can point straight at the `list-*`
439    /// command that would have shown it.
440    RefusedWrongObjectKind {
441        /// The id that was named.
442        object_id: i64,
443        /// `"chart"` or `"slicer"` — what the verb expected.
444        expected: String,
445        /// `"chart"` or `"slicer"` — what the id actually names.
446        found: String,
447    },
448    /// `update-chart` named a chart whose existing spec is not one of the
449    /// two supported kinds (basic or pie), or would switch between them.
450    RefusedUnsupportedChart {
451        /// The chart in question.
452        chart_id: i64,
453        /// The existing (or requested) kind that can't be handled, for the
454        /// error message.
455        detail: String,
456    },
457    /// The folder write-permission gate refused it.
458    Blocked {
459        /// The rule that decided the refusal, if any.
460        decided_by: Option<DecidingRule>,
461    },
462    /// No `--lease` was presented, and the deciding rule requires one
463    /// (ADR-0080 §9).
464    RefusedNoLease,
465    /// The presented lease has expired, or was never a token this ledger
466    /// knows about.
467    RefusedLeaseExpired,
468    /// The presented lease is bound to a different file id.
469    RefusedLeaseWrongFile,
470    /// The file has moved since the lease's recorded `version` — the
471    /// staleness check (ADR-0080 §6).
472    RefusedLeaseStale,
473    /// The mutation succeeded.
474    Changed {
475        /// Same summary as [`Self::WouldChange`].
476        summary: String,
477        /// The object's stable id — server-assigned for `add-chart`/
478        /// `add-slicer`, otherwise the one resolved against.
479        #[serde(skip_serializing_if = "Option::is_none")]
480        object_id: Option<i64>,
481        /// The object as it stood immediately before this change, for an
482        /// update or a delete — same as [`Self::WouldChange`]'s `object`.
483        #[serde(skip_serializing_if = "Option::is_none")]
484        object: Option<Box<EmbeddedObjectSummary>>,
485    },
486    /// An API or validation error.
487    Failed {
488        /// A human-readable summary of what failed.
489        detail: String,
490    },
491}
492
493impl FromLeaseRefusal for EmbeddedObjectResult {
494    fn from_no_lease() -> Self {
495        Self::RefusedNoLease
496    }
497    fn from_lease_expired() -> Self {
498        Self::RefusedLeaseExpired
499    }
500    fn from_lease_wrong_file() -> Self {
501        Self::RefusedLeaseWrongFile
502    }
503    fn from_lease_stale() -> Self {
504        Self::RefusedLeaseStale
505    }
506    fn from_lease_failed(detail: String) -> Self {
507        Self::Failed { detail }
508    }
509}
510
511impl EmbeddedObjectResult {
512    fn log_status(&self) -> &'static str {
513        match self {
514            Self::WouldChange { .. } => "would-change",
515            Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
516            Self::RefusedShortcut => "refused-shortcut",
517            Self::RefusedNoVisibleParents => "refused-no-visible-parents",
518            Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
519            Self::RefusedInvalidRange { .. } => "refused-invalid-range",
520            Self::RefusedObjectNotFound { .. } => "refused-object-not-found",
521            Self::RefusedWrongObjectKind { .. } => "refused-wrong-object-kind",
522            Self::RefusedUnsupportedChart { .. } => "refused-unsupported-chart",
523            Self::Blocked { .. } => "blocked",
524            Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
525            Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
526            Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
527            Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
528            Self::Changed { .. } => "changed",
529            Self::Failed { .. } => "failed",
530        }
531    }
532}
533
534/// The full outcome of one attempt.
535#[derive(Debug, Clone, Serialize, PartialEq)]
536pub struct EmbeddedObjectOutcome {
537    /// The spreadsheet acted on.
538    pub spreadsheet_id: String,
539    /// Its Drive file name, when the metadata fetch got that far.
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub file_name: Option<String>,
542    /// The folder the gate evaluated against.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub resolved_folder_id: Option<String>,
545    /// The sheet the object is (or would be) anchored on — `None` for a
546    /// chart put on its own new sheet, since that sheet doesn't exist yet.
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub sheet_id: Option<i64>,
549    /// Which mutation was attempted. Not serialised.
550    #[serde(skip)]
551    pub verb: EmbeddedObjectVerb,
552    /// What happened.
553    pub result: EmbeddedObjectResult,
554}
555
556impl JsonlSerialize for EmbeddedObjectOutcome {
557    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
558        write_scalar_jsonl(self, out)
559    }
560}
561
562/// Runs one embedded-object mutation, logging every attempt that isn't a
563/// dry run.
564pub async fn embedded_object(
565    drive: &DriveClient,
566    sheets: &SheetsClient,
567    opts: &EmbeddedObjectOptions,
568    rules: &[FolderPermissionRule],
569) -> EmbeddedObjectOutcome {
570    let started = Instant::now();
571    let outcome = embedded_object_inner(drive, sheets, opts, rules).await;
572    if !opts.dry_run {
573        record_attempt(&outcome, opts, started.elapsed());
574    }
575    outcome
576}
577
578async fn embedded_object_inner(
579    drive: &DriveClient,
580    sheets: &SheetsClient,
581    opts: &EmbeddedObjectOptions,
582    rules: &[FolderPermissionRule],
583) -> EmbeddedObjectOutcome {
584    let bare = |result| EmbeddedObjectOutcome {
585        spreadsheet_id: opts.spreadsheet_id.clone(),
586        file_name: None,
587        resolved_folder_id: None,
588        sheet_id: None,
589        verb: opts.verb.clone(),
590        result,
591    };
592
593    if let Err(detail) = validate_verb(&opts.verb) {
594        return bare(EmbeddedObjectResult::RefusedInvalidRange { detail });
595    }
596
597    let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
598        drive,
599        &opts.spreadsheet_id,
600        DriveOperation::SheetsStructure,
601        rules,
602    )
603    .await
604    {
605        target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
606            return bare(EmbeddedObjectResult::Failed { detail })
607        }
608        target_gate::TargetGateOutcome::Refused { target, refusal } => {
609            let result = match refusal {
610                SheetTargetRefusal::Shortcut => EmbeddedObjectResult::RefusedShortcut,
611                SheetTargetRefusal::NotASpreadsheet { mime_type } => {
612                    EmbeddedObjectResult::RefusedNotASpreadsheet { mime_type }
613                }
614                SheetTargetRefusal::NoVisibleParents => {
615                    EmbeddedObjectResult::RefusedNoVisibleParents
616                }
617            };
618            return EmbeddedObjectOutcome {
619                spreadsheet_id: opts.spreadsheet_id.clone(),
620                file_name: Some(target.name),
621                resolved_folder_id: None,
622                sheet_id: None,
623                verb: opts.verb.clone(),
624                result,
625            };
626        }
627        target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
628            return EmbeddedObjectOutcome {
629                spreadsheet_id: opts.spreadsheet_id.clone(),
630                file_name: Some(target.name),
631                resolved_folder_id: None,
632                sheet_id: None,
633                verb: opts.verb.clone(),
634                result: EmbeddedObjectResult::Failed { detail },
635            };
636        }
637        target_gate::TargetGateOutcome::Gated {
638            target,
639            decision,
640            resolved_folder_id,
641            requires_lease,
642        } => (target, decision, resolved_folder_id, requires_lease),
643    };
644
645    let pre_gated = |sheet_id, result| EmbeddedObjectOutcome {
646        spreadsheet_id: opts.spreadsheet_id.clone(),
647        file_name: Some(target.name.clone()),
648        resolved_folder_id: resolved_folder_id.clone(),
649        sheet_id,
650        verb: opts.verb.clone(),
651        result,
652    };
653
654    if decision.verdict == write_gate::Verdict::Deny {
655        return pre_gated(
656            None,
657            EmbeddedObjectResult::Blocked {
658                decided_by: decision.decided_by,
659            },
660        );
661    }
662
663    let api = SheetsApi::new(sheets);
664    let workbook = match api
665        .get_spreadsheet_with_embedded_objects(&opts.spreadsheet_id)
666        .await
667    {
668        Ok(workbook) => workbook,
669        Err(err) => {
670            return pre_gated(
671                None,
672                EmbeddedObjectResult::Failed {
673                    detail: format!("{err:#}"),
674                },
675            )
676        }
677    };
678
679    let plan = match build_plan(&workbook, &opts.verb) {
680        Ok(plan) => plan,
681        Err(result) => return pre_gated(None, result),
682    };
683
684    if opts.dry_run {
685        return pre_gated(
686            plan.sheet_id,
687            EmbeddedObjectResult::WouldChange {
688                summary: plan.summary.clone(),
689                object: plan.before.clone().map(Box::new),
690            },
691        );
692    }
693
694    let gated = |result| pre_gated(plan.sheet_id, result);
695
696    let files_api = FilesApi::new(drive);
697    let leased = LeasedWrite {
698        log_prefix: "drive sheets embedded-object",
699        operation: opts.verb.log_operation(),
700        ledger_path: &opts.ledger_path,
701        file_id: &opts.spreadsheet_id,
702    };
703    let lease_grant = match gate_optional_leased_write(
704        leased,
705        &files_api,
706        requires_lease,
707        opts.lease_token.as_deref(),
708    )
709    .await
710    {
711        Ok(grant) => grant,
712        Err(err) => return gated(err.into_result()),
713    };
714
715    let result = match conclude_native_leased_write(
716        leased,
717        &lease_grant,
718        &files_api,
719        api.batch_update(&opts.spreadsheet_id, vec![plan.request])
720            .await,
721        |err| format!("{err:#}"),
722    )
723    .await
724    {
725        Ok(response) => {
726            let object_id = added_object_id(&response).or(plan.existing_id);
727            EmbeddedObjectResult::Changed {
728                summary: plan.summary,
729                object_id,
730                object: plan.before.map(Box::new),
731            }
732        }
733        Err(err) => EmbeddedObjectResult::Failed {
734            detail: format!("{err:#}"),
735        },
736    };
737    drop(lease_grant);
738    gated(result)
739}
740
741/// Rejects a `--pie-hole` value outside the API's `0.0`-`1.0` range.
742fn validate_pie_hole(pie_hole: Option<f64>) -> Result<(), String> {
743    match pie_hole {
744        Some(hole) if !(0.0..=1.0).contains(&hole) => Err(format!(
745            "--pie-hole must be between 0.0 and 1.0, got {hole}"
746        )),
747        _ => Ok(()),
748    }
749}
750
751/// Rejects a verb whose own arguments are internally inconsistent, cheaply,
752/// before ever fetching the workbook.
753fn validate_verb(verb: &EmbeddedObjectVerb) -> Result<(), String> {
754    match verb {
755        EmbeddedObjectVerb::AddChart {
756            chart_type,
757            series,
758            pie_hole,
759            ..
760        } => {
761            if series.is_empty() {
762                return Err("at least one --series is required".to_string());
763            }
764            // Checked before `validate_pie_hole`'s range check, not after:
765            // `chart_type` is already fully known from user input (unlike
766            // `update-chart`, which only learns the existing chart's kind
767            // after fetching the workbook), so an inapplicable `--pie-hole`
768            // is cheap to catch here rather than letting the range check
769            // mask it — a user fixing an out-of-range value would
770            // otherwise "fix" it only to learn the flag never applied.
771            if pie_hole.is_some() && !matches!(parse_chart_type(chart_type), Ok(ChartKind::Pie)) {
772                return Err("--pie-hole only applies to a pie chart".to_string());
773            }
774            validate_pie_hole(*pie_hole)?;
775            Ok(())
776        }
777        EmbeddedObjectVerb::UpdateChart {
778            chart_type,
779            domain,
780            series,
781            title,
782            subtitle,
783            legend,
784            stacked,
785            header_count,
786            horizontal_axis_title,
787            vertical_axis_title,
788            pie_hole,
789            ..
790        } => {
791            let nothing_to_change = chart_type.is_none()
792                && domain.is_none()
793                && series.is_empty()
794                && title.is_none()
795                && subtitle.is_none()
796                && legend.is_none()
797                && stacked.is_none()
798                && header_count.is_none()
799                && horizontal_axis_title.is_none()
800                && vertical_axis_title.is_none()
801                && pie_hole.is_none();
802            if nothing_to_change {
803                return Err(
804                    "nothing to change: pass --type, --domain, --series, --title, --subtitle, \
805                     --legend, --stacked, --header-count, --horizontal-axis-title, \
806                     --vertical-axis-title, or --pie-hole"
807                        .to_string(),
808                );
809            }
810            // `--domain` and `--series` are deliberately *not* required
811            // together: `merge_basic_chart`/`merge_pie_chart` read the
812            // existing spec and only overwrite the field a flag actually
813            // names, so `--series` alone (e.g. adding a second series to
814            // an existing chart) or `--domain` alone (e.g. the range shifted
815            // after an insert) each fully work on their own. A prior
816            // version of this check required both together on the
817            // mistaken premise that `updateChartSpec` itself couples
818            // them — it doesn't; only the *whole request* has no field
819            // mask (see `UpdateChartSpecRequest`'s doc comment), which is
820            // orthogonal to whether this client-side merge treats the two
821            // fields independently.
822            validate_pie_hole(*pie_hole)?;
823            Ok(())
824        }
825        EmbeddedObjectVerb::UpdateSlicer {
826            range,
827            column,
828            hide_values,
829            clear_criteria,
830            title,
831            apply_to_pivot_tables,
832            ..
833        } => {
834            let nothing_to_change = range.is_none()
835                && column.is_none()
836                && hide_values.is_empty()
837                && !clear_criteria
838                && title.is_none()
839                && apply_to_pivot_tables.is_none();
840            if nothing_to_change {
841                return Err(
842                    "nothing to change: pass --range/--sheet, --column, --hide-values, \
843                     --clear-criteria, --title, or --apply-to-pivot-tables"
844                        .to_string(),
845                );
846            }
847            // Checked here *and* via `conflicts_with` on the CLI leaf's two
848            // flags: the CLI rejection is what a normal `omni-dev` user
849            // sees, but `validate_verb` is the actual gate — every caller,
850            // not just the CLI, funnels through `embedded_object()` — so
851            // the check belongs here regardless, and duplicating it costs
852            // nothing since it's unreachable through the CLI in practice.
853            //
854            // Unlike `filter.rs`'s `update-filter-view`, where
855            // `--clear-sort`/`--clear-criteria` **compose** with
856            // `--sort-by`/`--hide-values` (clear resets to empty, then the
857            // new entries are layered on top — see `build_update`), a
858            // slicer's `FilterCriteria` is a single value, not a per-column
859            // map: `--clear-criteria` and `--hide-values` would both just
860            // overwrite it wholesale, so "both together" has no sensible
861            // combined meaning to compose, unlike the per-column case.
862            if *clear_criteria && !hide_values.is_empty() {
863                return Err("--clear-criteria and --hide-values are mutually exclusive".to_string());
864            }
865            Ok(())
866        }
867        EmbeddedObjectVerb::MoveChart {
868            sheet,
869            anchor,
870            offset_x,
871            offset_y,
872            width,
873            height,
874            new_sheet,
875            ..
876        } => {
877            // Checked *before* `nothing_to_change` below: a `--sheet`-only
878            // call (no `--anchor`, nothing else set either) would otherwise
879            // fall through `nothing_to_change` and report the generic
880            // "nothing to change" error instead of naming the actual
881            // problem. `--sheet` only supplies the prefix `--anchor`
882            // doesn't carry itself; without `--anchor` it has nothing to
883            // prefix and would otherwise be silently ignored. The CLI
884            // leaf's `requires = "anchor"` on `--sheet` already refuses
885            // this, but `validate_verb` is the real gate (see the comment
886            // above the slicer arm's equivalent check).
887            if sheet.is_some() && anchor.is_none() {
888                return Err("--sheet has no effect without --anchor".to_string());
889            }
890            let nothing_to_change = !new_sheet
891                && anchor.is_none()
892                && offset_x.is_none()
893                && offset_y.is_none()
894                && width.is_none()
895                && height.is_none();
896            if nothing_to_change {
897                return Err(
898                    "nothing to change: pass --anchor, --offset-x, --offset-y, --width, \
899                     --height, or --new-sheet"
900                        .to_string(),
901                );
902            }
903            Ok(())
904        }
905        EmbeddedObjectVerb::MoveSlicer {
906            sheet,
907            anchor,
908            offset_x,
909            offset_y,
910            width,
911            height,
912            ..
913        } => {
914            // Checked *before* `nothing_to_change` below, for the same
915            // reason as the chart arm above: a `--sheet`-only call must
916            // name `--sheet`'s own problem, not fall through to the
917            // generic "nothing to change" error. Checked here *and* via
918            // `requires = "anchor"` on the CLI leaf's `--sheet` flag, for
919            // the same reason noted throughout this function:
920            // `validate_verb` is the actual gate every caller funnels
921            // through, not just the CLI.
922            if sheet.is_some() && anchor.is_none() {
923                return Err("--sheet has no effect without --anchor".to_string());
924            }
925            let nothing_to_change = anchor.is_none()
926                && offset_x.is_none()
927                && offset_y.is_none()
928                && width.is_none()
929                && height.is_none();
930            if nothing_to_change {
931                return Err(
932                    "nothing to change: pass --anchor, --offset-x, --offset-y, --width, or \
933                     --height"
934                        .to_string(),
935                );
936            }
937            Ok(())
938        }
939        EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } => match (color, clear) {
940            (None, false) => Err("nothing to change: pass --color or --clear".to_string()),
941            (Some(_), true) => Err("--color and --clear are mutually exclusive".to_string()),
942            _ => Ok(()),
943        },
944        EmbeddedObjectVerb::DeleteChart { .. }
945        | EmbeddedObjectVerb::DeleteSlicer { .. }
946        | EmbeddedObjectVerb::AddSlicer { .. } => Ok(()),
947    }
948    .map_err(|detail: String| detail)
949    .and(match verb {
950        EmbeddedObjectVerb::AddChart {
951            anchor,
952            new_sheet,
953            offset_x,
954            offset_y,
955            width,
956            height,
957            ..
958        } => {
959            if *new_sheet {
960                if anchor.is_some()
961                    || offset_x.is_some()
962                    || offset_y.is_some()
963                    || width.is_some()
964                    || height.is_some()
965                {
966                    Err(
967                        "--new-sheet cannot be combined with --anchor/--offset-x/--offset-y/\
968                         --width/--height"
969                            .to_string(),
970                    )
971                } else {
972                    Ok(())
973                }
974            } else if anchor.is_none() {
975                Err("either --anchor or --new-sheet is required".to_string())
976            } else {
977                Ok(())
978            }
979        }
980        // Mirrors `AddChart`'s own `--new-sheet` conflict check above,
981        // verbatim except that `move-chart` has no unconditional "an anchor
982        // is required" branch: leaving every placement flag unset is
983        // already refused above (`nothing_to_change`), and a resize-only
984        // move (no `--anchor`, no `--new-sheet`) is valid — the existing
985        // anchor is carried forward by `overlay_position_update`.
986        EmbeddedObjectVerb::MoveChart {
987            anchor,
988            offset_x,
989            offset_y,
990            width,
991            height,
992            new_sheet,
993            ..
994        } if *new_sheet => {
995            if anchor.is_some()
996                || offset_x.is_some()
997                || offset_y.is_some()
998                || width.is_some()
999                || height.is_some()
1000            {
1001                Err(
1002                    "--new-sheet cannot be combined with --anchor/--offset-x/--offset-y/\
1003                     --width/--height"
1004                        .to_string(),
1005                )
1006            } else {
1007                Ok(())
1008            }
1009        }
1010        _ => Ok(()),
1011    })
1012}
1013
1014/// A fully-resolved request, computed once and reused for both the
1015/// `--dry-run` report and the real mutation.
1016struct Plan {
1017    request: BatchUpdateRequestItem,
1018    summary: String,
1019    sheet_id: Option<i64>,
1020    before: Option<EmbeddedObjectSummary>,
1021    existing_id: Option<i64>,
1022}
1023
1024fn build_plan(
1025    workbook: &Spreadsheet,
1026    verb: &EmbeddedObjectVerb,
1027) -> Result<Plan, EmbeddedObjectResult> {
1028    match verb {
1029        EmbeddedObjectVerb::AddChart { .. } => build_add_chart(workbook, verb),
1030        EmbeddedObjectVerb::UpdateChart { chart_id, .. } => {
1031            build_update_chart(workbook, verb, *chart_id)
1032        }
1033        EmbeddedObjectVerb::DeleteChart { chart_id } => build_delete_chart(workbook, *chart_id),
1034        EmbeddedObjectVerb::AddSlicer { .. } => build_add_slicer(workbook, verb),
1035        EmbeddedObjectVerb::UpdateSlicer { slicer_id, .. } => {
1036            build_update_slicer(workbook, verb, *slicer_id)
1037        }
1038        EmbeddedObjectVerb::DeleteSlicer { slicer_id } => build_delete_slicer(workbook, *slicer_id),
1039        EmbeddedObjectVerb::MoveChart { chart_id, .. } => {
1040            build_move_chart(workbook, verb, *chart_id)
1041        }
1042        EmbeddedObjectVerb::MoveSlicer { slicer_id, .. } => {
1043            build_move_slicer(workbook, verb, *slicer_id)
1044        }
1045        EmbeddedObjectVerb::UpdateChartBorder { chart_id, .. } => {
1046            build_update_chart_border(workbook, verb, *chart_id)
1047        }
1048    }
1049}
1050
1051// ── Shared range/position resolution ────────────────────────────────────
1052
1053fn invalid(detail: impl Into<String>) -> EmbeddedObjectResult {
1054    EmbeddedObjectResult::RefusedInvalidRange {
1055        detail: detail.into(),
1056    }
1057}
1058
1059fn compose_and_resolve(
1060    workbook: &Spreadsheet,
1061    sheet: Option<&str>,
1062    range: &str,
1063) -> Result<GridRange, EmbeddedObjectResult> {
1064    let composed = a1::compose(sheet.filter(|s| !s.is_empty()), Some(range))
1065        .map_err(|err| invalid(err.to_string()))?;
1066    let (_, grid) =
1067        grid_range::resolve_grid_range(workbook, &composed, invalid, |title, available| {
1068            EmbeddedObjectResult::RefusedSheetNotFound { title, available }
1069        })?;
1070    Ok(grid)
1071}
1072
1073fn resolve_anchor(
1074    workbook: &Spreadsheet,
1075    sheet: Option<&str>,
1076    anchor: &str,
1077) -> Result<GridCoordinate, EmbeddedObjectResult> {
1078    let grid = compose_and_resolve(workbook, sheet, anchor)?;
1079    let (Some(start_row), Some(end_row), Some(start_col), Some(end_col)) = (
1080        grid.start_row_index,
1081        grid.end_row_index,
1082        grid.start_column_index,
1083        grid.end_column_index,
1084    ) else {
1085        return Err(invalid(format!(
1086            "'{anchor}' must name a single cell, not an open-ended range"
1087        )));
1088    };
1089    if end_row - start_row != 1 || end_col - start_col != 1 {
1090        return Err(invalid(format!(
1091            "'{anchor}' must name a single cell, not a range"
1092        )));
1093    }
1094    Ok(GridCoordinate {
1095        sheet_id: grid.sheet_id,
1096        row_index: start_row,
1097        column_index: start_col,
1098    })
1099}
1100
1101fn overlay_position(
1102    workbook: &Spreadsheet,
1103    sheet: Option<&str>,
1104    anchor: &str,
1105    offset_x: Option<i64>,
1106    offset_y: Option<i64>,
1107    width: Option<i64>,
1108    height: Option<i64>,
1109) -> Result<(EmbeddedObjectPosition, i64), EmbeddedObjectResult> {
1110    let anchor_cell = resolve_anchor(workbook, sheet, anchor)?;
1111    let sheet_id = anchor_cell.sheet_id;
1112    Ok((
1113        EmbeddedObjectPosition {
1114            overlay_position: Some(OverlayPosition {
1115                anchor_cell,
1116                offset_x_pixels: offset_x,
1117                offset_y_pixels: offset_y,
1118                width_pixels: width,
1119                height_pixels: height,
1120            }),
1121            ..Default::default()
1122        },
1123        sheet_id,
1124    ))
1125}
1126
1127fn chart_data(range: GridRange) -> ChartData {
1128    ChartData {
1129        source_range: ChartSourceRange {
1130            sources: vec![range],
1131        },
1132        extra: BTreeMap::new(),
1133    }
1134}
1135
1136fn parse_legend(raw: &str) -> Result<String, String> {
1137    match raw.to_ascii_lowercase().as_str() {
1138        "bottom" => Ok("BOTTOM_LEGEND".to_string()),
1139        "top" => Ok("TOP_LEGEND".to_string()),
1140        "left" => Ok("LEFT_LEGEND".to_string()),
1141        "right" => Ok("RIGHT_LEGEND".to_string()),
1142        "none" => Ok("NO_LEGEND".to_string()),
1143        other => Err(format!(
1144            "'{other}' is not a supported legend position; expected one of bottom, top, left, \
1145             right, none"
1146        )),
1147    }
1148}
1149
1150fn parse_stacked(raw: &str) -> Result<String, String> {
1151    match raw.to_ascii_lowercase().as_str() {
1152        "none" => Ok("NOT_STACKED".to_string()),
1153        "stacked" => Ok("STACKED".to_string()),
1154        "percent" => Ok("PERCENT_STACKED".to_string()),
1155        other => Err(format!(
1156            "'{other}' is not a supported stacking mode; expected one of none, stacked, percent"
1157        )),
1158    }
1159}
1160
1161/// Which chart family a `--type` value names.
1162#[derive(Debug)]
1163enum ChartKind {
1164    /// A `basicChart`, carrying its wire `chartType` literal.
1165    Basic(&'static str),
1166    /// A `pieChart`.
1167    Pie,
1168}
1169
1170fn parse_chart_type(raw: &str) -> Result<ChartKind, String> {
1171    match raw.to_ascii_lowercase().as_str() {
1172        "column" => Ok(ChartKind::Basic("COLUMN")),
1173        "bar" => Ok(ChartKind::Basic("BAR")),
1174        "line" => Ok(ChartKind::Basic("LINE")),
1175        "area" => Ok(ChartKind::Basic("AREA")),
1176        "scatter" => Ok(ChartKind::Basic("SCATTER")),
1177        "pie" => Ok(ChartKind::Pie),
1178        other => Err(format!(
1179            "'{other}' is not a supported chart type; expected one of column, bar, line, area, \
1180             scatter, pie"
1181        )),
1182    }
1183}
1184
1185fn axis_list(horizontal: Option<&str>, vertical: Option<&str>) -> Vec<BasicChartAxis> {
1186    let mut axis = Vec::new();
1187    if let Some(title) = horizontal {
1188        axis.push(BasicChartAxis {
1189            position: "BOTTOM_AXIS".to_string(),
1190            title: Some(title.to_string()),
1191            extra: BTreeMap::new(),
1192        });
1193    }
1194    if let Some(title) = vertical {
1195        axis.push(BasicChartAxis {
1196            position: "LEFT_AXIS".to_string(),
1197            title: Some(title.to_string()),
1198            extra: BTreeMap::new(),
1199        });
1200    }
1201    axis
1202}
1203
1204/// Upserts one axis title by physical position, leaving every other axis
1205/// entry (and every field an axis carries in `extra`) untouched.
1206fn upsert_axis_title(axis: &mut Vec<BasicChartAxis>, position: &str, title: &str) {
1207    if let Some(existing) = axis.iter_mut().find(|a| a.position == position) {
1208        existing.title = Some(title.to_string());
1209    } else {
1210        axis.push(BasicChartAxis {
1211            position: position.to_string(),
1212            title: Some(title.to_string()),
1213            extra: BTreeMap::new(),
1214        });
1215    }
1216}
1217
1218// ── add-chart ────────────────────────────────────────────────────────────
1219
1220fn build_add_chart(
1221    workbook: &Spreadsheet,
1222    verb: &EmbeddedObjectVerb,
1223) -> Result<Plan, EmbeddedObjectResult> {
1224    let EmbeddedObjectVerb::AddChart {
1225        chart_type,
1226        domain,
1227        series,
1228        sheet,
1229        title,
1230        subtitle,
1231        legend,
1232        stacked,
1233        header_count,
1234        horizontal_axis_title,
1235        vertical_axis_title,
1236        pie_hole,
1237        anchor,
1238        offset_x,
1239        offset_y,
1240        width,
1241        height,
1242        new_sheet,
1243    } = verb
1244    else {
1245        unreachable!("build_add_chart is only ever called for AddChart") // omni-dev: coverage ignore-line reason="build_plan only calls build_add_chart after matching verb as EmbeddedObjectVerb::AddChart; this else-arm exists only to destructure the already-known variant"
1246    };
1247
1248    let kind = parse_chart_type(chart_type).map_err(invalid)?;
1249    let domain_range = compose_and_resolve(workbook, sheet.as_deref(), domain)?;
1250    let series_ranges = series
1251        .iter()
1252        .map(|s| compose_and_resolve(workbook, sheet.as_deref(), s))
1253        .collect::<Result<Vec<_>, _>>()?;
1254    let legend = legend
1255        .as_deref()
1256        .map(parse_legend)
1257        .transpose()
1258        .map_err(invalid)?;
1259
1260    let (position, position_sheet_id) = if *new_sheet {
1261        (
1262            EmbeddedObjectPosition {
1263                new_sheet: Some(true),
1264                ..Default::default()
1265            },
1266            None,
1267        )
1268    } else {
1269        let Some(anchor) = anchor.as_deref() else {
1270            return Err(invalid("either --anchor or --new-sheet is required"));
1271        };
1272        let (position, sheet_id) = overlay_position(
1273            workbook,
1274            sheet.as_deref(),
1275            anchor,
1276            *offset_x,
1277            *offset_y,
1278            *width,
1279            *height,
1280        )?;
1281        (position, Some(sheet_id))
1282    };
1283
1284    let spec = match kind {
1285        ChartKind::Basic(chart_type) => {
1286            if pie_hole.is_some() {
1287                return Err(invalid("--pie-hole only applies to a pie chart"));
1288            }
1289            let stacked = stacked
1290                .as_deref()
1291                .map(parse_stacked)
1292                .transpose()
1293                .map_err(invalid)?;
1294            ChartSpec {
1295                title: title.clone(),
1296                subtitle: subtitle.clone(),
1297                basic_chart: Some(BasicChartSpec {
1298                    chart_type: chart_type.to_string(),
1299                    legend_position: legend,
1300                    stacked_type: stacked,
1301                    header_count: *header_count,
1302                    axis: axis_list(
1303                        horizontal_axis_title.as_deref(),
1304                        vertical_axis_title.as_deref(),
1305                    ),
1306                    domains: vec![BasicChartDomain {
1307                        domain: chart_data(domain_range),
1308                        extra: BTreeMap::new(),
1309                    }],
1310                    series: series_ranges
1311                        .into_iter()
1312                        .map(|r| BasicChartSeries {
1313                            series: chart_data(r),
1314                            target_axis: None,
1315                            extra: BTreeMap::new(),
1316                        })
1317                        .collect(),
1318                    extra: BTreeMap::new(),
1319                }),
1320                pie_chart: None,
1321                extra: BTreeMap::new(),
1322            }
1323        }
1324        ChartKind::Pie => {
1325            if header_count.is_some() {
1326                return Err(invalid(
1327                    "--header-count only applies to a column/bar/line/area/\
1328                    scatter chart",
1329                ));
1330            }
1331            if stacked.is_some() {
1332                return Err(invalid(
1333                    "--stacked only applies to a column/bar/line/area/scatter \
1334                    chart",
1335                ));
1336            }
1337            if horizontal_axis_title.is_some() || vertical_axis_title.is_some() {
1338                return Err(invalid(
1339                    "--horizontal-axis-title/--vertical-axis-title only apply \
1340                    to a column/bar/line/area/scatter chart",
1341                ));
1342            }
1343            if series_ranges.len() != 1 {
1344                return Err(invalid("a pie chart takes exactly one --series"));
1345            }
1346            let mut series_ranges = series_ranges;
1347            let Some(pie_series_range) = series_ranges.pop() else {
1348                unreachable!("checked series_ranges.len() == 1 above") // omni-dev: coverage ignore-line reason="the series_ranges.len() != 1 check immediately above has already returned, so the pop always yields Some; this else-arm exists only to unwrap it"
1349            };
1350            ChartSpec {
1351                title: title.clone(),
1352                subtitle: subtitle.clone(),
1353                basic_chart: None,
1354                pie_chart: Some(PieChartSpec {
1355                    domain: chart_data(domain_range),
1356                    series: chart_data(pie_series_range),
1357                    legend_position: legend,
1358                    pie_hole: *pie_hole,
1359                    extra: BTreeMap::new(),
1360                }),
1361                extra: BTreeMap::new(),
1362            }
1363        }
1364    };
1365
1366    let summary = format!(
1367        "add {} chart{}",
1368        chart_type.to_ascii_lowercase(),
1369        title
1370            .as_deref()
1371            .map_or_else(String::new, |t| format!(" '{t}'"))
1372    );
1373
1374    Ok(Plan {
1375        request: BatchUpdateRequestItem::AddChart(AddChartRequest {
1376            chart: EmbeddedChart {
1377                chart_id: None,
1378                spec: Some(spec),
1379                position: Some(position),
1380                border: None,
1381            },
1382        }),
1383        summary,
1384        sheet_id: position_sheet_id,
1385        before: None,
1386        existing_id: None,
1387    })
1388}
1389
1390// ── update-chart ─────────────────────────────────────────────────────────
1391
1392fn find_chart(workbook: &Spreadsheet, chart_id: i64) -> Option<(&Sheet, &EmbeddedChart)> {
1393    workbook.sheets.iter().find_map(|sheet| {
1394        sheet
1395            .charts
1396            .iter()
1397            .find(|c| c.chart_id == Some(chart_id))
1398            .map(|c| (sheet, c))
1399    })
1400}
1401
1402fn find_slicer(workbook: &Spreadsheet, slicer_id: i64) -> Option<(&Sheet, &Slicer)> {
1403    workbook.sheets.iter().find_map(|sheet| {
1404        sheet
1405            .slicers
1406            .iter()
1407            .find(|s| s.slicer_id == Some(slicer_id))
1408            .map(|s| (sheet, s))
1409    })
1410}
1411
1412/// Resolves `chart_id` against every chart-only verb's id lookup: found as a
1413/// chart it resolves normally, found instead as a slicer it refuses with
1414/// [`EmbeddedObjectResult::RefusedWrongObjectKind`] rather than the
1415/// misleading [`EmbeddedObjectResult::RefusedObjectNotFound`], and found as
1416/// neither it falls back to that not-found refusal.
1417fn find_chart_or_refuse(
1418    workbook: &Spreadsheet,
1419    chart_id: i64,
1420) -> Result<(&Sheet, &EmbeddedChart), EmbeddedObjectResult> {
1421    if let Some(found) = find_chart(workbook, chart_id) {
1422        return Ok(found);
1423    }
1424    if find_slicer(workbook, chart_id).is_some() {
1425        return Err(EmbeddedObjectResult::RefusedWrongObjectKind {
1426            object_id: chart_id,
1427            expected: "chart".to_string(),
1428            found: "slicer".to_string(),
1429        });
1430    }
1431    Err(EmbeddedObjectResult::RefusedObjectNotFound {
1432        object_id: chart_id,
1433    })
1434}
1435
1436/// The slicer-only mirror of [`find_chart_or_refuse`].
1437fn find_slicer_or_refuse(
1438    workbook: &Spreadsheet,
1439    slicer_id: i64,
1440) -> Result<(&Sheet, &Slicer), EmbeddedObjectResult> {
1441    if let Some(found) = find_slicer(workbook, slicer_id) {
1442        return Ok(found);
1443    }
1444    if find_chart(workbook, slicer_id).is_some() {
1445        return Err(EmbeddedObjectResult::RefusedWrongObjectKind {
1446            object_id: slicer_id,
1447            expected: "slicer".to_string(),
1448            found: "chart".to_string(),
1449        });
1450    }
1451    Err(EmbeddedObjectResult::RefusedObjectNotFound {
1452        object_id: slicer_id,
1453    })
1454}
1455
1456fn describe_overlay_position(sheet: &Sheet, overlay: &OverlayPosition) -> String {
1457    format!(
1458        "{}!{}{}",
1459        sheet.title(),
1460        grid_range::column_index_to_letters(overlay.anchor_cell.column_index),
1461        overlay.anchor_cell.row_index + 1
1462    )
1463}
1464
1465fn describe_position(sheet: &Sheet, position: Option<&EmbeddedObjectPosition>) -> String {
1466    match position {
1467        Some(p) if p.new_sheet == Some(true) || p.sheet_id.is_some() => p.sheet_id.map_or_else(
1468            || "own sheet".to_string(),
1469            |id| format!("own sheet (id {id})"),
1470        ),
1471        Some(p) => p.overlay_position.as_ref().map_or_else(
1472            || "unknown position".to_string(),
1473            |o| describe_overlay_position(sheet, o),
1474        ),
1475        None => "unknown position".to_string(),
1476    }
1477}
1478
1479/// Renders a chart border's colour for display.
1480///
1481/// Confirmed live against issue #1929: `update-chart-border --clear` and an
1482/// explicit `--color 000000` (pure black) read back as the **exact same**
1483/// wire shape — `border: {"colorStyle": {"rgbColor": {}}}`, proto3 omitting
1484/// every zero-valued channel either way. A chart whose border was never
1485/// touched at all comes back with no `border` field whatsoever, so that
1486/// case stays distinguishable (see [`summarise_chart`]) — but once a
1487/// `border` is present, an all-zero `rgbColor` is genuinely ambiguous
1488/// between "explicitly cleared" and "explicitly set to black", with no
1489/// second field to break the tie. Reporting a bare `#000000` here would
1490/// present a guess as a fact, so black is called out as ambiguous instead.
1491/// A completely empty `colorStyle` (neither arm set — issue #2020 made this
1492/// parse instead of failing) is the same ambiguity by construction: a
1493/// `oneof` with nothing set is indistinguishable, on the wire, from an
1494/// omitted-by-proto3 all-zero `rgbColor`, so it gets the same message
1495/// rather than `ColorStyle::describe`'s generic `(unset)`. A `themeColor`
1496/// border has no such ambiguity — it renders by name via
1497/// [`ColorStyle::describe`].
1498fn describe_border_color(color_style: &ColorStyle) -> String {
1499    let ambiguous = match color_style.rgb_color {
1500        Some(rgb) => rgb == Color::default(),
1501        None => color_style.theme_color.is_none(),
1502    };
1503    if ambiguous {
1504        "#000000 (or cleared — indistinguishable on read)".to_string()
1505    } else {
1506        color_style.describe()
1507    }
1508}
1509
1510fn summarise_chart(sheet: &Sheet, chart: &EmbeddedChart) -> Option<EmbeddedObjectSummary> {
1511    let object_id = chart.chart_id?;
1512    let spec = chart.spec.as_ref();
1513    let chart_type = spec.and_then(|s| {
1514        if let Some(basic) = &s.basic_chart {
1515            Some(basic.chart_type.clone())
1516        } else if s.pie_chart.is_some() {
1517            Some("PIE".to_string())
1518        } else {
1519            None
1520        }
1521    });
1522    Some(EmbeddedObjectSummary {
1523        object_id,
1524        kind: "chart".to_string(),
1525        chart_type,
1526        title: spec.and_then(|s| s.title.clone()),
1527        position: describe_position(sheet, chart.position.as_ref()),
1528        border: chart
1529            .border
1530            .as_ref()
1531            .and_then(|b| b.color_style.as_ref())
1532            .map(describe_border_color),
1533    })
1534}
1535
1536fn summarise_slicer(sheet: &Sheet, slicer: &Slicer) -> Option<EmbeddedObjectSummary> {
1537    let object_id = slicer.slicer_id?;
1538    Some(EmbeddedObjectSummary {
1539        object_id,
1540        kind: "slicer".to_string(),
1541        chart_type: None,
1542        title: slicer.spec.as_ref().and_then(|s| s.title.clone()),
1543        position: describe_position(sheet, slicer.position.as_ref()),
1544        border: None,
1545    })
1546}
1547
1548/// The two chart kinds `merge_chart_spec` accepts as the *existing* state
1549/// of a chart being updated.
1550#[derive(Debug)]
1551enum ExistingChartKind {
1552    Basic,
1553    Pie,
1554}
1555
1556fn existing_chart_kind(spec: &ChartSpec) -> Result<ExistingChartKind, String> {
1557    if let Some(basic) = &spec.basic_chart {
1558        if SUPPORTED_BASIC_CHART_TYPES.contains(&basic.chart_type.as_str()) {
1559            Ok(ExistingChartKind::Basic)
1560        } else {
1561            Err(basic.chart_type.clone())
1562        }
1563    } else if spec.pie_chart.is_some() {
1564        Ok(ExistingChartKind::Pie)
1565    } else {
1566        Err(spec
1567            .extra
1568            .keys()
1569            .next()
1570            .cloned()
1571            .unwrap_or_else(|| "unknown".to_string()))
1572    }
1573}
1574
1575fn merge_chart_spec(
1576    workbook: &Spreadsheet,
1577    existing: &ChartSpec,
1578    verb: &EmbeddedObjectVerb,
1579) -> Result<(ChartSpec, String), EmbeddedObjectResult> {
1580    let EmbeddedObjectVerb::UpdateChart {
1581        chart_id,
1582        chart_type,
1583        title,
1584        subtitle,
1585        legend,
1586        ..
1587    } = verb
1588    else {
1589        unreachable!("merge_chart_spec is only ever called for UpdateChart") // omni-dev: coverage ignore-line reason="merge_chart_spec is only ever called from build_update_chart, which build_plan reaches only after matching verb as EmbeddedObjectVerb::UpdateChart; this else-arm exists only to destructure the already-known variant"
1590    };
1591
1592    let existing_kind = existing_chart_kind(existing).map_err(|detail| {
1593        EmbeddedObjectResult::RefusedUnsupportedChart {
1594            chart_id: *chart_id,
1595            detail: format!("chart's existing spec is {detail}, not a supported basic/pie chart"),
1596        }
1597    })?;
1598
1599    let requested_kind = chart_type
1600        .as_deref()
1601        .map(parse_chart_type)
1602        .transpose()
1603        .map_err(invalid)?;
1604
1605    let target_is_pie = match &requested_kind {
1606        Some(ChartKind::Pie) => true,
1607        Some(ChartKind::Basic(_)) => false,
1608        None => matches!(existing_kind, ExistingChartKind::Pie),
1609    };
1610    if target_is_pie != matches!(existing_kind, ExistingChartKind::Pie) {
1611        return Err(EmbeddedObjectResult::RefusedUnsupportedChart {
1612            chart_id: *chart_id,
1613            detail: "switching between a basic chart and a pie chart is not supported; delete \
1614                     and re-add"
1615                .to_string(),
1616        });
1617    }
1618
1619    let mut changed = Vec::new();
1620    if title.is_some() {
1621        changed.push("title");
1622    }
1623    if subtitle.is_some() {
1624        changed.push("subtitle");
1625    }
1626    if legend.is_some() {
1627        changed.push("legend");
1628    }
1629
1630    let mut spec = existing.clone();
1631    if let Some(title) = title {
1632        spec.title = Some(title.clone());
1633    }
1634    if let Some(subtitle) = subtitle {
1635        spec.subtitle = Some(subtitle.clone());
1636    }
1637
1638    if target_is_pie {
1639        merge_pie_chart(workbook, &mut spec, verb, &mut changed)?;
1640    } else {
1641        merge_basic_chart(workbook, &mut spec, verb, requested_kind, &mut changed)?;
1642    }
1643
1644    let summary = format!("update chart {chart_id} ({})", changed.join(", "));
1645    Ok((spec, summary))
1646}
1647
1648/// The `target_is_pie` branch of [`merge_chart_spec`]: merges every
1649/// pie-only flag onto `spec.pie_chart`, refusing any basic-only flag.
1650fn merge_pie_chart(
1651    workbook: &Spreadsheet,
1652    spec: &mut ChartSpec,
1653    verb: &EmbeddedObjectVerb,
1654    changed: &mut Vec<&'static str>,
1655) -> Result<(), EmbeddedObjectResult> {
1656    let EmbeddedObjectVerb::UpdateChart {
1657        domain,
1658        series,
1659        sheet,
1660        legend,
1661        stacked,
1662        header_count,
1663        horizontal_axis_title,
1664        vertical_axis_title,
1665        pie_hole,
1666        ..
1667    } = verb
1668    else {
1669        unreachable!("merge_pie_chart is only ever called for UpdateChart") // omni-dev: coverage ignore-line reason="merge_pie_chart is only ever called from merge_chart_spec, which has already destructured the same verb as EmbeddedObjectVerb::UpdateChart; this else-arm exists only to destructure the already-known variant"
1670    };
1671
1672    if header_count.is_some() {
1673        return Err(invalid(
1674            "--header-count only applies to a column/bar/line/area/scatter chart",
1675        ));
1676    }
1677    if stacked.is_some() {
1678        return Err(invalid(
1679            "--stacked only applies to a column/bar/line/area/scatter chart",
1680        ));
1681    }
1682    if horizontal_axis_title.is_some() || vertical_axis_title.is_some() {
1683        return Err(invalid(
1684            "--horizontal-axis-title/--vertical-axis-title only apply to a \
1685             column/bar/line/area/scatter chart",
1686        ));
1687    }
1688
1689    let mut pie = spec.pie_chart.clone().unwrap_or_default();
1690    if let Some(domain) = domain {
1691        pie.domain = chart_data(compose_and_resolve(workbook, sheet.as_deref(), domain)?);
1692        changed.push("domain");
1693    }
1694    if !series.is_empty() {
1695        let [only_series] = series.as_slice() else {
1696            return Err(invalid("a pie chart takes exactly one --series"));
1697        };
1698        let range = compose_and_resolve(workbook, sheet.as_deref(), only_series)?;
1699        pie.series = chart_data(range);
1700        changed.push("series");
1701    }
1702    if let Some(legend) = legend
1703        .as_deref()
1704        .map(parse_legend)
1705        .transpose()
1706        .map_err(invalid)?
1707    {
1708        pie.legend_position = Some(legend);
1709    }
1710    if let Some(pie_hole) = pie_hole {
1711        pie.pie_hole = Some(*pie_hole);
1712        changed.push("pie-hole");
1713    }
1714    spec.pie_chart = Some(pie);
1715    spec.basic_chart = None;
1716    Ok(())
1717}
1718
1719/// The `!target_is_pie` branch of [`merge_chart_spec`]: merges every
1720/// basic-chart flag onto `spec.basic_chart`. `requested_kind` is passed in
1721/// (rather than re-parsed) since [`merge_chart_spec`] already needed it to
1722/// decide `target_is_pie`.
1723fn merge_basic_chart(
1724    workbook: &Spreadsheet,
1725    spec: &mut ChartSpec,
1726    verb: &EmbeddedObjectVerb,
1727    requested_kind: Option<ChartKind>,
1728    changed: &mut Vec<&'static str>,
1729) -> Result<(), EmbeddedObjectResult> {
1730    let EmbeddedObjectVerb::UpdateChart {
1731        domain,
1732        series,
1733        sheet,
1734        legend,
1735        stacked,
1736        header_count,
1737        horizontal_axis_title,
1738        vertical_axis_title,
1739        pie_hole,
1740        ..
1741    } = verb
1742    else {
1743        unreachable!("merge_basic_chart is only ever called for UpdateChart") // omni-dev: coverage ignore-line reason="merge_basic_chart is only ever called from merge_chart_spec, which has already destructured the same verb as EmbeddedObjectVerb::UpdateChart; this else-arm exists only to destructure the already-known variant"
1744    };
1745
1746    // The mirror image of `merge_pie_chart`'s basic-only rejections below:
1747    // `--pie-hole` has no meaning on a basic chart, and silently dropping
1748    // it would report `Changed` while the flag did nothing.
1749    if pie_hole.is_some() {
1750        return Err(invalid("--pie-hole only applies to a pie chart"));
1751    }
1752
1753    let mut basic = spec.basic_chart.clone().unwrap_or_default();
1754    if let Some(ChartKind::Basic(chart_type)) = requested_kind {
1755        basic.chart_type = chart_type.to_string();
1756        changed.push("type");
1757    }
1758    if let Some(domain) = domain {
1759        basic.domains = vec![BasicChartDomain {
1760            domain: chart_data(compose_and_resolve(workbook, sheet.as_deref(), domain)?),
1761            extra: BTreeMap::new(),
1762        }];
1763        changed.push("domain");
1764    }
1765    if !series.is_empty() {
1766        basic.series = series
1767            .iter()
1768            .map(|s| {
1769                compose_and_resolve(workbook, sheet.as_deref(), s).map(|range| BasicChartSeries {
1770                    series: chart_data(range),
1771                    target_axis: None,
1772                    extra: BTreeMap::new(),
1773                })
1774            })
1775            .collect::<Result<Vec<_>, _>>()?;
1776        changed.push("series");
1777    }
1778    if let Some(legend) = legend
1779        .as_deref()
1780        .map(parse_legend)
1781        .transpose()
1782        .map_err(invalid)?
1783    {
1784        basic.legend_position = Some(legend);
1785    }
1786    if let Some(stacked) = stacked
1787        .as_deref()
1788        .map(parse_stacked)
1789        .transpose()
1790        .map_err(invalid)?
1791    {
1792        basic.stacked_type = Some(stacked);
1793        changed.push("stacked");
1794    }
1795    if let Some(header_count) = header_count {
1796        basic.header_count = Some(*header_count);
1797        changed.push("header-count");
1798    }
1799    if let Some(title) = horizontal_axis_title {
1800        upsert_axis_title(&mut basic.axis, "BOTTOM_AXIS", title);
1801        changed.push("horizontal-axis-title");
1802    }
1803    if let Some(title) = vertical_axis_title {
1804        upsert_axis_title(&mut basic.axis, "LEFT_AXIS", title);
1805        changed.push("vertical-axis-title");
1806    }
1807    spec.basic_chart = Some(basic);
1808    spec.pie_chart = None;
1809    Ok(())
1810}
1811
1812fn build_update_chart(
1813    workbook: &Spreadsheet,
1814    verb: &EmbeddedObjectVerb,
1815    chart_id: i64,
1816) -> Result<Plan, EmbeddedObjectResult> {
1817    let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
1818    let Some(existing_spec) = chart.spec.as_ref() else {
1819        return Err(EmbeddedObjectResult::RefusedUnsupportedChart {
1820            chart_id,
1821            detail: "chart has no spec to merge onto".to_string(),
1822        });
1823    };
1824    let before = summarise_chart(sheet, chart);
1825    let sheet_id = sheet.sheet_id();
1826    let (spec, summary) = merge_chart_spec(workbook, existing_spec, verb)?;
1827
1828    Ok(Plan {
1829        request: BatchUpdateRequestItem::UpdateChartSpec(UpdateChartSpecRequest { chart_id, spec }),
1830        summary,
1831        sheet_id,
1832        before,
1833        existing_id: Some(chart_id),
1834    })
1835}
1836
1837fn build_delete_chart(workbook: &Spreadsheet, chart_id: i64) -> Result<Plan, EmbeddedObjectResult> {
1838    let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
1839    let before = summarise_chart(sheet, chart);
1840    let sheet_id = sheet.sheet_id();
1841    let summary = format!("delete chart {chart_id}");
1842    Ok(Plan {
1843        request: BatchUpdateRequestItem::DeleteEmbeddedObject(DeleteEmbeddedObjectRequest {
1844            object_id: chart_id,
1845        }),
1846        summary,
1847        sheet_id,
1848        before,
1849        existing_id: Some(chart_id),
1850    })
1851}
1852
1853// ── add-slicer / update-slicer / delete-slicer ──────────────────────────
1854
1855/// Refuses a slicer filter column that falls outside the slicer's data
1856/// range (issue #1945).
1857///
1858/// `SlicerSpec.columnIndex` is an **absolute** sheet column index (`0` =
1859/// column A), not an offset within the range — so for a range of
1860/// `B2:F8` the valid columns are `1..=5` (B–F). The live API rejects
1861/// anything else with a `400`; checking here means `--dry-run` catches it
1862/// too. An open-ended edge of the range (e.g. a whole-row `2:5`) leaves
1863/// that side unbounded.
1864fn check_slicer_column_in_range(
1865    column: i64,
1866    range: &GridRange,
1867) -> Result<(), EmbeddedObjectResult> {
1868    let grid_range::ColumnOutOfRange { start, end, .. } =
1869        match grid_range::check_column_in_range(column, range) {
1870            Ok(()) => return Ok(()),
1871            Err(err) => err,
1872        };
1873    let column_label = if column >= 0 {
1874        format!(
1875            "{column} (column {})",
1876            grid_range::column_index_to_letters(column)
1877        )
1878    } else {
1879        column.to_string()
1880    };
1881    let valid = match end {
1882        Some(end) if end <= start => "none (the range spans no columns)".to_string(),
1883        Some(end) => format!(
1884            "{start}..={} ({}–{})",
1885            end - 1,
1886            grid_range::column_index_to_letters(start),
1887            grid_range::column_index_to_letters(end - 1)
1888        ),
1889        None => format!(
1890            "{start} or greater ({} onward)",
1891            grid_range::column_index_to_letters(start)
1892        ),
1893    };
1894    Err(invalid(format!(
1895        "slicer column {column_label} is outside the slicer's range; valid columns are \
1896         {valid} — the column is an absolute sheet column index (0 = A), not an offset \
1897         within the range"
1898    )))
1899}
1900
1901fn build_add_slicer(
1902    workbook: &Spreadsheet,
1903    verb: &EmbeddedObjectVerb,
1904) -> Result<Plan, EmbeddedObjectResult> {
1905    let EmbeddedObjectVerb::AddSlicer {
1906        sheet,
1907        range,
1908        column,
1909        hide_values,
1910        title,
1911        apply_to_pivot_tables,
1912        anchor,
1913        offset_x,
1914        offset_y,
1915        width,
1916        height,
1917    } = verb
1918    else {
1919        unreachable!("build_add_slicer is only ever called for AddSlicer") // omni-dev: coverage ignore-line reason="build_plan only calls build_add_slicer after matching verb as EmbeddedObjectVerb::AddSlicer; this else-arm exists only to destructure the already-known variant"
1920    };
1921
1922    let data_range = compose_and_resolve(workbook, sheet.as_deref(), range)?;
1923    check_slicer_column_in_range(*column, &data_range)?;
1924    let (position, position_sheet_id) = overlay_position(
1925        workbook,
1926        sheet.as_deref(),
1927        anchor,
1928        *offset_x,
1929        *offset_y,
1930        *width,
1931        *height,
1932    )?;
1933
1934    let filter_criteria = if hide_values.is_empty() {
1935        None
1936    } else {
1937        Some(FilterCriteria::hiding(hide_values.clone()))
1938    };
1939
1940    let summary = format!(
1941        "add slicer{}",
1942        title
1943            .as_deref()
1944            .map_or_else(String::new, |t| format!(" '{t}'"))
1945    );
1946
1947    Ok(Plan {
1948        request: BatchUpdateRequestItem::AddSlicer(AddSlicerRequest {
1949            slicer: Slicer {
1950                slicer_id: None,
1951                spec: Some(SlicerSpec {
1952                    data_range: Some(data_range),
1953                    filter_criteria,
1954                    column_index: Some(*column),
1955                    apply_to_pivot_tables: *apply_to_pivot_tables,
1956                    title: title.clone(),
1957                    extra: BTreeMap::new(),
1958                }),
1959                position: Some(position),
1960            },
1961        }),
1962        summary,
1963        sheet_id: Some(position_sheet_id),
1964        before: None,
1965        existing_id: None,
1966    })
1967}
1968
1969fn build_update_slicer(
1970    workbook: &Spreadsheet,
1971    verb: &EmbeddedObjectVerb,
1972    slicer_id: i64,
1973) -> Result<Plan, EmbeddedObjectResult> {
1974    let EmbeddedObjectVerb::UpdateSlicer {
1975        sheet,
1976        range,
1977        column,
1978        hide_values,
1979        clear_criteria,
1980        title,
1981        apply_to_pivot_tables,
1982        ..
1983    } = verb
1984    else {
1985        // omni-dev: coverage ignore reason="build_plan only calls build_update_slicer after matching verb as EmbeddedObjectVerb::UpdateSlicer; this else-arm exists only to destructure the already-known variant"
1986        unreachable!("build_update_slicer is only ever called for UpdateSlicer")
1987        // omni-dev: coverage end
1988    };
1989
1990    let (host_sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
1991    let before = summarise_slicer(host_sheet, slicer);
1992    let sheet_id = host_sheet.sheet_id();
1993
1994    let mut spec = SlicerSpec::default();
1995    let mut fields = Vec::new();
1996    let mut changed = Vec::new();
1997    if let Some(range) = range {
1998        spec.data_range = Some(compose_and_resolve(workbook, sheet.as_deref(), range)?);
1999        fields.push("dataRange");
2000        changed.push("range");
2001    }
2002    if *clear_criteria {
2003        spec.filter_criteria = Some(FilterCriteria::default());
2004        fields.push("filterCriteria");
2005        changed.push("clear criteria");
2006    } else if !hide_values.is_empty() {
2007        spec.filter_criteria = Some(FilterCriteria::hiding(hide_values.clone()));
2008        fields.push("filterCriteria");
2009        changed.push("criteria");
2010    }
2011    if let Some(column) = column {
2012        spec.column_index = Some(*column);
2013        fields.push("columnIndex");
2014        changed.push("column");
2015    }
2016    // Only a change to the range or the column can move one outside the
2017    // other, so a title- or criteria-only update never re-litigates the
2018    // slicer's existing state. Whichever half isn't being replaced comes
2019    // from the slicer as it stands.
2020    if range.is_some() || column.is_some() {
2021        let existing = slicer.spec.as_ref();
2022        let effective_range = spec
2023            .data_range
2024            .as_ref()
2025            .or_else(|| existing.and_then(|s| s.data_range.as_ref()));
2026        // `existing.column_index` reads back `None` for a slicer filtering
2027        // column 0 (column A) — proto3 omits the zero value on the wire
2028        // (issue #1929), indistinguishable from "no column was ever set".
2029        // We only resolve that ambiguity toward 0 when `existing` also
2030        // carries a `data_range`: every slicer *this crate* creates always
2031        // sends `dataRange` and `columnIndex` together, so a stored spec
2032        // that has one but is missing the other is a real Google Sheets
2033        // slicer whose column just happens to be 0, not a bare/degenerate
2034        // spec. A spec with neither (predating this validation, or created
2035        // outside omni-dev — see the `..._refuses_when_an_existing_spec_
2036        // has_no_range_or_column` test) still falls through to the
2037        // refusal below rather than guessing.
2038        let effective_column = spec.column_index.or_else(|| {
2039            existing.and_then(|s| s.data_range.is_some().then(|| s.column_index.unwrap_or(0)))
2040        });
2041        match (effective_range, effective_column) {
2042            (Some(effective_range), Some(effective_column)) => {
2043                check_slicer_column_in_range(effective_column, effective_range)?;
2044            }
2045            _ => {
2046                // A stored slicer with no range and/or no column predates
2047                // this validation (or was created outside omni-dev); refuse
2048                // rather than silently skip the check this change exists to
2049                // enforce.
2050                return Err(EmbeddedObjectResult::RefusedInvalidRange {
2051                    detail: format!(
2052                        "slicer {slicer_id} has no {} on record, so its column can't be checked against its range locally — set --range and --column together to establish both"
2053                        ,
2054                        match (effective_range.is_some(), effective_column.is_some()) {
2055                            (false, false) => "range or column",
2056                            (false, true) => "range",
2057                            (true, false) => "column",
2058                            (true, true) => unreachable!(),
2059                        }
2060                    ),
2061                });
2062            }
2063        }
2064    }
2065    if let Some(title) = title {
2066        spec.title = Some(title.clone());
2067        fields.push("title");
2068        changed.push("title");
2069    }
2070    if let Some(apply) = apply_to_pivot_tables {
2071        spec.apply_to_pivot_tables = Some(*apply);
2072        fields.push("applyToPivotTables");
2073        changed.push("apply-to-pivot-tables");
2074    }
2075
2076    let summary = format!("update slicer {slicer_id} ({})", changed.join(", "));
2077    Ok(Plan {
2078        request: BatchUpdateRequestItem::UpdateSlicerSpec(UpdateSlicerSpecRequest {
2079            slicer_id,
2080            spec,
2081            fields: fields.join(","),
2082        }),
2083        summary,
2084        sheet_id,
2085        before,
2086        existing_id: Some(slicer_id),
2087    })
2088}
2089
2090fn build_delete_slicer(
2091    workbook: &Spreadsheet,
2092    slicer_id: i64,
2093) -> Result<Plan, EmbeddedObjectResult> {
2094    let (sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
2095    let before = summarise_slicer(sheet, slicer);
2096    let sheet_id = sheet.sheet_id();
2097    let summary = format!("delete slicer {slicer_id}");
2098    Ok(Plan {
2099        request: BatchUpdateRequestItem::DeleteEmbeddedObject(DeleteEmbeddedObjectRequest {
2100            object_id: slicer_id,
2101        }),
2102        summary,
2103        sheet_id,
2104        before,
2105        existing_id: Some(slicer_id),
2106    })
2107}
2108
2109// ── move-chart / move-slicer ────────────────────────────────────────────
2110
2111/// The four independent overlay-resize flags `move-chart`/`move-slicer`
2112/// share, grouped into one struct rather than passed as four positional
2113/// `Option<i64>`s — `overlay_position_update`/`build_move` each took eight
2114/// and twelve positional arguments before this, with no type-level guard
2115/// against transposing `offset_x`/`offset_y`/`width`/`height` at a call
2116/// site (issue #1837 review).
2117#[derive(Debug, Clone, Copy, Default)]
2118struct PlacementResize {
2119    offset_x: Option<i64>,
2120    offset_y: Option<i64>,
2121    width: Option<i64>,
2122    height: Option<i64>,
2123}
2124
2125/// Merges the caller's overlay overrides onto `existing`, returning the
2126/// position to send, the `fields` mask (relative to `overlayPosition`, per
2127/// [`UpdateEmbeddedObjectPositionRequest`]'s doc comment), and the
2128/// destination sheet id.
2129///
2130/// `anchor_cell` is a required field of `OverlayPosition` on the wire, so an
2131/// unnamed anchor is carried forward from the object's current position
2132/// rather than left as a zero value — `offset_x`/`offset_y`/`width`/
2133/// `height` need no such fallback, since they're all optional on the wire
2134/// and outside the mask when unset. An object with no current overlay
2135/// position (a chart on its own sheet; a slicer is always an overlay, so
2136/// this would mean a malformed read-back) requires `--anchor` to be moved
2137/// onto a grid.
2138fn overlay_position_update(
2139    workbook: &Spreadsheet,
2140    existing: Option<&OverlayPosition>,
2141    sheet: Option<&str>,
2142    anchor: Option<&str>,
2143    resize: PlacementResize,
2144) -> Result<(EmbeddedObjectPosition, String, i64), EmbeddedObjectResult> {
2145    let mut fields = Vec::new();
2146    let anchor_cell = match anchor {
2147        Some(anchor) => {
2148            fields.push("anchorCell");
2149            resolve_anchor(workbook, sheet, anchor)?
2150        }
2151        None => existing.map(|overlay| overlay.anchor_cell).ok_or_else(|| {
2152            invalid(
2153                "this object has no current overlay position (a chart can be on its own \
2154                 sheet; a slicer is always an overlay, so this would mean a malformed \
2155                 read-back); --anchor is required to move it onto a grid",
2156            )
2157        })?,
2158    };
2159    let sheet_id = anchor_cell.sheet_id;
2160
2161    if resize.offset_x.is_some() {
2162        fields.push("offsetXPixels");
2163    }
2164    if resize.offset_y.is_some() {
2165        fields.push("offsetYPixels");
2166    }
2167    if resize.width.is_some() {
2168        fields.push("widthPixels");
2169    }
2170    if resize.height.is_some() {
2171        fields.push("heightPixels");
2172    }
2173
2174    let position = EmbeddedObjectPosition {
2175        overlay_position: Some(OverlayPosition {
2176            anchor_cell,
2177            offset_x_pixels: resize.offset_x,
2178            offset_y_pixels: resize.offset_y,
2179            width_pixels: resize.width,
2180            height_pixels: resize.height,
2181        }),
2182        ..Default::default()
2183    };
2184    Ok((position, fields.join(","), sheet_id))
2185}
2186
2187/// The overlay-position tail shared by `build_move_chart`/`build_move_slicer`
2188/// once each has resolved its own verb-specific fields (and, for a chart,
2189/// ruled out `--new-sheet`) down to a plain overlay move — the `--sheet`/
2190/// `--anchor`/id-flag differences live in the two callers, not here.
2191///
2192/// The summary leads "move `<noun>` `<id>` to `<destination>`" when the
2193/// anchor changed, or "resize `<noun>` `<id>`" when it didn't (an
2194/// offset/width/height-only call) — `anchor_changed` is `true` whenever the
2195/// caller passed `--anchor`, even if it happens to resolve to the same
2196/// cell the object was already at.
2197#[allow(clippy::too_many_arguments)]
2198fn build_move(
2199    workbook: &Spreadsheet,
2200    object_id: i64,
2201    noun: &str,
2202    position: EmbeddedObjectPosition,
2203    fields: String,
2204    sheet_id: i64,
2205    anchor_changed: bool,
2206    resize: PlacementResize,
2207    before: Option<EmbeddedObjectSummary>,
2208) -> Plan {
2209    let mut changed = Vec::new();
2210    if let Some(offset_x) = resize.offset_x {
2211        changed.push(format!("offset-x {offset_x}"));
2212    }
2213    if let Some(offset_y) = resize.offset_y {
2214        changed.push(format!("offset-y {offset_y}"));
2215    }
2216    if let Some(width) = resize.width {
2217        changed.push(format!("width {width}"));
2218    }
2219    if let Some(height) = resize.height {
2220        changed.push(format!("height {height}"));
2221    }
2222    let suffix = if changed.is_empty() {
2223        String::new()
2224    } else {
2225        format!(" ({})", changed.join(", "))
2226    };
2227    let summary = if anchor_changed {
2228        let destination = position
2229            .overlay_position
2230            .as_ref()
2231            .and_then(|overlay| {
2232                grid_range::find_sheet_by_id(workbook, sheet_id)
2233                    .map(|sheet| describe_overlay_position(sheet, overlay))
2234            })
2235            .unwrap_or_else(|| format!("sheet {sheet_id}"));
2236        format!("move {noun} {object_id} to {destination}{suffix}")
2237    } else {
2238        format!("resize {noun} {object_id}{suffix}")
2239    };
2240
2241    Plan {
2242        request: BatchUpdateRequestItem::UpdateEmbeddedObjectPosition(
2243            UpdateEmbeddedObjectPositionRequest {
2244                object_id,
2245                new_position: position,
2246                fields,
2247            },
2248        ),
2249        summary,
2250        sheet_id: Some(sheet_id),
2251        before,
2252        existing_id: Some(object_id),
2253    }
2254}
2255
2256fn build_move_chart(
2257    workbook: &Spreadsheet,
2258    verb: &EmbeddedObjectVerb,
2259    chart_id: i64,
2260) -> Result<Plan, EmbeddedObjectResult> {
2261    let EmbeddedObjectVerb::MoveChart {
2262        sheet,
2263        anchor,
2264        offset_x,
2265        offset_y,
2266        width,
2267        height,
2268        new_sheet,
2269        ..
2270    } = verb
2271    else {
2272        unreachable!("build_move_chart is only ever called for MoveChart") // omni-dev: coverage ignore-line reason="build_plan only calls build_move_chart after matching verb as EmbeddedObjectVerb::MoveChart; this else-arm exists only to destructure the already-known variant"
2273    };
2274
2275    let (host_sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
2276    let before = summarise_chart(host_sheet, chart);
2277
2278    if *new_sheet {
2279        return Ok(Plan {
2280            request: BatchUpdateRequestItem::UpdateEmbeddedObjectPosition(
2281                UpdateEmbeddedObjectPositionRequest {
2282                    object_id: chart_id,
2283                    new_position: EmbeddedObjectPosition {
2284                        new_sheet: Some(true),
2285                        ..Default::default()
2286                    },
2287                    fields: String::new(),
2288                },
2289            ),
2290            summary: format!("move chart {chart_id} to a new sheet"),
2291            sheet_id: None,
2292            before,
2293            existing_id: Some(chart_id),
2294        });
2295    }
2296
2297    let resize = PlacementResize {
2298        offset_x: *offset_x,
2299        offset_y: *offset_y,
2300        width: *width,
2301        height: *height,
2302    };
2303    let existing_overlay = chart
2304        .position
2305        .as_ref()
2306        .and_then(|position| position.overlay_position.as_ref());
2307    let (position, fields, sheet_id) = overlay_position_update(
2308        workbook,
2309        existing_overlay,
2310        sheet.as_deref(),
2311        anchor.as_deref(),
2312        resize,
2313    )?;
2314
2315    Ok(build_move(
2316        workbook,
2317        chart_id,
2318        "chart",
2319        position,
2320        fields,
2321        sheet_id,
2322        anchor.is_some(),
2323        resize,
2324        before,
2325    ))
2326}
2327
2328fn build_move_slicer(
2329    workbook: &Spreadsheet,
2330    verb: &EmbeddedObjectVerb,
2331    slicer_id: i64,
2332) -> Result<Plan, EmbeddedObjectResult> {
2333    let EmbeddedObjectVerb::MoveSlicer {
2334        sheet,
2335        anchor,
2336        offset_x,
2337        offset_y,
2338        width,
2339        height,
2340        ..
2341    } = verb
2342    else {
2343        unreachable!("build_move_slicer is only ever called for MoveSlicer") // omni-dev: coverage ignore-line reason="build_plan only calls build_move_slicer after matching verb as EmbeddedObjectVerb::MoveSlicer; this else-arm exists only to destructure the already-known variant"
2344    };
2345
2346    let (host_sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
2347    let before = summarise_slicer(host_sheet, slicer);
2348
2349    let resize = PlacementResize {
2350        offset_x: *offset_x,
2351        offset_y: *offset_y,
2352        width: *width,
2353        height: *height,
2354    };
2355    let existing_overlay = slicer
2356        .position
2357        .as_ref()
2358        .and_then(|position| position.overlay_position.as_ref());
2359    let (position, fields, sheet_id) = overlay_position_update(
2360        workbook,
2361        existing_overlay,
2362        sheet.as_deref(),
2363        anchor.as_deref(),
2364        resize,
2365    )?;
2366
2367    Ok(build_move(
2368        workbook,
2369        slicer_id,
2370        "slicer",
2371        position,
2372        fields,
2373        sheet_id,
2374        anchor.is_some(),
2375        resize,
2376        before,
2377    ))
2378}
2379
2380// ── update-chart-border ─────────────────────────────────────────────────
2381
2382fn build_update_chart_border(
2383    workbook: &Spreadsheet,
2384    verb: &EmbeddedObjectVerb,
2385    chart_id: i64,
2386) -> Result<Plan, EmbeddedObjectResult> {
2387    let EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } = verb else {
2388        unreachable!("only ever called for UpdateChartBorder") // omni-dev: coverage ignore-line reason="build_plan only calls build_update_chart_border after matching verb as EmbeddedObjectVerb::UpdateChartBorder; this else-arm exists only to destructure the already-known variant"
2389    };
2390
2391    let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
2392    let before = summarise_chart(sheet, chart);
2393    let sheet_id = sheet.sheet_id();
2394
2395    let (border, summary) = if *clear {
2396        (
2397            EmbeddedObjectBorder::default(),
2398            format!("clear chart {chart_id} border"),
2399        )
2400    } else {
2401        let Some(color) = color else {
2402            unreachable!("validate_verb refuses neither --color nor --clear") // omni-dev: coverage ignore-line reason="validate_verb already refuses UpdateChartBorder { color: None, clear: false, .. } before build_plan is ever reached, so this arm can never run"
2403        };
2404        let rgb_color = parse_hex_color(color).map_err(invalid)?;
2405        (
2406            EmbeddedObjectBorder {
2407                color_style: Some(ColorStyle::rgb(rgb_color)),
2408            },
2409            format!("set chart {chart_id} border to {color}"),
2410        )
2411    };
2412
2413    Ok(Plan {
2414        request: BatchUpdateRequestItem::UpdateEmbeddedObjectBorder(
2415            UpdateEmbeddedObjectBorderRequest {
2416                object_id: chart_id,
2417                border,
2418                fields: "colorStyle".to_string(),
2419            },
2420        ),
2421        summary,
2422        sheet_id,
2423        before,
2424        existing_id: Some(chart_id),
2425    })
2426}
2427
2428// ── list-charts / list-slicers ───────────────────────────────────────────
2429
2430/// Extracts every chart's summary from an already-fetched workbook, for
2431/// `list-charts`.
2432///
2433/// Takes the workbook rather than fetching it itself — the CLI leaf fetches
2434/// once and reuses the same `Spreadsheet` for `-o json`/`-o yaml`'s raw
2435/// dump, matching `list-protections`/`list-filter-views`' own shape.
2436#[must_use]
2437pub fn charts_from_workbook(workbook: &Spreadsheet) -> Vec<EmbeddedObjectSummary> {
2438    workbook
2439        .sheets
2440        .iter()
2441        .flat_map(|sheet| {
2442            sheet
2443                .charts
2444                .iter()
2445                .filter_map(move |chart| summarise_chart(sheet, chart))
2446        })
2447        .collect()
2448}
2449
2450/// Extracts every slicer's summary from an already-fetched workbook, for
2451/// `list-slicers`. See [`charts_from_workbook`]'s doc comment.
2452#[must_use]
2453pub fn slicers_from_workbook(workbook: &Spreadsheet) -> Vec<EmbeddedObjectSummary> {
2454    workbook
2455        .sheets
2456        .iter()
2457        .flat_map(|sheet| {
2458            sheet
2459                .slicers
2460                .iter()
2461                .filter_map(move |slicer| summarise_slicer(sheet, slicer))
2462        })
2463        .collect()
2464}
2465
2466// ── logging and rendering ────────────────────────────────────────────────
2467
2468fn added_object_id(response: &BatchUpdateResponse) -> Option<i64> {
2469    response.replies.iter().find_map(|reply| {
2470        reply
2471            .add_chart
2472            .as_ref()
2473            .and_then(|r| r.chart.as_ref())
2474            .and_then(|c| c.chart_id)
2475            .or_else(|| {
2476                reply
2477                    .add_slicer
2478                    .as_ref()
2479                    .and_then(|r| r.slicer.as_ref())
2480                    .and_then(|s| s.slicer_id)
2481            })
2482    })
2483}
2484
2485fn record_attempt(
2486    outcome: &EmbeddedObjectOutcome,
2487    opts: &EmbeddedObjectOptions,
2488    duration: Duration,
2489) {
2490    let error = match &outcome.result {
2491        EmbeddedObjectResult::Failed { detail } => Some(detail.clone()),
2492        _ => None,
2493    };
2494    let decided_by = match &outcome.result {
2495        EmbeddedObjectResult::Blocked { decided_by } => decided_by.as_ref(),
2496        _ => None,
2497    };
2498    let decided_by = write_gate::decided_by_log_fields(decided_by);
2499    let (embedded_object_id, fields_changed) = match &outcome.result {
2500        EmbeddedObjectResult::Changed {
2501            object_id,
2502            summary,
2503            object,
2504        } => {
2505            // For the two deletes, `object` carries the ADR-0081 §3
2506            // preview — the spec that is about to become unrecoverable —
2507            // and it belongs in the audit trail exactly as much as in the
2508            // `--dry-run` report, so it is appended here rather than left
2509            // to the summary text alone.
2510            let fields_changed = object.as_deref().map_or_else(
2511                || summary.clone(),
2512                |object| format!("{summary} ({})", object_preview(object)),
2513            );
2514            (*object_id, Some(fields_changed))
2515        }
2516        _ => (None, None),
2517    };
2518
2519    request_log::record_drive_mutation(DriveMutationOutcome {
2520        operation: opts.verb.log_operation(),
2521        file_id: outcome.spreadsheet_id.clone(),
2522        file_name: outcome.file_name.clone().unwrap_or_default(),
2523        status: outcome.result.log_status().to_string(),
2524        resolved_folder_id: outcome.resolved_folder_id.clone(),
2525        decided_by_folder_id: decided_by.folder_id,
2526        decided_by_depth: decided_by.depth,
2527        decided_by_file_id: decided_by.file_id,
2528        sheet_id: outcome.sheet_id,
2529        embedded_object_id,
2530        fields_changed,
2531        error,
2532        duration,
2533        ..Default::default()
2534    });
2535}
2536
2537/// Renders an object's type/title/anchor — the ADR-0081 §3 preview text,
2538/// shared by the human-readable CLI rendering ([`describe_object`]) and the
2539/// `drivemutation` record's `fields_changed` ([`record_attempt`]).
2540fn object_preview(object: &EmbeddedObjectSummary) -> String {
2541    let kind_detail = object
2542        .chart_type
2543        .as_deref()
2544        .map_or_else(|| object.kind.clone(), |t| format!("{} ({t})", object.kind));
2545    let title = object
2546        .title
2547        .as_deref()
2548        .map_or_else(String::new, |t| format!(" '{t}'"));
2549    let border = object
2550        .border
2551        .as_deref()
2552        .map_or_else(String::new, |b| format!(", border {b}"));
2553    format!(
2554        "id {}: {kind_detail}{title}, anchored {}{border}",
2555        object.object_id, object.position
2556    )
2557}
2558
2559fn describe_object(object: &EmbeddedObjectSummary) -> String {
2560    format!("  {}", object_preview(object))
2561}
2562
2563/// Renders an outcome as human-readable text.
2564#[must_use]
2565pub fn describe(outcome: &EmbeddedObjectOutcome) -> String {
2566    describe_lines(outcome).join("\n")
2567}
2568
2569/// Renders an outcome as its individual lines, none of which contains a
2570/// newline — see `structure.rs::describe_lines` for why this shape exists.
2571#[must_use]
2572pub fn describe_lines(outcome: &EmbeddedObjectOutcome) -> Vec<String> {
2573    let verb = &outcome.verb;
2574    let book = outcome.file_name.as_deref().map_or_else(
2575        || format!("'{}'", outcome.spreadsheet_id),
2576        |n| format!("'{n}'"),
2577    );
2578    match &outcome.result {
2579        EmbeddedObjectResult::WouldChange { summary, object } => {
2580            let mut lines = vec![format!("Would {summary} in {book}")];
2581            if let Some(object) = object {
2582                lines.push(describe_object(object));
2583            }
2584            lines
2585        }
2586        EmbeddedObjectResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
2587            "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
2588             `drive sheets {}` only works on spreadsheets",
2589            verb.label()
2590        )],
2591        EmbeddedObjectResult::RefusedShortcut => vec![format!(
2592            "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
2593            verb.label()
2594        )],
2595        EmbeddedObjectResult::RefusedNoVisibleParents => vec![format!(
2596            "Refused: {book} has no parent folder visible to this account, so no folder \
2597             rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
2598             id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
2599        )],
2600        EmbeddedObjectResult::RefusedSheetNotFound { title, available } => {
2601            vec![grid_range::no_sheet_refusal(&book, title, available)]
2602        }
2603        EmbeddedObjectResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
2604        EmbeddedObjectResult::RefusedObjectNotFound { object_id } => vec![format!(
2605            "Refused: {book} has no chart or slicer with id {object_id}; run `drive sheets \
2606             list-charts`/`list-slicers` to see what exists"
2607        )],
2608        EmbeddedObjectResult::RefusedWrongObjectKind {
2609            object_id,
2610            expected,
2611            found,
2612        } => vec![format!(
2613            "Refused: id {object_id} in {book} is a {found}, not a {expected}; run `drive \
2614             sheets list-{found}s` to see what exists"
2615        )],
2616        EmbeddedObjectResult::RefusedUnsupportedChart { chart_id, detail } => {
2617            vec![format!("Refused: chart {chart_id} in {book}: {detail}")]
2618        }
2619        EmbeddedObjectResult::Blocked { decided_by } => vec![match decided_by {
2620            Some(rule) => format!(
2621                "Blocked: {} on {book} refused by rule on {} {}{}",
2622                verb.label(),
2623                rule.kind_label(),
2624                rule.id(),
2625                rule.depth_suffix()
2626            ),
2627            None => format!(
2628                "Blocked: {} on {book} refused by default policy (no matching rule for \
2629                 sheets-structure)",
2630                verb.label()
2631            ),
2632        }],
2633        EmbeddedObjectResult::RefusedNoLease => LeaseGateRefusal::NoLease
2634            .describe_line(&outcome.spreadsheet_id, &book)
2635            .into_iter()
2636            .collect(),
2637        EmbeddedObjectResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
2638            .describe_line(&outcome.spreadsheet_id, &book)
2639            .into_iter()
2640            .collect(),
2641        EmbeddedObjectResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
2642            .describe_line(&outcome.spreadsheet_id, &book)
2643            .into_iter()
2644            .collect(),
2645        EmbeddedObjectResult::RefusedLeaseStale => LeaseGateRefusal::Stale
2646            .describe_line(&outcome.spreadsheet_id, &book)
2647            .into_iter()
2648            .collect(),
2649        EmbeddedObjectResult::Changed {
2650            summary,
2651            object_id,
2652            object,
2653        } => {
2654            let id = object_id.map_or_else(String::new, |id| format!(" (id {id})"));
2655            let mut lines = vec![format!("Applied: {summary}{id} in {book}")];
2656            if let Some(object) = object {
2657                lines.push(describe_object(object));
2658            }
2659            lines
2660        }
2661        EmbeddedObjectResult::Failed { detail } => vec![format!("Failed: {detail}")],
2662    }
2663}
2664
2665#[cfg(test)]
2666#[allow(clippy::unwrap_used, clippy::expect_used)]
2667mod tests {
2668    use super::*;
2669    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
2670    use crate::drive::sheets::client::SHEETS_API_URL;
2671    use crate::drive::sheets::types::Sheet;
2672    use crate::drive::test_support::seed_lease;
2673    use crate::test_support::env::MapEnv;
2674    use crate::utils::secret::Secret;
2675    use std::collections::HashSet;
2676
2677    // ── parse_chart_type / parse_legend / parse_stacked ─────────────────
2678
2679    #[test]
2680    fn parse_chart_type_accepts_every_basic_type_and_pie() {
2681        for (raw, expect_basic) in [
2682            ("column", Some("COLUMN")),
2683            ("bar", Some("BAR")),
2684            ("line", Some("LINE")),
2685            ("area", Some("AREA")),
2686            ("scatter", Some("SCATTER")),
2687            ("PIE", None),
2688        ] {
2689            let kind = parse_chart_type(raw).unwrap();
2690            match (&kind, expect_basic) {
2691                (ChartKind::Basic(t), Some(expected)) => assert_eq!(*t, expected),
2692                (ChartKind::Pie, None) => {}
2693                _ => panic!("unexpected parse for {raw:?}: {kind:?}"), // omni-dev: coverage ignore-line reason="every row of the table above pairs its raw value with the kind `parse_chart_type` returns for it, so the mismatch arm only fires if one of the two assertions above would already have failed"
2694            }
2695        }
2696    }
2697
2698    #[test]
2699    fn parse_chart_type_rejects_combo_and_unknown() {
2700        let err = parse_chart_type("combo").unwrap_err();
2701        assert!(err.contains("not a supported chart type"), "{err}");
2702        let err = parse_chart_type("bogus").unwrap_err();
2703        assert!(
2704            err.contains("column, bar, line, area, scatter, pie"),
2705            "{err}"
2706        );
2707    }
2708
2709    #[test]
2710    fn parse_legend_accepts_every_position() {
2711        assert_eq!(parse_legend("bottom").unwrap(), "BOTTOM_LEGEND");
2712        assert_eq!(parse_legend("TOP").unwrap(), "TOP_LEGEND");
2713        assert_eq!(parse_legend("left").unwrap(), "LEFT_LEGEND");
2714        assert_eq!(parse_legend("right").unwrap(), "RIGHT_LEGEND");
2715        assert_eq!(parse_legend("none").unwrap(), "NO_LEGEND");
2716    }
2717
2718    #[test]
2719    fn parse_legend_rejects_unknown() {
2720        let err = parse_legend("middle").unwrap_err();
2721        assert!(err.contains("not a supported legend position"), "{err}");
2722    }
2723
2724    #[test]
2725    fn parse_stacked_accepts_every_mode() {
2726        assert_eq!(parse_stacked("none").unwrap(), "NOT_STACKED");
2727        assert_eq!(parse_stacked("stacked").unwrap(), "STACKED");
2728        assert_eq!(parse_stacked("PERCENT").unwrap(), "PERCENT_STACKED");
2729    }
2730
2731    #[test]
2732    fn parse_stacked_rejects_unknown() {
2733        let err = parse_stacked("half").unwrap_err();
2734        assert!(err.contains("not a supported stacking mode"), "{err}");
2735    }
2736
2737    // ── axis_list / upsert_axis_title ────────────────────────────────────
2738
2739    #[test]
2740    fn axis_list_builds_only_the_named_axes() {
2741        assert!(axis_list(None, None).is_empty());
2742        let axis = axis_list(Some("Quarter"), None);
2743        assert_eq!(axis.len(), 1);
2744        assert_eq!(axis[0].position, "BOTTOM_AXIS");
2745        assert_eq!(axis[0].title.as_deref(), Some("Quarter"));
2746        let axis = axis_list(Some("Quarter"), Some("Revenue"));
2747        assert_eq!(axis.len(), 2);
2748        assert_eq!(axis[1].position, "LEFT_AXIS");
2749    }
2750
2751    #[test]
2752    fn upsert_axis_title_replaces_an_existing_entry_in_place() {
2753        let mut axis = vec![
2754            BasicChartAxis {
2755                position: "BOTTOM_AXIS".to_string(),
2756                title: Some("Old".to_string()),
2757                extra: BTreeMap::new(),
2758            },
2759            BasicChartAxis {
2760                position: "LEFT_AXIS".to_string(),
2761                title: None,
2762                extra: BTreeMap::new(),
2763            },
2764        ];
2765        upsert_axis_title(&mut axis, "BOTTOM_AXIS", "New");
2766        assert_eq!(axis.len(), 2);
2767        assert_eq!(axis[0].title.as_deref(), Some("New"));
2768        assert_eq!(axis[1].title, None);
2769    }
2770
2771    #[test]
2772    fn upsert_axis_title_appends_when_absent() {
2773        let mut axis = Vec::new();
2774        upsert_axis_title(&mut axis, "LEFT_AXIS", "Revenue");
2775        assert_eq!(axis.len(), 1);
2776        assert_eq!(axis[0].position, "LEFT_AXIS");
2777    }
2778
2779    // ── log_operation / label ────────────────────────────────────────────
2780
2781    #[test]
2782    fn every_verb_has_a_distinct_log_operation_and_label() {
2783        let verbs = [
2784            EmbeddedObjectVerb::AddChart {
2785                chart_type: "column".to_string(),
2786                domain: String::new(),
2787                series: vec![String::new()],
2788                sheet: None,
2789                title: None,
2790                subtitle: None,
2791                legend: None,
2792                stacked: None,
2793                header_count: None,
2794                horizontal_axis_title: None,
2795                vertical_axis_title: None,
2796                pie_hole: None,
2797                anchor: Some(String::new()),
2798                offset_x: None,
2799                offset_y: None,
2800                width: None,
2801                height: None,
2802                new_sheet: false,
2803            },
2804            EmbeddedObjectVerb::UpdateChart {
2805                chart_id: 1,
2806                chart_type: None,
2807                domain: None,
2808                series: Vec::new(),
2809                sheet: None,
2810                title: None,
2811                subtitle: None,
2812                legend: None,
2813                stacked: None,
2814                header_count: None,
2815                horizontal_axis_title: None,
2816                vertical_axis_title: None,
2817                pie_hole: None,
2818            },
2819            EmbeddedObjectVerb::DeleteChart { chart_id: 1 },
2820            EmbeddedObjectVerb::AddSlicer {
2821                sheet: None,
2822                range: String::new(),
2823                column: 0,
2824                hide_values: Vec::new(),
2825                title: None,
2826                apply_to_pivot_tables: None,
2827                anchor: String::new(),
2828                offset_x: None,
2829                offset_y: None,
2830                width: None,
2831                height: None,
2832            },
2833            EmbeddedObjectVerb::UpdateSlicer {
2834                slicer_id: 1,
2835                sheet: None,
2836                range: None,
2837                column: None,
2838                hide_values: Vec::new(),
2839                clear_criteria: false,
2840                title: None,
2841                apply_to_pivot_tables: None,
2842            },
2843            EmbeddedObjectVerb::DeleteSlicer { slicer_id: 1 },
2844            EmbeddedObjectVerb::MoveChart {
2845                chart_id: 1,
2846                sheet: None,
2847                anchor: Some(String::new()),
2848                offset_x: None,
2849                offset_y: None,
2850                width: None,
2851                height: None,
2852                new_sheet: false,
2853            },
2854            EmbeddedObjectVerb::MoveSlicer {
2855                slicer_id: 1,
2856                sheet: None,
2857                anchor: Some(String::new()),
2858                offset_x: None,
2859                offset_y: None,
2860                width: None,
2861                height: None,
2862            },
2863            EmbeddedObjectVerb::UpdateChartBorder {
2864                chart_id: 1,
2865                color: Some(String::new()),
2866                clear: false,
2867            },
2868        ];
2869        let ops: HashSet<&str> = verbs
2870            .iter()
2871            .map(EmbeddedObjectVerb::log_operation)
2872            .collect();
2873        let labels: HashSet<&str> = verbs.iter().map(EmbeddedObjectVerb::label).collect();
2874        assert_eq!(ops.len(), verbs.len());
2875        assert_eq!(labels.len(), verbs.len());
2876    }
2877
2878    // ── validate_verb ────────────────────────────────────────────────────
2879
2880    fn add_chart_verb() -> EmbeddedObjectVerb {
2881        EmbeddedObjectVerb::AddChart {
2882            chart_type: "column".to_string(),
2883            domain: "A1:A10".to_string(),
2884            series: vec!["B1:B10".to_string()],
2885            sheet: Some("Sheet1".to_string()),
2886            title: None,
2887            subtitle: None,
2888            legend: None,
2889            stacked: None,
2890            header_count: None,
2891            horizontal_axis_title: None,
2892            vertical_axis_title: None,
2893            pie_hole: None,
2894            anchor: Some("E2".to_string()),
2895            offset_x: None,
2896            offset_y: None,
2897            width: None,
2898            height: None,
2899            new_sheet: false,
2900        }
2901    }
2902
2903    #[test]
2904    fn validate_verb_rejects_add_chart_with_no_series() {
2905        let mut verb = add_chart_verb();
2906        let EmbeddedObjectVerb::AddChart { series, .. } = &mut verb else {
2907            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
2908        };
2909        series.clear();
2910        let err = validate_verb(&verb).unwrap_err();
2911        assert!(err.contains("at least one --series"), "{err}");
2912    }
2913
2914    #[test]
2915    fn validate_verb_rejects_add_chart_with_neither_anchor_nor_new_sheet() {
2916        let mut verb = add_chart_verb();
2917        let EmbeddedObjectVerb::AddChart { anchor, .. } = &mut verb else {
2918            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
2919        };
2920        *anchor = None;
2921        let err = validate_verb(&verb).unwrap_err();
2922        assert!(err.contains("--anchor or --new-sheet"), "{err}");
2923    }
2924
2925    #[test]
2926    fn validate_verb_rejects_new_sheet_combined_with_anchor() {
2927        let mut verb = add_chart_verb();
2928        let EmbeddedObjectVerb::AddChart { new_sheet, .. } = &mut verb else {
2929            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
2930        };
2931        *new_sheet = true;
2932        let err = validate_verb(&verb).unwrap_err();
2933        assert!(err.contains("--new-sheet cannot be combined"), "{err}");
2934    }
2935
2936    #[test]
2937    fn validate_verb_accepts_new_sheet_alone() {
2938        let mut verb = add_chart_verb();
2939        let EmbeddedObjectVerb::AddChart {
2940            anchor, new_sheet, ..
2941        } = &mut verb
2942        else {
2943            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
2944        };
2945        *anchor = None;
2946        *new_sheet = true;
2947        assert!(validate_verb(&verb).is_ok());
2948    }
2949
2950    #[test]
2951    fn validate_verb_rejects_update_chart_with_nothing_to_change() {
2952        let verb = EmbeddedObjectVerb::UpdateChart {
2953            chart_id: 1,
2954            chart_type: None,
2955            domain: None,
2956            series: Vec::new(),
2957            sheet: None,
2958            title: None,
2959            subtitle: None,
2960            legend: None,
2961            stacked: None,
2962            header_count: None,
2963            horizontal_axis_title: None,
2964            vertical_axis_title: None,
2965            pie_hole: None,
2966        };
2967        let err = validate_verb(&verb).unwrap_err();
2968        assert!(err.contains("nothing to change"), "{err}");
2969    }
2970
2971    #[test]
2972    fn validate_verb_accepts_update_chart_domain_alone() {
2973        // `--domain` with no `--series` is a legitimate independent update
2974        // (e.g. the domain range shifted after an insert) — see
2975        // `merge_basic_chart`'s doc comment for why the two are not
2976        // coupled.
2977        let verb = EmbeddedObjectVerb::UpdateChart {
2978            chart_id: 1,
2979            chart_type: None,
2980            domain: Some("A1:A10".to_string()),
2981            series: Vec::new(),
2982            sheet: None,
2983            title: None,
2984            subtitle: None,
2985            legend: None,
2986            stacked: None,
2987            header_count: None,
2988            horizontal_axis_title: None,
2989            vertical_axis_title: None,
2990            pie_hole: None,
2991        };
2992        assert!(validate_verb(&verb).is_ok());
2993    }
2994
2995    #[test]
2996    fn validate_verb_accepts_update_chart_series_alone() {
2997        // `--series` with no `--domain` is likewise legitimate (e.g. adding
2998        // a second series to an existing chart).
2999        let verb = EmbeddedObjectVerb::UpdateChart {
3000            chart_id: 1,
3001            chart_type: None,
3002            domain: None,
3003            series: vec!["B1:B10".to_string()],
3004            sheet: None,
3005            title: None,
3006            subtitle: None,
3007            legend: None,
3008            stacked: None,
3009            header_count: None,
3010            horizontal_axis_title: None,
3011            vertical_axis_title: None,
3012            pie_hole: None,
3013        };
3014        assert!(validate_verb(&verb).is_ok());
3015    }
3016
3017    #[test]
3018    fn validate_verb_accepts_update_chart_domain_and_series_together() {
3019        let verb = EmbeddedObjectVerb::UpdateChart {
3020            chart_id: 1,
3021            chart_type: None,
3022            domain: Some("A1:A10".to_string()),
3023            series: vec!["B1:B10".to_string()],
3024            sheet: None,
3025            title: None,
3026            subtitle: None,
3027            legend: None,
3028            stacked: None,
3029            header_count: None,
3030            horizontal_axis_title: None,
3031            vertical_axis_title: None,
3032            pie_hole: None,
3033        };
3034        assert!(validate_verb(&verb).is_ok());
3035    }
3036
3037    #[test]
3038    fn validate_verb_rejects_add_chart_pie_hole_out_of_range() {
3039        let mut verb = add_chart_verb();
3040        let EmbeddedObjectVerb::AddChart {
3041            chart_type,
3042            pie_hole,
3043            ..
3044        } = &mut verb
3045        else {
3046            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3047        };
3048        // `chart_type` must be `pie` here, or the applicability check now
3049        // fires first (see the ordering test below) and this would assert
3050        // the wrong message.
3051        *chart_type = "pie".to_string();
3052        *pie_hole = Some(1.5);
3053        let err = validate_verb(&verb).unwrap_err();
3054        assert!(
3055            err.contains("--pie-hole must be between 0.0 and 1.0"),
3056            "{err}"
3057        );
3058    }
3059
3060    #[test]
3061    fn validate_verb_accepts_add_chart_pie_hole_in_range() {
3062        let mut verb = add_chart_verb();
3063        let EmbeddedObjectVerb::AddChart {
3064            chart_type,
3065            pie_hole,
3066            ..
3067        } = &mut verb
3068        else {
3069            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3070        };
3071        *chart_type = "pie".to_string();
3072        *pie_hole = Some(0.5);
3073        assert!(validate_verb(&verb).is_ok());
3074    }
3075
3076    #[test]
3077    fn validate_verb_accepts_add_chart_pie_hole_at_each_inclusive_boundary() {
3078        for boundary in [0.0, 1.0] {
3079            let mut verb = add_chart_verb();
3080            let EmbeddedObjectVerb::AddChart {
3081                chart_type,
3082                pie_hole,
3083                ..
3084            } = &mut verb
3085            else {
3086                unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3087            };
3088            *chart_type = "pie".to_string();
3089            *pie_hole = Some(boundary);
3090            assert!(
3091                validate_verb(&verb).is_ok(),
3092                "{boundary} should be in range"
3093            );
3094        }
3095    }
3096
3097    #[test]
3098    fn validate_verb_rejects_add_chart_pie_hole_nan_and_infinite() {
3099        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
3100            let mut verb = add_chart_verb();
3101            let EmbeddedObjectVerb::AddChart {
3102                chart_type,
3103                pie_hole,
3104                ..
3105            } = &mut verb
3106            else {
3107                unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3108            };
3109            *chart_type = "pie".to_string();
3110            *pie_hole = Some(bad);
3111            let err = validate_verb(&verb).unwrap_err();
3112            assert!(
3113                err.contains("--pie-hole must be between 0.0 and 1.0"),
3114                "{bad}: {err}"
3115            );
3116        }
3117    }
3118
3119    #[test]
3120    fn validate_verb_rejects_add_chart_pie_hole_on_a_non_pie_chart_before_the_range_check() {
3121        // `--type column --pie-hole 1.5` is invalid two ways at once
3122        // (inapplicable *and* out of range); the applicability error must
3123        // win, since the range check alone would tell the user to "fix"
3124        // the value only to learn the flag never applied — see
3125        // `validate_verb`'s `AddChart` arm doc comment.
3126        let mut verb = add_chart_verb();
3127        let EmbeddedObjectVerb::AddChart { pie_hole, .. } = &mut verb else {
3128            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3129        };
3130        *pie_hole = Some(1.5);
3131        let err = validate_verb(&verb).unwrap_err();
3132        assert!(err.contains("only applies to a pie chart"), "{err}");
3133        assert!(!err.contains("must be between"), "{err}");
3134    }
3135
3136    #[test]
3137    fn validate_verb_rejects_add_chart_pie_hole_on_a_non_pie_chart_even_in_range() {
3138        let mut verb = add_chart_verb();
3139        let EmbeddedObjectVerb::AddChart { pie_hole, .. } = &mut verb else {
3140            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3141        };
3142        *pie_hole = Some(0.5);
3143        let err = validate_verb(&verb).unwrap_err();
3144        assert!(err.contains("only applies to a pie chart"), "{err}");
3145    }
3146
3147    #[test]
3148    fn validate_verb_rejects_update_chart_pie_hole_out_of_range() {
3149        let verb = EmbeddedObjectVerb::UpdateChart {
3150            chart_id: 1,
3151            chart_type: None,
3152            domain: None,
3153            series: Vec::new(),
3154            sheet: None,
3155            title: None,
3156            subtitle: None,
3157            legend: None,
3158            stacked: None,
3159            header_count: None,
3160            horizontal_axis_title: None,
3161            vertical_axis_title: None,
3162            pie_hole: Some(-0.1),
3163        };
3164        let err = validate_verb(&verb).unwrap_err();
3165        assert!(
3166            err.contains("--pie-hole must be between 0.0 and 1.0"),
3167            "{err}"
3168        );
3169    }
3170
3171    #[test]
3172    fn validate_verb_rejects_update_slicer_with_nothing_to_change() {
3173        let verb = EmbeddedObjectVerb::UpdateSlicer {
3174            slicer_id: 1,
3175            sheet: None,
3176            range: None,
3177            column: None,
3178            hide_values: Vec::new(),
3179            clear_criteria: false,
3180            title: None,
3181            apply_to_pivot_tables: None,
3182        };
3183        let err = validate_verb(&verb).unwrap_err();
3184        assert!(err.contains("nothing to change"), "{err}");
3185    }
3186
3187    #[test]
3188    fn validate_verb_accepts_update_slicer_with_only_clear_criteria() {
3189        let verb = EmbeddedObjectVerb::UpdateSlicer {
3190            slicer_id: 1,
3191            sheet: None,
3192            range: None,
3193            column: None,
3194            hide_values: Vec::new(),
3195            clear_criteria: true,
3196            title: None,
3197            apply_to_pivot_tables: None,
3198        };
3199        assert!(validate_verb(&verb).is_ok());
3200    }
3201
3202    #[test]
3203    fn validate_verb_rejects_update_slicer_clear_criteria_with_hide_values() {
3204        let verb = EmbeddedObjectVerb::UpdateSlicer {
3205            slicer_id: 1,
3206            sheet: None,
3207            range: None,
3208            column: None,
3209            hide_values: vec!["foo".to_string()],
3210            clear_criteria: true,
3211            title: None,
3212            apply_to_pivot_tables: None,
3213        };
3214        let err = validate_verb(&verb).unwrap_err();
3215        assert!(
3216            err.contains("--clear-criteria and --hide-values are mutually exclusive"),
3217            "{err}"
3218        );
3219    }
3220
3221    // ── existing_chart_kind ──────────────────────────────────────────────
3222
3223    #[test]
3224    fn existing_chart_kind_accepts_a_supported_basic_type() {
3225        let spec = ChartSpec {
3226            basic_chart: Some(BasicChartSpec {
3227                chart_type: "COLUMN".to_string(),
3228                ..Default::default()
3229            }),
3230            ..Default::default()
3231        };
3232        assert!(matches!(
3233            existing_chart_kind(&spec),
3234            Ok(ExistingChartKind::Basic)
3235        ));
3236    }
3237
3238    #[test]
3239    fn existing_chart_kind_accepts_pie() {
3240        let spec = ChartSpec {
3241            pie_chart: Some(PieChartSpec::default()),
3242            ..Default::default()
3243        };
3244        assert!(matches!(
3245            existing_chart_kind(&spec),
3246            Ok(ExistingChartKind::Pie)
3247        ));
3248    }
3249
3250    #[test]
3251    fn existing_chart_kind_refuses_an_unsupported_basic_type() {
3252        let spec = ChartSpec {
3253            basic_chart: Some(BasicChartSpec {
3254                chart_type: "COMBO".to_string(),
3255                ..Default::default()
3256            }),
3257            ..Default::default()
3258        };
3259        let err = existing_chart_kind(&spec).unwrap_err();
3260        assert_eq!(err, "COMBO");
3261    }
3262
3263    #[test]
3264    fn existing_chart_kind_refuses_neither_basic_nor_pie() {
3265        let mut extra = BTreeMap::new();
3266        extra.insert("histogramChart".to_string(), serde_json::json!({}));
3267        let spec = ChartSpec {
3268            extra,
3269            ..Default::default()
3270        };
3271        let err = existing_chart_kind(&spec).unwrap_err();
3272        assert_eq!(err, "histogramChart");
3273    }
3274
3275    #[test]
3276    fn existing_chart_kind_names_an_empty_spec_unknown() {
3277        // A spec carrying neither union *and* no unmodelled key to name —
3278        // the error message still has to say something.
3279        let err = existing_chart_kind(&ChartSpec::default()).unwrap_err();
3280        assert_eq!(err, "unknown");
3281    }
3282
3283    // ── merge_chart_spec ─────────────────────────────────────────────────
3284
3285    fn workbook_with_sheet(sheet: Sheet) -> Spreadsheet {
3286        Spreadsheet {
3287            sheets: vec![sheet],
3288            ..Default::default()
3289        }
3290    }
3291
3292    fn basic_chart_sheet(chart_id: i64, chart_type: &str) -> Sheet {
3293        let mut extra = BTreeMap::new();
3294        extra.insert("maximized".to_string(), serde_json::json!(true));
3295        Sheet {
3296            properties: Some(crate::drive::sheets::types::SheetProperties {
3297                sheet_id: Some(0),
3298                title: "Q1".to_string(),
3299                ..Default::default()
3300            }),
3301            charts: vec![EmbeddedChart {
3302                chart_id: Some(chart_id),
3303                spec: Some(ChartSpec {
3304                    title: Some("Old title".to_string()),
3305                    basic_chart: Some(BasicChartSpec {
3306                        chart_type: chart_type.to_string(),
3307                        domains: vec![BasicChartDomain {
3308                            domain: chart_data(GridRange {
3309                                sheet_id: 0,
3310                                start_row_index: Some(0),
3311                                end_row_index: Some(10),
3312                                start_column_index: Some(0),
3313                                end_column_index: Some(1),
3314                            }),
3315                            extra: BTreeMap::new(),
3316                        }],
3317                        ..Default::default()
3318                    }),
3319                    extra,
3320                    ..Default::default()
3321                }),
3322                position: Some(EmbeddedObjectPosition {
3323                    overlay_position: Some(OverlayPosition {
3324                        anchor_cell: GridCoordinate {
3325                            sheet_id: 0,
3326                            row_index: 1,
3327                            column_index: 4,
3328                        },
3329                        ..Default::default()
3330                    }),
3331                    ..Default::default()
3332                }),
3333                border: None,
3334            }],
3335            ..Default::default()
3336        }
3337    }
3338
3339    fn update_chart_verb(chart_id: i64) -> EmbeddedObjectVerb {
3340        EmbeddedObjectVerb::UpdateChart {
3341            chart_id,
3342            chart_type: None,
3343            domain: None,
3344            series: Vec::new(),
3345            sheet: None,
3346            title: Some("New title".to_string()),
3347            subtitle: None,
3348            legend: None,
3349            stacked: None,
3350            header_count: None,
3351            horizontal_axis_title: None,
3352            vertical_axis_title: None,
3353            pie_hole: None,
3354        }
3355    }
3356
3357    #[test]
3358    fn merge_chart_spec_preserves_unmodelled_extra_fields() {
3359        let sheet = basic_chart_sheet(1, "COLUMN");
3360        let workbook = workbook_with_sheet(sheet);
3361        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3362        let verb = update_chart_verb(1);
3363        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3364        assert_eq!(spec.extra.get("maximized"), Some(&serde_json::json!(true)));
3365        assert_eq!(spec.title.as_deref(), Some("New title"));
3366        assert!(summary.contains("title"));
3367    }
3368
3369    #[test]
3370    fn merge_chart_spec_refuses_an_unsupported_existing_kind() {
3371        let sheet = basic_chart_sheet(1, "COMBO");
3372        let workbook = workbook_with_sheet(sheet);
3373        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3374        let verb = update_chart_verb(1);
3375        let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3376        assert!(matches!(
3377            err,
3378            EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
3379        ));
3380    }
3381
3382    #[test]
3383    fn merge_chart_spec_refuses_a_basic_to_pie_switch() {
3384        let sheet = basic_chart_sheet(1, "COLUMN");
3385        let workbook = workbook_with_sheet(sheet);
3386        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3387        let mut verb = update_chart_verb(1);
3388        let EmbeddedObjectVerb::UpdateChart { chart_type, .. } = &mut verb else {
3389            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3390        };
3391        *chart_type = Some("pie".to_string());
3392        let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3393        assert!(matches!(
3394            err,
3395            EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
3396        ));
3397    }
3398
3399    #[test]
3400    fn merge_chart_spec_allows_changing_type_within_the_basic_family() {
3401        let sheet = basic_chart_sheet(1, "COLUMN");
3402        let workbook = workbook_with_sheet(sheet);
3403        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3404        let mut verb = update_chart_verb(1);
3405        let EmbeddedObjectVerb::UpdateChart { chart_type, .. } = &mut verb else {
3406            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3407        };
3408        *chart_type = Some("bar".to_string());
3409        let (spec, _) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3410        assert_eq!(spec.basic_chart.unwrap().chart_type, "BAR");
3411    }
3412
3413    #[test]
3414    fn merge_chart_spec_upserts_an_axis_title_on_a_basic_chart() {
3415        let sheet = basic_chart_sheet(1, "COLUMN");
3416        let workbook = workbook_with_sheet(sheet);
3417        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3418        let mut verb = update_chart_verb(1);
3419        let EmbeddedObjectVerb::UpdateChart {
3420            horizontal_axis_title,
3421            ..
3422        } = &mut verb
3423        else {
3424            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3425        };
3426        *horizontal_axis_title = Some("Quarter".to_string());
3427        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3428        let axis = spec.basic_chart.unwrap().axis;
3429        assert_eq!(axis.len(), 1);
3430        assert_eq!(axis[0].position, "BOTTOM_AXIS");
3431        assert_eq!(axis[0].title.as_deref(), Some("Quarter"));
3432        assert!(summary.contains("horizontal-axis-title"), "{summary}");
3433    }
3434
3435    fn pie_chart_sheet(chart_id: i64) -> Sheet {
3436        Sheet {
3437            properties: Some(crate::drive::sheets::types::SheetProperties {
3438                sheet_id: Some(0),
3439                title: "Q1".to_string(),
3440                ..Default::default()
3441            }),
3442            charts: vec![EmbeddedChart {
3443                chart_id: Some(chart_id),
3444                spec: Some(ChartSpec {
3445                    pie_chart: Some(PieChartSpec {
3446                        domain: chart_data(GridRange {
3447                            sheet_id: 0,
3448                            start_row_index: Some(0),
3449                            end_row_index: Some(10),
3450                            start_column_index: Some(0),
3451                            end_column_index: Some(1),
3452                        }),
3453                        series: chart_data(GridRange {
3454                            sheet_id: 0,
3455                            start_row_index: Some(0),
3456                            end_row_index: Some(10),
3457                            start_column_index: Some(1),
3458                            end_column_index: Some(2),
3459                        }),
3460                        ..Default::default()
3461                    }),
3462                    ..Default::default()
3463                }),
3464                position: None,
3465                border: None,
3466            }],
3467            ..Default::default()
3468        }
3469    }
3470
3471    #[test]
3472    fn merge_chart_spec_refuses_a_basic_only_flag_on_an_existing_pie_chart() {
3473        let workbook = workbook_with_sheet(pie_chart_sheet(1));
3474        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3475        let mut verb = update_chart_verb(1);
3476        let EmbeddedObjectVerb::UpdateChart { header_count, .. } = &mut verb else {
3477            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3478        };
3479        *header_count = Some(1);
3480        let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3481        assert!(matches!(
3482            err,
3483            EmbeddedObjectResult::RefusedInvalidRange { .. }
3484        ));
3485    }
3486
3487    /// A basic chart sheet with both a domain **and** a series already
3488    /// set, distinct from `basic_chart_sheet` (domain only) — needed to
3489    /// prove that changing one independently leaves the other untouched.
3490    fn basic_chart_sheet_with_series(chart_id: i64) -> Sheet {
3491        let mut sheet = basic_chart_sheet(chart_id, "COLUMN");
3492        let basic = sheet.charts[0]
3493            .spec
3494            .as_mut()
3495            .unwrap()
3496            .basic_chart
3497            .as_mut()
3498            .unwrap();
3499        basic.series = vec![BasicChartSeries {
3500            series: chart_data(GridRange {
3501                sheet_id: 0,
3502                start_row_index: Some(0),
3503                end_row_index: Some(10),
3504                start_column_index: Some(1),
3505                end_column_index: Some(2),
3506            }),
3507            target_axis: None,
3508            extra: BTreeMap::new(),
3509        }];
3510        sheet
3511    }
3512
3513    #[test]
3514    fn merge_chart_spec_updates_series_alone_leaving_domain_untouched() {
3515        let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
3516        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3517        let original_domains = existing.basic_chart.as_ref().unwrap().domains.clone();
3518        let mut verb = update_chart_verb(1);
3519        let EmbeddedObjectVerb::UpdateChart {
3520            title,
3521            sheet,
3522            series,
3523            ..
3524        } = &mut verb
3525        else {
3526            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3527        };
3528        *title = None;
3529        *sheet = Some("Q1".to_string());
3530        *series = vec!["C1:C10".to_string()];
3531        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3532        let basic = spec.basic_chart.unwrap();
3533        assert_eq!(
3534            basic.domains, original_domains,
3535            "domain must survive a series-only update"
3536        );
3537        assert_eq!(basic.series.len(), 1);
3538        assert!(summary.contains("series"));
3539        assert!(!summary.contains("domain"));
3540    }
3541
3542    #[test]
3543    fn merge_chart_spec_updates_domain_alone_leaving_series_untouched() {
3544        let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
3545        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3546        let original_series = existing.basic_chart.as_ref().unwrap().series.clone();
3547        let mut verb = update_chart_verb(1);
3548        let EmbeddedObjectVerb::UpdateChart {
3549            title,
3550            sheet,
3551            domain,
3552            ..
3553        } = &mut verb
3554        else {
3555            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3556        };
3557        *title = None;
3558        *sheet = Some("Q1".to_string());
3559        *domain = Some("B1:B10".to_string());
3560        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3561        let basic = spec.basic_chart.unwrap();
3562        assert_eq!(
3563            basic.series, original_series,
3564            "series must survive a domain-only update"
3565        );
3566        assert_eq!(basic.domains.len(), 1);
3567        assert!(summary.contains("domain"));
3568        assert!(!summary.contains("series"));
3569    }
3570
3571    #[test]
3572    fn merge_chart_spec_refuses_pie_hole_on_an_existing_basic_chart() {
3573        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
3574        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3575        let mut verb = update_chart_verb(1);
3576        let EmbeddedObjectVerb::UpdateChart {
3577            title, pie_hole, ..
3578        } = &mut verb
3579        else {
3580            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::UpdateChart above, so this arm can never run"
3581        };
3582        *title = None;
3583        *pie_hole = Some(0.4);
3584        let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3585        assert!(
3586            matches!(err, EmbeddedObjectResult::RefusedInvalidRange { .. }),
3587            "{err:?}"
3588        );
3589    }
3590
3591    // ── summarise_chart / summarise_slicer / describe_position ──────────
3592
3593    #[test]
3594    fn summarise_chart_reports_overlay_position() {
3595        let sheet = basic_chart_sheet(7, "LINE");
3596        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3597        assert_eq!(summary.object_id, 7);
3598        assert_eq!(summary.kind, "chart");
3599        assert_eq!(summary.chart_type.as_deref(), Some("LINE"));
3600        assert_eq!(summary.title.as_deref(), Some("Old title"));
3601        assert_eq!(summary.position, "Q1!E2");
3602    }
3603
3604    #[test]
3605    fn summarise_chart_reports_own_sheet_for_a_new_sheet_chart() {
3606        let sheet = Sheet {
3607            properties: Some(crate::drive::sheets::types::SheetProperties {
3608                sheet_id: Some(9),
3609                title: "Charts".to_string(),
3610                ..Default::default()
3611            }),
3612            charts: vec![EmbeddedChart {
3613                chart_id: Some(3),
3614                spec: Some(ChartSpec {
3615                    pie_chart: Some(PieChartSpec::default()),
3616                    ..Default::default()
3617                }),
3618                position: Some(EmbeddedObjectPosition {
3619                    sheet_id: Some(9),
3620                    new_sheet: Some(true),
3621                    ..Default::default()
3622                }),
3623                border: None,
3624            }],
3625            ..Default::default()
3626        };
3627        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3628        assert_eq!(summary.chart_type.as_deref(), Some("PIE"));
3629        assert_eq!(summary.position, "own sheet (id 9)");
3630    }
3631
3632    #[test]
3633    fn summarise_chart_reports_a_non_default_border_colour_as_hex() {
3634        let mut sheet = basic_chart_sheet(7, "LINE");
3635        sheet.charts[0].border = Some(EmbeddedObjectBorder {
3636            color_style: Some(ColorStyle::rgb(parse_hex_color("#4A86E8").unwrap())),
3637        });
3638        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3639        assert_eq!(summary.border.as_deref(), Some("#4A86E8"));
3640    }
3641
3642    #[test]
3643    fn summarise_chart_reports_a_default_border_colour_as_ambiguous() {
3644        let mut sheet = basic_chart_sheet(7, "LINE");
3645        sheet.charts[0].border = Some(EmbeddedObjectBorder {
3646            color_style: Some(ColorStyle::rgb(Color::default())),
3647        });
3648        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3649        assert_eq!(
3650            summary.border.as_deref(),
3651            Some("#000000 (or cleared — indistinguishable on read)")
3652        );
3653    }
3654
3655    #[test]
3656    fn summarise_chart_reports_an_entirely_empty_color_style_as_ambiguous() {
3657        // Issue #2020: before ColorStyle's rgbColor arm became optional, a
3658        // colorStyle with neither arm set (`{}`) could not even parse. Now
3659        // that it can, it must read as the same ambiguous case as an
3660        // all-zero rgbColor, not as ColorStyle::describe()'s "(unset)".
3661        let mut sheet = basic_chart_sheet(7, "LINE");
3662        sheet.charts[0].border = Some(EmbeddedObjectBorder {
3663            color_style: Some(ColorStyle::default()),
3664        });
3665        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3666        assert_eq!(
3667            summary.border.as_deref(),
3668            Some("#000000 (or cleared — indistinguishable on read)")
3669        );
3670    }
3671
3672    #[test]
3673    fn summarise_chart_reports_a_theme_border_colour_by_name() {
3674        let mut sheet = basic_chart_sheet(7, "LINE");
3675        sheet.charts[0].border = Some(EmbeddedObjectBorder {
3676            color_style: Some(ColorStyle::theme("ACCENT1")),
3677        });
3678        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3679        assert_eq!(summary.border.as_deref(), Some("theme:ACCENT1"));
3680    }
3681
3682    #[test]
3683    fn summarise_slicer_reports_title_and_position() {
3684        let sheet = Sheet {
3685            properties: Some(crate::drive::sheets::types::SheetProperties {
3686                sheet_id: Some(0),
3687                title: "Q1".to_string(),
3688                ..Default::default()
3689            }),
3690            slicers: vec![Slicer {
3691                slicer_id: Some(4),
3692                spec: Some(SlicerSpec {
3693                    title: Some("Region".to_string()),
3694                    ..Default::default()
3695                }),
3696                position: Some(EmbeddedObjectPosition {
3697                    overlay_position: Some(OverlayPosition {
3698                        anchor_cell: GridCoordinate {
3699                            sheet_id: 0,
3700                            row_index: 0,
3701                            column_index: 5,
3702                        },
3703                        ..Default::default()
3704                    }),
3705                    ..Default::default()
3706                }),
3707            }],
3708            ..Default::default()
3709        };
3710        let summary = summarise_slicer(&sheet, &sheet.slicers[0]).unwrap();
3711        assert_eq!(summary.object_id, 4);
3712        assert_eq!(summary.kind, "slicer");
3713        assert_eq!(summary.chart_type, None);
3714        assert_eq!(summary.title.as_deref(), Some("Region"));
3715        assert_eq!(summary.position, "Q1!F1");
3716    }
3717
3718    // ── write_jsonl / log_status ─────────────────────────────────────────
3719
3720    #[test]
3721    fn object_preview_names_kind_title_and_position() {
3722        let object = EmbeddedObjectSummary {
3723            object_id: 5,
3724            kind: "chart".to_string(),
3725            chart_type: Some("PIE".to_string()),
3726            title: Some("Sales".to_string()),
3727            position: "Q1!C2".to_string(),
3728            border: None,
3729        };
3730        assert_eq!(
3731            object_preview(&object),
3732            "id 5: chart (PIE) 'Sales', anchored Q1!C2"
3733        );
3734    }
3735
3736    #[test]
3737    fn object_preview_reports_the_chart_border_colour() {
3738        let object = EmbeddedObjectSummary {
3739            object_id: 5,
3740            kind: "chart".to_string(),
3741            chart_type: Some("PIE".to_string()),
3742            title: Some("Sales".to_string()),
3743            position: "Q1!C2".to_string(),
3744            border: Some("#4A86E8".to_string()),
3745        };
3746        assert_eq!(
3747            object_preview(&object),
3748            "id 5: chart (PIE) 'Sales', anchored Q1!C2, border #4A86E8"
3749        );
3750    }
3751
3752    #[test]
3753    fn object_preview_omits_the_chart_type_and_title_a_slicer_lacks() {
3754        let object = EmbeddedObjectSummary {
3755            object_id: 9,
3756            kind: "slicer".to_string(),
3757            chart_type: None,
3758            title: None,
3759            position: "unknown position".to_string(),
3760            border: None,
3761        };
3762        assert_eq!(
3763            object_preview(&object),
3764            "id 9: slicer, anchored unknown position"
3765        );
3766    }
3767
3768    #[test]
3769    fn write_jsonl_emits_one_line_of_json() {
3770        let outcome = EmbeddedObjectOutcome {
3771            spreadsheet_id: "sheet-1".to_string(),
3772            file_name: Some("Budget".to_string()),
3773            resolved_folder_id: None,
3774            sheet_id: None,
3775            verb: EmbeddedObjectVerb::DeleteChart { chart_id: 3 },
3776            result: EmbeddedObjectResult::Changed {
3777                summary: "delete chart 3".to_string(),
3778                object_id: Some(3),
3779                object: None,
3780            },
3781        };
3782        let mut buf = Vec::new();
3783        outcome.write_jsonl(&mut buf).unwrap();
3784        let text = String::from_utf8(buf).unwrap();
3785        assert_eq!(text.matches('\n').count(), 1);
3786        let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3787        assert_eq!(parsed["result"]["status"], "changed");
3788    }
3789
3790    // ── end-to-end via wiremock ──────────────────────────────────────────
3791
3792    fn test_credentials() -> DriveCredentials {
3793        DriveCredentials {
3794            client_id: "client-1".to_string(),
3795            client_secret: Secret::new("secret-1"),
3796            refresh_token: Secret::new("refresh-1"),
3797            scope: DriveGrantedScopes::READONLY,
3798        }
3799    }
3800
3801    async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
3802        wiremock::Mock::given(wiremock::matchers::method("POST"))
3803            .and(wiremock::matchers::path("/token"))
3804            .respond_with(
3805                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3806                    "access_token": "test-token", "expires_in": 3600,
3807                })),
3808            )
3809            .mount(server)
3810            .await;
3811        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
3812        crate::drive::client::test_support::replace_session(
3813            &mut drive,
3814            &test_credentials(),
3815            &format!("{}/token", server.uri()),
3816        );
3817        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
3818        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
3819        (drive, sheets)
3820    }
3821
3822    fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
3823        let parents: Vec<&str> = parents.to_vec();
3824        wiremock::Mock::given(wiremock::matchers::method("GET"))
3825            .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
3826            .respond_with(
3827                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3828                    "id": id, "name": id, "mimeType": mime_type, "parents": parents,
3829                    "version": "1",
3830                })),
3831            )
3832    }
3833
3834    fn mount_folder(id: &str) -> wiremock::Mock {
3835        mount_file(id, "application/vnd.google-apps.folder", &[])
3836    }
3837
3838    fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
3839        wiremock::Mock::given(wiremock::matchers::method("GET"))
3840            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
3841            .respond_with(
3842                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3843                    "spreadsheetId": "sheet-1",
3844                    "properties": {"title": "Budget"},
3845                    "sheets": sheets,
3846                })),
3847            )
3848    }
3849
3850    fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
3851        let ledger_path = tempfile::tempdir()
3852            .unwrap()
3853            .keep()
3854            .join("lease-ledger.jsonl");
3855        let token = seed_lease(&ledger_path, spreadsheet_id, "1");
3856        (Some(token), ledger_path)
3857    }
3858
3859    fn allow_rule(folder: &str) -> FolderPermissionRule {
3860        FolderPermissionRule {
3861            folder_id: Some(folder.to_string()),
3862            file_id: None,
3863            recursive: true,
3864            allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3865            deny: HashSet::default(),
3866            require_lease: true,
3867        }
3868    }
3869
3870    #[tokio::test]
3871    async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
3872        let server = wiremock::MockServer::start().await;
3873        let (drive, sheets) = clients(&server).await;
3874        mount_file(
3875            "sheet-1",
3876            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3877            &["folder-1"],
3878        )
3879        .mount(&server)
3880        .await;
3881        mount_folder("folder-1").mount(&server).await;
3882        let rules: Vec<FolderPermissionRule> = Vec::new();
3883        let opts = EmbeddedObjectOptions {
3884            spreadsheet_id: "sheet-1".to_string(),
3885            verb: add_chart_verb(),
3886            dry_run: false,
3887            lease_token: None,
3888            ledger_path: std::path::PathBuf::new(),
3889        };
3890        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3891        assert!(matches!(
3892            outcome.result,
3893            EmbeddedObjectResult::Blocked { .. }
3894        ));
3895    }
3896
3897    #[tokio::test]
3898    async fn add_chart_sends_an_add_chart_request_and_reports_the_server_assigned_id() {
3899        let server = wiremock::MockServer::start().await;
3900        let (drive, sheets) = clients(&server).await;
3901        mount_file(
3902            "sheet-1",
3903            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3904            &["folder-1"],
3905        )
3906        .mount(&server)
3907        .await;
3908        mount_folder("folder-1").mount(&server).await;
3909        mount_workbook(serde_json::json!([
3910            {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
3911        ]))
3912        .mount(&server)
3913        .await;
3914        wiremock::Mock::given(wiremock::matchers::method("POST"))
3915            .and(wiremock::matchers::path(
3916                "/v4/spreadsheets/sheet-1:batchUpdate",
3917            ))
3918            .respond_with(
3919                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3920                    "replies": [{"addChart": {"chart": {"chartId": 42}}}]
3921                })),
3922            )
3923            .mount(&server)
3924            .await;
3925        let rules = vec![allow_rule("folder-1")];
3926        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3927        let opts = EmbeddedObjectOptions {
3928            spreadsheet_id: "sheet-1".to_string(),
3929            verb: add_chart_verb(),
3930            dry_run: false,
3931            lease_token,
3932            ledger_path,
3933        };
3934        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3935        assert!(
3936            matches!(
3937                outcome.result,
3938                EmbeddedObjectResult::Changed {
3939                    object_id: Some(42),
3940                    ..
3941                }
3942            ),
3943            "{:?}",
3944            outcome.result
3945        );
3946    }
3947
3948    #[tokio::test]
3949    async fn add_chart_pie_refuses_more_than_one_series_before_any_batch_update_call() {
3950        let server = wiremock::MockServer::start().await;
3951        let (drive, sheets) = clients(&server).await;
3952        mount_file(
3953            "sheet-1",
3954            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3955            &["folder-1"],
3956        )
3957        .mount(&server)
3958        .await;
3959        mount_folder("folder-1").mount(&server).await;
3960        mount_workbook(serde_json::json!([
3961            {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
3962        ]))
3963        .mount(&server)
3964        .await;
3965        let rules = vec![allow_rule("folder-1")];
3966        let mut verb = add_chart_verb();
3967        let EmbeddedObjectVerb::AddChart {
3968            chart_type, series, ..
3969        } = &mut verb
3970        else {
3971            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
3972        };
3973        *chart_type = "pie".to_string();
3974        series.push("C1:C10".to_string());
3975        let opts = EmbeddedObjectOptions {
3976            spreadsheet_id: "sheet-1".to_string(),
3977            verb,
3978            dry_run: true,
3979            lease_token: None,
3980            ledger_path: std::path::PathBuf::new(),
3981        };
3982        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3983        assert!(
3984            matches!(
3985                outcome.result,
3986                EmbeddedObjectResult::RefusedInvalidRange { .. }
3987            ),
3988            "{:?}",
3989            outcome.result
3990        );
3991    }
3992
3993    #[tokio::test]
3994    async fn update_chart_refuses_an_unsupported_existing_chart_with_no_batch_update_call() {
3995        let server = wiremock::MockServer::start().await;
3996        let (drive, sheets) = clients(&server).await;
3997        mount_file(
3998            "sheet-1",
3999            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4000            &["folder-1"],
4001        )
4002        .mount(&server)
4003        .await;
4004        mount_folder("folder-1").mount(&server).await;
4005        mount_workbook(serde_json::json!([
4006            {
4007                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4008                "charts": [{
4009                    "chartId": 1,
4010                    "spec": {"basicChart": {"chartType": "COMBO"}},
4011                }],
4012            },
4013        ]))
4014        .mount(&server)
4015        .await;
4016        let rules = vec![allow_rule("folder-1")];
4017        let opts = EmbeddedObjectOptions {
4018            spreadsheet_id: "sheet-1".to_string(),
4019            verb: update_chart_verb(1),
4020            dry_run: false,
4021            lease_token: None,
4022            ledger_path: std::path::PathBuf::new(),
4023        };
4024        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4025        assert!(
4026            matches!(
4027                outcome.result,
4028                EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
4029            ),
4030            "{:?}",
4031            outcome.result
4032        );
4033    }
4034
4035    #[tokio::test]
4036    async fn update_chart_refuses_an_unknown_id() {
4037        let server = wiremock::MockServer::start().await;
4038        let (drive, sheets) = clients(&server).await;
4039        mount_file(
4040            "sheet-1",
4041            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4042            &["folder-1"],
4043        )
4044        .mount(&server)
4045        .await;
4046        mount_folder("folder-1").mount(&server).await;
4047        mount_workbook(serde_json::json!([
4048            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
4049        ]))
4050        .mount(&server)
4051        .await;
4052        let rules = vec![allow_rule("folder-1")];
4053        let opts = EmbeddedObjectOptions {
4054            spreadsheet_id: "sheet-1".to_string(),
4055            verb: update_chart_verb(99),
4056            dry_run: true,
4057            lease_token: None,
4058            ledger_path: std::path::PathBuf::new(),
4059        };
4060        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4061        assert!(matches!(
4062            outcome.result,
4063            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
4064        ));
4065    }
4066
4067    #[tokio::test]
4068    async fn delete_chart_reports_a_preview_under_dry_run_with_no_batch_update_call() {
4069        let server = wiremock::MockServer::start().await;
4070        let (drive, sheets) = clients(&server).await;
4071        mount_file(
4072            "sheet-1",
4073            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4074            &["folder-1"],
4075        )
4076        .mount(&server)
4077        .await;
4078        mount_folder("folder-1").mount(&server).await;
4079        mount_workbook(serde_json::json!([
4080            {
4081                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4082                "charts": [{
4083                    "chartId": 5,
4084                    "spec": {"title": "Sales", "pieChart": {
4085                        "domain": {"sourceRange": {"sources": [{"sheetId": 0}]}},
4086                        "series": {"sourceRange": {"sources": [{"sheetId": 0}]}},
4087                    }},
4088                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
4089                }],
4090            },
4091        ]))
4092        .mount(&server)
4093        .await;
4094        let rules = vec![allow_rule("folder-1")];
4095        let opts = EmbeddedObjectOptions {
4096            spreadsheet_id: "sheet-1".to_string(),
4097            verb: EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
4098            dry_run: true,
4099            lease_token: None,
4100            ledger_path: std::path::PathBuf::new(),
4101        };
4102        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4103        match outcome.result {
4104            EmbeddedObjectResult::WouldChange { object, .. } => {
4105                let object = object.expect("delete-chart previews the object");
4106                assert_eq!(object.object_id, 5);
4107                assert_eq!(object.title.as_deref(), Some("Sales"));
4108                assert_eq!(object.position, "Q1!C2");
4109            }
4110            other => panic!("expected WouldChange, got {other:?}"),
4111        }
4112    }
4113
4114    #[tokio::test]
4115    async fn add_slicer_sends_an_add_slicer_request_and_reports_the_server_assigned_id() {
4116        let server = wiremock::MockServer::start().await;
4117        let (drive, sheets) = clients(&server).await;
4118        mount_file(
4119            "sheet-1",
4120            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4121            &["folder-1"],
4122        )
4123        .mount(&server)
4124        .await;
4125        mount_folder("folder-1").mount(&server).await;
4126        mount_workbook(serde_json::json!([
4127            {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
4128        ]))
4129        .mount(&server)
4130        .await;
4131        wiremock::Mock::given(wiremock::matchers::method("POST"))
4132            .and(wiremock::matchers::path(
4133                "/v4/spreadsheets/sheet-1:batchUpdate",
4134            ))
4135            .respond_with(
4136                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4137                    "replies": [{"addSlicer": {"slicer": {"slicerId": 8}}}]
4138                })),
4139            )
4140            .mount(&server)
4141            .await;
4142        let rules = vec![allow_rule("folder-1")];
4143        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
4144        let opts = EmbeddedObjectOptions {
4145            spreadsheet_id: "sheet-1".to_string(),
4146            verb: EmbeddedObjectVerb::AddSlicer {
4147                sheet: Some("Q1".to_string()),
4148                range: "A1:D10".to_string(),
4149                column: 1,
4150                hide_values: vec!["Closed".to_string()],
4151                title: Some("Status".to_string()),
4152                apply_to_pivot_tables: None,
4153                anchor: "F2".to_string(),
4154                offset_x: None,
4155                offset_y: None,
4156                width: None,
4157                height: None,
4158            },
4159            dry_run: false,
4160            lease_token,
4161            ledger_path,
4162        };
4163        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4164        assert!(
4165            matches!(
4166                outcome.result,
4167                EmbeddedObjectResult::Changed {
4168                    object_id: Some(8),
4169                    ..
4170                }
4171            ),
4172            "{:?}",
4173            outcome.result
4174        );
4175    }
4176
4177    #[tokio::test]
4178    async fn update_slicer_names_only_the_changed_fields_in_the_mask() {
4179        let server = wiremock::MockServer::start().await;
4180        let (drive, sheets) = clients(&server).await;
4181        mount_file(
4182            "sheet-1",
4183            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4184            &["folder-1"],
4185        )
4186        .mount(&server)
4187        .await;
4188        mount_folder("folder-1").mount(&server).await;
4189        mount_workbook(serde_json::json!([
4190            {
4191                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4192                "slicers": [{
4193                    "slicerId": 4,
4194                    "spec": {"title": "Region"},
4195                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
4196                }],
4197            },
4198        ]))
4199        .mount(&server)
4200        .await;
4201        let rules = vec![allow_rule("folder-1")];
4202        let opts = EmbeddedObjectOptions {
4203            spreadsheet_id: "sheet-1".to_string(),
4204            verb: EmbeddedObjectVerb::UpdateSlicer {
4205                slicer_id: 4,
4206                sheet: None,
4207                range: None,
4208                column: None,
4209                hide_values: Vec::new(),
4210                clear_criteria: false,
4211                title: Some("Territory".to_string()),
4212                apply_to_pivot_tables: None,
4213            },
4214            dry_run: true,
4215            lease_token: None,
4216            ledger_path: std::path::PathBuf::new(),
4217        };
4218        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4219        match outcome.result {
4220            EmbeddedObjectResult::WouldChange { summary, .. } => {
4221                assert!(summary.contains("title"), "{summary}");
4222            }
4223            other => panic!("expected WouldChange, got {other:?}"),
4224        }
4225    }
4226
4227    #[tokio::test]
4228    async fn delete_slicer_reports_a_preview_under_dry_run() {
4229        let server = wiremock::MockServer::start().await;
4230        let (drive, sheets) = clients(&server).await;
4231        mount_file(
4232            "sheet-1",
4233            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4234            &["folder-1"],
4235        )
4236        .mount(&server)
4237        .await;
4238        mount_folder("folder-1").mount(&server).await;
4239        mount_workbook(serde_json::json!([
4240            {
4241                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4242                "slicers": [{
4243                    "slicerId": 9,
4244                    "spec": {"title": "Region"},
4245                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
4246                }],
4247            },
4248        ]))
4249        .mount(&server)
4250        .await;
4251        let rules = vec![allow_rule("folder-1")];
4252        let opts = EmbeddedObjectOptions {
4253            spreadsheet_id: "sheet-1".to_string(),
4254            verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
4255            dry_run: true,
4256            lease_token: None,
4257            ledger_path: std::path::PathBuf::new(),
4258        };
4259        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4260        match outcome.result {
4261            EmbeddedObjectResult::WouldChange { object, .. } => {
4262                let object = object.expect("delete-slicer previews the object");
4263                assert_eq!(object.object_id, 9);
4264                assert_eq!(object.kind, "slicer");
4265                assert_eq!(object.title.as_deref(), Some("Region"));
4266            }
4267            other => panic!("expected WouldChange, got {other:?}"),
4268        }
4269    }
4270
4271    #[tokio::test]
4272    async fn dry_run_makes_no_batch_update_call() {
4273        let server = wiremock::MockServer::start().await;
4274        let (drive, sheets) = clients(&server).await;
4275        mount_file(
4276            "sheet-1",
4277            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4278            &["folder-1"],
4279        )
4280        .mount(&server)
4281        .await;
4282        mount_folder("folder-1").mount(&server).await;
4283        mount_workbook(serde_json::json!([
4284            {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
4285        ]))
4286        .mount(&server)
4287        .await;
4288        // No batchUpdate mock is registered at all — a call would 404 and
4289        // surface as `Failed`, not `WouldChange`.
4290        let rules = vec![allow_rule("folder-1")];
4291        let opts = EmbeddedObjectOptions {
4292            spreadsheet_id: "sheet-1".to_string(),
4293            verb: add_chart_verb(),
4294            dry_run: true,
4295            lease_token: None,
4296            ledger_path: std::path::PathBuf::new(),
4297        };
4298        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4299        assert!(
4300            matches!(outcome.result, EmbeddedObjectResult::WouldChange { .. }),
4301            "{:?}",
4302            outcome.result
4303        );
4304    }
4305
4306    /// Runs `verb` against a one-sheet (`Q1`) workbook carrying slicer 4
4307    /// over `B2:F8` filtering absolute column 5 (F), with a `batchUpdate`
4308    /// mock that echoes a new slicer id — so a refusal proves the check
4309    /// ran before the call, not that the call failed. The real run carries
4310    /// a valid lease so it reaches the plan builder (issue #1945).
4311    async fn run_slicer_column_verb(
4312        verb: EmbeddedObjectVerb,
4313        dry_run: bool,
4314    ) -> EmbeddedObjectResult {
4315        let server = wiremock::MockServer::start().await;
4316        let (drive, sheets) = clients(&server).await;
4317        mount_file(
4318            "sheet-1",
4319            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4320            &["folder-1"],
4321        )
4322        .mount(&server)
4323        .await;
4324        mount_folder("folder-1").mount(&server).await;
4325        mount_workbook(serde_json::json!([
4326            {
4327                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4328                "slicers": [{
4329                    "slicerId": 4,
4330                    "spec": {
4331                        "title": "Region",
4332                        "columnIndex": 5,
4333                        "dataRange": {
4334                            "sheetId": 0,
4335                            "startRowIndex": 1, "endRowIndex": 8,
4336                            "startColumnIndex": 1, "endColumnIndex": 6,
4337                        },
4338                    },
4339                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 7}}},
4340                }],
4341            },
4342        ]))
4343        .mount(&server)
4344        .await;
4345        wiremock::Mock::given(wiremock::matchers::method("POST"))
4346            .and(wiremock::matchers::path(
4347                "/v4/spreadsheets/sheet-1:batchUpdate",
4348            ))
4349            .respond_with(
4350                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4351                    "replies": [{"addSlicer": {"slicer": {"slicerId": 8}}}]
4352                })),
4353            )
4354            .mount(&server)
4355            .await;
4356        let rules = vec![allow_rule("folder-1")];
4357        let (lease_token, ledger_path) = if dry_run {
4358            (None, std::path::PathBuf::new())
4359        } else {
4360            leased_opts_for("sheet-1")
4361        };
4362        let opts = EmbeddedObjectOptions {
4363            spreadsheet_id: "sheet-1".to_string(),
4364            verb,
4365            dry_run,
4366            lease_token,
4367            ledger_path,
4368        };
4369        embedded_object(&drive, &sheets, &opts, &rules).await.result
4370    }
4371
4372    fn add_slicer_over(range: &str, column: i64) -> EmbeddedObjectVerb {
4373        EmbeddedObjectVerb::AddSlicer {
4374            sheet: Some("Q1".to_string()),
4375            range: range.to_string(),
4376            column,
4377            hide_values: Vec::new(),
4378            title: None,
4379            apply_to_pivot_tables: None,
4380            anchor: "H2".to_string(),
4381            offset_x: None,
4382            offset_y: None,
4383            width: None,
4384            height: None,
4385        }
4386    }
4387
4388    fn update_slicer_4(range: Option<&str>, column: Option<i64>) -> EmbeddedObjectVerb {
4389        EmbeddedObjectVerb::UpdateSlicer {
4390            slicer_id: 4,
4391            sheet: Some("Q1".to_string()),
4392            range: range.map(str::to_string),
4393            column,
4394            hide_values: Vec::new(),
4395            clear_criteria: false,
4396            title: None,
4397            apply_to_pivot_tables: None,
4398        }
4399    }
4400
4401    #[tokio::test]
4402    async fn add_slicer_refuses_a_column_outside_the_range_under_both_dry_run_and_a_real_run() {
4403        // Column 0 is absolute column A, left of B2:F8 — the live API 400s it.
4404        for dry_run in [true, false] {
4405            let result = run_slicer_column_verb(add_slicer_over("B2:F8", 0), dry_run).await;
4406            assert_invalid(&result, "0 (column A) is outside the slicer's range");
4407            assert_invalid(&result, "1..=5 (B–F)");
4408        }
4409    }
4410
4411    #[tokio::test]
4412    async fn add_slicer_accepts_an_absolute_column_inside_a_range_not_starting_at_a() {
4413        // Column 5 is absolute column F — inside B2:F8, though it would be
4414        // out of bounds read as an offset within the range.
4415        let preview = run_slicer_column_verb(add_slicer_over("B2:F8", 5), true).await;
4416        assert!(
4417            matches!(preview, EmbeddedObjectResult::WouldChange { .. }),
4418            "{preview:?}"
4419        );
4420        let real = run_slicer_column_verb(add_slicer_over("B2:F8", 5), false).await;
4421        assert!(
4422            matches!(
4423                real,
4424                EmbeddedObjectResult::Changed {
4425                    object_id: Some(8),
4426                    ..
4427                }
4428            ),
4429            "{real:?}"
4430        );
4431    }
4432
4433    #[tokio::test]
4434    async fn update_slicer_refuses_a_column_outside_the_range_under_both_dry_run_and_a_real_run() {
4435        let cases = [
4436            // A new column outside the slicer's existing B2:F8 range.
4437            (
4438                update_slicer_4(None, Some(0)),
4439                "0 (column A)",
4440                "1..=5 (B–F)",
4441            ),
4442            // One column past the right edge (G).
4443            (
4444                update_slicer_4(None, Some(6)),
4445                "6 (column G)",
4446                "1..=5 (B–F)",
4447            ),
4448            // A new range the slicer's existing column 5 (F) falls outside.
4449            (
4450                update_slicer_4(Some("H1:J5"), None),
4451                "5 (column F)",
4452                "7..=9 (H–J)",
4453            ),
4454            // Both replaced, still mismatched.
4455            (
4456                update_slicer_4(Some("H1:J5"), Some(1)),
4457                "1 (column B)",
4458                "7..=9 (H–J)",
4459            ),
4460        ];
4461        for (verb, column, valid) in cases {
4462            for dry_run in [true, false] {
4463                let result = run_slicer_column_verb(verb.clone(), dry_run).await;
4464                assert_invalid(&result, column);
4465                assert_invalid(&result, valid);
4466            }
4467        }
4468    }
4469
4470    #[tokio::test]
4471    async fn update_slicer_accepts_an_absolute_column_inside_a_range_not_starting_at_a() {
4472        for verb in [
4473            update_slicer_4(None, Some(5)),
4474            update_slicer_4(Some("C2:F8"), None),
4475            update_slicer_4(Some("H1:J5"), Some(9)),
4476        ] {
4477            let result = run_slicer_column_verb(verb, true).await;
4478            assert!(
4479                matches!(result, EmbeddedObjectResult::WouldChange { .. }),
4480                "{result:?}"
4481            );
4482        }
4483    }
4484
4485    #[test]
4486    fn check_slicer_column_in_range_leaves_an_open_ended_edge_unbounded() {
4487        let whole_rows = GridRange {
4488            sheet_id: 0,
4489            start_row_index: Some(1),
4490            end_row_index: Some(5),
4491            start_column_index: None,
4492            end_column_index: None,
4493        };
4494        assert!(check_slicer_column_in_range(0, &whole_rows).is_ok());
4495        assert!(check_slicer_column_in_range(700, &whole_rows).is_ok());
4496        let from_c = GridRange {
4497            start_column_index: Some(2),
4498            ..whole_rows
4499        };
4500        assert!(check_slicer_column_in_range(2, &from_c).is_ok());
4501        assert_invalid(
4502            &check_slicer_column_in_range(1, &from_c).unwrap_err(),
4503            "valid columns are 2 or greater (C onward)",
4504        );
4505        assert_invalid(
4506            &check_slicer_column_in_range(-1, &from_c).unwrap_err(),
4507            "slicer column -1 is outside",
4508        );
4509        let empty = GridRange {
4510            start_column_index: Some(2),
4511            end_column_index: Some(2),
4512            ..whole_rows
4513        };
4514        assert_invalid(
4515            &check_slicer_column_in_range(2, &empty).unwrap_err(),
4516            "the range spans no columns",
4517        );
4518    }
4519
4520    #[test]
4521    fn charts_from_workbook_and_slicers_from_workbook_report_every_object() {
4522        let mut with_slicer = basic_chart_sheet(1, "COLUMN");
4523        with_slicer.slicers.push(Slicer {
4524            slicer_id: Some(2),
4525            spec: Some(SlicerSpec::default()),
4526            position: Some(EmbeddedObjectPosition {
4527                overlay_position: Some(OverlayPosition {
4528                    anchor_cell: GridCoordinate {
4529                        sheet_id: 0,
4530                        row_index: 0,
4531                        column_index: 0,
4532                    },
4533                    ..Default::default()
4534                }),
4535                ..Default::default()
4536            }),
4537        });
4538        let workbook = workbook_with_sheet(with_slicer);
4539        assert_eq!(charts_from_workbook(&workbook).len(), 1);
4540        assert_eq!(slicers_from_workbook(&workbook).len(), 1);
4541    }
4542
4543    // ── shared assertion helpers ─────────────────────────────────────────
4544
4545    /// [`Plan`] deliberately carries no `Debug`, so `Result::unwrap_err` is
4546    /// unavailable on a plan builder's result; this is the equivalent.
4547    #[track_caller]
4548    fn refusal(plan: Result<Plan, EmbeddedObjectResult>) -> EmbeddedObjectResult {
4549        plan.err().expect("expected the plan builder to refuse")
4550    }
4551
4552    /// Asserts a refusal is [`EmbeddedObjectResult::RefusedInvalidRange`]
4553    /// and that its detail mentions `needle`.
4554    ///
4555    /// Written against the rendered `Debug` rather than a `match` so that
4556    /// no call site needs an arm that can never run.
4557    #[track_caller]
4558    fn assert_invalid(result: &EmbeddedObjectResult, needle: &str) {
4559        let rendered = format!("{result:?}");
4560        assert!(
4561            matches!(result, EmbeddedObjectResult::RefusedInvalidRange { .. }),
4562            "expected RefusedInvalidRange, got {rendered}"
4563        );
4564        assert!(rendered.contains(needle), "{rendered}");
4565    }
4566
4567    /// A one-sheet workbook with no charts or slicers on it.
4568    fn plain_workbook(title: &str) -> Spreadsheet {
4569        workbook_with_sheet(Sheet {
4570            properties: Some(crate::drive::sheets::types::SheetProperties {
4571                sheet_id: Some(0),
4572                title: title.to_string(),
4573                ..Default::default()
4574            }),
4575            ..Default::default()
4576        })
4577    }
4578
4579    // ── resolve_anchor ───────────────────────────────────────────────────
4580
4581    #[test]
4582    fn resolve_anchor_refuses_an_open_ended_range() {
4583        let workbook = plain_workbook("Sheet1");
4584        let err = resolve_anchor(&workbook, Some("Sheet1"), "A:A")
4585            .expect_err("an open-ended range is not a single cell");
4586        assert_invalid(&err, "not an open-ended range");
4587    }
4588
4589    #[test]
4590    fn resolve_anchor_refuses_a_multi_cell_range() {
4591        let workbook = plain_workbook("Sheet1");
4592        let err = resolve_anchor(&workbook, Some("Sheet1"), "A1:B2")
4593            .expect_err("a bounded 2x2 range is not a single cell");
4594        assert_invalid(&err, "not a range");
4595    }
4596
4597    #[test]
4598    fn resolve_anchor_accepts_a_single_cell() {
4599        let workbook = plain_workbook("Sheet1");
4600        let cell = resolve_anchor(&workbook, Some("Sheet1"), "E2").unwrap();
4601        assert_eq!(cell.sheet_id, 0);
4602        assert_eq!(cell.row_index, 1);
4603        assert_eq!(cell.column_index, 4);
4604    }
4605
4606    // ── build_add_chart ──────────────────────────────────────────────────
4607
4608    /// Mutates a freshly-built [`add_chart_verb`] through a closure, so the
4609    /// many one-flag variations below need no destructuring boilerplate.
4610    fn add_chart_verb_with(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
4611        let mut verb = add_chart_verb();
4612        tweak(&mut verb);
4613        verb
4614    }
4615
4616    fn set_pie(verb: &mut EmbeddedObjectVerb) {
4617        let EmbeddedObjectVerb::AddChart { chart_type, .. } = verb else {
4618            unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4619        };
4620        *chart_type = "pie".to_string();
4621    }
4622
4623    #[test]
4624    fn build_add_chart_puts_the_chart_on_its_own_new_sheet() {
4625        let workbook = plain_workbook("Sheet1");
4626        let verb = add_chart_verb_with(|verb| {
4627            let EmbeddedObjectVerb::AddChart {
4628                anchor, new_sheet, ..
4629            } = verb
4630            else {
4631                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4632            };
4633            *anchor = None;
4634            *new_sheet = true;
4635        });
4636        let plan = build_add_chart(&workbook, &verb).unwrap();
4637        assert_eq!(plan.sheet_id, None, "a new-sheet chart has no host sheet");
4638        let request = serde_json::to_value(&plan.request).unwrap();
4639        assert_eq!(request["addChart"]["chart"]["position"]["newSheet"], true);
4640        assert!(request["addChart"]["chart"]["position"]["overlayPosition"].is_null());
4641    }
4642
4643    #[test]
4644    fn build_add_chart_refuses_neither_anchor_nor_new_sheet() {
4645        // `validate_verb` rejects this combination first in the real flow;
4646        // `build_add_chart` keeps its own check so a non-CLI caller reaching
4647        // the builder directly still cannot produce a position-less chart.
4648        let workbook = plain_workbook("Sheet1");
4649        let verb = add_chart_verb_with(|verb| {
4650            let EmbeddedObjectVerb::AddChart { anchor, .. } = verb else {
4651                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4652            };
4653            *anchor = None;
4654        });
4655        let err = refusal(build_add_chart(&workbook, &verb));
4656        assert_invalid(&err, "either --anchor or --new-sheet is required");
4657    }
4658
4659    #[test]
4660    fn build_add_chart_propagates_an_unresolvable_anchor() {
4661        // The `?` on `overlay_position` is the only path out of
4662        // `build_add_chart` that a *position* can refuse on.
4663        let workbook = plain_workbook("Sheet1");
4664        let verb = add_chart_verb_with(|verb| {
4665            let EmbeddedObjectVerb::AddChart { anchor, .. } = verb else {
4666                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4667            };
4668            *anchor = Some("E2:F3".to_string());
4669        });
4670        let err = refusal(build_add_chart(&workbook, &verb));
4671        assert_invalid(&err, "must name a single cell, not a range");
4672    }
4673
4674    #[test]
4675    fn build_add_chart_refuses_pie_hole_on_a_basic_chart() {
4676        let workbook = plain_workbook("Sheet1");
4677        let verb = add_chart_verb_with(|verb| {
4678            let EmbeddedObjectVerb::AddChart { pie_hole, .. } = verb else {
4679                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4680            };
4681            *pie_hole = Some(0.4);
4682        });
4683        let err = refusal(build_add_chart(&workbook, &verb));
4684        assert_invalid(&err, "--pie-hole only applies to a pie chart");
4685    }
4686
4687    #[test]
4688    fn build_add_chart_refuses_every_basic_only_flag_on_a_pie_chart() {
4689        let workbook = plain_workbook("Sheet1");
4690        for (tweak, needle) in [
4691            (
4692                Box::new(|verb: &mut EmbeddedObjectVerb| {
4693                    let EmbeddedObjectVerb::AddChart { header_count, .. } = verb else {
4694                        unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4695                    };
4696                    *header_count = Some(1);
4697                }) as Box<dyn FnOnce(&mut EmbeddedObjectVerb)>,
4698                "--header-count only applies",
4699            ),
4700            (
4701                Box::new(|verb: &mut EmbeddedObjectVerb| {
4702                    let EmbeddedObjectVerb::AddChart { stacked, .. } = verb else {
4703                        unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4704                    };
4705                    *stacked = Some("stacked".to_string());
4706                }),
4707                "--stacked only applies",
4708            ),
4709            (
4710                Box::new(|verb: &mut EmbeddedObjectVerb| {
4711                    let EmbeddedObjectVerb::AddChart {
4712                        horizontal_axis_title,
4713                        ..
4714                    } = verb
4715                    else {
4716                        unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4717                    };
4718                    *horizontal_axis_title = Some("Quarter".to_string());
4719                }),
4720                "--horizontal-axis-title/--vertical-axis-title only apply",
4721            ),
4722            (
4723                Box::new(|verb: &mut EmbeddedObjectVerb| {
4724                    let EmbeddedObjectVerb::AddChart {
4725                        vertical_axis_title,
4726                        ..
4727                    } = verb
4728                    else {
4729                        unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4730                    };
4731                    *vertical_axis_title = Some("Revenue".to_string());
4732                }),
4733                "--horizontal-axis-title/--vertical-axis-title only apply",
4734            ),
4735        ] {
4736            let verb = add_chart_verb_with(|verb| {
4737                set_pie(verb);
4738                tweak(verb);
4739            });
4740            let err = refusal(build_add_chart(&workbook, &verb));
4741            assert_invalid(&err, needle);
4742        }
4743    }
4744
4745    #[test]
4746    fn build_add_chart_refuses_a_pie_chart_with_more_than_one_series() {
4747        let workbook = plain_workbook("Sheet1");
4748        let verb = add_chart_verb_with(|verb| {
4749            set_pie(verb);
4750            let EmbeddedObjectVerb::AddChart { series, .. } = verb else {
4751                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4752            };
4753            series.push("C1:C10".to_string());
4754        });
4755        let err = refusal(build_add_chart(&workbook, &verb));
4756        assert_invalid(&err, "a pie chart takes exactly one --series");
4757    }
4758
4759    #[test]
4760    fn build_add_chart_builds_a_full_pie_chart_spec() {
4761        let workbook = plain_workbook("Sheet1");
4762        let verb = add_chart_verb_with(|verb| {
4763            set_pie(verb);
4764            let EmbeddedObjectVerb::AddChart {
4765                title,
4766                subtitle,
4767                legend,
4768                pie_hole,
4769                ..
4770            } = verb
4771            else {
4772                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
4773            };
4774            *title = Some("Share".to_string());
4775            *subtitle = Some("by region".to_string());
4776            *legend = Some("right".to_string());
4777            *pie_hole = Some(0.4);
4778        });
4779        let plan = build_add_chart(&workbook, &verb).unwrap();
4780        assert_eq!(plan.sheet_id, Some(0));
4781        assert_eq!(plan.summary, "add pie chart 'Share'");
4782        assert!(plan.before.is_none());
4783        assert_eq!(plan.existing_id, None);
4784        let spec = &serde_json::to_value(&plan.request).unwrap()["addChart"]["chart"]["spec"];
4785        assert_eq!(spec["title"], "Share");
4786        assert_eq!(spec["subtitle"], "by region");
4787        assert!(
4788            spec["basicChart"].is_null(),
4789            "a pie chart has no basicChart"
4790        );
4791        assert_eq!(spec["pieChart"]["legendPosition"], "RIGHT_LEGEND");
4792        assert_eq!(spec["pieChart"]["pieHole"], 0.4);
4793        assert_eq!(
4794            spec["pieChart"]["domain"]["sourceRange"]["sources"][0]["startColumnIndex"],
4795            0
4796        );
4797        assert_eq!(
4798            spec["pieChart"]["series"]["sourceRange"]["sources"][0]["startColumnIndex"],
4799            1
4800        );
4801    }
4802
4803    // ── describe_position ────────────────────────────────────────────────
4804
4805    #[test]
4806    fn summarise_chart_reports_no_type_for_a_spec_that_is_neither_basic_nor_pie() {
4807        // A histogram (or any chart kind this crate doesn't model) still
4808        // has to list, even though its type can't be named.
4809        let sheet = Sheet {
4810            properties: Some(crate::drive::sheets::types::SheetProperties {
4811                sheet_id: Some(0),
4812                title: "Q1".to_string(),
4813                ..Default::default()
4814            }),
4815            charts: vec![EmbeddedChart {
4816                chart_id: Some(2),
4817                spec: Some(ChartSpec::default()),
4818                position: None,
4819                border: None,
4820            }],
4821            ..Default::default()
4822        };
4823        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
4824        assert_eq!(summary.chart_type, None);
4825        assert_eq!(summary.object_id, 2);
4826    }
4827
4828    #[test]
4829    fn describe_position_names_an_own_sheet_chart_with_and_without_an_id() {
4830        let sheet = plain_workbook("Q1").sheets.remove(0);
4831        let anonymous = EmbeddedObjectPosition {
4832            new_sheet: Some(true),
4833            ..Default::default()
4834        };
4835        assert_eq!(describe_position(&sheet, Some(&anonymous)), "own sheet");
4836        let identified = EmbeddedObjectPosition {
4837            sheet_id: Some(9),
4838            ..Default::default()
4839        };
4840        assert_eq!(
4841            describe_position(&sheet, Some(&identified)),
4842            "own sheet (id 9)"
4843        );
4844    }
4845
4846    #[test]
4847    fn describe_position_falls_back_to_unknown_without_an_overlay() {
4848        let sheet = plain_workbook("Q1").sheets.remove(0);
4849        let empty = EmbeddedObjectPosition::default();
4850        assert_eq!(
4851            describe_position(&sheet, Some(&empty)),
4852            "unknown position",
4853            "a position carrying neither an overlay nor a sheet is unknown"
4854        );
4855        assert_eq!(describe_position(&sheet, None), "unknown position");
4856    }
4857
4858    #[test]
4859    fn summarise_chart_reports_pie_for_a_pie_spec() {
4860        let sheet = pie_chart_sheet(3);
4861        let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
4862        assert_eq!(summary.chart_type.as_deref(), Some("PIE"));
4863        assert_eq!(
4864            summary.position, "unknown position",
4865            "pie_chart_sheet's chart carries no position at all"
4866        );
4867    }
4868
4869    // ── merge_chart_spec: title / subtitle / legend ──────────────────────
4870
4871    /// Mutates a freshly-built [`update_chart_verb`] through a closure,
4872    /// mirroring [`add_chart_verb_with`].
4873    fn update_chart_verb_with(
4874        chart_id: i64,
4875        tweak: impl FnOnce(&mut EmbeddedObjectVerb),
4876    ) -> EmbeddedObjectVerb {
4877        let mut verb = update_chart_verb(chart_id);
4878        tweak(&mut verb);
4879        verb
4880    }
4881
4882    #[test]
4883    fn merge_chart_spec_sets_subtitle_and_legend_alongside_the_title() {
4884        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
4885        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4886        let verb = update_chart_verb_with(1, |verb| {
4887            let EmbeddedObjectVerb::UpdateChart {
4888                subtitle, legend, ..
4889            } = verb
4890            else {
4891                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4892            };
4893            *subtitle = Some("FY25".to_string());
4894            *legend = Some("top".to_string());
4895        });
4896        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
4897        assert_eq!(spec.title.as_deref(), Some("New title"));
4898        assert_eq!(spec.subtitle.as_deref(), Some("FY25"));
4899        assert_eq!(
4900            spec.basic_chart.unwrap().legend_position.as_deref(),
4901            Some("TOP_LEGEND")
4902        );
4903        assert_eq!(summary, "update chart 1 (title, subtitle, legend)");
4904    }
4905
4906    // ── merge_pie_chart ──────────────────────────────────────────────────
4907
4908    #[test]
4909    fn merge_pie_chart_refuses_stacked_and_axis_titles() {
4910        let workbook = workbook_with_sheet(pie_chart_sheet(1));
4911        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4912
4913        let stacked = update_chart_verb_with(1, |verb| {
4914            let EmbeddedObjectVerb::UpdateChart { stacked, .. } = verb else {
4915                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4916            };
4917            *stacked = Some("stacked".to_string());
4918        });
4919        assert_invalid(
4920            &merge_chart_spec(&workbook, existing, &stacked).unwrap_err(),
4921            "--stacked only applies",
4922        );
4923
4924        let axis = update_chart_verb_with(1, |verb| {
4925            let EmbeddedObjectVerb::UpdateChart {
4926                horizontal_axis_title,
4927                ..
4928            } = verb
4929            else {
4930                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4931            };
4932            *horizontal_axis_title = Some("Quarter".to_string());
4933        });
4934        assert_invalid(
4935            &merge_chart_spec(&workbook, existing, &axis).unwrap_err(),
4936            "--horizontal-axis-title/--vertical-axis-title only apply",
4937        );
4938    }
4939
4940    #[test]
4941    fn merge_pie_chart_refuses_more_than_one_series() {
4942        let workbook = workbook_with_sheet(pie_chart_sheet(1));
4943        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4944        let verb = update_chart_verb_with(1, |verb| {
4945            let EmbeddedObjectVerb::UpdateChart { sheet, series, .. } = verb else {
4946                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4947            };
4948            *sheet = Some("Q1".to_string());
4949            *series = vec!["B1:B10".to_string(), "C1:C10".to_string()];
4950        });
4951        assert_invalid(
4952            &merge_chart_spec(&workbook, existing, &verb).unwrap_err(),
4953            "a pie chart takes exactly one --series",
4954        );
4955    }
4956
4957    #[test]
4958    fn merge_pie_chart_leaves_domain_and_series_alone_when_neither_is_named() {
4959        let workbook = workbook_with_sheet(pie_chart_sheet(1));
4960        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4961        let original = existing.pie_chart.clone().unwrap();
4962        let verb = update_chart_verb_with(1, |verb| {
4963            let EmbeddedObjectVerb::UpdateChart { pie_hole, .. } = verb else {
4964                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4965            };
4966            *pie_hole = Some(0.6);
4967        });
4968        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
4969        let pie = spec.pie_chart.unwrap();
4970        assert_eq!(pie.domain, original.domain, "domain must survive untouched");
4971        assert_eq!(pie.series, original.series, "series must survive untouched");
4972        assert_eq!(pie.pie_hole, Some(0.6));
4973        assert_eq!(summary, "update chart 1 (title, pie-hole)");
4974    }
4975
4976    #[test]
4977    fn merge_pie_chart_propagates_an_unresolvable_series_range() {
4978        let workbook = workbook_with_sheet(pie_chart_sheet(1));
4979        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4980        let verb = update_chart_verb_with(1, |verb| {
4981            let EmbeddedObjectVerb::UpdateChart { sheet, series, .. } = verb else {
4982                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
4983            };
4984            *sheet = Some("Nope".to_string());
4985            *series = vec!["B1:B10".to_string()];
4986        });
4987        let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
4988        assert!(
4989            matches!(err, EmbeddedObjectResult::RefusedSheetNotFound { .. }),
4990            "{err:?}"
4991        );
4992    }
4993
4994    #[test]
4995    fn merge_pie_chart_merges_domain_series_legend_and_pie_hole() {
4996        let workbook = workbook_with_sheet(pie_chart_sheet(1));
4997        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4998        let verb = update_chart_verb_with(1, |verb| {
4999            let EmbeddedObjectVerb::UpdateChart {
5000                title,
5001                sheet,
5002                domain,
5003                series,
5004                legend,
5005                pie_hole,
5006                ..
5007            } = verb
5008            else {
5009                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
5010            };
5011            *title = None;
5012            *sheet = Some("Q1".to_string());
5013            *domain = Some("C1:C10".to_string());
5014            *series = vec!["D1:D10".to_string()];
5015            *legend = Some("left".to_string());
5016            *pie_hole = Some(0.25);
5017        });
5018        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
5019        assert!(spec.basic_chart.is_none(), "the merge stays a pie chart");
5020        let pie = spec.pie_chart.unwrap();
5021        assert_eq!(
5022            pie.domain.source_range.sources[0].start_column_index,
5023            Some(2)
5024        );
5025        assert_eq!(
5026            pie.series.source_range.sources[0].start_column_index,
5027            Some(3)
5028        );
5029        assert_eq!(pie.legend_position.as_deref(), Some("LEFT_LEGEND"));
5030        assert_eq!(pie.pie_hole, Some(0.25));
5031        assert_eq!(summary, "update chart 1 (legend, domain, series, pie-hole)");
5032    }
5033
5034    // ── merge_basic_chart ────────────────────────────────────────────────
5035
5036    #[test]
5037    fn merge_basic_chart_merges_every_basic_flag_at_once() {
5038        let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
5039        let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
5040        let verb = update_chart_verb_with(1, |verb| {
5041            let EmbeddedObjectVerb::UpdateChart {
5042                title,
5043                sheet,
5044                domain,
5045                series,
5046                legend,
5047                stacked,
5048                header_count,
5049                horizontal_axis_title,
5050                vertical_axis_title,
5051                ..
5052            } = verb
5053            else {
5054                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_verb_with's only callers pass an EmbeddedObjectVerb::UpdateChart, so this arm can never run"
5055            };
5056            *title = None;
5057            *sheet = Some("Q1".to_string());
5058            *domain = Some("A1:A10".to_string());
5059            *series = vec!["B1:B10".to_string(), "C1:C10".to_string()];
5060            *legend = Some("bottom".to_string());
5061            *stacked = Some("percent".to_string());
5062            *header_count = Some(1);
5063            *horizontal_axis_title = Some("Quarter".to_string());
5064            *vertical_axis_title = Some("Revenue".to_string());
5065        });
5066        let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
5067        assert!(spec.pie_chart.is_none(), "the merge stays a basic chart");
5068        let basic = spec.basic_chart.unwrap();
5069        assert_eq!(basic.domains.len(), 1);
5070        assert_eq!(
5071            basic.series.len(),
5072            2,
5073            "--series replaces the list wholesale"
5074        );
5075        assert_eq!(basic.legend_position.as_deref(), Some("BOTTOM_LEGEND"));
5076        assert_eq!(basic.stacked_type.as_deref(), Some("PERCENT_STACKED"));
5077        assert_eq!(basic.header_count, Some(1));
5078        assert_eq!(basic.axis.len(), 2);
5079        assert_eq!(basic.axis[0].title.as_deref(), Some("Quarter"));
5080        assert_eq!(basic.axis[1].position, "LEFT_AXIS");
5081        assert_eq!(basic.axis[1].title.as_deref(), Some("Revenue"));
5082        assert_eq!(
5083            summary,
5084            "update chart 1 (legend, domain, series, stacked, header-count, \
5085             horizontal-axis-title, vertical-axis-title)"
5086        );
5087    }
5088
5089    // ── build_update_chart / build_delete_chart ──────────────────────────
5090
5091    #[test]
5092    fn build_update_chart_refuses_a_chart_with_no_spec() {
5093        let sheet = Sheet {
5094            properties: Some(crate::drive::sheets::types::SheetProperties {
5095                sheet_id: Some(0),
5096                title: "Q1".to_string(),
5097                ..Default::default()
5098            }),
5099            charts: vec![EmbeddedChart {
5100                chart_id: Some(1),
5101                spec: None,
5102                position: None,
5103                border: None,
5104            }],
5105            ..Default::default()
5106        };
5107        let workbook = workbook_with_sheet(sheet);
5108        let err = refusal(build_update_chart(&workbook, &update_chart_verb(1), 1));
5109        let rendered = format!("{err:?}");
5110        assert!(
5111            matches!(
5112                err,
5113                EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
5114            ),
5115            "{rendered}"
5116        );
5117        assert!(rendered.contains("no spec to merge onto"), "{rendered}");
5118    }
5119
5120    #[test]
5121    fn build_update_chart_carries_the_pre_change_object_and_the_existing_id() {
5122        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5123        let plan = build_update_chart(&workbook, &update_chart_verb(1), 1).unwrap();
5124        assert_eq!(plan.existing_id, Some(1));
5125        assert_eq!(plan.sheet_id, Some(0));
5126        let before = plan
5127            .before
5128            .expect("an update previews the pre-change state");
5129        assert_eq!(before.title.as_deref(), Some("Old title"));
5130        assert_eq!(before.position, "Q1!E2");
5131        let request = serde_json::to_value(&plan.request).unwrap();
5132        assert_eq!(request["updateChartSpec"]["chartId"], 1);
5133        assert_eq!(request["updateChartSpec"]["spec"]["title"], "New title");
5134    }
5135
5136    #[test]
5137    fn build_delete_chart_refuses_an_unknown_id() {
5138        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5139        assert!(matches!(
5140            refusal(build_delete_chart(&workbook, 99)),
5141            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5142        ));
5143    }
5144
5145    /// A workbook whose one sheet carries both a chart (id 1) and a slicer
5146    /// (id 4) — every `*_refuses_a_*_id` test below resolves the *other*
5147    /// kind's id against a chart-only or slicer-only verb.
5148    fn mixed_object_workbook() -> Spreadsheet {
5149        let mut sheet = basic_chart_sheet(1, "COLUMN");
5150        sheet.slicers = slicer_workbook().sheets.remove(0).slicers;
5151        workbook_with_sheet(sheet)
5152    }
5153
5154    #[test]
5155    fn build_update_chart_refuses_a_slicer_id() {
5156        let workbook = mixed_object_workbook();
5157        assert!(matches!(
5158            refusal(build_update_chart(&workbook, &update_chart_verb(4), 4)),
5159            EmbeddedObjectResult::RefusedWrongObjectKind {
5160                object_id: 4,
5161                expected,
5162                found,
5163            } if expected == "chart" && found == "slicer"
5164        ));
5165    }
5166
5167    #[test]
5168    fn build_delete_chart_refuses_a_slicer_id() {
5169        let workbook = mixed_object_workbook();
5170        assert!(matches!(
5171            refusal(build_delete_chart(&workbook, 4)),
5172            EmbeddedObjectResult::RefusedWrongObjectKind {
5173                object_id: 4,
5174                expected,
5175                found,
5176            } if expected == "chart" && found == "slicer"
5177        ));
5178    }
5179
5180    #[test]
5181    fn build_update_slicer_refuses_a_chart_id() {
5182        let workbook = mixed_object_workbook();
5183        let verb = update_slicer_verb(|verb| {
5184            let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5185                unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5186            };
5187            *title = Some("Territory".to_string());
5188        });
5189        assert!(matches!(
5190            refusal(build_update_slicer(&workbook, &verb, 1)),
5191            EmbeddedObjectResult::RefusedWrongObjectKind {
5192                object_id: 1,
5193                expected,
5194                found,
5195            } if expected == "slicer" && found == "chart"
5196        ));
5197    }
5198
5199    #[test]
5200    fn build_delete_slicer_refuses_a_chart_id() {
5201        let workbook = mixed_object_workbook();
5202        assert!(matches!(
5203            refusal(build_delete_slicer(&workbook, 1)),
5204            EmbeddedObjectResult::RefusedWrongObjectKind {
5205                object_id: 1,
5206                expected,
5207                found,
5208            } if expected == "slicer" && found == "chart"
5209        ));
5210    }
5211
5212    // ── build_add_slicer / build_update_slicer / build_delete_slicer ─────
5213
5214    fn add_slicer_verb() -> EmbeddedObjectVerb {
5215        EmbeddedObjectVerb::AddSlicer {
5216            sheet: Some("Q1".to_string()),
5217            range: "A1:D10".to_string(),
5218            column: 1,
5219            hide_values: Vec::new(),
5220            title: Some("Status".to_string()),
5221            apply_to_pivot_tables: None,
5222            anchor: "F2".to_string(),
5223            offset_x: None,
5224            offset_y: None,
5225            width: None,
5226            height: None,
5227        }
5228    }
5229
5230    #[test]
5231    fn build_add_slicer_omits_filter_criteria_when_no_values_are_hidden() {
5232        let workbook = plain_workbook("Q1");
5233        let plan = build_add_slicer(&workbook, &add_slicer_verb()).unwrap();
5234        assert_eq!(plan.summary, "add slicer 'Status'");
5235        assert_eq!(plan.sheet_id, Some(0));
5236        let spec = &serde_json::to_value(&plan.request).unwrap()["addSlicer"]["slicer"]["spec"];
5237        assert!(
5238            spec["filterCriteria"].is_null(),
5239            "no --hide-values means no filterCriteria at all: {spec}"
5240        );
5241        assert_eq!(spec["columnIndex"], 1);
5242        assert_eq!(spec["title"], "Status");
5243    }
5244
5245    #[test]
5246    fn build_add_slicer_propagates_an_unresolvable_anchor() {
5247        let workbook = plain_workbook("Q1");
5248        let EmbeddedObjectVerb::AddSlicer {
5249            sheet,
5250            range,
5251            column,
5252            hide_values,
5253            title,
5254            apply_to_pivot_tables,
5255            offset_x,
5256            offset_y,
5257            width,
5258            height,
5259            ..
5260        } = add_slicer_verb()
5261        else {
5262            unreachable!() // omni-dev: coverage ignore-line reason="add_slicer_verb always returns an EmbeddedObjectVerb::AddSlicer, so this arm can never run"
5263        };
5264        let verb = EmbeddedObjectVerb::AddSlicer {
5265            sheet,
5266            range,
5267            column,
5268            hide_values,
5269            title,
5270            apply_to_pivot_tables,
5271            anchor: "F2:G3".to_string(),
5272            offset_x,
5273            offset_y,
5274            width,
5275            height,
5276        };
5277        let err = refusal(build_add_slicer(&workbook, &verb));
5278        assert_invalid(&err, "must name a single cell, not a range");
5279    }
5280
5281    #[test]
5282    fn build_add_slicer_carries_hidden_values_when_given() {
5283        let workbook = plain_workbook("Q1");
5284        let EmbeddedObjectVerb::AddSlicer {
5285            sheet,
5286            range,
5287            column,
5288            title,
5289            anchor,
5290            ..
5291        } = add_slicer_verb()
5292        else {
5293            unreachable!() // omni-dev: coverage ignore-line reason="add_slicer_verb always returns an EmbeddedObjectVerb::AddSlicer, so this arm can never run"
5294        };
5295        let verb = EmbeddedObjectVerb::AddSlicer {
5296            sheet,
5297            range,
5298            column,
5299            hide_values: vec!["Closed".to_string()],
5300            title,
5301            apply_to_pivot_tables: Some(true),
5302            anchor,
5303            offset_x: Some(3),
5304            offset_y: Some(4),
5305            width: Some(300),
5306            height: Some(200),
5307        };
5308        let plan = build_add_slicer(&workbook, &verb).unwrap();
5309        let slicer = &serde_json::to_value(&plan.request).unwrap()["addSlicer"]["slicer"];
5310        assert_eq!(
5311            slicer["spec"]["filterCriteria"]["hiddenValues"][0],
5312            "Closed"
5313        );
5314        assert_eq!(slicer["spec"]["applyToPivotTables"], true);
5315        let overlay = &slicer["position"]["overlayPosition"];
5316        assert_eq!(overlay["offsetXPixels"], 3);
5317        assert_eq!(overlay["offsetYPixels"], 4);
5318        assert_eq!(overlay["widthPixels"], 300);
5319        assert_eq!(overlay["heightPixels"], 200);
5320    }
5321
5322    fn slicer_workbook() -> Spreadsheet {
5323        workbook_with_sheet(Sheet {
5324            properties: Some(crate::drive::sheets::types::SheetProperties {
5325                sheet_id: Some(0),
5326                title: "Q1".to_string(),
5327                ..Default::default()
5328            }),
5329            slicers: vec![Slicer {
5330                slicer_id: Some(4),
5331                spec: Some(SlicerSpec {
5332                    title: Some("Region".to_string()),
5333                    // A real slicer always carries both, per the API; see
5334                    // build_update_slicer_refuses_when_an_existing_spec_has_no_range_or_column
5335                    // for the legacy/corrupted-state case this deliberately excludes.
5336                    data_range: Some(GridRange {
5337                        sheet_id: 0,
5338                        start_row_index: Some(0),
5339                        end_row_index: Some(10),
5340                        start_column_index: Some(0),
5341                        end_column_index: Some(4),
5342                    }),
5343                    column_index: Some(1),
5344                    ..Default::default()
5345                }),
5346                position: Some(EmbeddedObjectPosition {
5347                    overlay_position: Some(OverlayPosition {
5348                        anchor_cell: GridCoordinate {
5349                            sheet_id: 0,
5350                            row_index: 0,
5351                            column_index: 5,
5352                        },
5353                        ..Default::default()
5354                    }),
5355                    ..Default::default()
5356                }),
5357            }],
5358            ..Default::default()
5359        })
5360    }
5361
5362    /// A boxed one-shot verb mutation, for the table-driven flag tests.
5363    type VerbTweak = Box<dyn FnOnce(&mut EmbeddedObjectVerb)>;
5364
5365    fn update_slicer_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5366        let mut verb = EmbeddedObjectVerb::UpdateSlicer {
5367            slicer_id: 4,
5368            sheet: Some("Q1".to_string()),
5369            range: None,
5370            column: None,
5371            hide_values: Vec::new(),
5372            clear_criteria: false,
5373            title: None,
5374            apply_to_pivot_tables: None,
5375        };
5376        tweak(&mut verb);
5377        verb
5378    }
5379
5380    #[test]
5381    fn build_update_slicer_names_exactly_the_field_each_flag_changes() {
5382        let workbook = slicer_workbook();
5383        let cases: Vec<(VerbTweak, &str, &str)> = vec![
5384            (
5385                Box::new(|verb: &mut EmbeddedObjectVerb| {
5386                    let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5387                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5388                    };
5389                    *range = Some("A1:D20".to_string());
5390                }),
5391                "dataRange",
5392                "range",
5393            ),
5394            (
5395                Box::new(|verb: &mut EmbeddedObjectVerb| {
5396                    let EmbeddedObjectVerb::UpdateSlicer { clear_criteria, .. } = verb else {
5397                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5398                    };
5399                    *clear_criteria = true;
5400                }),
5401                "filterCriteria",
5402                "clear criteria",
5403            ),
5404            (
5405                Box::new(|verb: &mut EmbeddedObjectVerb| {
5406                    let EmbeddedObjectVerb::UpdateSlicer { hide_values, .. } = verb else {
5407                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5408                    };
5409                    *hide_values = vec!["Closed".to_string()];
5410                }),
5411                "filterCriteria",
5412                "criteria",
5413            ),
5414            (
5415                Box::new(|verb: &mut EmbeddedObjectVerb| {
5416                    let EmbeddedObjectVerb::UpdateSlicer { column, .. } = verb else {
5417                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5418                    };
5419                    *column = Some(2);
5420                }),
5421                "columnIndex",
5422                "column",
5423            ),
5424            (
5425                Box::new(|verb: &mut EmbeddedObjectVerb| {
5426                    let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5427                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5428                    };
5429                    *title = Some("Territory".to_string());
5430                }),
5431                "title",
5432                "title",
5433            ),
5434            (
5435                Box::new(|verb: &mut EmbeddedObjectVerb| {
5436                    let EmbeddedObjectVerb::UpdateSlicer {
5437                        apply_to_pivot_tables,
5438                        ..
5439                    } = verb
5440                    else {
5441                        unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5442                    };
5443                    *apply_to_pivot_tables = Some(true);
5444                }),
5445                "applyToPivotTables",
5446                "apply-to-pivot-tables",
5447            ),
5448        ];
5449        for (tweak, field, changed) in cases {
5450            let verb = update_slicer_verb(tweak);
5451            let plan = build_update_slicer(&workbook, &verb, 4).unwrap();
5452            assert_eq!(plan.existing_id, Some(4));
5453            assert_eq!(plan.sheet_id, Some(0));
5454            let request = serde_json::to_value(&plan.request).unwrap();
5455            assert_eq!(
5456                request["updateSlicerSpec"]["fields"], field,
5457                "one flag must name exactly one field"
5458            );
5459            assert_eq!(plan.summary, format!("update slicer 4 ({changed})"));
5460        }
5461    }
5462
5463    #[test]
5464    fn build_update_slicer_refuses_when_an_existing_spec_has_no_range_or_column() {
5465        // A bare spec (no data_range, no column_index) predates this
5466        // validation, or was created outside omni-dev — see #1945's review.
5467        let workbook = workbook_with_sheet(Sheet {
5468            properties: Some(crate::drive::sheets::types::SheetProperties {
5469                sheet_id: Some(0),
5470                title: "Q1".to_string(),
5471                ..Default::default()
5472            }),
5473            slicers: vec![Slicer {
5474                slicer_id: Some(4),
5475                spec: Some(SlicerSpec {
5476                    title: Some("Region".to_string()),
5477                    ..Default::default()
5478                }),
5479                position: Some(EmbeddedObjectPosition {
5480                    overlay_position: Some(OverlayPosition {
5481                        anchor_cell: GridCoordinate {
5482                            sheet_id: 0,
5483                            row_index: 0,
5484                            column_index: 5,
5485                        },
5486                        ..Default::default()
5487                    }),
5488                    ..Default::default()
5489                }),
5490            }],
5491            ..Default::default()
5492        });
5493
5494        let verb = update_slicer_verb(|verb| {
5495            let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5496                unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5497            };
5498            *range = Some("A1:D20".to_string());
5499        });
5500        let err = refusal(build_update_slicer(&workbook, &verb, 4));
5501        assert_invalid(&err, "has no column on record");
5502
5503        let verb = update_slicer_verb(|verb| {
5504            let EmbeddedObjectVerb::UpdateSlicer { column, .. } = verb else {
5505                unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5506            };
5507            *column = Some(2);
5508        });
5509        let err = refusal(build_update_slicer(&workbook, &verb, 4));
5510        assert_invalid(&err, "has no range on record");
5511    }
5512
5513    #[test]
5514    fn build_update_slicer_treats_a_stored_column_index_of_none_as_column_zero() {
5515        // Issue #1929, confirmed live: a slicer filtering column 0 (column
5516        // A) reads back with `columnIndex` entirely omitted — proto3
5517        // dropping the zero value — not with an explicit `0`. Before the
5518        // fix, a `--range`-only `update-slicer` against such a slicer
5519        // refused with "has no column on record" even though the slicer
5520        // plainly has one. `data_range` being present (a real slicer
5521        // always sends `dataRange`) is what tells this apart from the
5522        // genuinely bare/degenerate spec the sibling refusal test covers.
5523        let workbook = workbook_with_sheet(Sheet {
5524            properties: Some(crate::drive::sheets::types::SheetProperties {
5525                sheet_id: Some(0),
5526                title: "Q1".to_string(),
5527                ..Default::default()
5528            }),
5529            slicers: vec![Slicer {
5530                slicer_id: Some(4),
5531                spec: Some(SlicerSpec {
5532                    title: Some("Region".to_string()),
5533                    data_range: Some(GridRange {
5534                        sheet_id: 0,
5535                        start_row_index: Some(0),
5536                        end_row_index: Some(10),
5537                        start_column_index: Some(0),
5538                        end_column_index: Some(4),
5539                    }),
5540                    column_index: None, // column 0, proto3-omitted
5541                    ..Default::default()
5542                }),
5543                position: Some(EmbeddedObjectPosition {
5544                    overlay_position: Some(OverlayPosition {
5545                        anchor_cell: GridCoordinate {
5546                            sheet_id: 0,
5547                            row_index: 0,
5548                            column_index: 5,
5549                        },
5550                        ..Default::default()
5551                    }),
5552                    ..Default::default()
5553                }),
5554            }],
5555            ..Default::default()
5556        });
5557
5558        let verb = update_slicer_verb(|verb| {
5559            let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5560                unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5561            };
5562            *range = Some("A1:D20".to_string());
5563        });
5564        let plan = build_update_slicer(&workbook, &verb, 4).unwrap();
5565        let request = serde_json::to_value(&plan.request).unwrap();
5566        assert_eq!(request["updateSlicerSpec"]["fields"], "dataRange");
5567    }
5568
5569    #[test]
5570    fn build_update_slicer_refuses_an_unknown_id() {
5571        let workbook = slicer_workbook();
5572        let verb = update_slicer_verb(|verb| {
5573            let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5574                unreachable!() // omni-dev: coverage ignore-line reason="update_slicer_verb always builds an EmbeddedObjectVerb::UpdateSlicer, so this arm can never run"
5575            };
5576            *title = Some("Territory".to_string());
5577        });
5578        assert!(matches!(
5579            refusal(build_update_slicer(&workbook, &verb, 99)),
5580            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5581        ));
5582    }
5583
5584    #[test]
5585    fn build_delete_slicer_refuses_an_unknown_id() {
5586        let workbook = slicer_workbook();
5587        assert!(matches!(
5588            refusal(build_delete_slicer(&workbook, 99)),
5589            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5590        ));
5591    }
5592
5593    // ── build_move_chart / build_move_slicer ─────────────────────────────
5594
5595    fn move_chart_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5596        let mut verb = EmbeddedObjectVerb::MoveChart {
5597            chart_id: 1,
5598            sheet: Some("Q1".to_string()),
5599            anchor: None,
5600            offset_x: None,
5601            offset_y: None,
5602            width: None,
5603            height: None,
5604            new_sheet: false,
5605        };
5606        tweak(&mut verb);
5607        verb
5608    }
5609
5610    fn move_slicer_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5611        let mut verb = EmbeddedObjectVerb::MoveSlicer {
5612            slicer_id: 4,
5613            sheet: Some("Q1".to_string()),
5614            anchor: None,
5615            offset_x: None,
5616            offset_y: None,
5617            width: None,
5618            height: None,
5619        };
5620        tweak(&mut verb);
5621        verb
5622    }
5623
5624    fn set_move_chart_anchor(verb: &mut EmbeddedObjectVerb, value: &str) {
5625        let EmbeddedObjectVerb::MoveChart { anchor, .. } = verb else {
5626            unreachable!() // omni-dev: coverage ignore-line reason="callers always pass a verb built by move_chart_verb, which is always MoveChart"
5627        };
5628        *anchor = Some(value.to_string());
5629    }
5630
5631    fn set_move_slicer_anchor(verb: &mut EmbeddedObjectVerb, value: &str) {
5632        let EmbeddedObjectVerb::MoveSlicer { anchor, .. } = verb else {
5633            unreachable!() // omni-dev: coverage ignore-line reason="callers always pass a verb built by move_slicer_verb, which is always MoveSlicer"
5634        };
5635        *anchor = Some(value.to_string());
5636    }
5637
5638    #[test]
5639    fn move_chart_sends_update_embedded_object_position() {
5640        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5641        let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5642        let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5643        let request = serde_json::to_value(&plan.request).unwrap();
5644        let overlay = &request["updateEmbeddedObjectPosition"]["newPosition"]["overlayPosition"];
5645        assert_eq!(overlay["anchorCell"]["rowIndex"], 1);
5646        assert_eq!(overlay["anchorCell"]["columnIndex"], 5);
5647        assert_eq!(
5648            request["updateEmbeddedObjectPosition"]["fields"],
5649            "anchorCell"
5650        );
5651        assert_eq!(plan.sheet_id, Some(0));
5652        assert_eq!(plan.summary, "move chart 1 to Q1!F2");
5653    }
5654
5655    #[test]
5656    fn position_field_mask_is_relative_to_overlay_position() {
5657        // THE regression guard: `updateEmbeddedObjectPosition`'s own rule is
5658        // that the mask is rooted at `newPosition.overlayPosition`, not at
5659        // `newPosition` — so a move sends `"anchorCell"`, never
5660        // `"overlayPosition.anchorCell"`. Invisible unless asserted.
5661        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5662        let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5663        let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5664        let request = serde_json::to_value(&plan.request).unwrap();
5665        let fields = request["updateEmbeddedObjectPosition"]["fields"]
5666            .as_str()
5667            .unwrap();
5668        assert_eq!(fields, "anchorCell");
5669        assert_ne!(fields, "overlayPosition.anchorCell");
5670    }
5671
5672    #[test]
5673    fn move_chart_resize_only_carries_the_existing_anchor_forward() {
5674        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5675        let verb = move_chart_verb(|verb| {
5676            let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5677                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5678            };
5679            *width = Some(480);
5680        });
5681        let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5682        let request = serde_json::to_value(&plan.request).unwrap();
5683        let overlay = &request["updateEmbeddedObjectPosition"]["newPosition"]["overlayPosition"];
5684        // `basic_chart_sheet(1, ..)` anchors the chart at row 1, column 4
5685        // (E2) — carried forward unchanged since `--anchor` wasn't given.
5686        assert_eq!(overlay["anchorCell"]["rowIndex"], 1);
5687        assert_eq!(overlay["anchorCell"]["columnIndex"], 4);
5688        assert_eq!(overlay["widthPixels"], 480);
5689        assert_eq!(
5690            request["updateEmbeddedObjectPosition"]["fields"],
5691            "widthPixels"
5692        );
5693        assert_eq!(plan.summary, "resize chart 1 (width 480)");
5694    }
5695
5696    #[test]
5697    fn move_chart_masks_exactly_the_flags_given() {
5698        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5699        let verb = move_chart_verb(|verb| {
5700            set_move_chart_anchor(verb, "F2");
5701            let EmbeddedObjectVerb::MoveChart {
5702                offset_x,
5703                offset_y,
5704                width,
5705                height,
5706                ..
5707            } = verb
5708            else {
5709                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5710            };
5711            *offset_x = Some(3);
5712            *offset_y = Some(4);
5713            *width = Some(480);
5714            *height = Some(300);
5715        });
5716        let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5717        let request = serde_json::to_value(&plan.request).unwrap();
5718        assert_eq!(
5719            request["updateEmbeddedObjectPosition"]["fields"],
5720            "anchorCell,offsetXPixels,offsetYPixels,widthPixels,heightPixels"
5721        );
5722        assert_eq!(
5723            plan.summary,
5724            "move chart 1 to Q1!F2 (offset-x 3, offset-y 4, width 480, height 300)"
5725        );
5726    }
5727
5728    #[test]
5729    fn move_chart_new_sheet_sends_new_sheet_true_and_no_mask() {
5730        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5731        let verb = move_chart_verb(|verb| {
5732            let EmbeddedObjectVerb::MoveChart { new_sheet, .. } = verb else {
5733                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5734            };
5735            *new_sheet = true;
5736        });
5737        let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5738        let request = serde_json::to_value(&plan.request).unwrap();
5739        assert_eq!(
5740            request["updateEmbeddedObjectPosition"]["newPosition"]["newSheet"],
5741            true
5742        );
5743        assert!(
5744            request["updateEmbeddedObjectPosition"]
5745                .get("fields")
5746                .is_none(),
5747            "a new-sheet move sends no fields key at all: {request}"
5748        );
5749        assert_eq!(plan.sheet_id, None);
5750        assert_eq!(plan.summary, "move chart 1 to a new sheet");
5751    }
5752
5753    #[test]
5754    fn move_chart_on_its_own_sheet_without_an_anchor_is_refused() {
5755        let sheet = Sheet {
5756            properties: Some(crate::drive::sheets::types::SheetProperties {
5757                sheet_id: Some(0),
5758                title: "Q1".to_string(),
5759                ..Default::default()
5760            }),
5761            charts: vec![EmbeddedChart {
5762                chart_id: Some(1),
5763                spec: Some(ChartSpec::default()),
5764                position: Some(EmbeddedObjectPosition {
5765                    sheet_id: Some(9),
5766                    new_sheet: Some(true),
5767                    ..Default::default()
5768                }),
5769                border: None,
5770            }],
5771            ..Default::default()
5772        };
5773        let workbook = workbook_with_sheet(sheet);
5774        let verb = move_chart_verb(|verb| {
5775            let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5776                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5777            };
5778            *width = Some(480);
5779        });
5780        let err = refusal(build_move_chart(&workbook, &verb, 1));
5781        assert_invalid(&err, "has no current overlay position");
5782    }
5783
5784    #[test]
5785    fn move_chart_refuses_an_empty_flag_set() {
5786        // Clears the fixture's default `sheet` too, so this exercises
5787        // `nothing_to_change` in isolation rather than tripping the
5788        // `--sheet`-without-`--anchor` check first (see
5789        // `move_chart_refuses_a_sheet_flag_with_no_anchor` for that case).
5790        let verb = move_chart_verb(|verb| {
5791            let EmbeddedObjectVerb::MoveChart { sheet, .. } = verb else {
5792                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5793            };
5794            *sheet = None;
5795        });
5796        let err = validate_verb(&verb).unwrap_err();
5797        assert!(err.contains("nothing to change"), "{err}");
5798    }
5799
5800    #[test]
5801    fn move_slicer_refuses_an_empty_flag_set() {
5802        // Clears the fixture's default `sheet` too, so this exercises
5803        // `nothing_to_change` in isolation rather than tripping the
5804        // `--sheet`-without-`--anchor` check first.
5805        let verb = move_slicer_verb(|verb| {
5806            let EmbeddedObjectVerb::MoveSlicer { sheet, .. } = verb else {
5807                unreachable!() // omni-dev: coverage ignore-line reason="move_slicer_verb always builds an EmbeddedObjectVerb::MoveSlicer, so this arm can never run"
5808            };
5809            *sheet = None;
5810        });
5811        let err = validate_verb(&verb).unwrap_err();
5812        assert!(err.contains("nothing to change"), "{err}");
5813    }
5814
5815    #[test]
5816    fn move_chart_refuses_a_sheet_only_move() {
5817        // The reordering fix (issue #1837 review): a `--sheet`-only call
5818        // must report `--sheet has no effect without --anchor`, not fall
5819        // through to the generic `nothing to change` error the old
5820        // unordered checks produced for this exact input.
5821        let verb = move_chart_verb(|_| {});
5822        let err = validate_verb(&verb).unwrap_err();
5823        assert!(
5824            err.contains("--sheet has no effect without --anchor"),
5825            "{err}"
5826        );
5827    }
5828
5829    #[test]
5830    fn move_slicer_refuses_a_sheet_only_move() {
5831        let verb = move_slicer_verb(|_| {});
5832        let err = validate_verb(&verb).unwrap_err();
5833        assert!(
5834            err.contains("--sheet has no effect without --anchor"),
5835            "{err}"
5836        );
5837    }
5838
5839    #[test]
5840    fn move_chart_refuses_a_sheet_flag_with_no_anchor() {
5841        // `--sheet` only supplies the prefix `--anchor` doesn't carry
5842        // itself; without `--anchor` it would otherwise be silently
5843        // ignored (issue #1837 review).
5844        let verb = move_chart_verb(|verb| {
5845            let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5846                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5847            };
5848            *width = Some(480);
5849        });
5850        let err = validate_verb(&verb).unwrap_err();
5851        assert!(
5852            err.contains("--sheet has no effect without --anchor"),
5853            "{err}"
5854        );
5855    }
5856
5857    #[test]
5858    fn move_slicer_refuses_a_sheet_flag_with_no_anchor() {
5859        let verb = move_slicer_verb(|verb| {
5860            let EmbeddedObjectVerb::MoveSlicer { width, .. } = verb else {
5861                unreachable!() // omni-dev: coverage ignore-line reason="move_slicer_verb always builds an EmbeddedObjectVerb::MoveSlicer, so this arm can never run"
5862            };
5863            *width = Some(480);
5864        });
5865        let err = validate_verb(&verb).unwrap_err();
5866        assert!(
5867            err.contains("--sheet has no effect without --anchor"),
5868            "{err}"
5869        );
5870    }
5871
5872    #[test]
5873    fn move_chart_refuses_new_sheet_combined_with_anchor() {
5874        let verb = move_chart_verb(|verb| {
5875            set_move_chart_anchor(verb, "F2");
5876            let EmbeddedObjectVerb::MoveChart { new_sheet, .. } = verb else {
5877                unreachable!() // omni-dev: coverage ignore-line reason="move_chart_verb always builds an EmbeddedObjectVerb::MoveChart, so this arm can never run"
5878            };
5879            *new_sheet = true;
5880        });
5881        let err = validate_verb(&verb).unwrap_err();
5882        assert!(err.contains("--new-sheet cannot be combined"), "{err}");
5883    }
5884
5885    #[test]
5886    fn move_chart_refuses_an_anchor_that_names_a_range() {
5887        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5888        let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2:G3"));
5889        let err = refusal(build_move_chart(&workbook, &verb, 1));
5890        assert_invalid(&err, "not a range");
5891    }
5892
5893    fn two_sheet_slicer_workbook() -> Spreadsheet {
5894        let mut workbook = slicer_workbook();
5895        workbook.sheets.push(Sheet {
5896            properties: Some(crate::drive::sheets::types::SheetProperties {
5897                sheet_id: Some(1),
5898                title: "Q2".to_string(),
5899                ..Default::default()
5900            }),
5901            ..Default::default()
5902        });
5903        workbook
5904    }
5905
5906    #[test]
5907    fn move_slicer_across_sheets_uses_the_destination_anchor_sheet_id() {
5908        let workbook = two_sheet_slicer_workbook();
5909        let verb = move_slicer_verb(|verb| {
5910            let EmbeddedObjectVerb::MoveSlicer { sheet, .. } = verb else {
5911                unreachable!() // omni-dev: coverage ignore-line reason="move_slicer_verb always builds an EmbeddedObjectVerb::MoveSlicer, so this arm can never run"
5912            };
5913            *sheet = Some("Q2".to_string());
5914            set_move_slicer_anchor(verb, "B2");
5915        });
5916        let plan = build_move_slicer(&workbook, &verb, 4).unwrap();
5917        assert_eq!(plan.sheet_id, Some(1));
5918        assert_eq!(plan.summary, "move slicer 4 to Q2!B2");
5919    }
5920
5921    #[test]
5922    fn move_slicer_refuses_a_chart_id() {
5923        let workbook = mixed_object_workbook();
5924        let verb = move_slicer_verb(|verb| set_move_slicer_anchor(verb, "F2"));
5925        assert!(matches!(
5926            refusal(build_move_slicer(&workbook, &verb, 1)),
5927            EmbeddedObjectResult::RefusedWrongObjectKind {
5928                object_id: 1,
5929                expected,
5930                found,
5931            } if expected == "slicer" && found == "chart"
5932        ));
5933    }
5934
5935    #[test]
5936    fn move_chart_refuses_a_slicer_id() {
5937        let workbook = mixed_object_workbook();
5938        let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5939        assert!(matches!(
5940            refusal(build_move_chart(&workbook, &verb, 4)),
5941            EmbeddedObjectResult::RefusedWrongObjectKind {
5942                object_id: 4,
5943                expected,
5944                found,
5945            } if expected == "chart" && found == "slicer"
5946        ));
5947    }
5948
5949    #[test]
5950    fn move_chart_refuses_an_unknown_object_id() {
5951        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5952        let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5953        assert!(matches!(
5954            refusal(build_move_chart(&workbook, &verb, 99)),
5955            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5956        ));
5957    }
5958
5959    // ── build_update_chart_border ────────────────────────────────────────
5960
5961    fn update_chart_border_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5962        let mut verb = EmbeddedObjectVerb::UpdateChartBorder {
5963            chart_id: 1,
5964            color: Some("#4A86E8".to_string()),
5965            clear: false,
5966        };
5967        tweak(&mut verb);
5968        verb
5969    }
5970
5971    #[test]
5972    fn update_chart_border_sets_color_style() {
5973        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5974        let verb = update_chart_border_verb(|_| {});
5975        let plan = build_update_chart_border(&workbook, &verb, 1).unwrap();
5976        let request = serde_json::to_value(&plan.request).unwrap();
5977        let body = &request["updateEmbeddedObjectBorder"];
5978        assert_eq!(body["fields"], "colorStyle");
5979        let rgb = &body["border"]["colorStyle"]["rgbColor"];
5980        let red = rgb["red"].as_f64().unwrap();
5981        assert!((red - f64::from(0x4Au8) / 255.0).abs() < 1e-6, "{rgb}");
5982        assert_eq!(plan.summary, "set chart 1 border to #4A86E8");
5983    }
5984
5985    #[test]
5986    fn update_chart_border_clear_sends_an_empty_border() {
5987        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5988        let verb = update_chart_border_verb(|verb| {
5989            let EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } = verb else {
5990                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_border_verb always builds an EmbeddedObjectVerb::UpdateChartBorder, so this arm can never run"
5991            };
5992            *color = None;
5993            *clear = true;
5994        });
5995        let plan = build_update_chart_border(&workbook, &verb, 1).unwrap();
5996        let request = serde_json::to_value(&plan.request).unwrap();
5997        let body = &request["updateEmbeddedObjectBorder"];
5998        assert_eq!(body["fields"], "colorStyle");
5999        assert_eq!(body["border"], serde_json::json!({}));
6000        assert_eq!(plan.summary, "clear chart 1 border");
6001    }
6002
6003    #[test]
6004    fn update_chart_border_refuses_a_bad_hex() {
6005        let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
6006        let verb = update_chart_border_verb(|verb| {
6007            let EmbeddedObjectVerb::UpdateChartBorder { color, .. } = verb else {
6008                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_border_verb always builds an EmbeddedObjectVerb::UpdateChartBorder, so this arm can never run"
6009            };
6010            *color = Some("not-a-color".to_string());
6011        });
6012        let err = refusal(build_update_chart_border(&workbook, &verb, 1));
6013        assert_invalid(&err, "is not a color");
6014    }
6015
6016    #[test]
6017    fn update_chart_border_refuses_an_empty_flag_set() {
6018        let verb = update_chart_border_verb(|verb| {
6019            let EmbeddedObjectVerb::UpdateChartBorder { color, .. } = verb else {
6020                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_border_verb always builds an EmbeddedObjectVerb::UpdateChartBorder, so this arm can never run"
6021            };
6022            *color = None;
6023        });
6024        let err = validate_verb(&verb).unwrap_err();
6025        assert!(err.contains("nothing to change"), "{err}");
6026    }
6027
6028    #[test]
6029    fn update_chart_border_refuses_color_and_clear_together() {
6030        let verb = update_chart_border_verb(|verb| {
6031            let EmbeddedObjectVerb::UpdateChartBorder { clear, .. } = verb else {
6032                unreachable!() // omni-dev: coverage ignore-line reason="update_chart_border_verb always builds an EmbeddedObjectVerb::UpdateChartBorder, so this arm can never run"
6033            };
6034            *clear = true;
6035        });
6036        let err = validate_verb(&verb).unwrap_err();
6037        assert!(err.contains("mutually exclusive"), "{err}");
6038    }
6039
6040    #[test]
6041    fn update_chart_border_refuses_a_slicer_id() {
6042        let workbook = mixed_object_workbook();
6043        let verb = update_chart_border_verb(|_| {});
6044        assert!(matches!(
6045            refusal(build_update_chart_border(&workbook, &verb, 4)),
6046            EmbeddedObjectResult::RefusedWrongObjectKind {
6047                object_id: 4,
6048                expected,
6049                found,
6050            } if expected == "chart" && found == "slicer"
6051        ));
6052    }
6053
6054    #[tokio::test]
6055    async fn move_chart_dry_run_reports_the_object_before_the_move() {
6056        let server = wiremock::MockServer::start().await;
6057        let (drive, sheets) = clients(&server).await;
6058        mount_file(
6059            "sheet-1",
6060            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6061            &["folder-1"],
6062        )
6063        .mount(&server)
6064        .await;
6065        mount_folder("folder-1").mount(&server).await;
6066        mount_workbook(serde_json::json!([
6067            {
6068                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
6069                "charts": [{
6070                    "chartId": 5,
6071                    "spec": {"title": "Sales", "pieChart": {
6072                        "domain": {"sourceRange": {"sources": [{"sheetId": 0}]}},
6073                        "series": {"sourceRange": {"sources": [{"sheetId": 0}]}},
6074                    }},
6075                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
6076                }],
6077            },
6078        ]))
6079        .mount(&server)
6080        .await;
6081        let rules = vec![allow_rule("folder-1")];
6082        let opts = EmbeddedObjectOptions {
6083            spreadsheet_id: "sheet-1".to_string(),
6084            verb: EmbeddedObjectVerb::MoveChart {
6085                chart_id: 5,
6086                sheet: Some("Q1".to_string()),
6087                anchor: Some("F2".to_string()),
6088                offset_x: None,
6089                offset_y: None,
6090                width: None,
6091                height: None,
6092                new_sheet: false,
6093            },
6094            dry_run: true,
6095            lease_token: None,
6096            ledger_path: std::path::PathBuf::new(),
6097        };
6098        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6099        match outcome.result {
6100            EmbeddedObjectResult::WouldChange { object, summary } => {
6101                let object = object.expect("move-chart previews the object before the move");
6102                assert_eq!(object.object_id, 5);
6103                assert_eq!(object.title.as_deref(), Some("Sales"));
6104                assert_eq!(object.position, "Q1!C2", "previews the position pre-move");
6105                assert_eq!(summary, "move chart 5 to Q1!F2");
6106            }
6107            other => panic!("expected WouldChange, got {other:?}"),
6108        }
6109    }
6110
6111    // ── describe / describe_lines ────────────────────────────────────────
6112
6113    fn outcome_with(
6114        verb: EmbeddedObjectVerb,
6115        file_name: Option<&str>,
6116        result: EmbeddedObjectResult,
6117    ) -> EmbeddedObjectOutcome {
6118        EmbeddedObjectOutcome {
6119            spreadsheet_id: "sheet-1".to_string(),
6120            file_name: file_name.map(str::to_string),
6121            resolved_folder_id: None,
6122            sheet_id: None,
6123            verb,
6124            result,
6125        }
6126    }
6127
6128    fn sample_object() -> EmbeddedObjectSummary {
6129        EmbeddedObjectSummary {
6130            object_id: 5,
6131            kind: "chart".to_string(),
6132            chart_type: Some("PIE".to_string()),
6133            title: Some("Sales".to_string()),
6134            position: "Q1!C2".to_string(),
6135            border: None,
6136        }
6137    }
6138
6139    #[test]
6140    fn describe_lines_renders_would_change_with_and_without_an_object() {
6141        let bare = outcome_with(
6142            add_chart_verb(),
6143            Some("Budget"),
6144            EmbeddedObjectResult::WouldChange {
6145                summary: "add column chart".to_string(),
6146                object: None,
6147            },
6148        );
6149        assert_eq!(
6150            describe_lines(&bare),
6151            vec!["Would add column chart in 'Budget'"]
6152        );
6153
6154        let previewed = outcome_with(
6155            EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6156            Some("Budget"),
6157            EmbeddedObjectResult::WouldChange {
6158                summary: "delete chart 5".to_string(),
6159                object: Some(Box::new(sample_object())),
6160            },
6161        );
6162        let lines = describe_lines(&previewed);
6163        assert_eq!(lines.len(), 2);
6164        assert_eq!(lines[1], "  id 5: chart (PIE) 'Sales', anchored Q1!C2");
6165    }
6166
6167    #[test]
6168    fn describe_lines_falls_back_to_the_spreadsheet_id_without_a_file_name() {
6169        let out = outcome_with(
6170            add_chart_verb(),
6171            None,
6172            EmbeddedObjectResult::RefusedNotASpreadsheet {
6173                mime_type: "application/vnd.google-apps.document".to_string(),
6174            },
6175        );
6176        let text = describe(&out);
6177        assert!(text.contains("'sheet-1' is not a Google Sheet"), "{text}");
6178        assert!(text.contains("drive sheets add-chart"), "{text}");
6179    }
6180
6181    #[test]
6182    fn describe_lines_renders_shortcut_and_no_visible_parents() {
6183        let shortcut = outcome_with(
6184            EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6185            Some("Budget"),
6186            EmbeddedObjectResult::RefusedShortcut,
6187        );
6188        let text = describe(&shortcut);
6189        assert!(text.contains("is a shortcut"), "{text}");
6190        assert!(text.contains("delete-slicer"), "{text}");
6191
6192        let orphan = outcome_with(
6193            add_chart_verb(),
6194            Some("Budget"),
6195            EmbeddedObjectResult::RefusedNoVisibleParents,
6196        );
6197        assert!(describe(&orphan).contains("sheets-structure"));
6198    }
6199
6200    #[test]
6201    fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
6202        let none = outcome_with(
6203            add_chart_verb(),
6204            Some("Budget"),
6205            EmbeddedObjectResult::RefusedSheetNotFound {
6206                title: "Q9".to_string(),
6207                available: Vec::new(),
6208            },
6209        );
6210        assert!(
6211            describe(&none).contains("Available: none"),
6212            "{}",
6213            describe(&none)
6214        );
6215
6216        let some = outcome_with(
6217            add_chart_verb(),
6218            Some("Budget"),
6219            EmbeddedObjectResult::RefusedSheetNotFound {
6220                title: "Q9".to_string(),
6221                available: vec!["Q1".to_string(), "Q3".to_string()],
6222            },
6223        );
6224        assert!(
6225            describe(&some).contains("'Q1', 'Q3'"),
6226            "{}",
6227            describe(&some)
6228        );
6229    }
6230
6231    #[test]
6232    fn describe_lines_renders_invalid_range_object_not_found_and_unsupported_chart() {
6233        let invalid = outcome_with(
6234            add_chart_verb(),
6235            Some("Budget"),
6236            EmbeddedObjectResult::RefusedInvalidRange {
6237                detail: "bad anchor".to_string(),
6238            },
6239        );
6240        assert_eq!(describe(&invalid), "Refused: bad anchor");
6241
6242        let missing = outcome_with(
6243            EmbeddedObjectVerb::DeleteChart { chart_id: 7 },
6244            Some("Budget"),
6245            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 7 },
6246        );
6247        let text = describe(&missing);
6248        assert!(text.contains("no chart or slicer with id 7"), "{text}");
6249        assert!(text.contains("list-charts"), "{text}");
6250
6251        let unsupported = outcome_with(
6252            update_chart_verb(7),
6253            Some("Budget"),
6254            EmbeddedObjectResult::RefusedUnsupportedChart {
6255                chart_id: 7,
6256                detail: "chart has no spec to merge onto".to_string(),
6257            },
6258        );
6259        assert_eq!(
6260            describe(&unsupported),
6261            "Refused: chart 7 in 'Budget': chart has no spec to merge onto"
6262        );
6263    }
6264
6265    #[test]
6266    fn describe_lines_renders_wrong_object_kind() {
6267        let out = outcome_with(
6268            update_chart_verb(4),
6269            Some("Budget"),
6270            EmbeddedObjectResult::RefusedWrongObjectKind {
6271                object_id: 4,
6272                expected: "chart".to_string(),
6273                found: "slicer".to_string(),
6274            },
6275        );
6276        assert_eq!(
6277            describe(&out),
6278            "Refused: id 4 in 'Budget' is a slicer, not a chart; run `drive sheets \
6279             list-slicers` to see what exists"
6280        );
6281    }
6282
6283    #[test]
6284    fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
6285        let by_rule = outcome_with(
6286            add_chart_verb(),
6287            Some("Budget"),
6288            EmbeddedObjectResult::Blocked {
6289                decided_by: Some(DecidingRule::Folder {
6290                    folder_id: "folder-1".to_string(),
6291                    depth: 2,
6292                }),
6293            },
6294        );
6295        let text = describe(&by_rule);
6296        assert!(text.contains("add-chart on 'Budget'"), "{text}");
6297        assert!(text.contains("folder-1"), "{text}");
6298
6299        let by_default = outcome_with(
6300            add_chart_verb(),
6301            Some("Budget"),
6302            EmbeddedObjectResult::Blocked { decided_by: None },
6303        );
6304        let text = describe(&by_default);
6305        assert!(text.contains("default policy"), "{text}");
6306        assert!(text.contains("sheets-structure"), "{text}");
6307    }
6308
6309    #[test]
6310    fn describe_lines_renders_every_lease_refusal_with_the_lease_acquire_hint() {
6311        for (result, phrase) in [
6312            (
6313                EmbeddedObjectResult::RefusedNoLease,
6314                "requires a Drive write lease",
6315            ),
6316            (
6317                EmbeddedObjectResult::RefusedLeaseExpired,
6318                "expired, released, or unknown",
6319            ),
6320            (
6321                EmbeddedObjectResult::RefusedLeaseWrongFile,
6322                "acquired for a different file",
6323            ),
6324            (
6325                EmbeddedObjectResult::RefusedLeaseStale,
6326                "changed since the lease was acquired",
6327            ),
6328        ] {
6329            let out = outcome_with(add_chart_verb(), Some("Budget"), result);
6330            let text = describe(&out);
6331            assert!(text.contains(phrase), "{text}");
6332            assert!(text.contains("drive lease acquire sheet-1"), "{text}");
6333        }
6334    }
6335
6336    #[test]
6337    fn describe_lines_renders_changed_with_and_without_an_id_and_an_object() {
6338        let added = outcome_with(
6339            add_chart_verb(),
6340            Some("Budget"),
6341            EmbeddedObjectResult::Changed {
6342                summary: "add column chart".to_string(),
6343                object_id: Some(42),
6344                object: None,
6345            },
6346        );
6347        assert_eq!(
6348            describe(&added),
6349            "Applied: add column chart (id 42) in 'Budget'"
6350        );
6351
6352        let deleted = outcome_with(
6353            EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6354            Some("Budget"),
6355            EmbeddedObjectResult::Changed {
6356                summary: "delete chart 5".to_string(),
6357                object_id: None,
6358                object: Some(Box::new(sample_object())),
6359            },
6360        );
6361        assert_eq!(
6362            describe_lines(&deleted),
6363            vec![
6364                "Applied: delete chart 5 in 'Budget'".to_string(),
6365                "  id 5: chart (PIE) 'Sales', anchored Q1!C2".to_string(),
6366            ]
6367        );
6368    }
6369
6370    #[test]
6371    fn describe_lines_renders_failed() {
6372        let out = outcome_with(
6373            add_chart_verb(),
6374            Some("Budget"),
6375            EmbeddedObjectResult::Failed {
6376                detail: "boom".to_string(),
6377            },
6378        );
6379        assert_eq!(describe(&out), "Failed: boom");
6380    }
6381
6382    #[test]
6383    fn every_result_variant_has_a_distinct_log_status() {
6384        let statuses: Vec<&str> = [
6385            EmbeddedObjectResult::WouldChange {
6386                summary: String::new(),
6387                object: None,
6388            },
6389            EmbeddedObjectResult::RefusedNotASpreadsheet {
6390                mime_type: String::new(),
6391            },
6392            EmbeddedObjectResult::RefusedShortcut,
6393            EmbeddedObjectResult::RefusedNoVisibleParents,
6394            EmbeddedObjectResult::RefusedSheetNotFound {
6395                title: String::new(),
6396                available: Vec::new(),
6397            },
6398            EmbeddedObjectResult::RefusedInvalidRange {
6399                detail: String::new(),
6400            },
6401            EmbeddedObjectResult::RefusedObjectNotFound { object_id: 1 },
6402            EmbeddedObjectResult::RefusedWrongObjectKind {
6403                object_id: 1,
6404                expected: String::new(),
6405                found: String::new(),
6406            },
6407            EmbeddedObjectResult::RefusedUnsupportedChart {
6408                chart_id: 1,
6409                detail: String::new(),
6410            },
6411            EmbeddedObjectResult::Blocked { decided_by: None },
6412            EmbeddedObjectResult::RefusedNoLease,
6413            EmbeddedObjectResult::RefusedLeaseExpired,
6414            EmbeddedObjectResult::RefusedLeaseWrongFile,
6415            EmbeddedObjectResult::RefusedLeaseStale,
6416            EmbeddedObjectResult::Changed {
6417                summary: String::new(),
6418                object_id: None,
6419                object: None,
6420            },
6421            EmbeddedObjectResult::Failed {
6422                detail: String::new(),
6423            },
6424        ]
6425        .iter()
6426        .map(EmbeddedObjectResult::log_status)
6427        .collect();
6428        let distinct: HashSet<&str> = statuses.iter().copied().collect();
6429        assert_eq!(distinct.len(), statuses.len(), "{statuses:?}");
6430    }
6431
6432    #[test]
6433    fn from_lease_refusal_maps_each_refusal_onto_its_own_variant() {
6434        assert!(matches!(
6435            EmbeddedObjectResult::from_no_lease(),
6436            EmbeddedObjectResult::RefusedNoLease
6437        ));
6438        assert!(matches!(
6439            EmbeddedObjectResult::from_lease_expired(),
6440            EmbeddedObjectResult::RefusedLeaseExpired
6441        ));
6442        assert!(matches!(
6443            EmbeddedObjectResult::from_lease_wrong_file(),
6444            EmbeddedObjectResult::RefusedLeaseWrongFile
6445        ));
6446        assert!(matches!(
6447            EmbeddedObjectResult::from_lease_stale(),
6448            EmbeddedObjectResult::RefusedLeaseStale
6449        ));
6450        assert_eq!(
6451            EmbeddedObjectResult::from_lease_failed("boom".to_string()),
6452            EmbeddedObjectResult::Failed {
6453                detail: "boom".to_string()
6454            }
6455        );
6456    }
6457
6458    // ── the target gate, end to end ──────────────────────────────────────
6459
6460    fn unleased_opts(verb: EmbeddedObjectVerb) -> EmbeddedObjectOptions {
6461        EmbeddedObjectOptions {
6462            spreadsheet_id: "sheet-1".to_string(),
6463            verb,
6464            dry_run: false,
6465            lease_token: None,
6466            ledger_path: std::path::PathBuf::new(),
6467        }
6468    }
6469
6470    #[tokio::test]
6471    async fn an_invalid_verb_is_refused_before_any_call() {
6472        // Nothing is mounted at all: a refusal decided from the flags alone
6473        // must make no HTTP request, so any call would surface as `Failed`.
6474        let server = wiremock::MockServer::start().await;
6475        let (drive, sheets) = clients(&server).await;
6476        let verb = add_chart_verb_with(|verb| {
6477            let EmbeddedObjectVerb::AddChart { series, .. } = verb else {
6478                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
6479            };
6480            series.clear();
6481        });
6482        let rules = vec![allow_rule("folder-1")];
6483        let outcome = embedded_object(&drive, &sheets, &unleased_opts(verb), &rules).await;
6484        assert_eq!(outcome.file_name, None);
6485        assert_invalid(&outcome.result, "at least one --series");
6486    }
6487
6488    #[tokio::test]
6489    async fn a_metadata_fetch_failure_surfaces_as_failed() {
6490        let server = wiremock::MockServer::start().await;
6491        let (drive, sheets) = clients(&server).await;
6492        wiremock::Mock::given(wiremock::matchers::method("GET"))
6493            .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
6494            .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
6495            .mount(&server)
6496            .await;
6497        let rules: Vec<FolderPermissionRule> = Vec::new();
6498        let outcome =
6499            embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6500        assert!(
6501            matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6502            "{:?}",
6503            outcome.result
6504        );
6505        assert_eq!(outcome.file_name, None);
6506    }
6507
6508    #[tokio::test]
6509    async fn a_shortcut_target_is_refused() {
6510        let server = wiremock::MockServer::start().await;
6511        let (drive, sheets) = clients(&server).await;
6512        mount_file(
6513            "sheet-1",
6514            "application/vnd.google-apps.shortcut",
6515            &["folder-1"],
6516        )
6517        .mount(&server)
6518        .await;
6519        let rules = vec![allow_rule("folder-1")];
6520        let outcome =
6521            embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6522        assert!(matches!(
6523            outcome.result,
6524            EmbeddedObjectResult::RefusedShortcut
6525        ));
6526        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
6527    }
6528
6529    #[tokio::test]
6530    async fn a_non_spreadsheet_target_is_refused() {
6531        let server = wiremock::MockServer::start().await;
6532        let (drive, sheets) = clients(&server).await;
6533        mount_file(
6534            "sheet-1",
6535            "application/vnd.google-apps.document",
6536            &["folder-1"],
6537        )
6538        .mount(&server)
6539        .await;
6540        let rules = vec![allow_rule("folder-1")];
6541        let outcome = embedded_object(
6542            &drive,
6543            &sheets,
6544            &unleased_opts(EmbeddedObjectVerb::DeleteChart { chart_id: 5 }),
6545            &rules,
6546        )
6547        .await;
6548        let rendered = format!("{:?}", outcome.result);
6549        assert!(
6550            matches!(
6551                outcome.result,
6552                EmbeddedObjectResult::RefusedNotASpreadsheet { .. }
6553            ),
6554            "{rendered}"
6555        );
6556        assert!(
6557            rendered.contains("application/vnd.google-apps.document"),
6558            "{rendered}"
6559        );
6560    }
6561
6562    #[tokio::test]
6563    async fn a_target_with_no_visible_parents_is_refused() {
6564        let server = wiremock::MockServer::start().await;
6565        let (drive, sheets) = clients(&server).await;
6566        mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
6567            .mount(&server)
6568            .await;
6569        let rules: Vec<FolderPermissionRule> = Vec::new();
6570        let outcome =
6571            embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6572        assert!(matches!(
6573            outcome.result,
6574            EmbeddedObjectResult::RefusedNoVisibleParents
6575        ));
6576    }
6577
6578    #[tokio::test]
6579    async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
6580        let server = wiremock::MockServer::start().await;
6581        let (drive, sheets) = clients(&server).await;
6582        mount_file(
6583            "sheet-1",
6584            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6585            &["folder-1"],
6586        )
6587        .mount(&server)
6588        .await;
6589        wiremock::Mock::given(wiremock::matchers::method("GET"))
6590            .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
6591            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6592            .mount(&server)
6593            .await;
6594        let rules = vec![allow_rule("folder-1")];
6595        let outcome =
6596            embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6597        assert!(
6598            matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6599            "{:?}",
6600            outcome.result
6601        );
6602        assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
6603    }
6604
6605    #[tokio::test]
6606    async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
6607        let server = wiremock::MockServer::start().await;
6608        let (drive, sheets) = clients(&server).await;
6609        mount_file(
6610            "sheet-1",
6611            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6612            &["folder-1"],
6613        )
6614        .mount(&server)
6615        .await;
6616        mount_folder("folder-1").mount(&server).await;
6617        wiremock::Mock::given(wiremock::matchers::method("GET"))
6618            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6619            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6620            .mount(&server)
6621            .await;
6622        let rules = vec![allow_rule("folder-1")];
6623        let outcome =
6624            embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6625        assert!(
6626            matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6627            "{:?}",
6628            outcome.result
6629        );
6630        assert_eq!(outcome.sheet_id, None);
6631    }
6632
6633    #[tokio::test]
6634    async fn add_chart_refuses_an_unknown_sheet() {
6635        let server = wiremock::MockServer::start().await;
6636        let (drive, sheets) = clients(&server).await;
6637        mount_file(
6638            "sheet-1",
6639            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6640            &["folder-1"],
6641        )
6642        .mount(&server)
6643        .await;
6644        mount_folder("folder-1").mount(&server).await;
6645        mount_workbook(serde_json::json!([
6646            {"properties": {"sheetId": 0, "title": "Q2", "index": 0}},
6647        ]))
6648        .mount(&server)
6649        .await;
6650        let rules = vec![allow_rule("folder-1")];
6651        let verb = add_chart_verb_with(|verb| {
6652            let EmbeddedObjectVerb::AddChart { sheet, .. } = verb else {
6653                unreachable!() // omni-dev: coverage ignore-line reason="add_chart_verb_with's only callers pass an EmbeddedObjectVerb::AddChart, so this arm can never run"
6654            };
6655            *sheet = Some("Nope".to_string());
6656        });
6657        let outcome = embedded_object(&drive, &sheets, &unleased_opts(verb), &rules).await;
6658        let rendered = format!("{:?}", outcome.result);
6659        assert!(
6660            matches!(
6661                outcome.result,
6662                EmbeddedObjectResult::RefusedSheetNotFound { .. }
6663            ),
6664            "{rendered}"
6665        );
6666        assert!(rendered.contains("Nope"), "{rendered}");
6667        assert!(rendered.contains("Q2"), "{rendered}");
6668    }
6669
6670    // ── the Drive write lease (ADR-0080 §9) and batchUpdate failure ──────
6671
6672    /// A workbook holding both a chart (id 5) and a slicer (id 9), so the
6673    /// plan always builds and the only thing left to decide the outcome is
6674    /// the lease — or the `batchUpdate` call itself.
6675    fn mount_workbook_with_chart_and_slicer() -> wiremock::Mock {
6676        mount_workbook(serde_json::json!([
6677            {
6678                "properties": {"sheetId": 0, "title": "Q1", "index": 0},
6679                "charts": [{
6680                    "chartId": 5,
6681                    "spec": {"title": "Sales", "basicChart": {"chartType": "COLUMN"}},
6682                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
6683                }],
6684                "slicers": [{
6685                    "slicerId": 9,
6686                    "spec": {"title": "Region"},
6687                    "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
6688                }],
6689            },
6690        ]))
6691    }
6692
6693    async fn mount_granted_gate_with_objects(server: &wiremock::MockServer) {
6694        mount_file(
6695            "sheet-1",
6696            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6697            &["folder-1"],
6698        )
6699        .mount(server)
6700        .await;
6701        mount_folder("folder-1").mount(server).await;
6702        mount_workbook_with_chart_and_slicer().mount(server).await;
6703    }
6704
6705    fn fresh_ledger_path() -> std::path::PathBuf {
6706        tempfile::tempdir()
6707            .unwrap()
6708            .keep()
6709            .join("lease-ledger.jsonl")
6710    }
6711
6712    #[tokio::test]
6713    async fn refuses_without_a_lease_when_the_rule_requires_one() {
6714        let server = wiremock::MockServer::start().await;
6715        let (drive, sheets) = clients(&server).await;
6716        mount_granted_gate_with_objects(&server).await;
6717        // No batchUpdate mock mounted — a refusal must make zero mutating
6718        // calls.
6719        let rules = vec![allow_rule("folder-1")];
6720        let opts = unleased_opts(EmbeddedObjectVerb::DeleteChart { chart_id: 5 });
6721        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6722        assert!(matches!(
6723            outcome.result,
6724            EmbeddedObjectResult::RefusedNoLease
6725        ));
6726        assert_eq!(outcome.result.log_status(), "refused-no-lease");
6727    }
6728
6729    #[tokio::test]
6730    async fn refuses_an_unknown_lease_token() {
6731        let server = wiremock::MockServer::start().await;
6732        let (drive, sheets) = clients(&server).await;
6733        mount_granted_gate_with_objects(&server).await;
6734        let rules = vec![allow_rule("folder-1")];
6735        // Never seeded — the ledger knows nothing of this token.
6736        let opts = EmbeddedObjectOptions {
6737            spreadsheet_id: "sheet-1".to_string(),
6738            verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6739            dry_run: false,
6740            lease_token: Some("bogus-token".to_string()),
6741            ledger_path: fresh_ledger_path(),
6742        };
6743        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6744        assert!(matches!(
6745            outcome.result,
6746            EmbeddedObjectResult::RefusedLeaseExpired
6747        ));
6748        assert_eq!(outcome.result.log_status(), "refused-lease-expired");
6749    }
6750
6751    #[tokio::test]
6752    async fn refuses_a_lease_bound_to_a_different_file() {
6753        let server = wiremock::MockServer::start().await;
6754        let (drive, sheets) = clients(&server).await;
6755        mount_granted_gate_with_objects(&server).await;
6756        let rules = vec![allow_rule("folder-1")];
6757        let ledger_path = fresh_ledger_path();
6758        // Seeded for a *different* spreadsheet id.
6759        let token = seed_lease(&ledger_path, "some-other-sheet", "1");
6760        let opts = EmbeddedObjectOptions {
6761            spreadsheet_id: "sheet-1".to_string(),
6762            verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6763            dry_run: false,
6764            lease_token: Some(token),
6765            ledger_path,
6766        };
6767        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6768        assert!(matches!(
6769            outcome.result,
6770            EmbeddedObjectResult::RefusedLeaseWrongFile
6771        ));
6772        assert_eq!(outcome.result.log_status(), "refused-lease-wrong-file");
6773    }
6774
6775    #[tokio::test]
6776    async fn refuses_a_stale_lease_when_the_file_has_moved() {
6777        let server = wiremock::MockServer::start().await;
6778        let (drive, sheets) = clients(&server).await;
6779        // `mount_file` always returns version "1"; the lease below was
6780        // acquired against version "0" — a foreign edit landed since.
6781        mount_granted_gate_with_objects(&server).await;
6782        let rules = vec![allow_rule("folder-1")];
6783        let ledger_path = fresh_ledger_path();
6784        let token = seed_lease(&ledger_path, "sheet-1", "0");
6785        let opts = EmbeddedObjectOptions {
6786            spreadsheet_id: "sheet-1".to_string(),
6787            verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6788            dry_run: false,
6789            lease_token: Some(token),
6790            ledger_path,
6791        };
6792        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6793        assert!(matches!(
6794            outcome.result,
6795            EmbeddedObjectResult::RefusedLeaseStale
6796        ));
6797        assert_eq!(outcome.result.log_status(), "refused-lease-stale");
6798    }
6799
6800    #[tokio::test]
6801    async fn a_batch_update_failure_surfaces_as_failed() {
6802        let server = wiremock::MockServer::start().await;
6803        let (drive, sheets) = clients(&server).await;
6804        mount_granted_gate_with_objects(&server).await;
6805        wiremock::Mock::given(wiremock::matchers::method("POST"))
6806            .and(wiremock::matchers::path(
6807                "/v4/spreadsheets/sheet-1:batchUpdate",
6808            ))
6809            .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6810            .mount(&server)
6811            .await;
6812        let rules = vec![allow_rule("folder-1")];
6813        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6814        let opts = EmbeddedObjectOptions {
6815            spreadsheet_id: "sheet-1".to_string(),
6816            verb: update_chart_verb(5),
6817            dry_run: false,
6818            lease_token,
6819            ledger_path,
6820        };
6821        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6822        let rendered = format!("{:?}", outcome.result);
6823        assert!(
6824            matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6825            "{rendered}"
6826        );
6827        assert!(rendered.contains("500"), "{rendered}");
6828    }
6829
6830    #[tokio::test]
6831    async fn a_real_delete_chart_reports_the_object_it_discarded() {
6832        let server = wiremock::MockServer::start().await;
6833        let (drive, sheets) = clients(&server).await;
6834        mount_granted_gate_with_objects(&server).await;
6835        wiremock::Mock::given(wiremock::matchers::method("POST"))
6836            .and(wiremock::matchers::path(
6837                "/v4/spreadsheets/sheet-1:batchUpdate",
6838            ))
6839            .respond_with(
6840                wiremock::ResponseTemplate::new(200)
6841                    .set_body_json(serde_json::json!({"replies": [{}]})),
6842            )
6843            .mount(&server)
6844            .await;
6845        let rules = vec![allow_rule("folder-1")];
6846        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6847        let opts = EmbeddedObjectOptions {
6848            spreadsheet_id: "sheet-1".to_string(),
6849            verb: EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6850            dry_run: false,
6851            lease_token,
6852            ledger_path,
6853        };
6854        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6855        let rendered = format!("{:?}", outcome.result);
6856        // `deleteEmbeddedObject` has no reply body, so the id comes from
6857        // the plan's `existing_id`, and the discarded object rides along
6858        // for the audit trail (ADR-0081 §3).
6859        assert!(
6860            matches!(
6861                outcome.result,
6862                EmbeddedObjectResult::Changed {
6863                    object_id: Some(5),
6864                    object: Some(_),
6865                    ..
6866                }
6867            ),
6868            "{rendered}"
6869        );
6870        assert!(rendered.contains("Sales"), "{rendered}");
6871        assert_eq!(describe_lines(&outcome).len(), 2);
6872    }
6873
6874    #[tokio::test]
6875    async fn a_real_add_chart_parses_a_reply_anchored_at_a1_on_sheet_0() {
6876        // Issue #1929, confirmed live: `add-chart --anchor A1` on the first
6877        // sheet creates the chart server-side, but the batchUpdate reply's
6878        // echoed `EmbeddedChart` anchors at row 1 / column A on sheet 0
6879        // with every `GridCoordinate` field proto3-omitted
6880        // (`anchorCell: {}`) — not the explicit-zero shape a hand-written
6881        // fixture would guess. Before this issue's fix, that made the
6882        // whole reply fail to parse and the verb report `Failed` even
6883        // though the chart existed.
6884        let server = wiremock::MockServer::start().await;
6885        let (drive, sheets) = clients(&server).await;
6886        mount_file(
6887            "sheet-1",
6888            crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6889            &["folder-1"],
6890        )
6891        .mount(&server)
6892        .await;
6893        mount_folder("folder-1").mount(&server).await;
6894        mount_workbook(serde_json::json!([
6895            {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
6896        ]))
6897        .mount(&server)
6898        .await;
6899        wiremock::Mock::given(wiremock::matchers::method("POST"))
6900            .and(wiremock::matchers::path(
6901                "/v4/spreadsheets/sheet-1:batchUpdate",
6902            ))
6903            .respond_with(
6904                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6905                    "replies": [{
6906                        "addChart": {
6907                            "chart": {
6908                                "chartId": 7,
6909                                "spec": {"basicChart": {"chartType": "COLUMN"}},
6910                                "position": {"overlayPosition": {"anchorCell": {}}},
6911                            },
6912                        },
6913                    }],
6914                })),
6915            )
6916            .mount(&server)
6917            .await;
6918        let rules = vec![allow_rule("folder-1")];
6919        let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6920        let mut verb = add_chart_verb();
6921        let EmbeddedObjectVerb::AddChart { anchor, .. } = &mut verb else {
6922            unreachable!() // omni-dev: coverage ignore-line reason="this test always constructs `verb` as EmbeddedObjectVerb::AddChart above, so this arm can never run"
6923        };
6924        *anchor = Some("A1".to_string());
6925        let opts = EmbeddedObjectOptions {
6926            spreadsheet_id: "sheet-1".to_string(),
6927            verb,
6928            dry_run: false,
6929            lease_token,
6930            ledger_path,
6931        };
6932        let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6933        let rendered = format!("{:?}", outcome.result);
6934        assert!(
6935            matches!(
6936                outcome.result,
6937                EmbeddedObjectResult::Changed {
6938                    object_id: Some(7),
6939                    ..
6940                }
6941            ),
6942            "{rendered}"
6943        );
6944    }
6945}