Skip to main content

omni_dev/drive/sheets/
api.rs

1//! Sheets v4 API façade — typed wrappers over the handful of endpoints the
2//! CLI needs, mirroring `crate::drive::files_api::FilesApi`'s shape.
3//!
4//! Free `build_*_url` functions take a literal `base_url` so they are
5//! unit-testable without a client, exactly like `files_api.rs`'s.
6
7use anyhow::{Context, Result};
8use url::Url;
9
10use crate::drive::api_client::GoogleApiClient;
11use crate::drive::files_api::{append_write_scope_hint, WriteCapability};
12use crate::drive::sheets::a1;
13use crate::drive::sheets::client::SheetsClient;
14use crate::drive::sheets::types::{
15    AppendValuesResponse, BatchGetValuesResponse, BatchUpdateRequest, BatchUpdateRequestItem,
16    BatchUpdateResponse, ClearValuesResponse, CopySheetToAnotherSpreadsheetRequest, DataFilter,
17    SearchDeveloperMetadataRequest, SearchDeveloperMetadataResponse, SheetProperties, Spreadsheet,
18    UpdateValuesResponse, ValueRange,
19};
20
21/// What a 2xx `spreadsheets.batchUpdate` produced (issues #2021, #1929).
22///
23/// A non-2xx is not represented here: it is the `Err` of
24/// [`SheetsApi::batch_update`]. Making the two 2xx cases a type, rather than a
25/// downcastable marker on the error, means every engine must decide what to
26/// say about an applied-but-unreadable reply — and, because both are `Ok`,
27/// `conclude_native_leased_write` refreshes the lease ledger and audits
28/// `allowed` for both, as it must for a mutation that really happened.
29#[derive(Debug)]
30pub(in crate::drive) enum BatchUpdateOutcome {
31    /// 2xx and the reply parsed.
32    Applied(BatchUpdateResponse),
33    /// 2xx — the mutation **was applied** — but the body could not be read or
34    /// deserialised. `detail` carries the read/serde error.
35    AppliedReplyUnreadable {
36        /// Why the reply could not be read.
37        detail: String,
38    },
39}
40
41/// Whether running a verb's request a second time is safe, for the retry
42/// advice in [`applied_reply_unreadable_line`].
43///
44/// Defaults to [`Self::NotIdempotent`]: after a mutation, excess caution is
45/// the cheaper mistake, because what it prevents is a silent duplicate.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub(in crate::drive) enum RetryHint {
48    /// A second run could create a duplicate, shift content or compound.
49    NotIdempotent,
50    /// A second run provably leaves the same end state.
51    Idempotent,
52}
53
54/// The one human-readable line every engine renders for an
55/// `AppliedReplyUnreadable` result (issue #2021). Shared so the 20 engines'
56/// wording cannot drift, the same reason `recovery_note` is.
57pub(in crate::drive) fn applied_reply_unreadable_line(
58    summary: &str,
59    book: &str,
60    hint: RetryHint,
61    detail: &str,
62) -> String {
63    let advice = match hint {
64        RetryHint::NotIdempotent => "do not retry; check the spreadsheet first",
65        RetryHint::Idempotent => "check the spreadsheet to confirm",
66    };
67    format!("Applied, but the reply could not be read: {summary} in {book} — {advice} ({detail})")
68}
69
70/// `fields` mask for `spreadsheets.get`.
71///
72/// **Not optional.** An unmasked `spreadsheets.get` embeds every cell of
73/// every sheet in the response, so on a large workbook the difference
74/// between sending this and not is an out-of-memory failure rather than a
75/// slower request. We only ever need the tab list.
76///
77/// `properties.locale,timeZone,autoRecalc,iterativeCalculationSettings`
78/// (issue #1836's `update-workbook-properties`) ride along on the base mask
79/// rather than getting their own `SPREADSHEET_FIELDS_WITH_*` widening like
80/// every other feature in this file: unlike a `sheets.*` list that grows
81/// with workbook content (protected ranges, charts, named ranges, …), these
82/// are four fixed scalar/small-object fields on the single `properties`
83/// object already fetched for `properties.title`, so including them
84/// unconditionally costs every other caller nothing measurable — and
85/// `structure.rs`'s `structure()` has exactly one `get_spreadsheet` call
86/// site shared by all fifteen `StructureVerb`s, so branching it per verb
87/// would be new complexity for no real savings.
88const SPREADSHEET_FIELDS: &str = "spreadsheetId,\
89    properties(title,locale,timeZone,autoRecalc,iterativeCalculationSettings),\
90    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines))";
91
92/// `fields` mask for `spreadsheets.get` when protected ranges are needed
93/// too (issue #1643's `list-protections`/`update-protection`/
94/// `unprotect-range`, which must resolve an *existing* protection before
95/// they can act on it). A superset of [`SPREADSHEET_FIELDS`], kept separate
96/// so every other caller — `sheets info`, `structure.rs`, `format.rs`,
97/// `validation.rs` — never pays for data it doesn't use.
98const SPREADSHEET_FIELDS_WITH_PROTECTIONS: &str = "spreadsheetId,properties.title,\
99    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
100    sheets.protectedRanges(protectedRangeId,range,description,warningOnly,editors.users)";
101
102/// `fields` mask for `spreadsheets.get` when the basic filter and filter
103/// views are needed too (issue #1794's `set-basic-filter`/
104/// `list-filter-views`/`update-filter-view`/`delete-filter-view`, which must
105/// resolve an *existing* filter view before they can act on one). A
106/// superset of [`SPREADSHEET_FIELDS`], kept separate for the same reason
107/// [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`] is.
108const SPREADSHEET_FIELDS_WITH_FILTER_VIEWS: &str = "spreadsheetId,properties.title,\
109    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
110    sheets.basicFilter(range,sortSpecs,criteria),\
111    sheets.filterViews(filterViewId,title,range,namedRangeId,tableId,sortSpecs,criteria)";
112
113/// `fields` mask for `spreadsheets.get` when conditional format rules are
114/// needed too (issue #1793's `add-conditional-format`/
115/// `update-conditional-format`/`delete-conditional-format`/
116/// `list-conditional-formats`, all four of which share one fetch — see
117/// [`SheetsApi::get_spreadsheet_with_conditional_formats`]'s doc comment for
118/// why `add` uses the same wider mask as the other three despite not
119/// strictly needing it). A superset of [`SPREADSHEET_FIELDS`], kept separate
120/// for the same reason [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`] is.
121const SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS: &str = "spreadsheetId,properties.title,\
122    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
123    sheets.conditionalFormats(ranges,booleanRule,gradientRule)";
124
125/// `fields` mask for `spreadsheets.get` when named ranges are needed too
126/// (issue #1796's `list-named-ranges`/`update-named-range`/
127/// `delete-named-range`, which must resolve an *existing* named range by
128/// name before they can act on one). A superset of [`SPREADSHEET_FIELDS`],
129/// kept separate for the same reason as
130/// [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`]: every other caller never pays
131/// for data it doesn't use. Named ranges are workbook-scoped, so
132/// `namedRanges` sits at the top level, not nested under `sheets` the way
133/// `protectedRanges` is.
134const SPREADSHEET_FIELDS_WITH_NAMED_RANGES: &str = "spreadsheetId,properties.title,\
135    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
136    namedRanges(namedRangeId,name,range)";
137
138/// `fields` mask for `spreadsheets.get` when charts and slicers are needed
139/// too (issue #1797's `add-chart`/`update-chart`/`delete-chart`/
140/// `list-charts`/`add-slicer`/`update-slicer`/`delete-slicer`/
141/// `list-slicers`, all eight of which share one fetch, mirroring
142/// `SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS`'s reuse across its four
143/// verbs). Deliberately requests `sheets.charts`/`sheets.slicers`
144/// **unmasked below the object level** — every other wider mask in this
145/// file narrows to the specific sub-fields each verb reads, but
146/// `update-chart` must merge onto the chart's *entire* existing spec (see
147/// [`crate::drive::sheets::types::UpdateChartSpecRequest`]'s doc comment),
148/// so nothing here can be safely left out. A superset of
149/// [`SPREADSHEET_FIELDS`], kept separate for the same reason
150/// [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`] is.
151const SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS: &str = "spreadsheetId,properties.title,\
152    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
153    sheets.charts,sheets.slicers";
154
155/// `fields` mask for `spreadsheets.get` when every pivot table in the
156/// workbook is needed (issue #1798's `list-pivot-tables`, which must scan
157/// every sheet's grid data for a populated `pivotTable` property — there is
158/// no index or list endpoint for pivot tables the way there is for
159/// protected ranges or filter views). Requesting `pivotTable` alone (no
160/// `formattedValue`) keeps the response to one `{}` per populated cell that
161/// has no pivot, same order of cost as `sheets read`. A superset of
162/// [`SPREADSHEET_FIELDS`], kept separate for the same reason
163/// [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`] is.
164const SPREADSHEET_FIELDS_WITH_PIVOT_TABLES: &str = "spreadsheetId,properties.title,\
165    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
166    sheets.data(startRow,startColumn,rowData.values(pivotTable))";
167
168/// `fields` mask for `spreadsheets.get` when only a single cell's pivot
169/// table (and displayed value) is needed — `add-pivot-table`'s
170/// occupied-anchor check and `delete-pivot-table`'s "currently:" preview,
171/// both of which read exactly one cell rather than the whole workbook.
172/// Paired with a `ranges=` query parameter scoping the response to that one
173/// cell (see [`SheetsApi::get_cell_pivot`]), so this mask alone would still
174/// return every sheet's grid data without it.
175const CELL_PIVOT_FIELDS: &str = "spreadsheetId,properties.title,\
176    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
177    sheets.data(startRow,startColumn,rowData.values(pivotTable,formattedValue))";
178
179/// `fields` mask for `spreadsheets.get` when a range's cell-level formatting
180/// is needed (issue #1878's `read-cell-format`, the tool that answers
181/// ADR-0083 §5's "does this verb move formatting?" question). Paired with a
182/// `ranges=` query parameter scoping the response to the caller's range
183/// (see [`SheetsApi::get_cell_formats`]), so this mask alone would still
184/// return every sheet's grid data without it.
185///
186/// Covers background color, text format (bold/italic/strikethrough/
187/// underline/color), number format, horizontal alignment, the four cell
188/// borders, notes and data validation. Deliberately **`userEnteredFormat`,
189/// not `effectiveFormat`** — see
190/// [`crate::drive::sheets::types::CellSnapshot::user_entered_format`]'s doc
191/// comment.
192///
193/// Borders are included even though ADR-0083 §5's four checked properties
194/// (background, bold, note, validation-presence) don't strictly need them:
195/// a border is still part of `userEnteredFormat`, so a verb that moves only
196/// borders would otherwise show as "no formatting changed" and be wrongly
197/// left ungated. **Merges are a separate cut** — `sheets.merges` is a
198/// different mask path entirely (a per-sheet list, not a per-cell
199/// property), so they need their own field and outcome shape; deferred as
200/// a follow-up (issue #1878's own open question).
201const CELL_FORMAT_FIELDS: &str = "spreadsheetId,properties.title,\
202    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
203    sheets.data(startRow,startColumn,rowData.values(userEnteredFormat(backgroundColorStyle,textFormat(bold,italic,strikethrough,underline,foregroundColorStyle),numberFormat,horizontalAlignment,borders(top,bottom,left,right)),note,dataValidation))";
204
205/// `fields` mask for `spreadsheets.get` when banded ranges are needed too
206/// (issue #1832's `add-banding`/`update-banding`/`delete-banding`/
207/// `list-bandings`, which must resolve an *existing* banded range by id
208/// before three of the four can act on one). A superset of
209/// [`SPREADSHEET_FIELDS`], kept separate for the same reason
210/// [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`] is.
211const SPREADSHEET_FIELDS_WITH_BANDING: &str = "spreadsheetId,properties.title,\
212    sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
213    sheets.bandedRanges(bandedRangeId,range,rowProperties,columnProperties)";
214
215/// `fields` mask for `spreadsheets.get` when dimension groups are needed
216/// too (issue #1833's `add-dimension-group`/`update-dimension-group`/
217/// `delete-dimension-group`/`list-dimension-groups`, which must resolve an
218/// *existing* group by its `(range, depth)` before three of the four can
219/// act on one). A superset of [`SPREADSHEET_FIELDS`], kept separate for
220/// the same reason [`SPREADSHEET_FIELDS_WITH_BANDING`] is.
221const SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS: &str = "spreadsheetId,properties.title,\
222    sheets.properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),\
223    sheets.rowGroups(range,depth,collapsed),sheets.columnGroups(range,depth,collapsed)";
224
225/// Maximum ranges sent in a single `values.batchGet`.
226///
227/// Each range is a percent-encoded, quoted sheet title in the query string,
228/// so a workbook with hundreds of tabs would otherwise build a URL past what
229/// servers and proxies accept. Chunking keeps each request bounded; the
230/// engine stitches the chunks back together.
231pub(crate) const MAX_RANGES_PER_BATCH: usize = 50;
232
233/// How the API should render cell values.
234///
235/// Engine-layer, deliberately free of any `clap` derive — the CLI keeps its
236/// own `ValueEnum` mirror, the same split `crate::cli::drive::permissions::check`
237/// uses for `DriveOperation`.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum ValueRenderOption {
240    /// Locale-formatted strings, as displayed in the UI (`"1,234.50"`,
241    /// `"$5.00"`). The default because it matches both the spreadsheet as
242    /// the user sees it and what `drive read --content`'s CSV export already
243    /// produces for a Sheet today.
244    #[default]
245    Formatted,
246    /// Raw typed values — JSON numbers and booleans rather than strings.
247    /// What you want when feeding the output to something that will do
248    /// arithmetic on it.
249    Unformatted,
250    /// The formula text (`=SUM(A1:A3)`) rather than its result.
251    Formula,
252}
253
254impl ValueRenderOption {
255    /// The wire value for the `valueRenderOption` query parameter.
256    #[must_use]
257    pub fn as_str(self) -> &'static str {
258        match self {
259            Self::Formatted => "FORMATTED_VALUE",
260            Self::Unformatted => "UNFORMATTED_VALUE",
261            Self::Formula => "FORMULA",
262        }
263    }
264}
265
266/// How the API should interpret the values being written.
267///
268/// Engine-layer, `clap`-free, like [`ValueRenderOption`].
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270pub enum ValueInputOption {
271    /// Parse each value the way typing it into the UI would: `=SUM(A1:A3)`
272    /// becomes a formula, `2026-09-06` becomes a date, `1,234` becomes a
273    /// number.
274    ///
275    /// The default because it is what a person means by "write this into the
276    /// sheet". It is also the one option whose *wrong* value silently
277    /// mangles data rather than erroring — neither choice fails, you just
278    /// get formulas you meant as text or the reverse — which is why both
279    /// spellings are spelled out in `--help` and in `docs/drive.md`. Note
280    /// `--dry-run` does *not* echo it: there is no wrong-looking output to
281    /// spot, so it has to be chosen deliberately.
282    #[default]
283    UserEntered,
284    /// Store every value verbatim as a string. A leading `=` stays literal
285    /// text rather than becoming a formula.
286    Raw,
287}
288
289impl ValueInputOption {
290    /// The wire value for the `valueInputOption` query parameter.
291    #[must_use]
292    pub fn as_str(self) -> &'static str {
293        match self {
294            Self::UserEntered => "USER_ENTERED",
295            Self::Raw => "RAW",
296        }
297    }
298}
299
300/// Sheets API façade.
301#[derive(Debug)]
302pub struct SheetsApi<'a> {
303    client: &'a SheetsClient,
304}
305
306impl<'a> SheetsApi<'a> {
307    /// Wraps an existing [`SheetsClient`].
308    #[must_use]
309    pub fn new(client: &'a SheetsClient) -> Self {
310        Self { client }
311    }
312
313    /// Fetches a spreadsheet's metadata — its title and the list of sheets.
314    ///
315    /// Always `fields`-masked; see [`SPREADSHEET_FIELDS`].
316    pub async fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
317        let url = build_spreadsheet_get_url(self.client.base_url(), spreadsheet_id)?;
318        self.client
319            .transport()
320            .get_parsed(url.as_str(), "Failed to parse Sheets spreadsheet metadata")
321            .await
322    }
323
324    /// Fetches a spreadsheet's metadata **including protected ranges** —
325    /// the one read the protection verbs need that no other caller does.
326    /// See [`SPREADSHEET_FIELDS_WITH_PROTECTIONS`].
327    pub async fn get_spreadsheet_with_protections(
328        &self,
329        spreadsheet_id: &str,
330    ) -> Result<Spreadsheet> {
331        let url =
332            build_spreadsheet_get_with_protections_url(self.client.base_url(), spreadsheet_id)?;
333        self.client
334            .transport()
335            .get_parsed(
336                url.as_str(),
337                "Failed to parse Sheets spreadsheet metadata (with protections)",
338            )
339            .await
340    }
341
342    /// Fetches a spreadsheet's metadata **including the basic filter and
343    /// filter views** — the one read `filter.rs`'s verbs need that no other
344    /// caller does. See [`SPREADSHEET_FIELDS_WITH_FILTER_VIEWS`].
345    pub async fn get_spreadsheet_with_filter_views(
346        &self,
347        spreadsheet_id: &str,
348    ) -> Result<Spreadsheet> {
349        let url =
350            build_spreadsheet_get_with_filter_views_url(self.client.base_url(), spreadsheet_id)?;
351        self.client
352            .transport()
353            .get_parsed(
354                url.as_str(),
355                "Failed to parse Sheets spreadsheet metadata (with filter views)",
356            )
357            .await
358    }
359
360    /// Fetches a spreadsheet's metadata **including conditional format
361    /// rules** — shared by all four `conditional_format.rs` verbs (issue
362    /// #1793), mirroring [`Self::get_spreadsheet_with_protections`]'s reuse
363    /// across all four protection verbs. `add-conditional-format` doesn't
364    /// strictly need the existing list, but reusing this one fetch instead
365    /// of adding a second, narrower one keeps `Sheet.conditional_formats`
366    /// consistently populated whenever any conditional-format verb runs.
367    /// See [`SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS`].
368    pub async fn get_spreadsheet_with_conditional_formats(
369        &self,
370        spreadsheet_id: &str,
371    ) -> Result<Spreadsheet> {
372        let url = build_spreadsheet_get_with_conditional_formats_url(
373            self.client.base_url(),
374            spreadsheet_id,
375        )?;
376        self.client
377            .transport()
378            .get_parsed(
379                url.as_str(),
380                "Failed to parse Sheets spreadsheet metadata (with conditional formats)",
381            )
382            .await
383    }
384
385    /// Fetches a spreadsheet's metadata **including named ranges** — the
386    /// one read the named-range verbs need that no other caller does. See
387    /// [`SPREADSHEET_FIELDS_WITH_NAMED_RANGES`].
388    pub async fn get_spreadsheet_with_named_ranges(
389        &self,
390        spreadsheet_id: &str,
391    ) -> Result<Spreadsheet> {
392        let url =
393            build_spreadsheet_get_with_named_ranges_url(self.client.base_url(), spreadsheet_id)?;
394        self.client
395            .transport()
396            .get_parsed(
397                url.as_str(),
398                "Failed to parse Sheets spreadsheet metadata (with named ranges)",
399            )
400            .await
401    }
402
403    /// Fetches a spreadsheet's metadata **including charts and slicers** —
404    /// shared by all eight `embedded_object.rs` verbs (issue #1797), the
405    /// same way [`Self::get_spreadsheet_with_conditional_formats`] is
406    /// shared by its four. See
407    /// [`SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS`].
408    pub async fn get_spreadsheet_with_embedded_objects(
409        &self,
410        spreadsheet_id: &str,
411    ) -> Result<Spreadsheet> {
412        let url = build_spreadsheet_get_with_embedded_objects_url(
413            self.client.base_url(),
414            spreadsheet_id,
415        )?;
416        self.client
417            .transport()
418            .get_parsed(
419                url.as_str(),
420                "Failed to parse Sheets spreadsheet metadata (with embedded objects)",
421            )
422            .await
423    }
424
425    /// Fetches a spreadsheet's metadata **including every populated cell's
426    /// `pivotTable` property** — `list-pivot-tables`' one fetch (issue
427    /// #1798). See [`SPREADSHEET_FIELDS_WITH_PIVOT_TABLES`].
428    pub async fn get_spreadsheet_with_pivot_tables(
429        &self,
430        spreadsheet_id: &str,
431    ) -> Result<Spreadsheet> {
432        let url =
433            build_spreadsheet_get_with_pivot_tables_url(self.client.base_url(), spreadsheet_id)?;
434        self.client
435            .transport()
436            .get_parsed(
437                url.as_str(),
438                "Failed to parse Sheets spreadsheet metadata (with pivot tables)",
439            )
440            .await
441    }
442
443    /// Fetches a spreadsheet's metadata **including banded ranges** —
444    /// shared by all four `banding.rs` verbs (issue #1832), mirroring
445    /// [`Self::get_spreadsheet_with_filter_views`]'s reuse across its own
446    /// four verbs. See [`SPREADSHEET_FIELDS_WITH_BANDING`].
447    pub async fn get_spreadsheet_with_banding(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
448        let url = build_spreadsheet_get_with_banding_url(self.client.base_url(), spreadsheet_id)?;
449        self.client
450            .transport()
451            .get_parsed(
452                url.as_str(),
453                "Failed to parse Sheets spreadsheet metadata (with banded ranges)",
454            )
455            .await
456    }
457
458    /// Fetches a spreadsheet's metadata **including dimension groups** —
459    /// shared by all four `dimension_group.rs` verbs (issue #1833),
460    /// mirroring [`Self::get_spreadsheet_with_banding`]'s reuse across its
461    /// own four verbs. See [`SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS`].
462    pub async fn get_spreadsheet_with_dimension_groups(
463        &self,
464        spreadsheet_id: &str,
465    ) -> Result<Spreadsheet> {
466        let url = build_spreadsheet_get_with_dimension_groups_url(
467            self.client.base_url(),
468            spreadsheet_id,
469        )?;
470        self.client
471            .transport()
472            .get_parsed(
473                url.as_str(),
474                "Failed to parse Sheets spreadsheet metadata (with dimension groups)",
475            )
476            .await
477    }
478
479    /// Fetches a single cell's `pivotTable` property and displayed value —
480    /// `add-pivot-table`'s occupied-anchor check and `delete-pivot-table`'s
481    /// "currently:" preview (issue #1798). `composed_a1` must be a
482    /// single-cell A1 reference carrying its own `Sheet!` prefix; the
483    /// response's `sheets` list still carries every sheet's properties (the
484    /// mask's own `sheets.properties(...)` clause), but `sheets.data` is
485    /// scoped to `composed_a1` by the `ranges` query parameter. See
486    /// [`CELL_PIVOT_FIELDS`].
487    pub async fn get_cell_pivot(
488        &self,
489        spreadsheet_id: &str,
490        composed_a1: &str,
491    ) -> Result<Spreadsheet> {
492        let url = build_cell_pivot_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
493        self.client
494            .transport()
495            .get_parsed(url.as_str(), "Failed to parse Sheets cell pivot response")
496            .await
497    }
498
499    /// Fetches a range's cell-level formatting — `read-cell-format` (issue
500    /// #1878). `composed_a1` may be any range `a1::compose` can build, not
501    /// just a single cell; the response's `sheets.data` is scoped to it by
502    /// the `ranges` query parameter. See [`CELL_FORMAT_FIELDS`].
503    pub async fn get_cell_formats(
504        &self,
505        spreadsheet_id: &str,
506        composed_a1: &str,
507    ) -> Result<Spreadsheet> {
508        let url = build_cell_format_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
509        self.client
510            .transport()
511            .get_parsed(url.as_str(), "Failed to parse Sheets cell format response")
512            .await
513    }
514
515    /// Fetches the cell values of a single A1 range.
516    pub async fn values_get(
517        &self,
518        spreadsheet_id: &str,
519        range: &str,
520        render: ValueRenderOption,
521    ) -> Result<ValueRange> {
522        let url = build_values_get_url(self.client.base_url(), spreadsheet_id, range, render)?;
523        self.client
524            .transport()
525            .get_parsed(url.as_str(), "Failed to parse Sheets values response")
526            .await
527    }
528
529    /// Fetches the cell values of several A1 ranges in one request.
530    ///
531    /// Callers must match results on each [`ValueRange::range`], never on the
532    /// order of `ranges` — see that field's docs.
533    pub async fn values_batch_get(
534        &self,
535        spreadsheet_id: &str,
536        ranges: &[String],
537        render: ValueRenderOption,
538    ) -> Result<BatchGetValuesResponse> {
539        let url =
540            build_values_batch_get_url(self.client.base_url(), spreadsheet_id, ranges, render)?;
541        self.client
542            .transport()
543            .get_parsed(url.as_str(), "Failed to parse Sheets batchGet response")
544            .await
545    }
546
547    /// Fetches every sheet named in `titles` via chunked `values.batchGet`,
548    /// pairing each result with the sheet title the server echoed back —
549    /// the "quote each title, batchGet in [`MAX_RANGES_PER_BATCH`]-sized
550    /// chunks, resolve each result's title from its own echoed `range`"
551    /// skeleton `read.rs::read_whole_workbook` and
552    /// `named_range.rs::scan_referencing_formulas` both need, factored out
553    /// once so a batching or echo-matching fix lands in one place.
554    ///
555    /// A result's title is `None`, never dropped, when
556    /// [`crate::drive::sheets::a1::sheet_title_of`] can't parse it back out
557    /// of the echoed range — the caller decides what that's worth, exactly
558    /// as `read_whole_workbook`'s `order_by_workbook` already does.
559    pub(crate) async fn batch_get_every_sheet(
560        &self,
561        spreadsheet_id: &str,
562        titles: &[String],
563        render: ValueRenderOption,
564    ) -> Result<Vec<(Option<String>, ValueRange)>> {
565        let mut out = Vec::with_capacity(titles.len());
566        for chunk in titles.chunks(MAX_RANGES_PER_BATCH) {
567            let ranges: Vec<String> = chunk.iter().map(|t| a1::quote_sheet_title(t)).collect();
568            let response = self
569                .values_batch_get(spreadsheet_id, &ranges, render)
570                .await?;
571            for value_range in response.value_ranges {
572                let title = value_range.range.as_deref().and_then(a1::sheet_title_of);
573                out.push((title, value_range));
574            }
575        }
576        Ok(out)
577    }
578
579    /// Overwrites the cells of `range`.
580    ///
581    /// `pub(in crate::drive)` so the CLI cannot reach it without going
582    /// through the gated engine — the same no-bypass-by-construction fence
583    /// `FilesApi::create`/`upload`/`edit_content` sit behind.
584    pub(in crate::drive) async fn values_update(
585        &self,
586        spreadsheet_id: &str,
587        range: &str,
588        values: &[Vec<String>],
589        input: ValueInputOption,
590    ) -> Result<UpdateValuesResponse> {
591        let url = build_values_update_url(self.client.base_url(), spreadsheet_id, range, input)?;
592        let body = serde_json::json!({ "range": range, "values": values });
593        let response = self
594            .client
595            .transport()
596            .put_json(url.as_str(), &body)
597            .await?;
598        self.client
599            .transport()
600            .parse_response(response, "Failed to parse Sheets update response")
601            .await
602            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
603    }
604
605    /// Appends rows after the last row of the table containing `range`.
606    pub(in crate::drive) async fn values_append(
607        &self,
608        spreadsheet_id: &str,
609        range: &str,
610        values: &[Vec<String>],
611        input: ValueInputOption,
612    ) -> Result<AppendValuesResponse> {
613        let url = build_values_append_url(self.client.base_url(), spreadsheet_id, range, input)?;
614        let body = serde_json::json!({ "range": range, "values": values });
615        let response = self
616            .client
617            .transport()
618            .post_json(url.as_str(), &body)
619            .await?;
620        self.client
621            .transport()
622            .parse_response(response, "Failed to parse Sheets append response")
623            .await
624            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
625    }
626
627    /// Clears the values in `range`, leaving formatting intact.
628    pub(in crate::drive) async fn values_clear(
629        &self,
630        spreadsheet_id: &str,
631        range: &str,
632    ) -> Result<ClearValuesResponse> {
633        let url = build_values_clear_url(self.client.base_url(), spreadsheet_id, range)?;
634        let response = self
635            .client
636            .transport()
637            .post_json(url.as_str(), &serde_json::json!({}))
638            .await?;
639        self.client
640            .transport()
641            .parse_response(response, "Failed to parse Sheets clear response")
642            .await
643            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
644    }
645
646    /// Applies structural `requests` to a spreadsheet (issue #1613).
647    ///
648    /// `pub(in crate::drive)` like the other mutating methods: that fence is
649    /// what makes gate bypass impossible by construction, since no module
650    /// outside `crate::drive` — where every engine runs the write gate first
651    /// — can reach it.
652    ///
653    /// Takes already-built [`BatchUpdateRequestItem`]s rather than a raw
654    /// JSON body, so the only requests expressible are the ones that type
655    /// models. That is the type-level half of the guarantee its doc comment
656    /// describes.
657    ///
658    /// `Err` means only that the server did **not** report success: a non-2xx,
659    /// or a send/transport failure before any status arrived. A 2xx whose body
660    /// cannot be read is [`BatchUpdateOutcome::AppliedReplyUnreadable`], returned as `Ok`
661    /// (issue #2021), because the mutation happened. A timeout or connection
662    /// reset before a status line stays `Err` too: the change may or may not
663    /// have been applied, and this method cannot claim it was.
664    pub(in crate::drive) async fn batch_update(
665        &self,
666        spreadsheet_id: &str,
667        requests: Vec<BatchUpdateRequestItem>,
668    ) -> Result<BatchUpdateOutcome> {
669        let url = build_batch_update_url(self.client.base_url(), spreadsheet_id)?;
670        let body = BatchUpdateRequest { requests };
671        let response = self
672            .client
673            .transport()
674            .post_json(url.as_str(), &body)
675            .await?;
676        let reply = self
677            .client
678            .transport()
679            .parse_success_response::<BatchUpdateResponse>(
680                response,
681                "Failed to parse Sheets batchUpdate response",
682            )
683            .await
684            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))?;
685        Ok(match reply {
686            Ok(response) => BatchUpdateOutcome::Applied(response),
687            Err(detail) => BatchUpdateOutcome::AppliedReplyUnreadable { detail },
688        })
689    }
690
691    /// Copies one sheet from `spreadsheet_id` into `destination_spreadsheet_id`
692    /// (ADR-0080 §10 / issue #1676's typed native-document restore path — a
693    /// deleted sheet's Drive-copy backup still contains it, so `drive lease
694    /// restore` copies it straight back into the live spreadsheet rather
695    /// than requiring a manual copy-back via the Drive UI).
696    ///
697    /// `pub(in crate::drive)` like the other mutating methods here — the
698    /// same no-bypass-by-construction fence.
699    pub(in crate::drive) async fn copy_to(
700        &self,
701        spreadsheet_id: &str,
702        sheet_id: i64,
703        destination_spreadsheet_id: &str,
704    ) -> Result<SheetProperties> {
705        let url = build_copy_to_url(self.client.base_url(), spreadsheet_id, sheet_id)?;
706        let body = CopySheetToAnotherSpreadsheetRequest {
707            destination_spreadsheet_id: destination_spreadsheet_id.to_string(),
708        };
709        let response = self
710            .client
711            .transport()
712            .post_json(url.as_str(), &body)
713            .await?;
714        self.client
715            .transport()
716            .parse_response(response, "Failed to parse Sheets copyTo response")
717            .await
718            .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
719    }
720
721    /// Searches for developer-metadata entries matching `filters`
722    /// (issue #1795, [ADR-0081](../../../docs/adrs/adr-0081.md) §4).
723    ///
724    /// Read-only, so — unlike [`Self::batch_update`]/[`Self::copy_to`] —
725    /// this is plain `pub`, not `pub(in crate::drive)`, and never goes
726    /// through [`append_write_scope_hint`]: nothing here mutates.
727    /// `developer_metadata.rs` is the one caller, and it always includes
728    /// `visibility: DOCUMENT_VISIBILITY` in every filter it builds, so the
729    /// server itself never returns a `PROJECT`-visibility entry to begin
730    /// with.
731    pub async fn search_developer_metadata(
732        &self,
733        spreadsheet_id: &str,
734        filters: Vec<DataFilter>,
735    ) -> Result<SearchDeveloperMetadataResponse> {
736        let url = build_developer_metadata_search_url(self.client.base_url(), spreadsheet_id)?;
737        let body = SearchDeveloperMetadataRequest {
738            data_filters: filters,
739        };
740        let response = self
741            .client
742            .transport()
743            .post_json(url.as_str(), &body)
744            .await?;
745        self.client
746            .transport()
747            .parse_response(
748                response,
749                "Failed to parse Sheets developerMetadata.search response",
750            )
751            .await
752    }
753}
754
755fn build_spreadsheet_get_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
756    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
757        .context("Invalid Sheets base URL")?;
758    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
759    url.query_pairs_mut()
760        .append_pair("fields", SPREADSHEET_FIELDS);
761    Ok(url)
762}
763
764fn build_spreadsheet_get_with_protections_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
765    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
766        .context("Invalid Sheets base URL")?;
767    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
768    url.query_pairs_mut()
769        .append_pair("fields", SPREADSHEET_FIELDS_WITH_PROTECTIONS);
770    Ok(url)
771}
772
773fn build_spreadsheet_get_with_filter_views_url(
774    base_url: &str,
775    spreadsheet_id: &str,
776) -> Result<Url> {
777    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
778        .context("Invalid Sheets base URL")?;
779    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
780    url.query_pairs_mut()
781        .append_pair("fields", SPREADSHEET_FIELDS_WITH_FILTER_VIEWS);
782    Ok(url)
783}
784
785fn build_spreadsheet_get_with_conditional_formats_url(
786    base_url: &str,
787    spreadsheet_id: &str,
788) -> Result<Url> {
789    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
790        .context("Invalid Sheets base URL")?;
791    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
792    url.query_pairs_mut()
793        .append_pair("fields", SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS);
794    Ok(url)
795}
796
797fn build_spreadsheet_get_with_named_ranges_url(
798    base_url: &str,
799    spreadsheet_id: &str,
800) -> Result<Url> {
801    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
802        .context("Invalid Sheets base URL")?;
803    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
804    url.query_pairs_mut()
805        .append_pair("fields", SPREADSHEET_FIELDS_WITH_NAMED_RANGES);
806    Ok(url)
807}
808
809fn build_spreadsheet_get_with_embedded_objects_url(
810    base_url: &str,
811    spreadsheet_id: &str,
812) -> Result<Url> {
813    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
814        .context("Invalid Sheets base URL")?;
815    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
816    url.query_pairs_mut()
817        .append_pair("fields", SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS);
818    Ok(url)
819}
820
821fn build_spreadsheet_get_with_pivot_tables_url(
822    base_url: &str,
823    spreadsheet_id: &str,
824) -> Result<Url> {
825    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
826        .context("Invalid Sheets base URL")?;
827    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
828    url.query_pairs_mut()
829        .append_pair("fields", SPREADSHEET_FIELDS_WITH_PIVOT_TABLES);
830    Ok(url)
831}
832
833fn build_spreadsheet_get_with_banding_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
834    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
835        .context("Invalid Sheets base URL")?;
836    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
837    url.query_pairs_mut()
838        .append_pair("fields", SPREADSHEET_FIELDS_WITH_BANDING);
839    Ok(url)
840}
841
842fn build_spreadsheet_get_with_dimension_groups_url(
843    base_url: &str,
844    spreadsheet_id: &str,
845) -> Result<Url> {
846    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
847        .context("Invalid Sheets base URL")?;
848    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
849    url.query_pairs_mut()
850        .append_pair("fields", SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS);
851    Ok(url)
852}
853
854fn build_cell_pivot_get_url(
855    base_url: &str,
856    spreadsheet_id: &str,
857    composed_a1: &str,
858) -> Result<Url> {
859    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
860        .context("Invalid Sheets base URL")?;
861    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
862    url.query_pairs_mut()
863        .append_pair("ranges", composed_a1)
864        .append_pair("fields", CELL_PIVOT_FIELDS);
865    Ok(url)
866}
867
868fn build_cell_format_get_url(
869    base_url: &str,
870    spreadsheet_id: &str,
871    composed_a1: &str,
872) -> Result<Url> {
873    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
874        .context("Invalid Sheets base URL")?;
875    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
876    url.query_pairs_mut()
877        .append_pair("ranges", composed_a1)
878        .append_pair("fields", CELL_FORMAT_FIELDS);
879    Ok(url)
880}
881
882fn build_values_get_url(
883    base_url: &str,
884    spreadsheet_id: &str,
885    range: &str,
886    render: ValueRenderOption,
887) -> Result<Url> {
888    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
889        .context("Invalid Sheets base URL")?;
890    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
891    url.query_pairs_mut()
892        .append_pair("valueRenderOption", render.as_str());
893    Ok(url)
894}
895
896fn build_values_batch_get_url(
897    base_url: &str,
898    spreadsheet_id: &str,
899    ranges: &[String],
900    render: ValueRenderOption,
901) -> Result<Url> {
902    anyhow::ensure!(
903        !ranges.is_empty(),
904        "values.batchGet requires at least one range"
905    );
906    anyhow::ensure!(
907        ranges.len() <= MAX_RANGES_PER_BATCH,
908        "values.batchGet was given {} ranges, over the {MAX_RANGES_PER_BATCH} per-request cap; \
909         callers must chunk",
910        ranges.len()
911    );
912    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
913        .context("Invalid Sheets base URL")?;
914    // `:batchGet` is a suffix on the `values` segment, not a segment of its
915    // own; `:` carries no meaning inside a path segment so it survives
916    // encoding untouched.
917    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values:batchGet"])?;
918    {
919        let mut pairs = url.query_pairs_mut();
920        for range in ranges {
921            pairs.append_pair("ranges", range);
922        }
923        pairs.append_pair("valueRenderOption", render.as_str());
924    }
925    Ok(url)
926}
927
928fn build_values_update_url(
929    base_url: &str,
930    spreadsheet_id: &str,
931    range: &str,
932    input: ValueInputOption,
933) -> Result<Url> {
934    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
935        .context("Invalid Sheets base URL")?;
936    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
937    url.query_pairs_mut()
938        .append_pair("valueInputOption", input.as_str());
939    Ok(url)
940}
941
942fn build_values_append_url(
943    base_url: &str,
944    spreadsheet_id: &str,
945    range: &str,
946    input: ValueInputOption,
947) -> Result<Url> {
948    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
949        .context("Invalid Sheets base URL")?;
950    GoogleApiClient::push_path_segments(
951        &mut url,
952        &[spreadsheet_id, "values", &format!("{range}:append")],
953    )?;
954    {
955        let mut pairs = url.query_pairs_mut();
956        pairs.append_pair("valueInputOption", input.as_str());
957        // Insert whole rows rather than overwriting whatever sits below the
958        // table; `OVERWRITE` is the API default and is the destructive one.
959        pairs.append_pair("insertDataOption", "INSERT_ROWS");
960    }
961    Ok(url)
962}
963
964fn build_values_clear_url(base_url: &str, spreadsheet_id: &str, range: &str) -> Result<Url> {
965    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
966        .context("Invalid Sheets base URL")?;
967    GoogleApiClient::push_path_segments(
968        &mut url,
969        &[spreadsheet_id, "values", &format!("{range}:clear")],
970    )?;
971    Ok(url)
972}
973
974/// `POST /v4/spreadsheets/{id}:batchUpdate`.
975///
976/// Unlike the `values.*` builders, nothing caller-influenced reaches the
977/// path — a spreadsheet id is `[A-Za-z0-9_-]`. The `:batchUpdate` suffix
978/// rides the id segment, following the `values:batchGet` precedent above;
979/// `:` carries no meaning inside a path segment, so it survives encoding.
980fn build_batch_update_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
981    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
982        .context("Invalid Sheets base URL")?;
983    GoogleApiClient::push_path_segments(&mut url, &[&format!("{spreadsheet_id}:batchUpdate")])?;
984    Ok(url)
985}
986
987/// `POST /v4/spreadsheets/{spreadsheetId}/sheets/{sheetId}:copyTo`.
988///
989/// The `:copyTo` suffix rides the sheet-id segment, same reasoning as
990/// [`build_batch_update_url`]'s `:batchUpdate` suffix — a sheet id is a
991/// plain integer, so nothing caller-influenced reaches the path here either.
992fn build_copy_to_url(base_url: &str, spreadsheet_id: &str, sheet_id: i64) -> Result<Url> {
993    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
994        .context("Invalid Sheets base URL")?;
995    GoogleApiClient::push_path_segments(
996        &mut url,
997        &[spreadsheet_id, "sheets", &format!("{sheet_id}:copyTo")],
998    )?;
999    Ok(url)
1000}
1001
1002/// `POST /v4/spreadsheets/{spreadsheetId}/developerMetadata:search`.
1003///
1004/// Same `:suffix`-on-segment trick as [`build_copy_to_url`]'s `:copyTo` —
1005/// `spreadsheet_id` is always `[A-Za-z0-9_-]`, so nothing caller-controlled
1006/// reaches the path unescaped.
1007fn build_developer_metadata_search_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
1008    let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
1009        .context("Invalid Sheets base URL")?;
1010    GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "developerMetadata:search"])?;
1011    Ok(url)
1012}
1013
1014#[cfg(test)]
1015#[allow(clippy::unwrap_used, clippy::expect_used)]
1016mod tests {
1017    use super::*;
1018
1019    const BASE: &str = "https://sheets.googleapis.com";
1020
1021    // ── spreadsheets.get ───────────────────────────────────────────────
1022
1023    #[test]
1024    fn spreadsheet_get_url_masks_fields() {
1025        let url = build_spreadsheet_get_url(BASE, "sheet-1").unwrap();
1026        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1027        let fields = url
1028            .query_pairs()
1029            .find(|(k, _)| k == "fields")
1030            .map(|(_, v)| v.to_string())
1031            .expect("fields mask must always be sent");
1032        assert!(fields.contains("sheets.properties"));
1033        assert!(fields.contains("title"));
1034        // issue #1835: frozen rows/columns/hidden-gridlines/rightToLeft ride
1035        // the same `sheets.properties` sub-mask every constant shares.
1036        assert!(fields.contains("frozenRowCount"));
1037    }
1038
1039    #[test]
1040    fn spreadsheet_get_with_conditional_formats_url_masks_the_wider_fields() {
1041        let url = build_spreadsheet_get_with_conditional_formats_url(BASE, "sheet-1").unwrap();
1042        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1043        let fields = url
1044            .query_pairs()
1045            .find(|(k, _)| k == "fields")
1046            .map(|(_, v)| v.to_string())
1047            .expect("fields mask must always be sent");
1048        assert!(fields.contains("sheets.conditionalFormats"));
1049        assert!(fields.contains("booleanRule"));
1050        assert!(fields.contains("gradientRule"));
1051        // issue #1835: every SPREADSHEET_FIELDS* constant carries the same
1052        // sheets.properties sub-mask, so this one must too.
1053        assert!(fields.contains("frozenRowCount"));
1054    }
1055
1056    #[tokio::test]
1057    async fn get_spreadsheet_with_embedded_objects_surfaces_an_unparseable_base_url() {
1058        // The `?` on the URL builder is the only failure this method can
1059        // reach before the network call, so a base URL the `url` crate
1060        // cannot parse is what exercises it — and it must surface as an
1061        // error, never a panic.
1062        use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1063        use crate::drive::client::DriveClient;
1064        use crate::drive::sheets::client::SHEETS_API_URL;
1065        use crate::test_support::env::MapEnv;
1066        use crate::utils::secret::Secret;
1067
1068        let credentials = DriveCredentials {
1069            client_id: "client-1".to_string(),
1070            client_secret: Secret::new("secret-1"),
1071            refresh_token: Secret::new("refresh-1"),
1072            scope: DriveGrantedScopes::READONLY,
1073        };
1074        let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1075        let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1076        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1077
1078        let err = SheetsApi::new(&sheets)
1079            .get_spreadsheet_with_embedded_objects("sheet-1")
1080            .await
1081            .unwrap_err();
1082        assert!(
1083            err.to_string().contains("Invalid Sheets base URL"),
1084            "{err:#}"
1085        );
1086    }
1087
1088    #[test]
1089    fn spreadsheet_get_with_embedded_objects_url_masks_the_wider_fields() {
1090        let url = build_spreadsheet_get_with_embedded_objects_url(BASE, "sheet-1").unwrap();
1091        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1092        let fields = url
1093            .query_pairs()
1094            .find(|(k, _)| k == "fields")
1095            .map(|(_, v)| v.to_string())
1096            .expect("fields mask must always be sent");
1097        assert!(fields.contains("sheets.charts"));
1098        assert!(fields.contains("sheets.slicers"));
1099        // issue #1835: every SPREADSHEET_FIELDS* constant carries the same
1100        // sheets.properties sub-mask, so this one must too.
1101        assert!(fields.contains("frozenRowCount"));
1102    }
1103
1104    #[test]
1105    fn spreadsheet_get_with_pivot_tables_url_masks_the_wider_fields() {
1106        let url = build_spreadsheet_get_with_pivot_tables_url(BASE, "sheet-1").unwrap();
1107        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1108        let fields = url
1109            .query_pairs()
1110            .find(|(k, _)| k == "fields")
1111            .map(|(_, v)| v.to_string())
1112            .expect("fields mask must always be sent");
1113        assert!(fields.contains("sheets.data"));
1114        assert!(fields.contains("pivotTable"));
1115        assert!(!fields.contains("formattedValue"));
1116        // issue #1835: every SPREADSHEET_FIELDS* constant carries the same
1117        // sheets.properties sub-mask, so this one must too.
1118        assert!(fields.contains("frozenRowCount"));
1119    }
1120
1121    #[tokio::test]
1122    async fn get_spreadsheet_with_dimension_groups_surfaces_an_unparseable_base_url() {
1123        // Same reasoning as
1124        // `get_spreadsheet_with_embedded_objects_surfaces_an_unparseable_base_url`:
1125        // the `?` on this URL builder is the only failure this method can
1126        // reach before the network call.
1127        use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1128        use crate::drive::client::DriveClient;
1129        use crate::drive::sheets::client::SHEETS_API_URL;
1130        use crate::test_support::env::MapEnv;
1131        use crate::utils::secret::Secret;
1132
1133        let credentials = DriveCredentials {
1134            client_id: "client-1".to_string(),
1135            client_secret: Secret::new("secret-1"),
1136            refresh_token: Secret::new("refresh-1"),
1137            scope: DriveGrantedScopes::READONLY,
1138        };
1139        let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1140        let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1141        let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1142
1143        let err = SheetsApi::new(&sheets)
1144            .get_spreadsheet_with_dimension_groups("sheet-1")
1145            .await
1146            .unwrap_err();
1147        assert!(
1148            err.to_string().contains("Invalid Sheets base URL"),
1149            "{err:#}"
1150        );
1151    }
1152
1153    #[test]
1154    fn spreadsheet_get_with_dimension_groups_url_masks_the_wider_fields() {
1155        let url = build_spreadsheet_get_with_dimension_groups_url(BASE, "sheet-1").unwrap();
1156        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1157        let fields = url
1158            .query_pairs()
1159            .find(|(k, _)| k == "fields")
1160            .map(|(_, v)| v.to_string())
1161            .expect("fields mask must always be sent");
1162        assert!(fields.contains("rowGroups"));
1163        assert!(fields.contains("columnGroups"));
1164    }
1165
1166    #[test]
1167    fn cell_pivot_get_url_scopes_ranges_and_masks_fields() {
1168        let url = build_cell_pivot_get_url(BASE, "sheet-1", "'Report'!A1").unwrap();
1169        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1170        let ranges = url
1171            .query_pairs()
1172            .find(|(k, _)| k == "ranges")
1173            .map(|(_, v)| v.to_string())
1174            .expect("ranges must always be sent");
1175        assert_eq!(ranges, "'Report'!A1");
1176        let fields = url
1177            .query_pairs()
1178            .find(|(k, _)| k == "fields")
1179            .map(|(_, v)| v.to_string())
1180            .expect("fields mask must always be sent");
1181        assert!(fields.contains("pivotTable"));
1182        assert!(fields.contains("formattedValue"));
1183        // issue #1835: CELL_PIVOT_FIELDS carries the same sheets.properties
1184        // sub-mask every other SPREADSHEET_FIELDS* constant does.
1185        assert!(fields.contains("frozenRowCount"));
1186    }
1187
1188    #[test]
1189    fn cell_format_get_url_scopes_ranges_and_masks_fields() {
1190        let url = build_cell_format_get_url(BASE, "sheet-1", "'Report'!A1:D10").unwrap();
1191        assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1192        let ranges = url
1193            .query_pairs()
1194            .find(|(k, _)| k == "ranges")
1195            .map(|(_, v)| v.to_string())
1196            .expect("ranges must always be sent");
1197        assert_eq!(ranges, "'Report'!A1:D10");
1198        let fields = url
1199            .query_pairs()
1200            .find(|(k, _)| k == "fields")
1201            .map(|(_, v)| v.to_string())
1202            .expect("fields mask must always be sent");
1203        assert!(fields.contains("userEnteredFormat"));
1204        assert!(fields.contains("backgroundColorStyle"));
1205        assert!(fields.contains("borders(top,bottom,left,right)"));
1206        assert!(fields.contains("note"));
1207        assert!(fields.contains("dataValidation"));
1208        // issue #1878: read userEnteredFormat, never effectiveFormat — the
1209        // latter folds in conditional formatting and would give false
1210        // positives when checking whether a verb moved formatting.
1211        assert!(!fields.contains("effectiveFormat"));
1212        assert!(!fields.contains("formattedValue"));
1213        assert!(fields.contains("frozenRowCount"));
1214    }
1215
1216    // ── values.get: encoding is the whole point ────────────────────────
1217
1218    #[test]
1219    fn values_get_url_percent_encodes_a_space_in_a_quoted_title() {
1220        let url = build_values_get_url(
1221            BASE,
1222            "sheet-1",
1223            "'My Sheet'!A1:B2",
1224            ValueRenderOption::Formatted,
1225        )
1226        .unwrap();
1227        assert!(
1228            url.as_str().contains("'My%20Sheet'!A1:B2"),
1229            "space must be encoded: {url}"
1230        );
1231    }
1232
1233    #[test]
1234    fn values_get_url_encodes_characters_that_would_reshape_the_url() {
1235        // Each of these silently corrupts the request if interpolated raw:
1236        // `#` truncates to a fragment, `?` starts a query, `/` invents a
1237        // path segment.
1238        for (title, encoded) in [("A#B", "%23"), ("A?B", "%3F"), ("A/B", "%2F")] {
1239            let range = format!("'{title}'!A1");
1240            let url =
1241                build_values_get_url(BASE, "s", &range, ValueRenderOption::Formatted).unwrap();
1242            assert!(
1243                url.as_str().contains(encoded),
1244                "{title:?} must encode to {encoded}: {url}"
1245            );
1246            assert!(url.fragment().is_none(), "{title:?} leaked a fragment");
1247            assert!(url.query().is_some_and(|q| !q.contains("!A1")));
1248            // The range must remain ONE path segment: /v4/spreadsheets/s/
1249            // values/<range> is exactly five.
1250            let segments: Vec<&str> = url.path_segments().unwrap().collect();
1251            assert_eq!(segments.len(), 5, "{title:?} split the path: {url}");
1252            assert_eq!(segments[3], "values", "{title:?} shifted the path: {url}");
1253        }
1254    }
1255
1256    #[test]
1257    fn values_get_url_round_trips_the_range_through_decoding() {
1258        let range = "'Bob''s Sheet'!A1:C9";
1259        let url = build_values_get_url(BASE, "s", range, ValueRenderOption::Formatted).unwrap();
1260        let decoded = url
1261            .path_segments()
1262            .unwrap()
1263            .next_back()
1264            .map(percent_decode)
1265            .unwrap();
1266        assert_eq!(decoded, range);
1267    }
1268
1269    fn percent_decode(segment: &str) -> String {
1270        percent_encoding::percent_decode_str(segment)
1271            .decode_utf8()
1272            .unwrap()
1273            .to_string()
1274    }
1275
1276    #[test]
1277    fn values_get_url_sends_the_render_option() {
1278        for (render, wire) in [
1279            (ValueRenderOption::Formatted, "FORMATTED_VALUE"),
1280            (ValueRenderOption::Unformatted, "UNFORMATTED_VALUE"),
1281            (ValueRenderOption::Formula, "FORMULA"),
1282        ] {
1283            let url = build_values_get_url(BASE, "s", "A1", render).unwrap();
1284            let got = url
1285                .query_pairs()
1286                .find(|(k, _)| k == "valueRenderOption")
1287                .map(|(_, v)| v.to_string())
1288                .unwrap();
1289            assert_eq!(got, wire);
1290        }
1291    }
1292
1293    // ── values.batchGet ────────────────────────────────────────────────
1294
1295    #[test]
1296    fn batch_get_url_repeats_the_ranges_parameter() {
1297        let ranges = vec!["'A'!A1:B2".to_string(), "'B'!A1".to_string()];
1298        let url =
1299            build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted).unwrap();
1300        assert_eq!(url.path(), "/v4/spreadsheets/s/values:batchGet");
1301        let got: Vec<String> = url
1302            .query_pairs()
1303            .filter(|(k, _)| k == "ranges")
1304            .map(|(_, v)| v.to_string())
1305            .collect();
1306        assert_eq!(got, ranges);
1307    }
1308
1309    #[test]
1310    fn batch_get_url_keeps_the_colon_suffix_unencoded() {
1311        let url = build_values_batch_get_url(
1312            BASE,
1313            "s",
1314            &["A1".to_string()],
1315            ValueRenderOption::Formatted,
1316        )
1317        .unwrap();
1318        assert!(
1319            url.as_str().contains("/values:batchGet?"),
1320            "the :batchGet suffix must survive path encoding: {url}"
1321        );
1322    }
1323
1324    #[test]
1325    fn batch_get_url_rejects_an_empty_range_list() {
1326        let err =
1327            build_values_batch_get_url(BASE, "s", &[], ValueRenderOption::Formatted).unwrap_err();
1328        assert!(err.to_string().contains("at least one range"), "{err}");
1329    }
1330
1331    #[test]
1332    fn batch_get_url_rejects_more_ranges_than_the_cap() {
1333        let ranges: Vec<String> = (0..=MAX_RANGES_PER_BATCH)
1334            .map(|i| format!("S{i}"))
1335            .collect();
1336        let err = build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted)
1337            .unwrap_err();
1338        assert!(err.to_string().contains("per-request cap"), "{err}");
1339    }
1340
1341    // ── values.update / values.append / values.clear ──────────────────
1342
1343    #[test]
1344    fn update_url_sends_the_value_input_option() {
1345        let url =
1346            build_values_update_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1347        assert_eq!(url.path(), "/v4/spreadsheets/s/values/A1:B2");
1348        let input = url
1349            .query_pairs()
1350            .find(|(k, _)| k == "valueInputOption")
1351            .map(|(_, v)| v.to_string());
1352        assert_eq!(input, Some("USER_ENTERED".to_string()));
1353    }
1354
1355    #[test]
1356    fn update_url_percent_encodes_a_quoted_sheet_title() {
1357        let url =
1358            build_values_update_url(BASE, "s", "'My Sheet'!A1", ValueInputOption::Raw).unwrap();
1359        assert!(url.path().contains("'My%20Sheet'"), "{url}");
1360    }
1361
1362    #[test]
1363    fn append_url_keeps_the_colon_suffix_and_sets_insert_rows() {
1364        let url =
1365            build_values_append_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1366        assert!(url.as_str().contains("/values/A1:B2:append"), "{url}");
1367        let insert_data_option = url
1368            .query_pairs()
1369            .find(|(k, _)| k == "insertDataOption")
1370            .map(|(_, v)| v.to_string());
1371        assert_eq!(insert_data_option, Some("INSERT_ROWS".to_string()));
1372        let input = url
1373            .query_pairs()
1374            .find(|(k, _)| k == "valueInputOption")
1375            .map(|(_, v)| v.to_string());
1376        assert_eq!(input, Some("USER_ENTERED".to_string()));
1377    }
1378
1379    #[test]
1380    fn clear_url_keeps_the_colon_suffix_and_sends_no_query() {
1381        let url = build_values_clear_url(BASE, "s", "A1:B2").unwrap();
1382        assert!(url.as_str().contains("/values/A1:B2:clear"), "{url}");
1383        assert!(url.query().is_none(), "{url}");
1384    }
1385
1386    // ── spreadsheets.sheets.copyTo ─────────────────────────────────────
1387
1388    #[test]
1389    fn copy_to_url_keeps_the_colon_suffix_on_the_sheet_id_segment() {
1390        let url = build_copy_to_url(BASE, "dest-or-source-id", 12345).unwrap();
1391        assert_eq!(
1392            url.path(),
1393            "/v4/spreadsheets/dest-or-source-id/sheets/12345:copyTo"
1394        );
1395        assert!(url.query().is_none(), "{url}");
1396    }
1397
1398    // ── batchUpdate reply handling (issue #2021) ───────────────────────
1399
1400    /// A `SheetsApi` backed by `server`, with the OAuth token endpoint mounted.
1401    async fn sheets_client_for(server: &wiremock::MockServer) -> SheetsClient {
1402        use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1403        use crate::drive::client::DriveClient;
1404        use crate::drive::sheets::client::SHEETS_API_URL;
1405        use crate::test_support::env::MapEnv;
1406        use crate::utils::secret::Secret;
1407
1408        wiremock::Mock::given(wiremock::matchers::method("POST"))
1409            .and(wiremock::matchers::path("/token"))
1410            .respond_with(
1411                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1412                    "access_token": "test-token", "expires_in": 3600,
1413                })),
1414            )
1415            .mount(server)
1416            .await;
1417        let credentials = DriveCredentials {
1418            client_id: "client-1".to_string(),
1419            client_secret: Secret::new("secret-1"),
1420            refresh_token: Secret::new("refresh-1"),
1421            scope: DriveGrantedScopes::READONLY,
1422        };
1423        let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
1424        crate::drive::client::test_support::replace_session(
1425            &mut drive,
1426            &credentials,
1427            &format!("{}/token", server.uri()),
1428        );
1429        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1430        SheetsClient::from_drive_client_with(&env, &drive).unwrap()
1431    }
1432
1433    /// Mounts a `batchUpdate` answering with `template`, expecting one call.
1434    async fn mount_batch_update_reply(
1435        server: &wiremock::MockServer,
1436        template: wiremock::ResponseTemplate,
1437    ) {
1438        wiremock::Mock::given(wiremock::matchers::method("POST"))
1439            .and(wiremock::matchers::path(
1440                "/v4/spreadsheets/sheet-1:batchUpdate",
1441            ))
1442            .respond_with(template)
1443            .expect(1)
1444            .mount(server)
1445            .await;
1446    }
1447
1448    fn one_request() -> Vec<BatchUpdateRequestItem> {
1449        vec![BatchUpdateRequestItem::DeleteEmbeddedObject(
1450            crate::drive::sheets::types::DeleteEmbeddedObjectRequest { object_id: 1 },
1451        )]
1452    }
1453
1454    #[tokio::test]
1455    async fn batch_update_parses_a_well_formed_2xx_reply_as_applied() {
1456        let server = wiremock::MockServer::start().await;
1457        let sheets = sheets_client_for(&server).await;
1458        mount_batch_update_reply(
1459            &server,
1460            wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1461                "spreadsheetId": "sheet-1",
1462                "replies": [{"addChart": {"chart": {"chartId": 42}}}],
1463            })),
1464        )
1465        .await;
1466
1467        let outcome = SheetsApi::new(&sheets)
1468            .batch_update("sheet-1", one_request())
1469            .await
1470            .unwrap();
1471        assert!(
1472            matches!(outcome, BatchUpdateOutcome::Applied(_)),
1473            "{outcome:?}"
1474        );
1475    }
1476
1477    #[tokio::test]
1478    async fn batch_update_reports_a_2xx_with_a_non_json_body_as_applied_reply_unreadable() {
1479        let server = wiremock::MockServer::start().await;
1480        let sheets = sheets_client_for(&server).await;
1481        mount_batch_update_reply(
1482            &server,
1483            wiremock::ResponseTemplate::new(200).set_body_string("not json"),
1484        )
1485        .await;
1486
1487        let outcome = SheetsApi::new(&sheets)
1488            .batch_update("sheet-1", one_request())
1489            .await
1490            .unwrap();
1491        let BatchUpdateOutcome::AppliedReplyUnreadable { detail } = outcome else {
1492            panic!("expected AppliedReplyUnreadable, got {outcome:?}"); // omni-dev: coverage ignore-line reason="the assertion arm only fires if the outcome type regresses, in which case the test has already failed"
1493        };
1494        assert!(
1495            detail.contains("Failed to parse Sheets batchUpdate response"),
1496            "{detail}"
1497        );
1498    }
1499
1500    #[tokio::test]
1501    async fn batch_update_reports_a_2xx_with_a_mistyped_reply_as_applied_reply_unreadable() {
1502        let server = wiremock::MockServer::start().await;
1503        let sheets = sheets_client_for(&server).await;
1504        // `chartId` is an integer on the wire; a string is a reply-shape gap
1505        // of exactly the kind #1929 fixed one instance of.
1506        mount_batch_update_reply(
1507            &server,
1508            wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1509                "replies": [{"addChart": {"chart": {"chartId": "x"}}}],
1510            })),
1511        )
1512        .await;
1513
1514        let outcome = SheetsApi::new(&sheets)
1515            .batch_update("sheet-1", one_request())
1516            .await
1517            .unwrap();
1518        assert!(
1519            matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
1520            "{outcome:?}"
1521        );
1522    }
1523
1524    #[tokio::test]
1525    async fn batch_update_reports_a_2xx_with_an_empty_body_as_applied_reply_unreadable() {
1526        let server = wiremock::MockServer::start().await;
1527        let sheets = sheets_client_for(&server).await;
1528        mount_batch_update_reply(&server, wiremock::ResponseTemplate::new(200)).await;
1529
1530        let outcome = SheetsApi::new(&sheets)
1531            .batch_update("sheet-1", one_request())
1532            .await
1533            .unwrap();
1534        assert!(
1535            matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
1536            "{outcome:?}"
1537        );
1538    }
1539
1540    #[tokio::test]
1541    async fn batch_update_reports_a_non_2xx_as_an_error_not_applied() {
1542        let server = wiremock::MockServer::start().await;
1543        let sheets = sheets_client_for(&server).await;
1544        mount_batch_update_reply(
1545            &server,
1546            wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1547                "error": {
1548                    "code": 403,
1549                    "message": "The caller does not have permission",
1550                    "status": "PERMISSION_DENIED",
1551                }
1552            })),
1553        )
1554        .await;
1555
1556        let err = SheetsApi::new(&sheets)
1557            .batch_update("sheet-1", one_request())
1558            .await
1559            .unwrap_err();
1560        let message = format!("{err:#}");
1561        assert!(message.contains("PERMISSION_DENIED"), "{message}");
1562        assert!(message.contains("--write-full"), "{message}");
1563    }
1564
1565    #[test]
1566    fn applied_reply_unreadable_line_words_each_retry_hint() {
1567        assert_eq!(
1568            applied_reply_unreadable_line(
1569                "add chart",
1570                "'Budget'",
1571                RetryHint::NotIdempotent,
1572                "bad reply"
1573            ),
1574            "Applied, but the reply could not be read: add chart in 'Budget' — \
1575             do not retry; check the spreadsheet first (bad reply)"
1576        );
1577        assert_eq!(
1578            applied_reply_unreadable_line("sort A1:B2", "'Budget'", RetryHint::Idempotent, "x"),
1579            "Applied, but the reply could not be read: sort A1:B2 in 'Budget' — \
1580             check the spreadsheet to confirm (x)"
1581        );
1582    }
1583
1584    /// Every engine that calls `batch_update` must name [`BatchUpdateOutcome`].
1585    ///
1586    /// The compiler only forces this where the engine reads the reply: an
1587    /// engine that matches `Ok(_)` compiles unchanged and would report an
1588    /// applied-but-unreadable reply as a plain `Changed` with no signal. The
1589    /// same grep-guard idiom keeps other cross-cutting rules honest.
1590    #[test]
1591    fn every_batch_update_caller_handles_an_unreadable_reply() {
1592        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/drive");
1593        let mut files = Vec::new();
1594        let mut dirs = vec![root.join("sheets"), root.join("lease")];
1595        while let Some(dir) = dirs.pop() {
1596            for entry in std::fs::read_dir(&dir).unwrap() {
1597                let path = entry.unwrap().path();
1598                if path.is_dir() {
1599                    dirs.push(path);
1600                } else if path.extension().is_some_and(|e| e == "rs") {
1601                    files.push(path);
1602                }
1603            }
1604        }
1605        let mut callers = Vec::new();
1606        for path in files {
1607            let source = std::fs::read_to_string(&path).unwrap();
1608            // Only production code: everything before the `#[cfg(test)]`
1609            // that gates the trailing `mod tests`, so an earlier
1610            // cfg-gated item cannot hide the code after it.
1611            let production = source.rfind("\nmod tests").map_or(source.as_str(), |at| {
1612                source[..at]
1613                    .rfind("#[cfg(test)]")
1614                    .map_or(&source[..at], |cfg| &source[..cfg])
1615            });
1616            if production.contains(".batch_update(") {
1617                // The qualified path, not the bare identifier: an import or
1618                // a comment naming the variant must not satisfy the guard.
1619                callers.push((
1620                    path,
1621                    production.contains("BatchUpdateOutcome::AppliedReplyUnreadable"),
1622                ));
1623            }
1624        }
1625        assert!(
1626            callers.len() >= 21,
1627            "expected 21 callers, found {callers:?}"
1628        );
1629        let unhandled: Vec<_> = callers
1630            .iter()
1631            .filter(|(_, handled)| !handled)
1632            .map(|(path, _)| path.display().to_string())
1633            .collect();
1634        assert!(
1635            unhandled.is_empty(),
1636            "these batch_update callers never handle AppliedReplyUnreadable: {unhandled:?}"
1637        );
1638    }
1639
1640    // ── base URL handling ──────────────────────────────────────────────
1641
1642    #[test]
1643    fn urls_respect_a_wiremock_style_base_with_a_port() {
1644        let url = build_values_get_url(
1645            "http://127.0.0.1:9123",
1646            "s",
1647            "'My Sheet'!A1",
1648            ValueRenderOption::Formatted,
1649        )
1650        .unwrap();
1651        assert_eq!(url.host_str(), Some("127.0.0.1"));
1652        assert_eq!(url.port(), Some(9123));
1653        assert!(url.path().starts_with("/v4/spreadsheets/s/values/"));
1654    }
1655
1656    #[test]
1657    fn render_option_default_is_formatted() {
1658        assert_eq!(ValueRenderOption::default(), ValueRenderOption::Formatted);
1659    }
1660}