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