Skip to main content

omni_dev/drive/sheets/
read.rs

1//! `drive sheets read` engine — cell values for one range, or for every
2//! sheet in a workbook.
3//!
4//! The multi-sheet path is the reason this module exists: Drive's
5//! `files.export` can only ever render a Sheet's **first** tab as CSV
6//! (`crate::cli::drive::read`), because Drive has no multi-sheet CSV format.
7//! Reading every tab needs one `spreadsheets.get` for the titles and then
8//! `values.batchGet` for the data.
9//!
10//! Read-only, so — unlike the mutating verbs — it consults no write gate.
11//! That matches `drive search`/`read`/`dedupe`: [ADR-0071](../../../docs/adrs/adr-0071.md)
12//! §11 records read-path gate enforcement as a deliberate, still-open
13//! fast-follow across the whole Drive surface, not something this command
14//! opts out of on its own.
15
16use anyhow::Result;
17use serde::Serialize;
18
19use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
20use crate::drive::sheets::a1;
21use crate::drive::sheets::api::{SheetsApi, ValueRenderOption};
22
23/// Refuse to fan out across more tabs than this in one command.
24///
25/// Errors rather than truncating, the same posture as
26/// `folder_ancestry::MAX_CHAIN_DEPTH` and [ADR-0070](../../../docs/adrs/adr-0070.md)
27/// §3: silently returning some of a workbook would look identical to a
28/// workbook that genuinely had that little in it.
29const MAX_SHEETS_PER_READ: usize = 200;
30
31/// Per-call read options.
32#[derive(Debug, Clone)]
33pub struct ReadOptions {
34    /// Spreadsheet id.
35    pub spreadsheet_id: String,
36    /// An explicit A1 range, which may carry its own `Sheet!` prefix.
37    pub range: Option<String>,
38    /// A sheet title, supplying a prefix for a bare `range` or selecting a
39    /// whole tab on its own.
40    pub sheet: Option<String>,
41    /// How the API should render cell values.
42    pub render: ValueRenderOption,
43}
44
45impl ReadOptions {
46    /// Whether this asks for the whole workbook rather than one range.
47    fn is_whole_workbook(&self) -> bool {
48        self.range.is_none() && self.sheet.is_none()
49    }
50}
51
52/// One sheet's worth of cells.
53#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
54pub struct SheetValues {
55    /// The sheet title, when it could be determined.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub title: Option<String>,
58    /// The server-normalised range these values came from.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub range: Option<String>,
61    /// Row-major values, **ragged** exactly as the API returned them:
62    /// trailing empty cells are truncated per row. Rendering to CSV pads;
63    /// JSON/YAML preserve the raggedness as the truthful shape.
64    pub values: Vec<Vec<serde_json::Value>>,
65}
66
67/// The result of a read.
68///
69/// Serialised as an ordered **list** of sheets rather than a
70/// `{title: rows}` map: a map's key order is not guaranteed through
71/// `serde_json`, and workbook order is meaningful to the reader.
72#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
73pub struct ReadOutcome {
74    /// The spreadsheet that was read.
75    pub spreadsheet_id: String,
76    /// Its workbook title, when known (absent on a single-range read, which
77    /// never fetches workbook metadata).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub spreadsheet_title: Option<String>,
80    /// One entry per sheet read, in workbook order.
81    pub sheets: Vec<SheetValues>,
82}
83
84impl JsonlSerialize for ReadOutcome {
85    fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
86        write_scalar_jsonl(self, out)
87    }
88}
89
90/// Reads cell values, either for one range or for the whole workbook.
91pub async fn read(api: &SheetsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
92    if opts.is_whole_workbook() {
93        read_whole_workbook(api, opts).await
94    } else {
95        read_single_range(api, opts).await
96    }
97}
98
99/// One `values.get` for a caller-specified range.
100async fn read_single_range(api: &SheetsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
101    let range = a1::compose(opts.sheet.as_deref(), opts.range.as_deref())?;
102    let value_range = api
103        .values_get(&opts.spreadsheet_id, &range, opts.render)
104        .await?;
105    // Prefer the title the server echoes back; fall back to what the caller
106    // asked for, which is all we know if the response omits the range.
107    let title = value_range
108        .range
109        .as_deref()
110        .and_then(a1::sheet_title_of)
111        .or_else(|| opts.sheet.clone());
112    Ok(ReadOutcome {
113        spreadsheet_id: opts.spreadsheet_id.clone(),
114        spreadsheet_title: None,
115        sheets: vec![SheetValues {
116            title,
117            range: value_range.range,
118            values: value_range.values,
119        }],
120    })
121}
122
123/// `spreadsheets.get` for the tab list, then chunked `values.batchGet`.
124async fn read_whole_workbook(api: &SheetsApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
125    let spreadsheet = api.get_spreadsheet(&opts.spreadsheet_id).await?;
126    let titles = spreadsheet.sheet_titles();
127
128    anyhow::ensure!(
129        titles.len() <= MAX_SHEETS_PER_READ,
130        "spreadsheet has {} sheets, over the {MAX_SHEETS_PER_READ}-sheet cap for a whole-workbook \
131         read; refusing to return a partial workbook — narrow it with --sheet or --range",
132        titles.len()
133    );
134
135    // A workbook with no grid sheets at all is legal (every tab a chart).
136    if titles.is_empty() {
137        return Ok(ReadOutcome {
138            spreadsheet_id: opts.spreadsheet_id.clone(),
139            spreadsheet_title: Some(spreadsheet.title().to_string()),
140            sheets: Vec::new(),
141        });
142    }
143
144    // Each range is a quoted, percent-encoded title in the query string, so
145    // a wide workbook has to be split across requests — see
146    // `SheetsApi::batch_get_every_sheet`.
147    let mut by_title: Vec<SheetValues> = Vec::with_capacity(titles.len());
148    for (title, value_range) in api
149        .batch_get_every_sheet(&opts.spreadsheet_id, &titles, opts.render)
150        .await?
151    {
152        by_title.push(SheetValues {
153            title,
154            range: value_range.range,
155            values: value_range.values,
156        });
157    }
158
159    Ok(ReadOutcome {
160        spreadsheet_id: opts.spreadsheet_id.clone(),
161        spreadsheet_title: Some(spreadsheet.title().to_string()),
162        sheets: order_by_workbook(by_title, &titles),
163    })
164}
165
166/// Restores workbook order, and appends anything whose title didn't match a
167/// known tab rather than dropping it.
168///
169/// Nothing is discarded: a range the server normalised into a shape
170/// `sheet_title_of` can't read still reaches the caller, just at the end.
171fn order_by_workbook(mut fetched: Vec<SheetValues>, titles: &[String]) -> Vec<SheetValues> {
172    let mut ordered = Vec::with_capacity(fetched.len());
173    for title in titles {
174        if let Some(pos) = fetched
175            .iter()
176            .position(|s| s.title.as_deref() == Some(title.as_str()))
177        {
178            ordered.push(fetched.remove(pos));
179        }
180    }
181    ordered.extend(fetched);
182    ordered
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used)]
187mod tests {
188    use super::*;
189    use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
190    use crate::drive::client::DriveClient;
191    use crate::drive::sheets::client::{SheetsClient, SHEETS_API_URL};
192    use crate::test_support::env::MapEnv;
193    use crate::utils::secret::Secret;
194
195    fn test_credentials() -> DriveCredentials {
196        DriveCredentials {
197            client_id: "client-1".to_string(),
198            client_secret: Secret::new("secret-1"),
199            refresh_token: Secret::new("refresh-1"),
200            scope: DriveGrantedScopes::READONLY,
201        }
202    }
203
204    /// Builds a Sheets client pointed at wiremock, via the real derivation
205    /// path.
206    ///
207    /// Note the ordering: `replace_session` swaps the Drive client's whole
208    /// transport, so it must run **before** the derive. Deriving first would
209    /// leave the Sheets client holding the original session, pointed at the
210    /// real `oauth2.googleapis.com` — a live network call from a unit test.
211    async fn sheets_client(server: &wiremock::MockServer) -> SheetsClient {
212        wiremock::Mock::given(wiremock::matchers::method("POST"))
213            .and(wiremock::matchers::path("/token"))
214            .respond_with(
215                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
216                    "access_token": "test-token",
217                    "expires_in": 3600,
218                })),
219            )
220            .mount(server)
221            .await;
222
223        let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
224        crate::drive::client::test_support::replace_session(
225            &mut drive,
226            &test_credentials(),
227            &format!("{}/token", server.uri()),
228        );
229        let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
230        SheetsClient::from_drive_client_with(&env, &drive).unwrap()
231    }
232
233    fn opts(range: Option<&str>, sheet: Option<&str>) -> ReadOptions {
234        ReadOptions {
235            spreadsheet_id: "sheet-1".to_string(),
236            range: range.map(str::to_string),
237            sheet: sheet.map(str::to_string),
238            render: ValueRenderOption::Formatted,
239        }
240    }
241
242    fn mount_spreadsheet_get(titles: &[&str]) -> wiremock::Mock {
243        let sheets: Vec<serde_json::Value> = titles
244            .iter()
245            .enumerate()
246            .map(|(i, t)| serde_json::json!({"properties": {"sheetId": i, "title": t, "index": i}}))
247            .collect();
248        wiremock::Mock::given(wiremock::matchers::method("GET"))
249            .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
250            .respond_with(
251                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
252                    "spreadsheetId": "sheet-1",
253                    "properties": {"title": "Book"},
254                    "sheets": sheets,
255                })),
256            )
257    }
258
259    // ── single-range reads ─────────────────────────────────────────────
260
261    #[tokio::test]
262    async fn single_range_read_calls_values_get_once_and_never_fetches_metadata() {
263        let server = wiremock::MockServer::start().await;
264        let client = sheets_client(&server).await;
265        // No `spreadsheets.get` mock: a single-range read must not pay for
266        // workbook metadata it does not use.
267        // The path is matched in its **encoded** form: this is the
268        // end-to-end proof that a sheet title containing a space reaches the
269        // wire as `%20` rather than breaking the request.
270        wiremock::Mock::given(wiremock::matchers::method("GET"))
271            .and(wiremock::matchers::path(
272                "/v4/spreadsheets/sheet-1/values/'My%20Sheet'!A1:B2",
273            ))
274            .and(wiremock::matchers::query_param(
275                "valueRenderOption",
276                "FORMATTED_VALUE",
277            ))
278            .respond_with(
279                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
280                    "range": "'My Sheet'!A1:B2",
281                    "values": [["a", "b"], ["c"]],
282                })),
283            )
284            .expect(1)
285            .mount(&server)
286            .await;
287
288        let api = SheetsApi::new(&client);
289        let outcome = read(&api, &opts(Some("A1:B2"), Some("My Sheet")))
290            .await
291            .unwrap();
292        assert_eq!(outcome.sheets.len(), 1);
293        assert_eq!(outcome.sheets[0].title.as_deref(), Some("My Sheet"));
294        assert_eq!(outcome.sheets[0].values.len(), 2);
295        assert_eq!(outcome.spreadsheet_title, None);
296    }
297
298    #[tokio::test]
299    async fn single_range_read_honours_the_render_option() {
300        let server = wiremock::MockServer::start().await;
301        let client = sheets_client(&server).await;
302        wiremock::Mock::given(wiremock::matchers::method("GET"))
303            .and(wiremock::matchers::path(
304                "/v4/spreadsheets/sheet-1/values/A1",
305            ))
306            .and(wiremock::matchers::query_param(
307                "valueRenderOption",
308                "FORMULA",
309            ))
310            .respond_with(
311                wiremock::ResponseTemplate::new(200)
312                    .set_body_json(serde_json::json!({"range": "S!A1", "values": [["=SUM(B:B)"]]})),
313            )
314            .expect(1)
315            .mount(&server)
316            .await;
317
318        let api = SheetsApi::new(&client);
319        let mut o = opts(Some("A1"), None);
320        o.render = ValueRenderOption::Formula;
321        let outcome = read(&api, &o).await.unwrap();
322        assert_eq!(outcome.sheets[0].values[0][0], "=SUM(B:B)");
323    }
324
325    #[tokio::test]
326    async fn single_range_read_surfaces_an_api_error() {
327        let server = wiremock::MockServer::start().await;
328        let client = sheets_client(&server).await;
329        wiremock::Mock::given(wiremock::matchers::method("GET"))
330            .and(wiremock::matchers::path(
331                "/v4/spreadsheets/sheet-1/values/A1",
332            ))
333            .respond_with(
334                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
335                    "error": {"code": 400, "message": "Unable to parse range: A1",
336                              "status": "INVALID_ARGUMENT"},
337                })),
338            )
339            .mount(&server)
340            .await;
341
342        let api = SheetsApi::new(&client);
343        let err = read(&api, &opts(Some("A1"), None)).await.unwrap_err();
344        let msg = err.to_string();
345        assert!(msg.contains("Unable to parse range"), "{msg}");
346        assert!(msg.contains("INVALID_ARGUMENT"), "{msg}");
347    }
348
349    #[tokio::test]
350    async fn conflicting_sheet_and_prefixed_range_fails_before_any_request() {
351        let server = wiremock::MockServer::start().await;
352        let client = sheets_client(&server).await;
353        // No API mocks at all: the conflict must be caught client-side.
354        let api = SheetsApi::new(&client);
355        let err = read(&api, &opts(Some("Other!A1"), Some("Mine")))
356            .await
357            .unwrap_err();
358        assert!(err.to_string().contains("already names a sheet"), "{err}");
359    }
360
361    // ── whole-workbook reads ───────────────────────────────────────────
362
363    #[tokio::test]
364    async fn whole_workbook_read_composes_get_then_batch_get() {
365        let server = wiremock::MockServer::start().await;
366        let client = sheets_client(&server).await;
367        mount_spreadsheet_get(&["Q1", "My Sheet"])
368            .expect(1)
369            .mount(&server)
370            .await;
371        wiremock::Mock::given(wiremock::matchers::method("GET"))
372            .and(wiremock::matchers::path(
373                "/v4/spreadsheets/sheet-1/values:batchGet",
374            ))
375            .respond_with(
376                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
377                    "valueRanges": [
378                        {"range": "'Q1'!A1:B2", "values": [["1", "2"]]},
379                        {"range": "'My Sheet'!A1:A1", "values": [["x"]]},
380                    ],
381                })),
382            )
383            .expect(1)
384            .mount(&server)
385            .await;
386
387        let api = SheetsApi::new(&client);
388        let outcome = read(&api, &opts(None, None)).await.unwrap();
389        assert_eq!(outcome.spreadsheet_title.as_deref(), Some("Book"));
390        assert_eq!(outcome.sheets.len(), 2);
391        assert_eq!(outcome.sheets[0].title.as_deref(), Some("Q1"));
392        assert_eq!(outcome.sheets[1].title.as_deref(), Some("My Sheet"));
393    }
394
395    #[tokio::test]
396    async fn whole_workbook_read_uses_workbook_order_not_response_order() {
397        let server = wiremock::MockServer::start().await;
398        let client = sheets_client(&server).await;
399        mount_spreadsheet_get(&["A", "B"]).mount(&server).await;
400        // Server returns them the other way round; workbook order must win.
401        wiremock::Mock::given(wiremock::matchers::method("GET"))
402            .and(wiremock::matchers::path(
403                "/v4/spreadsheets/sheet-1/values:batchGet",
404            ))
405            .respond_with(
406                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
407                    "valueRanges": [
408                        {"range": "'B'!A1", "values": [["b"]]},
409                        {"range": "'A'!A1", "values": [["a"]]},
410                    ],
411                })),
412            )
413            .mount(&server)
414            .await;
415
416        let api = SheetsApi::new(&client);
417        let outcome = read(&api, &opts(None, None)).await.unwrap();
418        let titles: Vec<_> = outcome
419            .sheets
420            .iter()
421            .map(|s| s.title.clone().unwrap())
422            .collect();
423        assert_eq!(titles, ["A", "B"]);
424        assert_eq!(outcome.sheets[0].values[0][0], "a");
425    }
426
427    #[tokio::test]
428    async fn whole_workbook_read_handles_an_empty_sheet_with_no_values_key() {
429        let server = wiremock::MockServer::start().await;
430        let client = sheets_client(&server).await;
431        mount_spreadsheet_get(&["Blank"]).mount(&server).await;
432        wiremock::Mock::given(wiremock::matchers::method("GET"))
433            .and(wiremock::matchers::path(
434                "/v4/spreadsheets/sheet-1/values:batchGet",
435            ))
436            .respond_with(
437                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
438                    "valueRanges": [{"range": "'Blank'!A1:Z1000"}],
439                })),
440            )
441            .mount(&server)
442            .await;
443
444        let api = SheetsApi::new(&client);
445        let outcome = read(&api, &opts(None, None)).await.unwrap();
446        assert_eq!(outcome.sheets.len(), 1);
447        assert!(outcome.sheets[0].values.is_empty());
448    }
449
450    #[tokio::test]
451    async fn whole_workbook_read_with_no_grid_sheets_makes_no_batch_call() {
452        let server = wiremock::MockServer::start().await;
453        let client = sheets_client(&server).await;
454        mount_spreadsheet_get(&[]).mount(&server).await;
455        // No batchGet mock: an empty range list must never be sent.
456        let api = SheetsApi::new(&client);
457        let outcome = read(&api, &opts(None, None)).await.unwrap();
458        assert!(outcome.sheets.is_empty());
459        assert_eq!(outcome.spreadsheet_title.as_deref(), Some("Book"));
460    }
461
462    #[tokio::test]
463    async fn whole_workbook_read_refuses_a_workbook_over_the_sheet_cap() {
464        let server = wiremock::MockServer::start().await;
465        let client = sheets_client(&server).await;
466        let titles: Vec<String> = (0..=MAX_SHEETS_PER_READ).map(|i| format!("S{i}")).collect();
467        let refs: Vec<&str> = titles.iter().map(String::as_str).collect();
468        mount_spreadsheet_get(&refs).mount(&server).await;
469        // No batchGet mock: it must refuse before fetching a partial book.
470
471        let api = SheetsApi::new(&client);
472        let err = read(&api, &opts(None, None)).await.unwrap_err();
473        assert!(err.to_string().contains("sheet cap"), "{err}");
474        assert!(err.to_string().contains("--sheet"), "{err}");
475    }
476
477    fn sheet(title: Option<&str>) -> SheetValues {
478        SheetValues {
479            title: title.map(str::to_string),
480            range: None,
481            values: Vec::new(),
482        }
483    }
484
485    #[test]
486    fn order_by_workbook_restores_declared_order() {
487        let fetched = vec![sheet(Some("C")), sheet(Some("A")), sheet(Some("B"))];
488        let titles = ["A".to_string(), "B".to_string(), "C".to_string()];
489        let ordered = order_by_workbook(fetched, &titles);
490        let got: Vec<_> = ordered.iter().map(|s| s.title.clone().unwrap()).collect();
491        assert_eq!(got, ["A", "B", "C"]);
492    }
493
494    #[test]
495    fn order_by_workbook_appends_unmatched_entries_rather_than_dropping_them() {
496        let fetched = vec![sheet(None), sheet(Some("A"))];
497        let titles = ["A".to_string()];
498        let ordered = order_by_workbook(fetched, &titles);
499        assert_eq!(ordered.len(), 2, "an unmatched range must not be dropped");
500        assert_eq!(ordered[0].title.as_deref(), Some("A"));
501        assert_eq!(ordered[1].title, None);
502    }
503
504    #[test]
505    fn order_by_workbook_tolerates_a_title_the_server_did_not_return() {
506        let fetched = vec![sheet(Some("A"))];
507        let titles = ["A".to_string(), "Missing".to_string()];
508        let ordered = order_by_workbook(fetched, &titles);
509        assert_eq!(ordered.len(), 1);
510    }
511
512    #[test]
513    fn order_by_workbook_keeps_both_of_two_identically_titled_ranges() {
514        // Titles are unique in a real workbook, but the ordering pass must
515        // not silently collapse duplicates if one ever appears.
516        let fetched = vec![sheet(Some("A")), sheet(Some("A"))];
517        let titles = ["A".to_string()];
518        let ordered = order_by_workbook(fetched, &titles);
519        assert_eq!(ordered.len(), 2);
520    }
521
522    #[test]
523    fn is_whole_workbook_only_when_neither_selector_is_given() {
524        let base = ReadOptions {
525            spreadsheet_id: "s".to_string(),
526            range: None,
527            sheet: None,
528            render: ValueRenderOption::Formatted,
529        };
530        assert!(base.is_whole_workbook());
531        assert!(!ReadOptions {
532            range: Some("A1".to_string()),
533            ..base.clone()
534        }
535        .is_whole_workbook());
536        assert!(!ReadOptions {
537            sheet: Some("S".to_string()),
538            ..base
539        }
540        .is_whole_workbook());
541    }
542
543    #[test]
544    fn read_outcome_serialises_sheets_as_an_ordered_list() {
545        let outcome = ReadOutcome {
546            spreadsheet_id: "s".to_string(),
547            spreadsheet_title: Some("Book".to_string()),
548            sheets: vec![sheet(Some("Z")), sheet(Some("A"))],
549        };
550        let json = serde_json::to_value(&outcome).unwrap();
551        let arr = json["sheets"].as_array().unwrap();
552        assert_eq!(arr[0]["title"], "Z", "workbook order must survive");
553        assert_eq!(arr[1]["title"], "A");
554    }
555
556    #[test]
557    fn read_outcome_omits_absent_optional_fields() {
558        let outcome = ReadOutcome {
559            spreadsheet_id: "s".to_string(),
560            spreadsheet_title: None,
561            sheets: vec![sheet(None)],
562        };
563        let json = serde_json::to_value(&outcome).unwrap();
564        assert!(json.get("spreadsheet_title").is_none());
565        assert!(json["sheets"][0].get("title").is_none());
566        assert!(json["sheets"][0].get("range").is_none());
567    }
568}