1use 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
23const MAX_SHEETS_PER_READ: usize = 200;
30
31#[derive(Debug, Clone)]
33pub struct ReadOptions {
34 pub spreadsheet_id: String,
36 pub range: Option<String>,
38 pub sheet: Option<String>,
41 pub render: ValueRenderOption,
43}
44
45impl ReadOptions {
46 fn is_whole_workbook(&self) -> bool {
48 self.range.is_none() && self.sheet.is_none()
49 }
50}
51
52#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
54pub struct SheetValues {
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub title: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub range: Option<String>,
61 pub values: Vec<Vec<serde_json::Value>>,
65}
66
67#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
73pub struct ReadOutcome {
74 pub spreadsheet_id: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
79 pub spreadsheet_title: Option<String>,
80 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
90pub 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
99async 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 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
123async 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 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 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
166fn 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 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 #[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 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 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 #[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 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 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 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 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}