1use 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#[derive(Debug)]
30pub(in crate::drive) enum BatchUpdateOutcome {
31 Applied(BatchUpdateResponse),
33 AppliedReplyUnreadable {
36 detail: String,
38 },
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub(in crate::drive) enum RetryHint {
48 NotIdempotent,
50 Idempotent,
52}
53
54pub(in crate::drive) fn applied_reply_unreadable_line(
58 summary: &str,
59 book: &str,
60 hint: RetryHint,
61 detail: &str,
62) -> String {
63 let advice = match hint {
64 RetryHint::NotIdempotent => "do not retry; check the spreadsheet first",
65 RetryHint::Idempotent => "check the spreadsheet to confirm",
66 };
67 format!("Applied, but the reply could not be read: {summary} in {book} — {advice} ({detail})")
68}
69
70const SPREADSHEET_FIELDS: &str = "spreadsheetId,\
89 properties(title,locale,timeZone,autoRecalc,iterativeCalculationSettings),\
90 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines))";
91
92const SPREADSHEET_FIELDS_WITH_PROTECTIONS: &str = "spreadsheetId,properties.title,\
99 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
100 sheets.protectedRanges(protectedRangeId,range,description,warningOnly,editors.users)";
101
102const SPREADSHEET_FIELDS_WITH_FILTER_VIEWS: &str = "spreadsheetId,properties.title,\
109 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
110 sheets.basicFilter(range,sortSpecs,criteria),\
111 sheets.filterViews(filterViewId,title,range,namedRangeId,tableId,sortSpecs,criteria)";
112
113const SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS: &str = "spreadsheetId,properties.title,\
122 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
123 sheets.conditionalFormats(ranges,booleanRule,gradientRule)";
124
125const SPREADSHEET_FIELDS_WITH_NAMED_RANGES: &str = "spreadsheetId,properties.title,\
135 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
136 namedRanges(namedRangeId,name,range)";
137
138const SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS: &str = "spreadsheetId,properties.title,\
152 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
153 sheets.charts,sheets.slicers";
154
155const SPREADSHEET_FIELDS_WITH_PIVOT_TABLES: &str = "spreadsheetId,properties.title,\
165 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
166 sheets.data(startRow,startColumn,rowData.values(pivotTable))";
167
168const CELL_PIVOT_FIELDS: &str = "spreadsheetId,properties.title,\
176 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
177 sheets.data(startRow,startColumn,rowData.values(pivotTable,formattedValue))";
178
179const CELL_FORMAT_FIELDS: &str = "spreadsheetId,properties.title,\
202 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
203 sheets.data(startRow,startColumn,rowData.values(userEnteredFormat(backgroundColorStyle,textFormat(bold,italic,strikethrough,underline,foregroundColorStyle),numberFormat,horizontalAlignment,borders(top,bottom,left,right)),note,dataValidation))";
204
205const SPREADSHEET_FIELDS_WITH_BANDING: &str = "spreadsheetId,properties.title,\
212 sheets.properties(sheetId,title,index,hidden,rightToLeft,gridProperties(rowCount,columnCount,frozenRowCount,frozenColumnCount,hideGridlines)),\
213 sheets.bandedRanges(bandedRangeId,range,rowProperties,columnProperties)";
214
215const SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS: &str = "spreadsheetId,properties.title,\
222 sheets.properties(sheetId,title,index,hidden,gridProperties(rowCount,columnCount)),\
223 sheets.rowGroups(range,depth,collapsed),sheets.columnGroups(range,depth,collapsed)";
224
225pub(crate) const MAX_RANGES_PER_BATCH: usize = 50;
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum ValueRenderOption {
240 #[default]
245 Formatted,
246 Unformatted,
250 Formula,
252}
253
254impl ValueRenderOption {
255 #[must_use]
257 pub fn as_str(self) -> &'static str {
258 match self {
259 Self::Formatted => "FORMATTED_VALUE",
260 Self::Unformatted => "UNFORMATTED_VALUE",
261 Self::Formula => "FORMULA",
262 }
263 }
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270pub enum ValueInputOption {
271 #[default]
283 UserEntered,
284 Raw,
287}
288
289impl ValueInputOption {
290 #[must_use]
292 pub fn as_str(self) -> &'static str {
293 match self {
294 Self::UserEntered => "USER_ENTERED",
295 Self::Raw => "RAW",
296 }
297 }
298}
299
300#[derive(Debug)]
302pub struct SheetsApi<'a> {
303 client: &'a SheetsClient,
304}
305
306impl<'a> SheetsApi<'a> {
307 #[must_use]
309 pub fn new(client: &'a SheetsClient) -> Self {
310 Self { client }
311 }
312
313 pub async fn get_spreadsheet(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
317 let url = build_spreadsheet_get_url(self.client.base_url(), spreadsheet_id)?;
318 self.client
319 .transport()
320 .get_parsed(url.as_str(), "Failed to parse Sheets spreadsheet metadata")
321 .await
322 }
323
324 pub async fn get_spreadsheet_with_protections(
328 &self,
329 spreadsheet_id: &str,
330 ) -> Result<Spreadsheet> {
331 let url =
332 build_spreadsheet_get_with_protections_url(self.client.base_url(), spreadsheet_id)?;
333 self.client
334 .transport()
335 .get_parsed(
336 url.as_str(),
337 "Failed to parse Sheets spreadsheet metadata (with protections)",
338 )
339 .await
340 }
341
342 pub async fn get_spreadsheet_with_filter_views(
346 &self,
347 spreadsheet_id: &str,
348 ) -> Result<Spreadsheet> {
349 let url =
350 build_spreadsheet_get_with_filter_views_url(self.client.base_url(), spreadsheet_id)?;
351 self.client
352 .transport()
353 .get_parsed(
354 url.as_str(),
355 "Failed to parse Sheets spreadsheet metadata (with filter views)",
356 )
357 .await
358 }
359
360 pub async fn get_spreadsheet_with_conditional_formats(
369 &self,
370 spreadsheet_id: &str,
371 ) -> Result<Spreadsheet> {
372 let url = build_spreadsheet_get_with_conditional_formats_url(
373 self.client.base_url(),
374 spreadsheet_id,
375 )?;
376 self.client
377 .transport()
378 .get_parsed(
379 url.as_str(),
380 "Failed to parse Sheets spreadsheet metadata (with conditional formats)",
381 )
382 .await
383 }
384
385 pub async fn get_spreadsheet_with_named_ranges(
389 &self,
390 spreadsheet_id: &str,
391 ) -> Result<Spreadsheet> {
392 let url =
393 build_spreadsheet_get_with_named_ranges_url(self.client.base_url(), spreadsheet_id)?;
394 self.client
395 .transport()
396 .get_parsed(
397 url.as_str(),
398 "Failed to parse Sheets spreadsheet metadata (with named ranges)",
399 )
400 .await
401 }
402
403 pub async fn get_spreadsheet_with_embedded_objects(
409 &self,
410 spreadsheet_id: &str,
411 ) -> Result<Spreadsheet> {
412 let url = build_spreadsheet_get_with_embedded_objects_url(
413 self.client.base_url(),
414 spreadsheet_id,
415 )?;
416 self.client
417 .transport()
418 .get_parsed(
419 url.as_str(),
420 "Failed to parse Sheets spreadsheet metadata (with embedded objects)",
421 )
422 .await
423 }
424
425 pub async fn get_spreadsheet_with_pivot_tables(
429 &self,
430 spreadsheet_id: &str,
431 ) -> Result<Spreadsheet> {
432 let url =
433 build_spreadsheet_get_with_pivot_tables_url(self.client.base_url(), spreadsheet_id)?;
434 self.client
435 .transport()
436 .get_parsed(
437 url.as_str(),
438 "Failed to parse Sheets spreadsheet metadata (with pivot tables)",
439 )
440 .await
441 }
442
443 pub async fn get_spreadsheet_with_banding(&self, spreadsheet_id: &str) -> Result<Spreadsheet> {
448 let url = build_spreadsheet_get_with_banding_url(self.client.base_url(), spreadsheet_id)?;
449 self.client
450 .transport()
451 .get_parsed(
452 url.as_str(),
453 "Failed to parse Sheets spreadsheet metadata (with banded ranges)",
454 )
455 .await
456 }
457
458 pub async fn get_spreadsheet_with_dimension_groups(
463 &self,
464 spreadsheet_id: &str,
465 ) -> Result<Spreadsheet> {
466 let url = build_spreadsheet_get_with_dimension_groups_url(
467 self.client.base_url(),
468 spreadsheet_id,
469 )?;
470 self.client
471 .transport()
472 .get_parsed(
473 url.as_str(),
474 "Failed to parse Sheets spreadsheet metadata (with dimension groups)",
475 )
476 .await
477 }
478
479 pub async fn get_cell_pivot(
488 &self,
489 spreadsheet_id: &str,
490 composed_a1: &str,
491 ) -> Result<Spreadsheet> {
492 let url = build_cell_pivot_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
493 self.client
494 .transport()
495 .get_parsed(url.as_str(), "Failed to parse Sheets cell pivot response")
496 .await
497 }
498
499 pub async fn get_cell_formats(
504 &self,
505 spreadsheet_id: &str,
506 composed_a1: &str,
507 ) -> Result<Spreadsheet> {
508 let url = build_cell_format_get_url(self.client.base_url(), spreadsheet_id, composed_a1)?;
509 self.client
510 .transport()
511 .get_parsed(url.as_str(), "Failed to parse Sheets cell format response")
512 .await
513 }
514
515 pub async fn values_get(
517 &self,
518 spreadsheet_id: &str,
519 range: &str,
520 render: ValueRenderOption,
521 ) -> Result<ValueRange> {
522 let url = build_values_get_url(self.client.base_url(), spreadsheet_id, range, render)?;
523 self.client
524 .transport()
525 .get_parsed(url.as_str(), "Failed to parse Sheets values response")
526 .await
527 }
528
529 pub async fn values_batch_get(
534 &self,
535 spreadsheet_id: &str,
536 ranges: &[String],
537 render: ValueRenderOption,
538 ) -> Result<BatchGetValuesResponse> {
539 let url =
540 build_values_batch_get_url(self.client.base_url(), spreadsheet_id, ranges, render)?;
541 self.client
542 .transport()
543 .get_parsed(url.as_str(), "Failed to parse Sheets batchGet response")
544 .await
545 }
546
547 pub(crate) async fn batch_get_every_sheet(
560 &self,
561 spreadsheet_id: &str,
562 titles: &[String],
563 render: ValueRenderOption,
564 ) -> Result<Vec<(Option<String>, ValueRange)>> {
565 let mut out = Vec::with_capacity(titles.len());
566 for chunk in titles.chunks(MAX_RANGES_PER_BATCH) {
567 let ranges: Vec<String> = chunk.iter().map(|t| a1::quote_sheet_title(t)).collect();
568 let response = self
569 .values_batch_get(spreadsheet_id, &ranges, render)
570 .await?;
571 for value_range in response.value_ranges {
572 let title = value_range.range.as_deref().and_then(a1::sheet_title_of);
573 out.push((title, value_range));
574 }
575 }
576 Ok(out)
577 }
578
579 pub(in crate::drive) async fn values_update(
585 &self,
586 spreadsheet_id: &str,
587 range: &str,
588 values: &[Vec<String>],
589 input: ValueInputOption,
590 ) -> Result<UpdateValuesResponse> {
591 let url = build_values_update_url(self.client.base_url(), spreadsheet_id, range, input)?;
592 let body = serde_json::json!({ "range": range, "values": values });
593 let response = self
594 .client
595 .transport()
596 .put_json(url.as_str(), &body)
597 .await?;
598 self.client
599 .transport()
600 .parse_response(response, "Failed to parse Sheets update response")
601 .await
602 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
603 }
604
605 pub(in crate::drive) async fn values_append(
607 &self,
608 spreadsheet_id: &str,
609 range: &str,
610 values: &[Vec<String>],
611 input: ValueInputOption,
612 ) -> Result<AppendValuesResponse> {
613 let url = build_values_append_url(self.client.base_url(), spreadsheet_id, range, input)?;
614 let body = serde_json::json!({ "range": range, "values": values });
615 let response = self
616 .client
617 .transport()
618 .post_json(url.as_str(), &body)
619 .await?;
620 self.client
621 .transport()
622 .parse_response(response, "Failed to parse Sheets append response")
623 .await
624 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
625 }
626
627 pub(in crate::drive) async fn values_clear(
629 &self,
630 spreadsheet_id: &str,
631 range: &str,
632 ) -> Result<ClearValuesResponse> {
633 let url = build_values_clear_url(self.client.base_url(), spreadsheet_id, range)?;
634 let response = self
635 .client
636 .transport()
637 .post_json(url.as_str(), &serde_json::json!({}))
638 .await?;
639 self.client
640 .transport()
641 .parse_response(response, "Failed to parse Sheets clear response")
642 .await
643 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
644 }
645
646 pub(in crate::drive) async fn batch_update(
665 &self,
666 spreadsheet_id: &str,
667 requests: Vec<BatchUpdateRequestItem>,
668 ) -> Result<BatchUpdateOutcome> {
669 let url = build_batch_update_url(self.client.base_url(), spreadsheet_id)?;
670 let body = BatchUpdateRequest { requests };
671 let response = self
672 .client
673 .transport()
674 .post_json(url.as_str(), &body)
675 .await?;
676 let reply = self
677 .client
678 .transport()
679 .parse_success_response::<BatchUpdateResponse>(
680 response,
681 "Failed to parse Sheets batchUpdate response",
682 )
683 .await
684 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))?;
685 Ok(match reply {
686 Ok(response) => BatchUpdateOutcome::Applied(response),
687 Err(detail) => BatchUpdateOutcome::AppliedReplyUnreadable { detail },
688 })
689 }
690
691 pub(in crate::drive) async fn copy_to(
700 &self,
701 spreadsheet_id: &str,
702 sheet_id: i64,
703 destination_spreadsheet_id: &str,
704 ) -> Result<SheetProperties> {
705 let url = build_copy_to_url(self.client.base_url(), spreadsheet_id, sheet_id)?;
706 let body = CopySheetToAnotherSpreadsheetRequest {
707 destination_spreadsheet_id: destination_spreadsheet_id.to_string(),
708 };
709 let response = self
710 .client
711 .transport()
712 .post_json(url.as_str(), &body)
713 .await?;
714 self.client
715 .transport()
716 .parse_response(response, "Failed to parse Sheets copyTo response")
717 .await
718 .map_err(|err| append_write_scope_hint(err, WriteCapability::EditContent))
719 }
720
721 pub async fn search_developer_metadata(
732 &self,
733 spreadsheet_id: &str,
734 filters: Vec<DataFilter>,
735 ) -> Result<SearchDeveloperMetadataResponse> {
736 let url = build_developer_metadata_search_url(self.client.base_url(), spreadsheet_id)?;
737 let body = SearchDeveloperMetadataRequest {
738 data_filters: filters,
739 };
740 let response = self
741 .client
742 .transport()
743 .post_json(url.as_str(), &body)
744 .await?;
745 self.client
746 .transport()
747 .parse_response(
748 response,
749 "Failed to parse Sheets developerMetadata.search response",
750 )
751 .await
752 }
753}
754
755fn build_spreadsheet_get_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
756 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
757 .context("Invalid Sheets base URL")?;
758 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
759 url.query_pairs_mut()
760 .append_pair("fields", SPREADSHEET_FIELDS);
761 Ok(url)
762}
763
764fn build_spreadsheet_get_with_protections_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
765 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
766 .context("Invalid Sheets base URL")?;
767 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
768 url.query_pairs_mut()
769 .append_pair("fields", SPREADSHEET_FIELDS_WITH_PROTECTIONS);
770 Ok(url)
771}
772
773fn build_spreadsheet_get_with_filter_views_url(
774 base_url: &str,
775 spreadsheet_id: &str,
776) -> Result<Url> {
777 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
778 .context("Invalid Sheets base URL")?;
779 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
780 url.query_pairs_mut()
781 .append_pair("fields", SPREADSHEET_FIELDS_WITH_FILTER_VIEWS);
782 Ok(url)
783}
784
785fn build_spreadsheet_get_with_conditional_formats_url(
786 base_url: &str,
787 spreadsheet_id: &str,
788) -> Result<Url> {
789 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
790 .context("Invalid Sheets base URL")?;
791 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
792 url.query_pairs_mut()
793 .append_pair("fields", SPREADSHEET_FIELDS_WITH_CONDITIONAL_FORMATS);
794 Ok(url)
795}
796
797fn build_spreadsheet_get_with_named_ranges_url(
798 base_url: &str,
799 spreadsheet_id: &str,
800) -> Result<Url> {
801 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
802 .context("Invalid Sheets base URL")?;
803 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
804 url.query_pairs_mut()
805 .append_pair("fields", SPREADSHEET_FIELDS_WITH_NAMED_RANGES);
806 Ok(url)
807}
808
809fn build_spreadsheet_get_with_embedded_objects_url(
810 base_url: &str,
811 spreadsheet_id: &str,
812) -> Result<Url> {
813 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
814 .context("Invalid Sheets base URL")?;
815 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
816 url.query_pairs_mut()
817 .append_pair("fields", SPREADSHEET_FIELDS_WITH_EMBEDDED_OBJECTS);
818 Ok(url)
819}
820
821fn build_spreadsheet_get_with_pivot_tables_url(
822 base_url: &str,
823 spreadsheet_id: &str,
824) -> Result<Url> {
825 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
826 .context("Invalid Sheets base URL")?;
827 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
828 url.query_pairs_mut()
829 .append_pair("fields", SPREADSHEET_FIELDS_WITH_PIVOT_TABLES);
830 Ok(url)
831}
832
833fn build_spreadsheet_get_with_banding_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
834 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
835 .context("Invalid Sheets base URL")?;
836 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
837 url.query_pairs_mut()
838 .append_pair("fields", SPREADSHEET_FIELDS_WITH_BANDING);
839 Ok(url)
840}
841
842fn build_spreadsheet_get_with_dimension_groups_url(
843 base_url: &str,
844 spreadsheet_id: &str,
845) -> Result<Url> {
846 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
847 .context("Invalid Sheets base URL")?;
848 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
849 url.query_pairs_mut()
850 .append_pair("fields", SPREADSHEET_FIELDS_WITH_DIMENSION_GROUPS);
851 Ok(url)
852}
853
854fn build_cell_pivot_get_url(
855 base_url: &str,
856 spreadsheet_id: &str,
857 composed_a1: &str,
858) -> Result<Url> {
859 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
860 .context("Invalid Sheets base URL")?;
861 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
862 url.query_pairs_mut()
863 .append_pair("ranges", composed_a1)
864 .append_pair("fields", CELL_PIVOT_FIELDS);
865 Ok(url)
866}
867
868fn build_cell_format_get_url(
869 base_url: &str,
870 spreadsheet_id: &str,
871 composed_a1: &str,
872) -> Result<Url> {
873 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
874 .context("Invalid Sheets base URL")?;
875 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id])?;
876 url.query_pairs_mut()
877 .append_pair("ranges", composed_a1)
878 .append_pair("fields", CELL_FORMAT_FIELDS);
879 Ok(url)
880}
881
882fn build_values_get_url(
883 base_url: &str,
884 spreadsheet_id: &str,
885 range: &str,
886 render: ValueRenderOption,
887) -> Result<Url> {
888 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
889 .context("Invalid Sheets base URL")?;
890 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
891 url.query_pairs_mut()
892 .append_pair("valueRenderOption", render.as_str());
893 Ok(url)
894}
895
896fn build_values_batch_get_url(
897 base_url: &str,
898 spreadsheet_id: &str,
899 ranges: &[String],
900 render: ValueRenderOption,
901) -> Result<Url> {
902 anyhow::ensure!(
903 !ranges.is_empty(),
904 "values.batchGet requires at least one range"
905 );
906 anyhow::ensure!(
907 ranges.len() <= MAX_RANGES_PER_BATCH,
908 "values.batchGet was given {} ranges, over the {MAX_RANGES_PER_BATCH} per-request cap; \
909 callers must chunk",
910 ranges.len()
911 );
912 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
913 .context("Invalid Sheets base URL")?;
914 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values:batchGet"])?;
918 {
919 let mut pairs = url.query_pairs_mut();
920 for range in ranges {
921 pairs.append_pair("ranges", range);
922 }
923 pairs.append_pair("valueRenderOption", render.as_str());
924 }
925 Ok(url)
926}
927
928fn build_values_update_url(
929 base_url: &str,
930 spreadsheet_id: &str,
931 range: &str,
932 input: ValueInputOption,
933) -> Result<Url> {
934 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
935 .context("Invalid Sheets base URL")?;
936 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "values", range])?;
937 url.query_pairs_mut()
938 .append_pair("valueInputOption", input.as_str());
939 Ok(url)
940}
941
942fn build_values_append_url(
943 base_url: &str,
944 spreadsheet_id: &str,
945 range: &str,
946 input: ValueInputOption,
947) -> Result<Url> {
948 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
949 .context("Invalid Sheets base URL")?;
950 GoogleApiClient::push_path_segments(
951 &mut url,
952 &[spreadsheet_id, "values", &format!("{range}:append")],
953 )?;
954 {
955 let mut pairs = url.query_pairs_mut();
956 pairs.append_pair("valueInputOption", input.as_str());
957 pairs.append_pair("insertDataOption", "INSERT_ROWS");
960 }
961 Ok(url)
962}
963
964fn build_values_clear_url(base_url: &str, spreadsheet_id: &str, range: &str) -> Result<Url> {
965 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
966 .context("Invalid Sheets base URL")?;
967 GoogleApiClient::push_path_segments(
968 &mut url,
969 &[spreadsheet_id, "values", &format!("{range}:clear")],
970 )?;
971 Ok(url)
972}
973
974fn build_batch_update_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
981 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
982 .context("Invalid Sheets base URL")?;
983 GoogleApiClient::push_path_segments(&mut url, &[&format!("{spreadsheet_id}:batchUpdate")])?;
984 Ok(url)
985}
986
987fn build_copy_to_url(base_url: &str, spreadsheet_id: &str, sheet_id: i64) -> Result<Url> {
993 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
994 .context("Invalid Sheets base URL")?;
995 GoogleApiClient::push_path_segments(
996 &mut url,
997 &[spreadsheet_id, "sheets", &format!("{sheet_id}:copyTo")],
998 )?;
999 Ok(url)
1000}
1001
1002fn build_developer_metadata_search_url(base_url: &str, spreadsheet_id: &str) -> Result<Url> {
1008 let mut url = GoogleApiClient::api_url(base_url, "/v4/spreadsheets")
1009 .context("Invalid Sheets base URL")?;
1010 GoogleApiClient::push_path_segments(&mut url, &[spreadsheet_id, "developerMetadata:search"])?;
1011 Ok(url)
1012}
1013
1014#[cfg(test)]
1015#[allow(clippy::unwrap_used, clippy::expect_used)]
1016mod tests {
1017 use super::*;
1018
1019 const BASE: &str = "https://sheets.googleapis.com";
1020
1021 #[test]
1024 fn spreadsheet_get_url_masks_fields() {
1025 let url = build_spreadsheet_get_url(BASE, "sheet-1").unwrap();
1026 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1027 let fields = url
1028 .query_pairs()
1029 .find(|(k, _)| k == "fields")
1030 .map(|(_, v)| v.to_string())
1031 .expect("fields mask must always be sent");
1032 assert!(fields.contains("sheets.properties"));
1033 assert!(fields.contains("title"));
1034 assert!(fields.contains("frozenRowCount"));
1037 }
1038
1039 #[test]
1040 fn spreadsheet_get_with_conditional_formats_url_masks_the_wider_fields() {
1041 let url = build_spreadsheet_get_with_conditional_formats_url(BASE, "sheet-1").unwrap();
1042 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1043 let fields = url
1044 .query_pairs()
1045 .find(|(k, _)| k == "fields")
1046 .map(|(_, v)| v.to_string())
1047 .expect("fields mask must always be sent");
1048 assert!(fields.contains("sheets.conditionalFormats"));
1049 assert!(fields.contains("booleanRule"));
1050 assert!(fields.contains("gradientRule"));
1051 assert!(fields.contains("frozenRowCount"));
1054 }
1055
1056 #[tokio::test]
1057 async fn get_spreadsheet_with_embedded_objects_surfaces_an_unparseable_base_url() {
1058 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1063 use crate::drive::client::DriveClient;
1064 use crate::drive::sheets::client::SHEETS_API_URL;
1065 use crate::test_support::env::MapEnv;
1066 use crate::utils::secret::Secret;
1067
1068 let credentials = DriveCredentials {
1069 client_id: "client-1".to_string(),
1070 client_secret: Secret::new("secret-1"),
1071 refresh_token: Secret::new("refresh-1"),
1072 scope: DriveGrantedScopes::READONLY,
1073 };
1074 let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1075 let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1076 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1077
1078 let err = SheetsApi::new(&sheets)
1079 .get_spreadsheet_with_embedded_objects("sheet-1")
1080 .await
1081 .unwrap_err();
1082 assert!(
1083 err.to_string().contains("Invalid Sheets base URL"),
1084 "{err:#}"
1085 );
1086 }
1087
1088 #[test]
1089 fn spreadsheet_get_with_embedded_objects_url_masks_the_wider_fields() {
1090 let url = build_spreadsheet_get_with_embedded_objects_url(BASE, "sheet-1").unwrap();
1091 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1092 let fields = url
1093 .query_pairs()
1094 .find(|(k, _)| k == "fields")
1095 .map(|(_, v)| v.to_string())
1096 .expect("fields mask must always be sent");
1097 assert!(fields.contains("sheets.charts"));
1098 assert!(fields.contains("sheets.slicers"));
1099 assert!(fields.contains("frozenRowCount"));
1102 }
1103
1104 #[test]
1105 fn spreadsheet_get_with_pivot_tables_url_masks_the_wider_fields() {
1106 let url = build_spreadsheet_get_with_pivot_tables_url(BASE, "sheet-1").unwrap();
1107 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1108 let fields = url
1109 .query_pairs()
1110 .find(|(k, _)| k == "fields")
1111 .map(|(_, v)| v.to_string())
1112 .expect("fields mask must always be sent");
1113 assert!(fields.contains("sheets.data"));
1114 assert!(fields.contains("pivotTable"));
1115 assert!(!fields.contains("formattedValue"));
1116 assert!(fields.contains("frozenRowCount"));
1119 }
1120
1121 #[tokio::test]
1122 async fn get_spreadsheet_with_dimension_groups_surfaces_an_unparseable_base_url() {
1123 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1128 use crate::drive::client::DriveClient;
1129 use crate::drive::sheets::client::SHEETS_API_URL;
1130 use crate::test_support::env::MapEnv;
1131 use crate::utils::secret::Secret;
1132
1133 let credentials = DriveCredentials {
1134 client_id: "client-1".to_string(),
1135 client_secret: Secret::new("secret-1"),
1136 refresh_token: Secret::new("refresh-1"),
1137 scope: DriveGrantedScopes::READONLY,
1138 };
1139 let drive = DriveClient::new("https://www.googleapis.com", &credentials).unwrap();
1140 let env = MapEnv::new().with(SHEETS_API_URL, "not a url");
1141 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1142
1143 let err = SheetsApi::new(&sheets)
1144 .get_spreadsheet_with_dimension_groups("sheet-1")
1145 .await
1146 .unwrap_err();
1147 assert!(
1148 err.to_string().contains("Invalid Sheets base URL"),
1149 "{err:#}"
1150 );
1151 }
1152
1153 #[test]
1154 fn spreadsheet_get_with_dimension_groups_url_masks_the_wider_fields() {
1155 let url = build_spreadsheet_get_with_dimension_groups_url(BASE, "sheet-1").unwrap();
1156 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1157 let fields = url
1158 .query_pairs()
1159 .find(|(k, _)| k == "fields")
1160 .map(|(_, v)| v.to_string())
1161 .expect("fields mask must always be sent");
1162 assert!(fields.contains("rowGroups"));
1163 assert!(fields.contains("columnGroups"));
1164 }
1165
1166 #[test]
1167 fn cell_pivot_get_url_scopes_ranges_and_masks_fields() {
1168 let url = build_cell_pivot_get_url(BASE, "sheet-1", "'Report'!A1").unwrap();
1169 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1170 let ranges = url
1171 .query_pairs()
1172 .find(|(k, _)| k == "ranges")
1173 .map(|(_, v)| v.to_string())
1174 .expect("ranges must always be sent");
1175 assert_eq!(ranges, "'Report'!A1");
1176 let fields = url
1177 .query_pairs()
1178 .find(|(k, _)| k == "fields")
1179 .map(|(_, v)| v.to_string())
1180 .expect("fields mask must always be sent");
1181 assert!(fields.contains("pivotTable"));
1182 assert!(fields.contains("formattedValue"));
1183 assert!(fields.contains("frozenRowCount"));
1186 }
1187
1188 #[test]
1189 fn cell_format_get_url_scopes_ranges_and_masks_fields() {
1190 let url = build_cell_format_get_url(BASE, "sheet-1", "'Report'!A1:D10").unwrap();
1191 assert_eq!(url.path(), "/v4/spreadsheets/sheet-1");
1192 let ranges = url
1193 .query_pairs()
1194 .find(|(k, _)| k == "ranges")
1195 .map(|(_, v)| v.to_string())
1196 .expect("ranges must always be sent");
1197 assert_eq!(ranges, "'Report'!A1:D10");
1198 let fields = url
1199 .query_pairs()
1200 .find(|(k, _)| k == "fields")
1201 .map(|(_, v)| v.to_string())
1202 .expect("fields mask must always be sent");
1203 assert!(fields.contains("userEnteredFormat"));
1204 assert!(fields.contains("backgroundColorStyle"));
1205 assert!(fields.contains("borders(top,bottom,left,right)"));
1206 assert!(fields.contains("note"));
1207 assert!(fields.contains("dataValidation"));
1208 assert!(!fields.contains("effectiveFormat"));
1212 assert!(!fields.contains("formattedValue"));
1213 assert!(fields.contains("frozenRowCount"));
1214 }
1215
1216 #[test]
1219 fn values_get_url_percent_encodes_a_space_in_a_quoted_title() {
1220 let url = build_values_get_url(
1221 BASE,
1222 "sheet-1",
1223 "'My Sheet'!A1:B2",
1224 ValueRenderOption::Formatted,
1225 )
1226 .unwrap();
1227 assert!(
1228 url.as_str().contains("'My%20Sheet'!A1:B2"),
1229 "space must be encoded: {url}"
1230 );
1231 }
1232
1233 #[test]
1234 fn values_get_url_encodes_characters_that_would_reshape_the_url() {
1235 for (title, encoded) in [("A#B", "%23"), ("A?B", "%3F"), ("A/B", "%2F")] {
1239 let range = format!("'{title}'!A1");
1240 let url =
1241 build_values_get_url(BASE, "s", &range, ValueRenderOption::Formatted).unwrap();
1242 assert!(
1243 url.as_str().contains(encoded),
1244 "{title:?} must encode to {encoded}: {url}"
1245 );
1246 assert!(url.fragment().is_none(), "{title:?} leaked a fragment");
1247 assert!(url.query().is_some_and(|q| !q.contains("!A1")));
1248 let segments: Vec<&str> = url.path_segments().unwrap().collect();
1251 assert_eq!(segments.len(), 5, "{title:?} split the path: {url}");
1252 assert_eq!(segments[3], "values", "{title:?} shifted the path: {url}");
1253 }
1254 }
1255
1256 #[test]
1257 fn values_get_url_round_trips_the_range_through_decoding() {
1258 let range = "'Bob''s Sheet'!A1:C9";
1259 let url = build_values_get_url(BASE, "s", range, ValueRenderOption::Formatted).unwrap();
1260 let decoded = url
1261 .path_segments()
1262 .unwrap()
1263 .next_back()
1264 .map(percent_decode)
1265 .unwrap();
1266 assert_eq!(decoded, range);
1267 }
1268
1269 fn percent_decode(segment: &str) -> String {
1270 percent_encoding::percent_decode_str(segment)
1271 .decode_utf8()
1272 .unwrap()
1273 .to_string()
1274 }
1275
1276 #[test]
1277 fn values_get_url_sends_the_render_option() {
1278 for (render, wire) in [
1279 (ValueRenderOption::Formatted, "FORMATTED_VALUE"),
1280 (ValueRenderOption::Unformatted, "UNFORMATTED_VALUE"),
1281 (ValueRenderOption::Formula, "FORMULA"),
1282 ] {
1283 let url = build_values_get_url(BASE, "s", "A1", render).unwrap();
1284 let got = url
1285 .query_pairs()
1286 .find(|(k, _)| k == "valueRenderOption")
1287 .map(|(_, v)| v.to_string())
1288 .unwrap();
1289 assert_eq!(got, wire);
1290 }
1291 }
1292
1293 #[test]
1296 fn batch_get_url_repeats_the_ranges_parameter() {
1297 let ranges = vec!["'A'!A1:B2".to_string(), "'B'!A1".to_string()];
1298 let url =
1299 build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted).unwrap();
1300 assert_eq!(url.path(), "/v4/spreadsheets/s/values:batchGet");
1301 let got: Vec<String> = url
1302 .query_pairs()
1303 .filter(|(k, _)| k == "ranges")
1304 .map(|(_, v)| v.to_string())
1305 .collect();
1306 assert_eq!(got, ranges);
1307 }
1308
1309 #[test]
1310 fn batch_get_url_keeps_the_colon_suffix_unencoded() {
1311 let url = build_values_batch_get_url(
1312 BASE,
1313 "s",
1314 &["A1".to_string()],
1315 ValueRenderOption::Formatted,
1316 )
1317 .unwrap();
1318 assert!(
1319 url.as_str().contains("/values:batchGet?"),
1320 "the :batchGet suffix must survive path encoding: {url}"
1321 );
1322 }
1323
1324 #[test]
1325 fn batch_get_url_rejects_an_empty_range_list() {
1326 let err =
1327 build_values_batch_get_url(BASE, "s", &[], ValueRenderOption::Formatted).unwrap_err();
1328 assert!(err.to_string().contains("at least one range"), "{err}");
1329 }
1330
1331 #[test]
1332 fn batch_get_url_rejects_more_ranges_than_the_cap() {
1333 let ranges: Vec<String> = (0..=MAX_RANGES_PER_BATCH)
1334 .map(|i| format!("S{i}"))
1335 .collect();
1336 let err = build_values_batch_get_url(BASE, "s", &ranges, ValueRenderOption::Formatted)
1337 .unwrap_err();
1338 assert!(err.to_string().contains("per-request cap"), "{err}");
1339 }
1340
1341 #[test]
1344 fn update_url_sends_the_value_input_option() {
1345 let url =
1346 build_values_update_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1347 assert_eq!(url.path(), "/v4/spreadsheets/s/values/A1:B2");
1348 let input = url
1349 .query_pairs()
1350 .find(|(k, _)| k == "valueInputOption")
1351 .map(|(_, v)| v.to_string());
1352 assert_eq!(input, Some("USER_ENTERED".to_string()));
1353 }
1354
1355 #[test]
1356 fn update_url_percent_encodes_a_quoted_sheet_title() {
1357 let url =
1358 build_values_update_url(BASE, "s", "'My Sheet'!A1", ValueInputOption::Raw).unwrap();
1359 assert!(url.path().contains("'My%20Sheet'"), "{url}");
1360 }
1361
1362 #[test]
1363 fn append_url_keeps_the_colon_suffix_and_sets_insert_rows() {
1364 let url =
1365 build_values_append_url(BASE, "s", "A1:B2", ValueInputOption::UserEntered).unwrap();
1366 assert!(url.as_str().contains("/values/A1:B2:append"), "{url}");
1367 let insert_data_option = url
1368 .query_pairs()
1369 .find(|(k, _)| k == "insertDataOption")
1370 .map(|(_, v)| v.to_string());
1371 assert_eq!(insert_data_option, Some("INSERT_ROWS".to_string()));
1372 let input = url
1373 .query_pairs()
1374 .find(|(k, _)| k == "valueInputOption")
1375 .map(|(_, v)| v.to_string());
1376 assert_eq!(input, Some("USER_ENTERED".to_string()));
1377 }
1378
1379 #[test]
1380 fn clear_url_keeps_the_colon_suffix_and_sends_no_query() {
1381 let url = build_values_clear_url(BASE, "s", "A1:B2").unwrap();
1382 assert!(url.as_str().contains("/values/A1:B2:clear"), "{url}");
1383 assert!(url.query().is_none(), "{url}");
1384 }
1385
1386 #[test]
1389 fn copy_to_url_keeps_the_colon_suffix_on_the_sheet_id_segment() {
1390 let url = build_copy_to_url(BASE, "dest-or-source-id", 12345).unwrap();
1391 assert_eq!(
1392 url.path(),
1393 "/v4/spreadsheets/dest-or-source-id/sheets/12345:copyTo"
1394 );
1395 assert!(url.query().is_none(), "{url}");
1396 }
1397
1398 async fn sheets_client_for(server: &wiremock::MockServer) -> SheetsClient {
1402 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1403 use crate::drive::client::DriveClient;
1404 use crate::drive::sheets::client::SHEETS_API_URL;
1405 use crate::test_support::env::MapEnv;
1406 use crate::utils::secret::Secret;
1407
1408 wiremock::Mock::given(wiremock::matchers::method("POST"))
1409 .and(wiremock::matchers::path("/token"))
1410 .respond_with(
1411 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1412 "access_token": "test-token", "expires_in": 3600,
1413 })),
1414 )
1415 .mount(server)
1416 .await;
1417 let credentials = DriveCredentials {
1418 client_id: "client-1".to_string(),
1419 client_secret: Secret::new("secret-1"),
1420 refresh_token: Secret::new("refresh-1"),
1421 scope: DriveGrantedScopes::READONLY,
1422 };
1423 let mut drive = DriveClient::new(&server.uri(), &credentials).unwrap();
1424 crate::drive::client::test_support::replace_session(
1425 &mut drive,
1426 &credentials,
1427 &format!("{}/token", server.uri()),
1428 );
1429 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1430 SheetsClient::from_drive_client_with(&env, &drive).unwrap()
1431 }
1432
1433 async fn mount_batch_update_reply(
1435 server: &wiremock::MockServer,
1436 template: wiremock::ResponseTemplate,
1437 ) {
1438 wiremock::Mock::given(wiremock::matchers::method("POST"))
1439 .and(wiremock::matchers::path(
1440 "/v4/spreadsheets/sheet-1:batchUpdate",
1441 ))
1442 .respond_with(template)
1443 .expect(1)
1444 .mount(server)
1445 .await;
1446 }
1447
1448 fn one_request() -> Vec<BatchUpdateRequestItem> {
1449 vec![BatchUpdateRequestItem::DeleteEmbeddedObject(
1450 crate::drive::sheets::types::DeleteEmbeddedObjectRequest { object_id: 1 },
1451 )]
1452 }
1453
1454 #[tokio::test]
1455 async fn batch_update_parses_a_well_formed_2xx_reply_as_applied() {
1456 let server = wiremock::MockServer::start().await;
1457 let sheets = sheets_client_for(&server).await;
1458 mount_batch_update_reply(
1459 &server,
1460 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1461 "spreadsheetId": "sheet-1",
1462 "replies": [{"addChart": {"chart": {"chartId": 42}}}],
1463 })),
1464 )
1465 .await;
1466
1467 let outcome = SheetsApi::new(&sheets)
1468 .batch_update("sheet-1", one_request())
1469 .await
1470 .unwrap();
1471 assert!(
1472 matches!(outcome, BatchUpdateOutcome::Applied(_)),
1473 "{outcome:?}"
1474 );
1475 }
1476
1477 #[tokio::test]
1478 async fn batch_update_reports_a_2xx_with_a_non_json_body_as_applied_reply_unreadable() {
1479 let server = wiremock::MockServer::start().await;
1480 let sheets = sheets_client_for(&server).await;
1481 mount_batch_update_reply(
1482 &server,
1483 wiremock::ResponseTemplate::new(200).set_body_string("not json"),
1484 )
1485 .await;
1486
1487 let outcome = SheetsApi::new(&sheets)
1488 .batch_update("sheet-1", one_request())
1489 .await
1490 .unwrap();
1491 let BatchUpdateOutcome::AppliedReplyUnreadable { detail } = outcome else {
1492 panic!("expected AppliedReplyUnreadable, got {outcome:?}"); };
1494 assert!(
1495 detail.contains("Failed to parse Sheets batchUpdate response"),
1496 "{detail}"
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn batch_update_reports_a_2xx_with_a_mistyped_reply_as_applied_reply_unreadable() {
1502 let server = wiremock::MockServer::start().await;
1503 let sheets = sheets_client_for(&server).await;
1504 mount_batch_update_reply(
1507 &server,
1508 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1509 "replies": [{"addChart": {"chart": {"chartId": "x"}}}],
1510 })),
1511 )
1512 .await;
1513
1514 let outcome = SheetsApi::new(&sheets)
1515 .batch_update("sheet-1", one_request())
1516 .await
1517 .unwrap();
1518 assert!(
1519 matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
1520 "{outcome:?}"
1521 );
1522 }
1523
1524 #[tokio::test]
1525 async fn batch_update_reports_a_2xx_with_an_empty_body_as_applied_reply_unreadable() {
1526 let server = wiremock::MockServer::start().await;
1527 let sheets = sheets_client_for(&server).await;
1528 mount_batch_update_reply(&server, wiremock::ResponseTemplate::new(200)).await;
1529
1530 let outcome = SheetsApi::new(&sheets)
1531 .batch_update("sheet-1", one_request())
1532 .await
1533 .unwrap();
1534 assert!(
1535 matches!(outcome, BatchUpdateOutcome::AppliedReplyUnreadable { .. }),
1536 "{outcome:?}"
1537 );
1538 }
1539
1540 #[tokio::test]
1541 async fn batch_update_reports_a_non_2xx_as_an_error_not_applied() {
1542 let server = wiremock::MockServer::start().await;
1543 let sheets = sheets_client_for(&server).await;
1544 mount_batch_update_reply(
1545 &server,
1546 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
1547 "error": {
1548 "code": 403,
1549 "message": "The caller does not have permission",
1550 "status": "PERMISSION_DENIED",
1551 }
1552 })),
1553 )
1554 .await;
1555
1556 let err = SheetsApi::new(&sheets)
1557 .batch_update("sheet-1", one_request())
1558 .await
1559 .unwrap_err();
1560 let message = format!("{err:#}");
1561 assert!(message.contains("PERMISSION_DENIED"), "{message}");
1562 assert!(message.contains("--write-full"), "{message}");
1563 }
1564
1565 #[test]
1566 fn applied_reply_unreadable_line_words_each_retry_hint() {
1567 assert_eq!(
1568 applied_reply_unreadable_line(
1569 "add chart",
1570 "'Budget'",
1571 RetryHint::NotIdempotent,
1572 "bad reply"
1573 ),
1574 "Applied, but the reply could not be read: add chart in 'Budget' — \
1575 do not retry; check the spreadsheet first (bad reply)"
1576 );
1577 assert_eq!(
1578 applied_reply_unreadable_line("sort A1:B2", "'Budget'", RetryHint::Idempotent, "x"),
1579 "Applied, but the reply could not be read: sort A1:B2 in 'Budget' — \
1580 check the spreadsheet to confirm (x)"
1581 );
1582 }
1583
1584 #[test]
1591 fn every_batch_update_caller_handles_an_unreadable_reply() {
1592 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/drive");
1593 let mut files = Vec::new();
1594 let mut dirs = vec![root.join("sheets"), root.join("lease")];
1595 while let Some(dir) = dirs.pop() {
1596 for entry in std::fs::read_dir(&dir).unwrap() {
1597 let path = entry.unwrap().path();
1598 if path.is_dir() {
1599 dirs.push(path);
1600 } else if path.extension().is_some_and(|e| e == "rs") {
1601 files.push(path);
1602 }
1603 }
1604 }
1605 let mut callers = Vec::new();
1606 for path in files {
1607 let source = std::fs::read_to_string(&path).unwrap();
1608 let production = source.rfind("\nmod tests").map_or(source.as_str(), |at| {
1612 source[..at]
1613 .rfind("#[cfg(test)]")
1614 .map_or(&source[..at], |cfg| &source[..cfg])
1615 });
1616 if production.contains(".batch_update(") {
1617 callers.push((
1620 path,
1621 production.contains("BatchUpdateOutcome::AppliedReplyUnreadable"),
1622 ));
1623 }
1624 }
1625 assert!(
1626 callers.len() >= 21,
1627 "expected 21 callers, found {callers:?}"
1628 );
1629 let unhandled: Vec<_> = callers
1630 .iter()
1631 .filter(|(_, handled)| !handled)
1632 .map(|(path, _)| path.display().to_string())
1633 .collect();
1634 assert!(
1635 unhandled.is_empty(),
1636 "these batch_update callers never handle AppliedReplyUnreadable: {unhandled:?}"
1637 );
1638 }
1639
1640 #[test]
1643 fn urls_respect_a_wiremock_style_base_with_a_port() {
1644 let url = build_values_get_url(
1645 "http://127.0.0.1:9123",
1646 "s",
1647 "'My Sheet'!A1",
1648 ValueRenderOption::Formatted,
1649 )
1650 .unwrap();
1651 assert_eq!(url.host_str(), Some("127.0.0.1"));
1652 assert_eq!(url.port(), Some(9123));
1653 assert!(url.path().starts_with("/v4/spreadsheets/s/values/"));
1654 }
1655
1656 #[test]
1657 fn render_option_default_is_formatted() {
1658 assert_eq!(ValueRenderOption::default(), ValueRenderOption::Formatted);
1659 }
1660}