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