1use std::collections::BTreeMap;
83use std::path::PathBuf;
84use std::time::{Duration, Instant};
85
86use serde::Serialize;
87
88use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
89use crate::drive::client::DriveClient;
90use crate::drive::files_api::FilesApi;
91use crate::drive::lease::check::{
92 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
93 LeasedWrite,
94};
95use crate::drive::sheets::a1;
96use crate::drive::sheets::api::SheetsApi;
97use crate::drive::sheets::client::SheetsClient;
98use crate::drive::sheets::format::parse_hex_color;
99use crate::drive::sheets::grid_range;
100use crate::drive::sheets::target_gate;
101use crate::drive::sheets::types::{
102 AddChartRequest, AddSlicerRequest, BasicChartAxis, BasicChartDomain, BasicChartSeries,
103 BasicChartSpec, BatchUpdateRequestItem, BatchUpdateResponse, ChartData, ChartSourceRange,
104 ChartSpec, Color, ColorStyle, DeleteEmbeddedObjectRequest, EmbeddedChart, EmbeddedObjectBorder,
105 EmbeddedObjectPosition, FilterCriteria, GridCoordinate, GridRange, OverlayPosition,
106 PieChartSpec, Sheet, Slicer, SlicerSpec, Spreadsheet, UpdateChartSpecRequest,
107 UpdateEmbeddedObjectBorderRequest, UpdateEmbeddedObjectPositionRequest,
108 UpdateSlicerSpecRequest,
109};
110use crate::drive::types::SheetTargetRefusal;
111use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
112use crate::request_log::{self, DriveMutationOutcome};
113
114const SUPPORTED_BASIC_CHART_TYPES: &[&str] = &["COLUMN", "BAR", "LINE", "AREA", "SCATTER"];
118
119#[derive(Debug, Clone, PartialEq)]
121pub enum EmbeddedObjectVerb {
122 AddChart {
124 chart_type: String,
126 domain: String,
128 series: Vec<String>,
131 sheet: Option<String>,
134 title: Option<String>,
136 subtitle: Option<String>,
138 legend: Option<String>,
140 stacked: Option<String>,
142 header_count: Option<i64>,
145 horizontal_axis_title: Option<String>,
147 vertical_axis_title: Option<String>,
149 pie_hole: Option<f64>,
151 anchor: Option<String>,
153 offset_x: Option<i64>,
155 offset_y: Option<i64>,
157 width: Option<i64>,
159 height: Option<i64>,
161 new_sheet: bool,
165 },
166 UpdateChart {
168 chart_id: i64,
170 chart_type: Option<String>,
173 domain: Option<String>,
175 series: Vec<String>,
177 sheet: Option<String>,
180 title: Option<String>,
182 subtitle: Option<String>,
184 legend: Option<String>,
186 stacked: Option<String>,
188 header_count: Option<i64>,
191 horizontal_axis_title: Option<String>,
193 vertical_axis_title: Option<String>,
195 pie_hole: Option<f64>,
197 },
198 DeleteChart {
200 chart_id: i64,
202 },
203 AddSlicer {
205 sheet: Option<String>,
208 range: String,
210 column: i64,
214 hide_values: Vec<String>,
216 title: Option<String>,
218 apply_to_pivot_tables: Option<bool>,
221 anchor: String,
223 offset_x: Option<i64>,
225 offset_y: Option<i64>,
227 width: Option<i64>,
229 height: Option<i64>,
231 },
232 UpdateSlicer {
235 slicer_id: i64,
237 sheet: Option<String>,
240 range: Option<String>,
242 column: Option<i64>,
246 hide_values: Vec<String>,
248 clear_criteria: bool,
250 title: Option<String>,
252 apply_to_pivot_tables: Option<bool>,
254 },
255 DeleteSlicer {
257 slicer_id: i64,
259 },
260 MoveChart {
262 chart_id: i64,
264 sheet: Option<String>,
267 anchor: Option<String>,
271 offset_x: Option<i64>,
273 offset_y: Option<i64>,
275 width: Option<i64>,
277 height: Option<i64>,
279 new_sheet: bool,
282 },
283 MoveSlicer {
286 slicer_id: i64,
288 sheet: Option<String>,
291 anchor: Option<String>,
293 offset_x: Option<i64>,
295 offset_y: Option<i64>,
297 width: Option<i64>,
299 height: Option<i64>,
301 },
302 UpdateChartBorder {
304 chart_id: i64,
306 color: Option<String>,
309 clear: bool,
312 },
313}
314
315impl EmbeddedObjectVerb {
316 const fn log_operation(&self) -> &'static str {
317 match self {
318 Self::AddChart { .. } => "sheets-add-chart",
319 Self::UpdateChart { .. } => "sheets-update-chart",
320 Self::DeleteChart { .. } => "sheets-delete-chart",
321 Self::AddSlicer { .. } => "sheets-add-slicer",
322 Self::UpdateSlicer { .. } => "sheets-update-slicer",
323 Self::DeleteSlicer { .. } => "sheets-delete-slicer",
324 Self::MoveChart { .. } => "sheets-move-chart",
325 Self::MoveSlicer { .. } => "sheets-move-slicer",
326 Self::UpdateChartBorder { .. } => "sheets-update-chart-border",
327 }
328 }
329
330 const fn label(&self) -> &'static str {
331 match self {
332 Self::AddChart { .. } => "add-chart",
333 Self::UpdateChart { .. } => "update-chart",
334 Self::DeleteChart { .. } => "delete-chart",
335 Self::AddSlicer { .. } => "add-slicer",
336 Self::UpdateSlicer { .. } => "update-slicer",
337 Self::DeleteSlicer { .. } => "delete-slicer",
338 Self::MoveChart { .. } => "move-chart",
339 Self::MoveSlicer { .. } => "move-slicer",
340 Self::UpdateChartBorder { .. } => "update-chart-border",
341 }
342 }
343}
344
345#[derive(Debug, Clone)]
347pub struct EmbeddedObjectOptions {
348 pub spreadsheet_id: String,
350 pub verb: EmbeddedObjectVerb,
352 pub dry_run: bool,
354 pub lease_token: Option<String>,
359 pub ledger_path: PathBuf,
363}
364
365#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
369pub struct EmbeddedObjectSummary {
370 pub object_id: i64,
372 pub kind: String,
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub chart_type: Option<String>,
377 #[serde(skip_serializing_if = "Option::is_none")]
379 pub title: Option<String>,
380 pub position: String,
382 #[serde(skip_serializing_if = "Option::is_none")]
387 pub border: Option<String>,
388}
389
390#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
392#[serde(tag = "status", rename_all = "kebab-case")]
393pub enum EmbeddedObjectResult {
394 WouldChange {
398 summary: String,
400 #[serde(skip_serializing_if = "Option::is_none")]
402 object: Option<Box<EmbeddedObjectSummary>>,
403 },
404 RefusedNotASpreadsheet {
406 mime_type: String,
408 },
409 RefusedShortcut,
411 RefusedNoVisibleParents,
413 RefusedSheetNotFound {
415 title: String,
417 available: Vec<String>,
419 },
420 RefusedInvalidRange {
423 detail: String,
425 },
426 RefusedObjectNotFound {
430 object_id: i64,
432 },
433 RefusedWrongObjectKind {
441 object_id: i64,
443 expected: String,
445 found: String,
447 },
448 RefusedUnsupportedChart {
451 chart_id: i64,
453 detail: String,
456 },
457 Blocked {
459 decided_by: Option<DecidingRule>,
461 },
462 RefusedNoLease,
465 RefusedLeaseExpired,
468 RefusedLeaseWrongFile,
470 RefusedLeaseStale,
473 Changed {
475 summary: String,
477 #[serde(skip_serializing_if = "Option::is_none")]
480 object_id: Option<i64>,
481 #[serde(skip_serializing_if = "Option::is_none")]
484 object: Option<Box<EmbeddedObjectSummary>>,
485 },
486 Failed {
488 detail: String,
490 },
491}
492
493impl FromLeaseRefusal for EmbeddedObjectResult {
494 fn from_no_lease() -> Self {
495 Self::RefusedNoLease
496 }
497 fn from_lease_expired() -> Self {
498 Self::RefusedLeaseExpired
499 }
500 fn from_lease_wrong_file() -> Self {
501 Self::RefusedLeaseWrongFile
502 }
503 fn from_lease_stale() -> Self {
504 Self::RefusedLeaseStale
505 }
506 fn from_lease_failed(detail: String) -> Self {
507 Self::Failed { detail }
508 }
509}
510
511impl EmbeddedObjectResult {
512 fn log_status(&self) -> &'static str {
513 match self {
514 Self::WouldChange { .. } => "would-change",
515 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
516 Self::RefusedShortcut => "refused-shortcut",
517 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
518 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
519 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
520 Self::RefusedObjectNotFound { .. } => "refused-object-not-found",
521 Self::RefusedWrongObjectKind { .. } => "refused-wrong-object-kind",
522 Self::RefusedUnsupportedChart { .. } => "refused-unsupported-chart",
523 Self::Blocked { .. } => "blocked",
524 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
525 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
526 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
527 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
528 Self::Changed { .. } => "changed",
529 Self::Failed { .. } => "failed",
530 }
531 }
532}
533
534#[derive(Debug, Clone, Serialize, PartialEq)]
536pub struct EmbeddedObjectOutcome {
537 pub spreadsheet_id: String,
539 #[serde(skip_serializing_if = "Option::is_none")]
541 pub file_name: Option<String>,
542 #[serde(skip_serializing_if = "Option::is_none")]
544 pub resolved_folder_id: Option<String>,
545 #[serde(skip_serializing_if = "Option::is_none")]
548 pub sheet_id: Option<i64>,
549 #[serde(skip)]
551 pub verb: EmbeddedObjectVerb,
552 pub result: EmbeddedObjectResult,
554}
555
556impl JsonlSerialize for EmbeddedObjectOutcome {
557 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
558 write_scalar_jsonl(self, out)
559 }
560}
561
562pub async fn embedded_object(
565 drive: &DriveClient,
566 sheets: &SheetsClient,
567 opts: &EmbeddedObjectOptions,
568 rules: &[FolderPermissionRule],
569) -> EmbeddedObjectOutcome {
570 let started = Instant::now();
571 let outcome = embedded_object_inner(drive, sheets, opts, rules).await;
572 if !opts.dry_run {
573 record_attempt(&outcome, opts, started.elapsed());
574 }
575 outcome
576}
577
578async fn embedded_object_inner(
579 drive: &DriveClient,
580 sheets: &SheetsClient,
581 opts: &EmbeddedObjectOptions,
582 rules: &[FolderPermissionRule],
583) -> EmbeddedObjectOutcome {
584 let bare = |result| EmbeddedObjectOutcome {
585 spreadsheet_id: opts.spreadsheet_id.clone(),
586 file_name: None,
587 resolved_folder_id: None,
588 sheet_id: None,
589 verb: opts.verb.clone(),
590 result,
591 };
592
593 if let Err(detail) = validate_verb(&opts.verb) {
594 return bare(EmbeddedObjectResult::RefusedInvalidRange { detail });
595 }
596
597 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
598 drive,
599 &opts.spreadsheet_id,
600 DriveOperation::SheetsStructure,
601 rules,
602 )
603 .await
604 {
605 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
606 return bare(EmbeddedObjectResult::Failed { detail })
607 }
608 target_gate::TargetGateOutcome::Refused { target, refusal } => {
609 let result = match refusal {
610 SheetTargetRefusal::Shortcut => EmbeddedObjectResult::RefusedShortcut,
611 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
612 EmbeddedObjectResult::RefusedNotASpreadsheet { mime_type }
613 }
614 SheetTargetRefusal::NoVisibleParents => {
615 EmbeddedObjectResult::RefusedNoVisibleParents
616 }
617 };
618 return EmbeddedObjectOutcome {
619 spreadsheet_id: opts.spreadsheet_id.clone(),
620 file_name: Some(target.name),
621 resolved_folder_id: None,
622 sheet_id: None,
623 verb: opts.verb.clone(),
624 result,
625 };
626 }
627 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
628 return EmbeddedObjectOutcome {
629 spreadsheet_id: opts.spreadsheet_id.clone(),
630 file_name: Some(target.name),
631 resolved_folder_id: None,
632 sheet_id: None,
633 verb: opts.verb.clone(),
634 result: EmbeddedObjectResult::Failed { detail },
635 };
636 }
637 target_gate::TargetGateOutcome::Gated {
638 target,
639 decision,
640 resolved_folder_id,
641 requires_lease,
642 } => (target, decision, resolved_folder_id, requires_lease),
643 };
644
645 let pre_gated = |sheet_id, result| EmbeddedObjectOutcome {
646 spreadsheet_id: opts.spreadsheet_id.clone(),
647 file_name: Some(target.name.clone()),
648 resolved_folder_id: resolved_folder_id.clone(),
649 sheet_id,
650 verb: opts.verb.clone(),
651 result,
652 };
653
654 if decision.verdict == write_gate::Verdict::Deny {
655 return pre_gated(
656 None,
657 EmbeddedObjectResult::Blocked {
658 decided_by: decision.decided_by,
659 },
660 );
661 }
662
663 let api = SheetsApi::new(sheets);
664 let workbook = match api
665 .get_spreadsheet_with_embedded_objects(&opts.spreadsheet_id)
666 .await
667 {
668 Ok(workbook) => workbook,
669 Err(err) => {
670 return pre_gated(
671 None,
672 EmbeddedObjectResult::Failed {
673 detail: format!("{err:#}"),
674 },
675 )
676 }
677 };
678
679 let plan = match build_plan(&workbook, &opts.verb) {
680 Ok(plan) => plan,
681 Err(result) => return pre_gated(None, result),
682 };
683
684 if opts.dry_run {
685 return pre_gated(
686 plan.sheet_id,
687 EmbeddedObjectResult::WouldChange {
688 summary: plan.summary.clone(),
689 object: plan.before.clone().map(Box::new),
690 },
691 );
692 }
693
694 let gated = |result| pre_gated(plan.sheet_id, result);
695
696 let files_api = FilesApi::new(drive);
697 let leased = LeasedWrite {
698 log_prefix: "drive sheets embedded-object",
699 operation: opts.verb.log_operation(),
700 ledger_path: &opts.ledger_path,
701 file_id: &opts.spreadsheet_id,
702 };
703 let lease_grant = match gate_optional_leased_write(
704 leased,
705 &files_api,
706 requires_lease,
707 opts.lease_token.as_deref(),
708 )
709 .await
710 {
711 Ok(grant) => grant,
712 Err(err) => return gated(err.into_result()),
713 };
714
715 let result = match conclude_native_leased_write(
716 leased,
717 &lease_grant,
718 &files_api,
719 api.batch_update(&opts.spreadsheet_id, vec![plan.request])
720 .await,
721 |err| format!("{err:#}"),
722 )
723 .await
724 {
725 Ok(response) => {
726 let object_id = added_object_id(&response).or(plan.existing_id);
727 EmbeddedObjectResult::Changed {
728 summary: plan.summary,
729 object_id,
730 object: plan.before.map(Box::new),
731 }
732 }
733 Err(err) => EmbeddedObjectResult::Failed {
734 detail: format!("{err:#}"),
735 },
736 };
737 drop(lease_grant);
738 gated(result)
739}
740
741fn validate_pie_hole(pie_hole: Option<f64>) -> Result<(), String> {
743 match pie_hole {
744 Some(hole) if !(0.0..=1.0).contains(&hole) => Err(format!(
745 "--pie-hole must be between 0.0 and 1.0, got {hole}"
746 )),
747 _ => Ok(()),
748 }
749}
750
751fn validate_verb(verb: &EmbeddedObjectVerb) -> Result<(), String> {
754 match verb {
755 EmbeddedObjectVerb::AddChart {
756 chart_type,
757 series,
758 pie_hole,
759 ..
760 } => {
761 if series.is_empty() {
762 return Err("at least one --series is required".to_string());
763 }
764 if pie_hole.is_some() && !matches!(parse_chart_type(chart_type), Ok(ChartKind::Pie)) {
772 return Err("--pie-hole only applies to a pie chart".to_string());
773 }
774 validate_pie_hole(*pie_hole)?;
775 Ok(())
776 }
777 EmbeddedObjectVerb::UpdateChart {
778 chart_type,
779 domain,
780 series,
781 title,
782 subtitle,
783 legend,
784 stacked,
785 header_count,
786 horizontal_axis_title,
787 vertical_axis_title,
788 pie_hole,
789 ..
790 } => {
791 let nothing_to_change = chart_type.is_none()
792 && domain.is_none()
793 && series.is_empty()
794 && title.is_none()
795 && subtitle.is_none()
796 && legend.is_none()
797 && stacked.is_none()
798 && header_count.is_none()
799 && horizontal_axis_title.is_none()
800 && vertical_axis_title.is_none()
801 && pie_hole.is_none();
802 if nothing_to_change {
803 return Err(
804 "nothing to change: pass --type, --domain, --series, --title, --subtitle, \
805 --legend, --stacked, --header-count, --horizontal-axis-title, \
806 --vertical-axis-title, or --pie-hole"
807 .to_string(),
808 );
809 }
810 validate_pie_hole(*pie_hole)?;
823 Ok(())
824 }
825 EmbeddedObjectVerb::UpdateSlicer {
826 range,
827 column,
828 hide_values,
829 clear_criteria,
830 title,
831 apply_to_pivot_tables,
832 ..
833 } => {
834 let nothing_to_change = range.is_none()
835 && column.is_none()
836 && hide_values.is_empty()
837 && !clear_criteria
838 && title.is_none()
839 && apply_to_pivot_tables.is_none();
840 if nothing_to_change {
841 return Err(
842 "nothing to change: pass --range/--sheet, --column, --hide-values, \
843 --clear-criteria, --title, or --apply-to-pivot-tables"
844 .to_string(),
845 );
846 }
847 if *clear_criteria && !hide_values.is_empty() {
863 return Err("--clear-criteria and --hide-values are mutually exclusive".to_string());
864 }
865 Ok(())
866 }
867 EmbeddedObjectVerb::MoveChart {
868 sheet,
869 anchor,
870 offset_x,
871 offset_y,
872 width,
873 height,
874 new_sheet,
875 ..
876 } => {
877 if sheet.is_some() && anchor.is_none() {
888 return Err("--sheet has no effect without --anchor".to_string());
889 }
890 let nothing_to_change = !new_sheet
891 && anchor.is_none()
892 && offset_x.is_none()
893 && offset_y.is_none()
894 && width.is_none()
895 && height.is_none();
896 if nothing_to_change {
897 return Err(
898 "nothing to change: pass --anchor, --offset-x, --offset-y, --width, \
899 --height, or --new-sheet"
900 .to_string(),
901 );
902 }
903 Ok(())
904 }
905 EmbeddedObjectVerb::MoveSlicer {
906 sheet,
907 anchor,
908 offset_x,
909 offset_y,
910 width,
911 height,
912 ..
913 } => {
914 if sheet.is_some() && anchor.is_none() {
923 return Err("--sheet has no effect without --anchor".to_string());
924 }
925 let nothing_to_change = anchor.is_none()
926 && offset_x.is_none()
927 && offset_y.is_none()
928 && width.is_none()
929 && height.is_none();
930 if nothing_to_change {
931 return Err(
932 "nothing to change: pass --anchor, --offset-x, --offset-y, --width, or \
933 --height"
934 .to_string(),
935 );
936 }
937 Ok(())
938 }
939 EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } => match (color, clear) {
940 (None, false) => Err("nothing to change: pass --color or --clear".to_string()),
941 (Some(_), true) => Err("--color and --clear are mutually exclusive".to_string()),
942 _ => Ok(()),
943 },
944 EmbeddedObjectVerb::DeleteChart { .. }
945 | EmbeddedObjectVerb::DeleteSlicer { .. }
946 | EmbeddedObjectVerb::AddSlicer { .. } => Ok(()),
947 }
948 .map_err(|detail: String| detail)
949 .and(match verb {
950 EmbeddedObjectVerb::AddChart {
951 anchor,
952 new_sheet,
953 offset_x,
954 offset_y,
955 width,
956 height,
957 ..
958 } => {
959 if *new_sheet {
960 if anchor.is_some()
961 || offset_x.is_some()
962 || offset_y.is_some()
963 || width.is_some()
964 || height.is_some()
965 {
966 Err(
967 "--new-sheet cannot be combined with --anchor/--offset-x/--offset-y/\
968 --width/--height"
969 .to_string(),
970 )
971 } else {
972 Ok(())
973 }
974 } else if anchor.is_none() {
975 Err("either --anchor or --new-sheet is required".to_string())
976 } else {
977 Ok(())
978 }
979 }
980 EmbeddedObjectVerb::MoveChart {
987 anchor,
988 offset_x,
989 offset_y,
990 width,
991 height,
992 new_sheet,
993 ..
994 } if *new_sheet => {
995 if anchor.is_some()
996 || offset_x.is_some()
997 || offset_y.is_some()
998 || width.is_some()
999 || height.is_some()
1000 {
1001 Err(
1002 "--new-sheet cannot be combined with --anchor/--offset-x/--offset-y/\
1003 --width/--height"
1004 .to_string(),
1005 )
1006 } else {
1007 Ok(())
1008 }
1009 }
1010 _ => Ok(()),
1011 })
1012}
1013
1014struct Plan {
1017 request: BatchUpdateRequestItem,
1018 summary: String,
1019 sheet_id: Option<i64>,
1020 before: Option<EmbeddedObjectSummary>,
1021 existing_id: Option<i64>,
1022}
1023
1024fn build_plan(
1025 workbook: &Spreadsheet,
1026 verb: &EmbeddedObjectVerb,
1027) -> Result<Plan, EmbeddedObjectResult> {
1028 match verb {
1029 EmbeddedObjectVerb::AddChart { .. } => build_add_chart(workbook, verb),
1030 EmbeddedObjectVerb::UpdateChart { chart_id, .. } => {
1031 build_update_chart(workbook, verb, *chart_id)
1032 }
1033 EmbeddedObjectVerb::DeleteChart { chart_id } => build_delete_chart(workbook, *chart_id),
1034 EmbeddedObjectVerb::AddSlicer { .. } => build_add_slicer(workbook, verb),
1035 EmbeddedObjectVerb::UpdateSlicer { slicer_id, .. } => {
1036 build_update_slicer(workbook, verb, *slicer_id)
1037 }
1038 EmbeddedObjectVerb::DeleteSlicer { slicer_id } => build_delete_slicer(workbook, *slicer_id),
1039 EmbeddedObjectVerb::MoveChart { chart_id, .. } => {
1040 build_move_chart(workbook, verb, *chart_id)
1041 }
1042 EmbeddedObjectVerb::MoveSlicer { slicer_id, .. } => {
1043 build_move_slicer(workbook, verb, *slicer_id)
1044 }
1045 EmbeddedObjectVerb::UpdateChartBorder { chart_id, .. } => {
1046 build_update_chart_border(workbook, verb, *chart_id)
1047 }
1048 }
1049}
1050
1051fn invalid(detail: impl Into<String>) -> EmbeddedObjectResult {
1054 EmbeddedObjectResult::RefusedInvalidRange {
1055 detail: detail.into(),
1056 }
1057}
1058
1059fn compose_and_resolve(
1060 workbook: &Spreadsheet,
1061 sheet: Option<&str>,
1062 range: &str,
1063) -> Result<GridRange, EmbeddedObjectResult> {
1064 let composed = a1::compose(sheet.filter(|s| !s.is_empty()), Some(range))
1065 .map_err(|err| invalid(err.to_string()))?;
1066 let (_, grid) =
1067 grid_range::resolve_grid_range(workbook, &composed, invalid, |title, available| {
1068 EmbeddedObjectResult::RefusedSheetNotFound { title, available }
1069 })?;
1070 Ok(grid)
1071}
1072
1073fn resolve_anchor(
1074 workbook: &Spreadsheet,
1075 sheet: Option<&str>,
1076 anchor: &str,
1077) -> Result<GridCoordinate, EmbeddedObjectResult> {
1078 let grid = compose_and_resolve(workbook, sheet, anchor)?;
1079 let (Some(start_row), Some(end_row), Some(start_col), Some(end_col)) = (
1080 grid.start_row_index,
1081 grid.end_row_index,
1082 grid.start_column_index,
1083 grid.end_column_index,
1084 ) else {
1085 return Err(invalid(format!(
1086 "'{anchor}' must name a single cell, not an open-ended range"
1087 )));
1088 };
1089 if end_row - start_row != 1 || end_col - start_col != 1 {
1090 return Err(invalid(format!(
1091 "'{anchor}' must name a single cell, not a range"
1092 )));
1093 }
1094 Ok(GridCoordinate {
1095 sheet_id: grid.sheet_id,
1096 row_index: start_row,
1097 column_index: start_col,
1098 })
1099}
1100
1101fn overlay_position(
1102 workbook: &Spreadsheet,
1103 sheet: Option<&str>,
1104 anchor: &str,
1105 offset_x: Option<i64>,
1106 offset_y: Option<i64>,
1107 width: Option<i64>,
1108 height: Option<i64>,
1109) -> Result<(EmbeddedObjectPosition, i64), EmbeddedObjectResult> {
1110 let anchor_cell = resolve_anchor(workbook, sheet, anchor)?;
1111 let sheet_id = anchor_cell.sheet_id;
1112 Ok((
1113 EmbeddedObjectPosition {
1114 overlay_position: Some(OverlayPosition {
1115 anchor_cell,
1116 offset_x_pixels: offset_x,
1117 offset_y_pixels: offset_y,
1118 width_pixels: width,
1119 height_pixels: height,
1120 }),
1121 ..Default::default()
1122 },
1123 sheet_id,
1124 ))
1125}
1126
1127fn chart_data(range: GridRange) -> ChartData {
1128 ChartData {
1129 source_range: ChartSourceRange {
1130 sources: vec![range],
1131 },
1132 extra: BTreeMap::new(),
1133 }
1134}
1135
1136fn parse_legend(raw: &str) -> Result<String, String> {
1137 match raw.to_ascii_lowercase().as_str() {
1138 "bottom" => Ok("BOTTOM_LEGEND".to_string()),
1139 "top" => Ok("TOP_LEGEND".to_string()),
1140 "left" => Ok("LEFT_LEGEND".to_string()),
1141 "right" => Ok("RIGHT_LEGEND".to_string()),
1142 "none" => Ok("NO_LEGEND".to_string()),
1143 other => Err(format!(
1144 "'{other}' is not a supported legend position; expected one of bottom, top, left, \
1145 right, none"
1146 )),
1147 }
1148}
1149
1150fn parse_stacked(raw: &str) -> Result<String, String> {
1151 match raw.to_ascii_lowercase().as_str() {
1152 "none" => Ok("NOT_STACKED".to_string()),
1153 "stacked" => Ok("STACKED".to_string()),
1154 "percent" => Ok("PERCENT_STACKED".to_string()),
1155 other => Err(format!(
1156 "'{other}' is not a supported stacking mode; expected one of none, stacked, percent"
1157 )),
1158 }
1159}
1160
1161#[derive(Debug)]
1163enum ChartKind {
1164 Basic(&'static str),
1166 Pie,
1168}
1169
1170fn parse_chart_type(raw: &str) -> Result<ChartKind, String> {
1171 match raw.to_ascii_lowercase().as_str() {
1172 "column" => Ok(ChartKind::Basic("COLUMN")),
1173 "bar" => Ok(ChartKind::Basic("BAR")),
1174 "line" => Ok(ChartKind::Basic("LINE")),
1175 "area" => Ok(ChartKind::Basic("AREA")),
1176 "scatter" => Ok(ChartKind::Basic("SCATTER")),
1177 "pie" => Ok(ChartKind::Pie),
1178 other => Err(format!(
1179 "'{other}' is not a supported chart type; expected one of column, bar, line, area, \
1180 scatter, pie"
1181 )),
1182 }
1183}
1184
1185fn axis_list(horizontal: Option<&str>, vertical: Option<&str>) -> Vec<BasicChartAxis> {
1186 let mut axis = Vec::new();
1187 if let Some(title) = horizontal {
1188 axis.push(BasicChartAxis {
1189 position: "BOTTOM_AXIS".to_string(),
1190 title: Some(title.to_string()),
1191 extra: BTreeMap::new(),
1192 });
1193 }
1194 if let Some(title) = vertical {
1195 axis.push(BasicChartAxis {
1196 position: "LEFT_AXIS".to_string(),
1197 title: Some(title.to_string()),
1198 extra: BTreeMap::new(),
1199 });
1200 }
1201 axis
1202}
1203
1204fn upsert_axis_title(axis: &mut Vec<BasicChartAxis>, position: &str, title: &str) {
1207 if let Some(existing) = axis.iter_mut().find(|a| a.position == position) {
1208 existing.title = Some(title.to_string());
1209 } else {
1210 axis.push(BasicChartAxis {
1211 position: position.to_string(),
1212 title: Some(title.to_string()),
1213 extra: BTreeMap::new(),
1214 });
1215 }
1216}
1217
1218fn build_add_chart(
1221 workbook: &Spreadsheet,
1222 verb: &EmbeddedObjectVerb,
1223) -> Result<Plan, EmbeddedObjectResult> {
1224 let EmbeddedObjectVerb::AddChart {
1225 chart_type,
1226 domain,
1227 series,
1228 sheet,
1229 title,
1230 subtitle,
1231 legend,
1232 stacked,
1233 header_count,
1234 horizontal_axis_title,
1235 vertical_axis_title,
1236 pie_hole,
1237 anchor,
1238 offset_x,
1239 offset_y,
1240 width,
1241 height,
1242 new_sheet,
1243 } = verb
1244 else {
1245 unreachable!("build_add_chart is only ever called for AddChart") };
1247
1248 let kind = parse_chart_type(chart_type).map_err(invalid)?;
1249 let domain_range = compose_and_resolve(workbook, sheet.as_deref(), domain)?;
1250 let series_ranges = series
1251 .iter()
1252 .map(|s| compose_and_resolve(workbook, sheet.as_deref(), s))
1253 .collect::<Result<Vec<_>, _>>()?;
1254 let legend = legend
1255 .as_deref()
1256 .map(parse_legend)
1257 .transpose()
1258 .map_err(invalid)?;
1259
1260 let (position, position_sheet_id) = if *new_sheet {
1261 (
1262 EmbeddedObjectPosition {
1263 new_sheet: Some(true),
1264 ..Default::default()
1265 },
1266 None,
1267 )
1268 } else {
1269 let Some(anchor) = anchor.as_deref() else {
1270 return Err(invalid("either --anchor or --new-sheet is required"));
1271 };
1272 let (position, sheet_id) = overlay_position(
1273 workbook,
1274 sheet.as_deref(),
1275 anchor,
1276 *offset_x,
1277 *offset_y,
1278 *width,
1279 *height,
1280 )?;
1281 (position, Some(sheet_id))
1282 };
1283
1284 let spec = match kind {
1285 ChartKind::Basic(chart_type) => {
1286 if pie_hole.is_some() {
1287 return Err(invalid("--pie-hole only applies to a pie chart"));
1288 }
1289 let stacked = stacked
1290 .as_deref()
1291 .map(parse_stacked)
1292 .transpose()
1293 .map_err(invalid)?;
1294 ChartSpec {
1295 title: title.clone(),
1296 subtitle: subtitle.clone(),
1297 basic_chart: Some(BasicChartSpec {
1298 chart_type: chart_type.to_string(),
1299 legend_position: legend,
1300 stacked_type: stacked,
1301 header_count: *header_count,
1302 axis: axis_list(
1303 horizontal_axis_title.as_deref(),
1304 vertical_axis_title.as_deref(),
1305 ),
1306 domains: vec![BasicChartDomain {
1307 domain: chart_data(domain_range),
1308 extra: BTreeMap::new(),
1309 }],
1310 series: series_ranges
1311 .into_iter()
1312 .map(|r| BasicChartSeries {
1313 series: chart_data(r),
1314 target_axis: None,
1315 extra: BTreeMap::new(),
1316 })
1317 .collect(),
1318 extra: BTreeMap::new(),
1319 }),
1320 pie_chart: None,
1321 extra: BTreeMap::new(),
1322 }
1323 }
1324 ChartKind::Pie => {
1325 if header_count.is_some() {
1326 return Err(invalid(
1327 "--header-count only applies to a column/bar/line/area/\
1328 scatter chart",
1329 ));
1330 }
1331 if stacked.is_some() {
1332 return Err(invalid(
1333 "--stacked only applies to a column/bar/line/area/scatter \
1334 chart",
1335 ));
1336 }
1337 if horizontal_axis_title.is_some() || vertical_axis_title.is_some() {
1338 return Err(invalid(
1339 "--horizontal-axis-title/--vertical-axis-title only apply \
1340 to a column/bar/line/area/scatter chart",
1341 ));
1342 }
1343 if series_ranges.len() != 1 {
1344 return Err(invalid("a pie chart takes exactly one --series"));
1345 }
1346 let mut series_ranges = series_ranges;
1347 let Some(pie_series_range) = series_ranges.pop() else {
1348 unreachable!("checked series_ranges.len() == 1 above") };
1350 ChartSpec {
1351 title: title.clone(),
1352 subtitle: subtitle.clone(),
1353 basic_chart: None,
1354 pie_chart: Some(PieChartSpec {
1355 domain: chart_data(domain_range),
1356 series: chart_data(pie_series_range),
1357 legend_position: legend,
1358 pie_hole: *pie_hole,
1359 extra: BTreeMap::new(),
1360 }),
1361 extra: BTreeMap::new(),
1362 }
1363 }
1364 };
1365
1366 let summary = format!(
1367 "add {} chart{}",
1368 chart_type.to_ascii_lowercase(),
1369 title
1370 .as_deref()
1371 .map_or_else(String::new, |t| format!(" '{t}'"))
1372 );
1373
1374 Ok(Plan {
1375 request: BatchUpdateRequestItem::AddChart(AddChartRequest {
1376 chart: EmbeddedChart {
1377 chart_id: None,
1378 spec: Some(spec),
1379 position: Some(position),
1380 border: None,
1381 },
1382 }),
1383 summary,
1384 sheet_id: position_sheet_id,
1385 before: None,
1386 existing_id: None,
1387 })
1388}
1389
1390fn find_chart(workbook: &Spreadsheet, chart_id: i64) -> Option<(&Sheet, &EmbeddedChart)> {
1393 workbook.sheets.iter().find_map(|sheet| {
1394 sheet
1395 .charts
1396 .iter()
1397 .find(|c| c.chart_id == Some(chart_id))
1398 .map(|c| (sheet, c))
1399 })
1400}
1401
1402fn find_slicer(workbook: &Spreadsheet, slicer_id: i64) -> Option<(&Sheet, &Slicer)> {
1403 workbook.sheets.iter().find_map(|sheet| {
1404 sheet
1405 .slicers
1406 .iter()
1407 .find(|s| s.slicer_id == Some(slicer_id))
1408 .map(|s| (sheet, s))
1409 })
1410}
1411
1412fn find_chart_or_refuse(
1418 workbook: &Spreadsheet,
1419 chart_id: i64,
1420) -> Result<(&Sheet, &EmbeddedChart), EmbeddedObjectResult> {
1421 if let Some(found) = find_chart(workbook, chart_id) {
1422 return Ok(found);
1423 }
1424 if find_slicer(workbook, chart_id).is_some() {
1425 return Err(EmbeddedObjectResult::RefusedWrongObjectKind {
1426 object_id: chart_id,
1427 expected: "chart".to_string(),
1428 found: "slicer".to_string(),
1429 });
1430 }
1431 Err(EmbeddedObjectResult::RefusedObjectNotFound {
1432 object_id: chart_id,
1433 })
1434}
1435
1436fn find_slicer_or_refuse(
1438 workbook: &Spreadsheet,
1439 slicer_id: i64,
1440) -> Result<(&Sheet, &Slicer), EmbeddedObjectResult> {
1441 if let Some(found) = find_slicer(workbook, slicer_id) {
1442 return Ok(found);
1443 }
1444 if find_chart(workbook, slicer_id).is_some() {
1445 return Err(EmbeddedObjectResult::RefusedWrongObjectKind {
1446 object_id: slicer_id,
1447 expected: "slicer".to_string(),
1448 found: "chart".to_string(),
1449 });
1450 }
1451 Err(EmbeddedObjectResult::RefusedObjectNotFound {
1452 object_id: slicer_id,
1453 })
1454}
1455
1456fn describe_overlay_position(sheet: &Sheet, overlay: &OverlayPosition) -> String {
1457 format!(
1458 "{}!{}{}",
1459 sheet.title(),
1460 grid_range::column_index_to_letters(overlay.anchor_cell.column_index),
1461 overlay.anchor_cell.row_index + 1
1462 )
1463}
1464
1465fn describe_position(sheet: &Sheet, position: Option<&EmbeddedObjectPosition>) -> String {
1466 match position {
1467 Some(p) if p.new_sheet == Some(true) || p.sheet_id.is_some() => p.sheet_id.map_or_else(
1468 || "own sheet".to_string(),
1469 |id| format!("own sheet (id {id})"),
1470 ),
1471 Some(p) => p.overlay_position.as_ref().map_or_else(
1472 || "unknown position".to_string(),
1473 |o| describe_overlay_position(sheet, o),
1474 ),
1475 None => "unknown position".to_string(),
1476 }
1477}
1478
1479fn describe_border_color(color_style: &ColorStyle) -> String {
1499 let ambiguous = match color_style.rgb_color {
1500 Some(rgb) => rgb == Color::default(),
1501 None => color_style.theme_color.is_none(),
1502 };
1503 if ambiguous {
1504 "#000000 (or cleared — indistinguishable on read)".to_string()
1505 } else {
1506 color_style.describe()
1507 }
1508}
1509
1510fn summarise_chart(sheet: &Sheet, chart: &EmbeddedChart) -> Option<EmbeddedObjectSummary> {
1511 let object_id = chart.chart_id?;
1512 let spec = chart.spec.as_ref();
1513 let chart_type = spec.and_then(|s| {
1514 if let Some(basic) = &s.basic_chart {
1515 Some(basic.chart_type.clone())
1516 } else if s.pie_chart.is_some() {
1517 Some("PIE".to_string())
1518 } else {
1519 None
1520 }
1521 });
1522 Some(EmbeddedObjectSummary {
1523 object_id,
1524 kind: "chart".to_string(),
1525 chart_type,
1526 title: spec.and_then(|s| s.title.clone()),
1527 position: describe_position(sheet, chart.position.as_ref()),
1528 border: chart
1529 .border
1530 .as_ref()
1531 .and_then(|b| b.color_style.as_ref())
1532 .map(describe_border_color),
1533 })
1534}
1535
1536fn summarise_slicer(sheet: &Sheet, slicer: &Slicer) -> Option<EmbeddedObjectSummary> {
1537 let object_id = slicer.slicer_id?;
1538 Some(EmbeddedObjectSummary {
1539 object_id,
1540 kind: "slicer".to_string(),
1541 chart_type: None,
1542 title: slicer.spec.as_ref().and_then(|s| s.title.clone()),
1543 position: describe_position(sheet, slicer.position.as_ref()),
1544 border: None,
1545 })
1546}
1547
1548#[derive(Debug)]
1551enum ExistingChartKind {
1552 Basic,
1553 Pie,
1554}
1555
1556fn existing_chart_kind(spec: &ChartSpec) -> Result<ExistingChartKind, String> {
1557 if let Some(basic) = &spec.basic_chart {
1558 if SUPPORTED_BASIC_CHART_TYPES.contains(&basic.chart_type.as_str()) {
1559 Ok(ExistingChartKind::Basic)
1560 } else {
1561 Err(basic.chart_type.clone())
1562 }
1563 } else if spec.pie_chart.is_some() {
1564 Ok(ExistingChartKind::Pie)
1565 } else {
1566 Err(spec
1567 .extra
1568 .keys()
1569 .next()
1570 .cloned()
1571 .unwrap_or_else(|| "unknown".to_string()))
1572 }
1573}
1574
1575fn merge_chart_spec(
1576 workbook: &Spreadsheet,
1577 existing: &ChartSpec,
1578 verb: &EmbeddedObjectVerb,
1579) -> Result<(ChartSpec, String), EmbeddedObjectResult> {
1580 let EmbeddedObjectVerb::UpdateChart {
1581 chart_id,
1582 chart_type,
1583 title,
1584 subtitle,
1585 legend,
1586 ..
1587 } = verb
1588 else {
1589 unreachable!("merge_chart_spec is only ever called for UpdateChart") };
1591
1592 let existing_kind = existing_chart_kind(existing).map_err(|detail| {
1593 EmbeddedObjectResult::RefusedUnsupportedChart {
1594 chart_id: *chart_id,
1595 detail: format!("chart's existing spec is {detail}, not a supported basic/pie chart"),
1596 }
1597 })?;
1598
1599 let requested_kind = chart_type
1600 .as_deref()
1601 .map(parse_chart_type)
1602 .transpose()
1603 .map_err(invalid)?;
1604
1605 let target_is_pie = match &requested_kind {
1606 Some(ChartKind::Pie) => true,
1607 Some(ChartKind::Basic(_)) => false,
1608 None => matches!(existing_kind, ExistingChartKind::Pie),
1609 };
1610 if target_is_pie != matches!(existing_kind, ExistingChartKind::Pie) {
1611 return Err(EmbeddedObjectResult::RefusedUnsupportedChart {
1612 chart_id: *chart_id,
1613 detail: "switching between a basic chart and a pie chart is not supported; delete \
1614 and re-add"
1615 .to_string(),
1616 });
1617 }
1618
1619 let mut changed = Vec::new();
1620 if title.is_some() {
1621 changed.push("title");
1622 }
1623 if subtitle.is_some() {
1624 changed.push("subtitle");
1625 }
1626 if legend.is_some() {
1627 changed.push("legend");
1628 }
1629
1630 let mut spec = existing.clone();
1631 if let Some(title) = title {
1632 spec.title = Some(title.clone());
1633 }
1634 if let Some(subtitle) = subtitle {
1635 spec.subtitle = Some(subtitle.clone());
1636 }
1637
1638 if target_is_pie {
1639 merge_pie_chart(workbook, &mut spec, verb, &mut changed)?;
1640 } else {
1641 merge_basic_chart(workbook, &mut spec, verb, requested_kind, &mut changed)?;
1642 }
1643
1644 let summary = format!("update chart {chart_id} ({})", changed.join(", "));
1645 Ok((spec, summary))
1646}
1647
1648fn merge_pie_chart(
1651 workbook: &Spreadsheet,
1652 spec: &mut ChartSpec,
1653 verb: &EmbeddedObjectVerb,
1654 changed: &mut Vec<&'static str>,
1655) -> Result<(), EmbeddedObjectResult> {
1656 let EmbeddedObjectVerb::UpdateChart {
1657 domain,
1658 series,
1659 sheet,
1660 legend,
1661 stacked,
1662 header_count,
1663 horizontal_axis_title,
1664 vertical_axis_title,
1665 pie_hole,
1666 ..
1667 } = verb
1668 else {
1669 unreachable!("merge_pie_chart is only ever called for UpdateChart") };
1671
1672 if header_count.is_some() {
1673 return Err(invalid(
1674 "--header-count only applies to a column/bar/line/area/scatter chart",
1675 ));
1676 }
1677 if stacked.is_some() {
1678 return Err(invalid(
1679 "--stacked only applies to a column/bar/line/area/scatter chart",
1680 ));
1681 }
1682 if horizontal_axis_title.is_some() || vertical_axis_title.is_some() {
1683 return Err(invalid(
1684 "--horizontal-axis-title/--vertical-axis-title only apply to a \
1685 column/bar/line/area/scatter chart",
1686 ));
1687 }
1688
1689 let mut pie = spec.pie_chart.clone().unwrap_or_default();
1690 if let Some(domain) = domain {
1691 pie.domain = chart_data(compose_and_resolve(workbook, sheet.as_deref(), domain)?);
1692 changed.push("domain");
1693 }
1694 if !series.is_empty() {
1695 let [only_series] = series.as_slice() else {
1696 return Err(invalid("a pie chart takes exactly one --series"));
1697 };
1698 let range = compose_and_resolve(workbook, sheet.as_deref(), only_series)?;
1699 pie.series = chart_data(range);
1700 changed.push("series");
1701 }
1702 if let Some(legend) = legend
1703 .as_deref()
1704 .map(parse_legend)
1705 .transpose()
1706 .map_err(invalid)?
1707 {
1708 pie.legend_position = Some(legend);
1709 }
1710 if let Some(pie_hole) = pie_hole {
1711 pie.pie_hole = Some(*pie_hole);
1712 changed.push("pie-hole");
1713 }
1714 spec.pie_chart = Some(pie);
1715 spec.basic_chart = None;
1716 Ok(())
1717}
1718
1719fn merge_basic_chart(
1724 workbook: &Spreadsheet,
1725 spec: &mut ChartSpec,
1726 verb: &EmbeddedObjectVerb,
1727 requested_kind: Option<ChartKind>,
1728 changed: &mut Vec<&'static str>,
1729) -> Result<(), EmbeddedObjectResult> {
1730 let EmbeddedObjectVerb::UpdateChart {
1731 domain,
1732 series,
1733 sheet,
1734 legend,
1735 stacked,
1736 header_count,
1737 horizontal_axis_title,
1738 vertical_axis_title,
1739 pie_hole,
1740 ..
1741 } = verb
1742 else {
1743 unreachable!("merge_basic_chart is only ever called for UpdateChart") };
1745
1746 if pie_hole.is_some() {
1750 return Err(invalid("--pie-hole only applies to a pie chart"));
1751 }
1752
1753 let mut basic = spec.basic_chart.clone().unwrap_or_default();
1754 if let Some(ChartKind::Basic(chart_type)) = requested_kind {
1755 basic.chart_type = chart_type.to_string();
1756 changed.push("type");
1757 }
1758 if let Some(domain) = domain {
1759 basic.domains = vec![BasicChartDomain {
1760 domain: chart_data(compose_and_resolve(workbook, sheet.as_deref(), domain)?),
1761 extra: BTreeMap::new(),
1762 }];
1763 changed.push("domain");
1764 }
1765 if !series.is_empty() {
1766 basic.series = series
1767 .iter()
1768 .map(|s| {
1769 compose_and_resolve(workbook, sheet.as_deref(), s).map(|range| BasicChartSeries {
1770 series: chart_data(range),
1771 target_axis: None,
1772 extra: BTreeMap::new(),
1773 })
1774 })
1775 .collect::<Result<Vec<_>, _>>()?;
1776 changed.push("series");
1777 }
1778 if let Some(legend) = legend
1779 .as_deref()
1780 .map(parse_legend)
1781 .transpose()
1782 .map_err(invalid)?
1783 {
1784 basic.legend_position = Some(legend);
1785 }
1786 if let Some(stacked) = stacked
1787 .as_deref()
1788 .map(parse_stacked)
1789 .transpose()
1790 .map_err(invalid)?
1791 {
1792 basic.stacked_type = Some(stacked);
1793 changed.push("stacked");
1794 }
1795 if let Some(header_count) = header_count {
1796 basic.header_count = Some(*header_count);
1797 changed.push("header-count");
1798 }
1799 if let Some(title) = horizontal_axis_title {
1800 upsert_axis_title(&mut basic.axis, "BOTTOM_AXIS", title);
1801 changed.push("horizontal-axis-title");
1802 }
1803 if let Some(title) = vertical_axis_title {
1804 upsert_axis_title(&mut basic.axis, "LEFT_AXIS", title);
1805 changed.push("vertical-axis-title");
1806 }
1807 spec.basic_chart = Some(basic);
1808 spec.pie_chart = None;
1809 Ok(())
1810}
1811
1812fn build_update_chart(
1813 workbook: &Spreadsheet,
1814 verb: &EmbeddedObjectVerb,
1815 chart_id: i64,
1816) -> Result<Plan, EmbeddedObjectResult> {
1817 let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
1818 let Some(existing_spec) = chart.spec.as_ref() else {
1819 return Err(EmbeddedObjectResult::RefusedUnsupportedChart {
1820 chart_id,
1821 detail: "chart has no spec to merge onto".to_string(),
1822 });
1823 };
1824 let before = summarise_chart(sheet, chart);
1825 let sheet_id = sheet.sheet_id();
1826 let (spec, summary) = merge_chart_spec(workbook, existing_spec, verb)?;
1827
1828 Ok(Plan {
1829 request: BatchUpdateRequestItem::UpdateChartSpec(UpdateChartSpecRequest { chart_id, spec }),
1830 summary,
1831 sheet_id,
1832 before,
1833 existing_id: Some(chart_id),
1834 })
1835}
1836
1837fn build_delete_chart(workbook: &Spreadsheet, chart_id: i64) -> Result<Plan, EmbeddedObjectResult> {
1838 let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
1839 let before = summarise_chart(sheet, chart);
1840 let sheet_id = sheet.sheet_id();
1841 let summary = format!("delete chart {chart_id}");
1842 Ok(Plan {
1843 request: BatchUpdateRequestItem::DeleteEmbeddedObject(DeleteEmbeddedObjectRequest {
1844 object_id: chart_id,
1845 }),
1846 summary,
1847 sheet_id,
1848 before,
1849 existing_id: Some(chart_id),
1850 })
1851}
1852
1853fn check_slicer_column_in_range(
1865 column: i64,
1866 range: &GridRange,
1867) -> Result<(), EmbeddedObjectResult> {
1868 let grid_range::ColumnOutOfRange { start, end, .. } =
1869 match grid_range::check_column_in_range(column, range) {
1870 Ok(()) => return Ok(()),
1871 Err(err) => err,
1872 };
1873 let column_label = if column >= 0 {
1874 format!(
1875 "{column} (column {})",
1876 grid_range::column_index_to_letters(column)
1877 )
1878 } else {
1879 column.to_string()
1880 };
1881 let valid = match end {
1882 Some(end) if end <= start => "none (the range spans no columns)".to_string(),
1883 Some(end) => format!(
1884 "{start}..={} ({}–{})",
1885 end - 1,
1886 grid_range::column_index_to_letters(start),
1887 grid_range::column_index_to_letters(end - 1)
1888 ),
1889 None => format!(
1890 "{start} or greater ({} onward)",
1891 grid_range::column_index_to_letters(start)
1892 ),
1893 };
1894 Err(invalid(format!(
1895 "slicer column {column_label} is outside the slicer's range; valid columns are \
1896 {valid} — the column is an absolute sheet column index (0 = A), not an offset \
1897 within the range"
1898 )))
1899}
1900
1901fn build_add_slicer(
1902 workbook: &Spreadsheet,
1903 verb: &EmbeddedObjectVerb,
1904) -> Result<Plan, EmbeddedObjectResult> {
1905 let EmbeddedObjectVerb::AddSlicer {
1906 sheet,
1907 range,
1908 column,
1909 hide_values,
1910 title,
1911 apply_to_pivot_tables,
1912 anchor,
1913 offset_x,
1914 offset_y,
1915 width,
1916 height,
1917 } = verb
1918 else {
1919 unreachable!("build_add_slicer is only ever called for AddSlicer") };
1921
1922 let data_range = compose_and_resolve(workbook, sheet.as_deref(), range)?;
1923 check_slicer_column_in_range(*column, &data_range)?;
1924 let (position, position_sheet_id) = overlay_position(
1925 workbook,
1926 sheet.as_deref(),
1927 anchor,
1928 *offset_x,
1929 *offset_y,
1930 *width,
1931 *height,
1932 )?;
1933
1934 let filter_criteria = if hide_values.is_empty() {
1935 None
1936 } else {
1937 Some(FilterCriteria::hiding(hide_values.clone()))
1938 };
1939
1940 let summary = format!(
1941 "add slicer{}",
1942 title
1943 .as_deref()
1944 .map_or_else(String::new, |t| format!(" '{t}'"))
1945 );
1946
1947 Ok(Plan {
1948 request: BatchUpdateRequestItem::AddSlicer(AddSlicerRequest {
1949 slicer: Slicer {
1950 slicer_id: None,
1951 spec: Some(SlicerSpec {
1952 data_range: Some(data_range),
1953 filter_criteria,
1954 column_index: Some(*column),
1955 apply_to_pivot_tables: *apply_to_pivot_tables,
1956 title: title.clone(),
1957 extra: BTreeMap::new(),
1958 }),
1959 position: Some(position),
1960 },
1961 }),
1962 summary,
1963 sheet_id: Some(position_sheet_id),
1964 before: None,
1965 existing_id: None,
1966 })
1967}
1968
1969fn build_update_slicer(
1970 workbook: &Spreadsheet,
1971 verb: &EmbeddedObjectVerb,
1972 slicer_id: i64,
1973) -> Result<Plan, EmbeddedObjectResult> {
1974 let EmbeddedObjectVerb::UpdateSlicer {
1975 sheet,
1976 range,
1977 column,
1978 hide_values,
1979 clear_criteria,
1980 title,
1981 apply_to_pivot_tables,
1982 ..
1983 } = verb
1984 else {
1985 unreachable!("build_update_slicer is only ever called for UpdateSlicer")
1987 };
1989
1990 let (host_sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
1991 let before = summarise_slicer(host_sheet, slicer);
1992 let sheet_id = host_sheet.sheet_id();
1993
1994 let mut spec = SlicerSpec::default();
1995 let mut fields = Vec::new();
1996 let mut changed = Vec::new();
1997 if let Some(range) = range {
1998 spec.data_range = Some(compose_and_resolve(workbook, sheet.as_deref(), range)?);
1999 fields.push("dataRange");
2000 changed.push("range");
2001 }
2002 if *clear_criteria {
2003 spec.filter_criteria = Some(FilterCriteria::default());
2004 fields.push("filterCriteria");
2005 changed.push("clear criteria");
2006 } else if !hide_values.is_empty() {
2007 spec.filter_criteria = Some(FilterCriteria::hiding(hide_values.clone()));
2008 fields.push("filterCriteria");
2009 changed.push("criteria");
2010 }
2011 if let Some(column) = column {
2012 spec.column_index = Some(*column);
2013 fields.push("columnIndex");
2014 changed.push("column");
2015 }
2016 if range.is_some() || column.is_some() {
2021 let existing = slicer.spec.as_ref();
2022 let effective_range = spec
2023 .data_range
2024 .as_ref()
2025 .or_else(|| existing.and_then(|s| s.data_range.as_ref()));
2026 let effective_column = spec.column_index.or_else(|| {
2039 existing.and_then(|s| s.data_range.is_some().then(|| s.column_index.unwrap_or(0)))
2040 });
2041 match (effective_range, effective_column) {
2042 (Some(effective_range), Some(effective_column)) => {
2043 check_slicer_column_in_range(effective_column, effective_range)?;
2044 }
2045 _ => {
2046 return Err(EmbeddedObjectResult::RefusedInvalidRange {
2051 detail: format!(
2052 "slicer {slicer_id} has no {} on record, so its column can't be checked against its range locally — set --range and --column together to establish both"
2053 ,
2054 match (effective_range.is_some(), effective_column.is_some()) {
2055 (false, false) => "range or column",
2056 (false, true) => "range",
2057 (true, false) => "column",
2058 (true, true) => unreachable!(),
2059 }
2060 ),
2061 });
2062 }
2063 }
2064 }
2065 if let Some(title) = title {
2066 spec.title = Some(title.clone());
2067 fields.push("title");
2068 changed.push("title");
2069 }
2070 if let Some(apply) = apply_to_pivot_tables {
2071 spec.apply_to_pivot_tables = Some(*apply);
2072 fields.push("applyToPivotTables");
2073 changed.push("apply-to-pivot-tables");
2074 }
2075
2076 let summary = format!("update slicer {slicer_id} ({})", changed.join(", "));
2077 Ok(Plan {
2078 request: BatchUpdateRequestItem::UpdateSlicerSpec(UpdateSlicerSpecRequest {
2079 slicer_id,
2080 spec,
2081 fields: fields.join(","),
2082 }),
2083 summary,
2084 sheet_id,
2085 before,
2086 existing_id: Some(slicer_id),
2087 })
2088}
2089
2090fn build_delete_slicer(
2091 workbook: &Spreadsheet,
2092 slicer_id: i64,
2093) -> Result<Plan, EmbeddedObjectResult> {
2094 let (sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
2095 let before = summarise_slicer(sheet, slicer);
2096 let sheet_id = sheet.sheet_id();
2097 let summary = format!("delete slicer {slicer_id}");
2098 Ok(Plan {
2099 request: BatchUpdateRequestItem::DeleteEmbeddedObject(DeleteEmbeddedObjectRequest {
2100 object_id: slicer_id,
2101 }),
2102 summary,
2103 sheet_id,
2104 before,
2105 existing_id: Some(slicer_id),
2106 })
2107}
2108
2109#[derive(Debug, Clone, Copy, Default)]
2118struct PlacementResize {
2119 offset_x: Option<i64>,
2120 offset_y: Option<i64>,
2121 width: Option<i64>,
2122 height: Option<i64>,
2123}
2124
2125fn overlay_position_update(
2139 workbook: &Spreadsheet,
2140 existing: Option<&OverlayPosition>,
2141 sheet: Option<&str>,
2142 anchor: Option<&str>,
2143 resize: PlacementResize,
2144) -> Result<(EmbeddedObjectPosition, String, i64), EmbeddedObjectResult> {
2145 let mut fields = Vec::new();
2146 let anchor_cell = match anchor {
2147 Some(anchor) => {
2148 fields.push("anchorCell");
2149 resolve_anchor(workbook, sheet, anchor)?
2150 }
2151 None => existing.map(|overlay| overlay.anchor_cell).ok_or_else(|| {
2152 invalid(
2153 "this object has no current overlay position (a chart can be on its own \
2154 sheet; a slicer is always an overlay, so this would mean a malformed \
2155 read-back); --anchor is required to move it onto a grid",
2156 )
2157 })?,
2158 };
2159 let sheet_id = anchor_cell.sheet_id;
2160
2161 if resize.offset_x.is_some() {
2162 fields.push("offsetXPixels");
2163 }
2164 if resize.offset_y.is_some() {
2165 fields.push("offsetYPixels");
2166 }
2167 if resize.width.is_some() {
2168 fields.push("widthPixels");
2169 }
2170 if resize.height.is_some() {
2171 fields.push("heightPixels");
2172 }
2173
2174 let position = EmbeddedObjectPosition {
2175 overlay_position: Some(OverlayPosition {
2176 anchor_cell,
2177 offset_x_pixels: resize.offset_x,
2178 offset_y_pixels: resize.offset_y,
2179 width_pixels: resize.width,
2180 height_pixels: resize.height,
2181 }),
2182 ..Default::default()
2183 };
2184 Ok((position, fields.join(","), sheet_id))
2185}
2186
2187#[allow(clippy::too_many_arguments)]
2198fn build_move(
2199 workbook: &Spreadsheet,
2200 object_id: i64,
2201 noun: &str,
2202 position: EmbeddedObjectPosition,
2203 fields: String,
2204 sheet_id: i64,
2205 anchor_changed: bool,
2206 resize: PlacementResize,
2207 before: Option<EmbeddedObjectSummary>,
2208) -> Plan {
2209 let mut changed = Vec::new();
2210 if let Some(offset_x) = resize.offset_x {
2211 changed.push(format!("offset-x {offset_x}"));
2212 }
2213 if let Some(offset_y) = resize.offset_y {
2214 changed.push(format!("offset-y {offset_y}"));
2215 }
2216 if let Some(width) = resize.width {
2217 changed.push(format!("width {width}"));
2218 }
2219 if let Some(height) = resize.height {
2220 changed.push(format!("height {height}"));
2221 }
2222 let suffix = if changed.is_empty() {
2223 String::new()
2224 } else {
2225 format!(" ({})", changed.join(", "))
2226 };
2227 let summary = if anchor_changed {
2228 let destination = position
2229 .overlay_position
2230 .as_ref()
2231 .and_then(|overlay| {
2232 grid_range::find_sheet_by_id(workbook, sheet_id)
2233 .map(|sheet| describe_overlay_position(sheet, overlay))
2234 })
2235 .unwrap_or_else(|| format!("sheet {sheet_id}"));
2236 format!("move {noun} {object_id} to {destination}{suffix}")
2237 } else {
2238 format!("resize {noun} {object_id}{suffix}")
2239 };
2240
2241 Plan {
2242 request: BatchUpdateRequestItem::UpdateEmbeddedObjectPosition(
2243 UpdateEmbeddedObjectPositionRequest {
2244 object_id,
2245 new_position: position,
2246 fields,
2247 },
2248 ),
2249 summary,
2250 sheet_id: Some(sheet_id),
2251 before,
2252 existing_id: Some(object_id),
2253 }
2254}
2255
2256fn build_move_chart(
2257 workbook: &Spreadsheet,
2258 verb: &EmbeddedObjectVerb,
2259 chart_id: i64,
2260) -> Result<Plan, EmbeddedObjectResult> {
2261 let EmbeddedObjectVerb::MoveChart {
2262 sheet,
2263 anchor,
2264 offset_x,
2265 offset_y,
2266 width,
2267 height,
2268 new_sheet,
2269 ..
2270 } = verb
2271 else {
2272 unreachable!("build_move_chart is only ever called for MoveChart") };
2274
2275 let (host_sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
2276 let before = summarise_chart(host_sheet, chart);
2277
2278 if *new_sheet {
2279 return Ok(Plan {
2280 request: BatchUpdateRequestItem::UpdateEmbeddedObjectPosition(
2281 UpdateEmbeddedObjectPositionRequest {
2282 object_id: chart_id,
2283 new_position: EmbeddedObjectPosition {
2284 new_sheet: Some(true),
2285 ..Default::default()
2286 },
2287 fields: String::new(),
2288 },
2289 ),
2290 summary: format!("move chart {chart_id} to a new sheet"),
2291 sheet_id: None,
2292 before,
2293 existing_id: Some(chart_id),
2294 });
2295 }
2296
2297 let resize = PlacementResize {
2298 offset_x: *offset_x,
2299 offset_y: *offset_y,
2300 width: *width,
2301 height: *height,
2302 };
2303 let existing_overlay = chart
2304 .position
2305 .as_ref()
2306 .and_then(|position| position.overlay_position.as_ref());
2307 let (position, fields, sheet_id) = overlay_position_update(
2308 workbook,
2309 existing_overlay,
2310 sheet.as_deref(),
2311 anchor.as_deref(),
2312 resize,
2313 )?;
2314
2315 Ok(build_move(
2316 workbook,
2317 chart_id,
2318 "chart",
2319 position,
2320 fields,
2321 sheet_id,
2322 anchor.is_some(),
2323 resize,
2324 before,
2325 ))
2326}
2327
2328fn build_move_slicer(
2329 workbook: &Spreadsheet,
2330 verb: &EmbeddedObjectVerb,
2331 slicer_id: i64,
2332) -> Result<Plan, EmbeddedObjectResult> {
2333 let EmbeddedObjectVerb::MoveSlicer {
2334 sheet,
2335 anchor,
2336 offset_x,
2337 offset_y,
2338 width,
2339 height,
2340 ..
2341 } = verb
2342 else {
2343 unreachable!("build_move_slicer is only ever called for MoveSlicer") };
2345
2346 let (host_sheet, slicer) = find_slicer_or_refuse(workbook, slicer_id)?;
2347 let before = summarise_slicer(host_sheet, slicer);
2348
2349 let resize = PlacementResize {
2350 offset_x: *offset_x,
2351 offset_y: *offset_y,
2352 width: *width,
2353 height: *height,
2354 };
2355 let existing_overlay = slicer
2356 .position
2357 .as_ref()
2358 .and_then(|position| position.overlay_position.as_ref());
2359 let (position, fields, sheet_id) = overlay_position_update(
2360 workbook,
2361 existing_overlay,
2362 sheet.as_deref(),
2363 anchor.as_deref(),
2364 resize,
2365 )?;
2366
2367 Ok(build_move(
2368 workbook,
2369 slicer_id,
2370 "slicer",
2371 position,
2372 fields,
2373 sheet_id,
2374 anchor.is_some(),
2375 resize,
2376 before,
2377 ))
2378}
2379
2380fn build_update_chart_border(
2383 workbook: &Spreadsheet,
2384 verb: &EmbeddedObjectVerb,
2385 chart_id: i64,
2386) -> Result<Plan, EmbeddedObjectResult> {
2387 let EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } = verb else {
2388 unreachable!("only ever called for UpdateChartBorder") };
2390
2391 let (sheet, chart) = find_chart_or_refuse(workbook, chart_id)?;
2392 let before = summarise_chart(sheet, chart);
2393 let sheet_id = sheet.sheet_id();
2394
2395 let (border, summary) = if *clear {
2396 (
2397 EmbeddedObjectBorder::default(),
2398 format!("clear chart {chart_id} border"),
2399 )
2400 } else {
2401 let Some(color) = color else {
2402 unreachable!("validate_verb refuses neither --color nor --clear") };
2404 let rgb_color = parse_hex_color(color).map_err(invalid)?;
2405 (
2406 EmbeddedObjectBorder {
2407 color_style: Some(ColorStyle::rgb(rgb_color)),
2408 },
2409 format!("set chart {chart_id} border to {color}"),
2410 )
2411 };
2412
2413 Ok(Plan {
2414 request: BatchUpdateRequestItem::UpdateEmbeddedObjectBorder(
2415 UpdateEmbeddedObjectBorderRequest {
2416 object_id: chart_id,
2417 border,
2418 fields: "colorStyle".to_string(),
2419 },
2420 ),
2421 summary,
2422 sheet_id,
2423 before,
2424 existing_id: Some(chart_id),
2425 })
2426}
2427
2428#[must_use]
2437pub fn charts_from_workbook(workbook: &Spreadsheet) -> Vec<EmbeddedObjectSummary> {
2438 workbook
2439 .sheets
2440 .iter()
2441 .flat_map(|sheet| {
2442 sheet
2443 .charts
2444 .iter()
2445 .filter_map(move |chart| summarise_chart(sheet, chart))
2446 })
2447 .collect()
2448}
2449
2450#[must_use]
2453pub fn slicers_from_workbook(workbook: &Spreadsheet) -> Vec<EmbeddedObjectSummary> {
2454 workbook
2455 .sheets
2456 .iter()
2457 .flat_map(|sheet| {
2458 sheet
2459 .slicers
2460 .iter()
2461 .filter_map(move |slicer| summarise_slicer(sheet, slicer))
2462 })
2463 .collect()
2464}
2465
2466fn added_object_id(response: &BatchUpdateResponse) -> Option<i64> {
2469 response.replies.iter().find_map(|reply| {
2470 reply
2471 .add_chart
2472 .as_ref()
2473 .and_then(|r| r.chart.as_ref())
2474 .and_then(|c| c.chart_id)
2475 .or_else(|| {
2476 reply
2477 .add_slicer
2478 .as_ref()
2479 .and_then(|r| r.slicer.as_ref())
2480 .and_then(|s| s.slicer_id)
2481 })
2482 })
2483}
2484
2485fn record_attempt(
2486 outcome: &EmbeddedObjectOutcome,
2487 opts: &EmbeddedObjectOptions,
2488 duration: Duration,
2489) {
2490 let error = match &outcome.result {
2491 EmbeddedObjectResult::Failed { detail } => Some(detail.clone()),
2492 _ => None,
2493 };
2494 let decided_by = match &outcome.result {
2495 EmbeddedObjectResult::Blocked { decided_by } => decided_by.as_ref(),
2496 _ => None,
2497 };
2498 let decided_by = write_gate::decided_by_log_fields(decided_by);
2499 let (embedded_object_id, fields_changed) = match &outcome.result {
2500 EmbeddedObjectResult::Changed {
2501 object_id,
2502 summary,
2503 object,
2504 } => {
2505 let fields_changed = object.as_deref().map_or_else(
2511 || summary.clone(),
2512 |object| format!("{summary} ({})", object_preview(object)),
2513 );
2514 (*object_id, Some(fields_changed))
2515 }
2516 _ => (None, None),
2517 };
2518
2519 request_log::record_drive_mutation(DriveMutationOutcome {
2520 operation: opts.verb.log_operation(),
2521 file_id: outcome.spreadsheet_id.clone(),
2522 file_name: outcome.file_name.clone().unwrap_or_default(),
2523 status: outcome.result.log_status().to_string(),
2524 resolved_folder_id: outcome.resolved_folder_id.clone(),
2525 decided_by_folder_id: decided_by.folder_id,
2526 decided_by_depth: decided_by.depth,
2527 decided_by_file_id: decided_by.file_id,
2528 sheet_id: outcome.sheet_id,
2529 embedded_object_id,
2530 fields_changed,
2531 error,
2532 duration,
2533 ..Default::default()
2534 });
2535}
2536
2537fn object_preview(object: &EmbeddedObjectSummary) -> String {
2541 let kind_detail = object
2542 .chart_type
2543 .as_deref()
2544 .map_or_else(|| object.kind.clone(), |t| format!("{} ({t})", object.kind));
2545 let title = object
2546 .title
2547 .as_deref()
2548 .map_or_else(String::new, |t| format!(" '{t}'"));
2549 let border = object
2550 .border
2551 .as_deref()
2552 .map_or_else(String::new, |b| format!(", border {b}"));
2553 format!(
2554 "id {}: {kind_detail}{title}, anchored {}{border}",
2555 object.object_id, object.position
2556 )
2557}
2558
2559fn describe_object(object: &EmbeddedObjectSummary) -> String {
2560 format!(" {}", object_preview(object))
2561}
2562
2563#[must_use]
2565pub fn describe(outcome: &EmbeddedObjectOutcome) -> String {
2566 describe_lines(outcome).join("\n")
2567}
2568
2569#[must_use]
2572pub fn describe_lines(outcome: &EmbeddedObjectOutcome) -> Vec<String> {
2573 let verb = &outcome.verb;
2574 let book = outcome.file_name.as_deref().map_or_else(
2575 || format!("'{}'", outcome.spreadsheet_id),
2576 |n| format!("'{n}'"),
2577 );
2578 match &outcome.result {
2579 EmbeddedObjectResult::WouldChange { summary, object } => {
2580 let mut lines = vec![format!("Would {summary} in {book}")];
2581 if let Some(object) = object {
2582 lines.push(describe_object(object));
2583 }
2584 lines
2585 }
2586 EmbeddedObjectResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
2587 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
2588 `drive sheets {}` only works on spreadsheets",
2589 verb.label()
2590 )],
2591 EmbeddedObjectResult::RefusedShortcut => vec![format!(
2592 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
2593 verb.label()
2594 )],
2595 EmbeddedObjectResult::RefusedNoVisibleParents => vec![format!(
2596 "Refused: {book} has no parent folder visible to this account, so no folder \
2597 rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
2598 id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
2599 )],
2600 EmbeddedObjectResult::RefusedSheetNotFound { title, available } => {
2601 vec![grid_range::no_sheet_refusal(&book, title, available)]
2602 }
2603 EmbeddedObjectResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
2604 EmbeddedObjectResult::RefusedObjectNotFound { object_id } => vec![format!(
2605 "Refused: {book} has no chart or slicer with id {object_id}; run `drive sheets \
2606 list-charts`/`list-slicers` to see what exists"
2607 )],
2608 EmbeddedObjectResult::RefusedWrongObjectKind {
2609 object_id,
2610 expected,
2611 found,
2612 } => vec![format!(
2613 "Refused: id {object_id} in {book} is a {found}, not a {expected}; run `drive \
2614 sheets list-{found}s` to see what exists"
2615 )],
2616 EmbeddedObjectResult::RefusedUnsupportedChart { chart_id, detail } => {
2617 vec![format!("Refused: chart {chart_id} in {book}: {detail}")]
2618 }
2619 EmbeddedObjectResult::Blocked { decided_by } => vec![match decided_by {
2620 Some(rule) => format!(
2621 "Blocked: {} on {book} refused by rule on {} {}{}",
2622 verb.label(),
2623 rule.kind_label(),
2624 rule.id(),
2625 rule.depth_suffix()
2626 ),
2627 None => format!(
2628 "Blocked: {} on {book} refused by default policy (no matching rule for \
2629 sheets-structure)",
2630 verb.label()
2631 ),
2632 }],
2633 EmbeddedObjectResult::RefusedNoLease => LeaseGateRefusal::NoLease
2634 .describe_line(&outcome.spreadsheet_id, &book)
2635 .into_iter()
2636 .collect(),
2637 EmbeddedObjectResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
2638 .describe_line(&outcome.spreadsheet_id, &book)
2639 .into_iter()
2640 .collect(),
2641 EmbeddedObjectResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
2642 .describe_line(&outcome.spreadsheet_id, &book)
2643 .into_iter()
2644 .collect(),
2645 EmbeddedObjectResult::RefusedLeaseStale => LeaseGateRefusal::Stale
2646 .describe_line(&outcome.spreadsheet_id, &book)
2647 .into_iter()
2648 .collect(),
2649 EmbeddedObjectResult::Changed {
2650 summary,
2651 object_id,
2652 object,
2653 } => {
2654 let id = object_id.map_or_else(String::new, |id| format!(" (id {id})"));
2655 let mut lines = vec![format!("Applied: {summary}{id} in {book}")];
2656 if let Some(object) = object {
2657 lines.push(describe_object(object));
2658 }
2659 lines
2660 }
2661 EmbeddedObjectResult::Failed { detail } => vec![format!("Failed: {detail}")],
2662 }
2663}
2664
2665#[cfg(test)]
2666#[allow(clippy::unwrap_used, clippy::expect_used)]
2667mod tests {
2668 use super::*;
2669 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
2670 use crate::drive::sheets::client::SHEETS_API_URL;
2671 use crate::drive::sheets::types::Sheet;
2672 use crate::drive::test_support::seed_lease;
2673 use crate::test_support::env::MapEnv;
2674 use crate::utils::secret::Secret;
2675 use std::collections::HashSet;
2676
2677 #[test]
2680 fn parse_chart_type_accepts_every_basic_type_and_pie() {
2681 for (raw, expect_basic) in [
2682 ("column", Some("COLUMN")),
2683 ("bar", Some("BAR")),
2684 ("line", Some("LINE")),
2685 ("area", Some("AREA")),
2686 ("scatter", Some("SCATTER")),
2687 ("PIE", None),
2688 ] {
2689 let kind = parse_chart_type(raw).unwrap();
2690 match (&kind, expect_basic) {
2691 (ChartKind::Basic(t), Some(expected)) => assert_eq!(*t, expected),
2692 (ChartKind::Pie, None) => {}
2693 _ => panic!("unexpected parse for {raw:?}: {kind:?}"), }
2695 }
2696 }
2697
2698 #[test]
2699 fn parse_chart_type_rejects_combo_and_unknown() {
2700 let err = parse_chart_type("combo").unwrap_err();
2701 assert!(err.contains("not a supported chart type"), "{err}");
2702 let err = parse_chart_type("bogus").unwrap_err();
2703 assert!(
2704 err.contains("column, bar, line, area, scatter, pie"),
2705 "{err}"
2706 );
2707 }
2708
2709 #[test]
2710 fn parse_legend_accepts_every_position() {
2711 assert_eq!(parse_legend("bottom").unwrap(), "BOTTOM_LEGEND");
2712 assert_eq!(parse_legend("TOP").unwrap(), "TOP_LEGEND");
2713 assert_eq!(parse_legend("left").unwrap(), "LEFT_LEGEND");
2714 assert_eq!(parse_legend("right").unwrap(), "RIGHT_LEGEND");
2715 assert_eq!(parse_legend("none").unwrap(), "NO_LEGEND");
2716 }
2717
2718 #[test]
2719 fn parse_legend_rejects_unknown() {
2720 let err = parse_legend("middle").unwrap_err();
2721 assert!(err.contains("not a supported legend position"), "{err}");
2722 }
2723
2724 #[test]
2725 fn parse_stacked_accepts_every_mode() {
2726 assert_eq!(parse_stacked("none").unwrap(), "NOT_STACKED");
2727 assert_eq!(parse_stacked("stacked").unwrap(), "STACKED");
2728 assert_eq!(parse_stacked("PERCENT").unwrap(), "PERCENT_STACKED");
2729 }
2730
2731 #[test]
2732 fn parse_stacked_rejects_unknown() {
2733 let err = parse_stacked("half").unwrap_err();
2734 assert!(err.contains("not a supported stacking mode"), "{err}");
2735 }
2736
2737 #[test]
2740 fn axis_list_builds_only_the_named_axes() {
2741 assert!(axis_list(None, None).is_empty());
2742 let axis = axis_list(Some("Quarter"), None);
2743 assert_eq!(axis.len(), 1);
2744 assert_eq!(axis[0].position, "BOTTOM_AXIS");
2745 assert_eq!(axis[0].title.as_deref(), Some("Quarter"));
2746 let axis = axis_list(Some("Quarter"), Some("Revenue"));
2747 assert_eq!(axis.len(), 2);
2748 assert_eq!(axis[1].position, "LEFT_AXIS");
2749 }
2750
2751 #[test]
2752 fn upsert_axis_title_replaces_an_existing_entry_in_place() {
2753 let mut axis = vec![
2754 BasicChartAxis {
2755 position: "BOTTOM_AXIS".to_string(),
2756 title: Some("Old".to_string()),
2757 extra: BTreeMap::new(),
2758 },
2759 BasicChartAxis {
2760 position: "LEFT_AXIS".to_string(),
2761 title: None,
2762 extra: BTreeMap::new(),
2763 },
2764 ];
2765 upsert_axis_title(&mut axis, "BOTTOM_AXIS", "New");
2766 assert_eq!(axis.len(), 2);
2767 assert_eq!(axis[0].title.as_deref(), Some("New"));
2768 assert_eq!(axis[1].title, None);
2769 }
2770
2771 #[test]
2772 fn upsert_axis_title_appends_when_absent() {
2773 let mut axis = Vec::new();
2774 upsert_axis_title(&mut axis, "LEFT_AXIS", "Revenue");
2775 assert_eq!(axis.len(), 1);
2776 assert_eq!(axis[0].position, "LEFT_AXIS");
2777 }
2778
2779 #[test]
2782 fn every_verb_has_a_distinct_log_operation_and_label() {
2783 let verbs = [
2784 EmbeddedObjectVerb::AddChart {
2785 chart_type: "column".to_string(),
2786 domain: String::new(),
2787 series: vec![String::new()],
2788 sheet: None,
2789 title: None,
2790 subtitle: None,
2791 legend: None,
2792 stacked: None,
2793 header_count: None,
2794 horizontal_axis_title: None,
2795 vertical_axis_title: None,
2796 pie_hole: None,
2797 anchor: Some(String::new()),
2798 offset_x: None,
2799 offset_y: None,
2800 width: None,
2801 height: None,
2802 new_sheet: false,
2803 },
2804 EmbeddedObjectVerb::UpdateChart {
2805 chart_id: 1,
2806 chart_type: None,
2807 domain: None,
2808 series: Vec::new(),
2809 sheet: None,
2810 title: None,
2811 subtitle: None,
2812 legend: None,
2813 stacked: None,
2814 header_count: None,
2815 horizontal_axis_title: None,
2816 vertical_axis_title: None,
2817 pie_hole: None,
2818 },
2819 EmbeddedObjectVerb::DeleteChart { chart_id: 1 },
2820 EmbeddedObjectVerb::AddSlicer {
2821 sheet: None,
2822 range: String::new(),
2823 column: 0,
2824 hide_values: Vec::new(),
2825 title: None,
2826 apply_to_pivot_tables: None,
2827 anchor: String::new(),
2828 offset_x: None,
2829 offset_y: None,
2830 width: None,
2831 height: None,
2832 },
2833 EmbeddedObjectVerb::UpdateSlicer {
2834 slicer_id: 1,
2835 sheet: None,
2836 range: None,
2837 column: None,
2838 hide_values: Vec::new(),
2839 clear_criteria: false,
2840 title: None,
2841 apply_to_pivot_tables: None,
2842 },
2843 EmbeddedObjectVerb::DeleteSlicer { slicer_id: 1 },
2844 EmbeddedObjectVerb::MoveChart {
2845 chart_id: 1,
2846 sheet: None,
2847 anchor: Some(String::new()),
2848 offset_x: None,
2849 offset_y: None,
2850 width: None,
2851 height: None,
2852 new_sheet: false,
2853 },
2854 EmbeddedObjectVerb::MoveSlicer {
2855 slicer_id: 1,
2856 sheet: None,
2857 anchor: Some(String::new()),
2858 offset_x: None,
2859 offset_y: None,
2860 width: None,
2861 height: None,
2862 },
2863 EmbeddedObjectVerb::UpdateChartBorder {
2864 chart_id: 1,
2865 color: Some(String::new()),
2866 clear: false,
2867 },
2868 ];
2869 let ops: HashSet<&str> = verbs
2870 .iter()
2871 .map(EmbeddedObjectVerb::log_operation)
2872 .collect();
2873 let labels: HashSet<&str> = verbs.iter().map(EmbeddedObjectVerb::label).collect();
2874 assert_eq!(ops.len(), verbs.len());
2875 assert_eq!(labels.len(), verbs.len());
2876 }
2877
2878 fn add_chart_verb() -> EmbeddedObjectVerb {
2881 EmbeddedObjectVerb::AddChart {
2882 chart_type: "column".to_string(),
2883 domain: "A1:A10".to_string(),
2884 series: vec!["B1:B10".to_string()],
2885 sheet: Some("Sheet1".to_string()),
2886 title: None,
2887 subtitle: None,
2888 legend: None,
2889 stacked: None,
2890 header_count: None,
2891 horizontal_axis_title: None,
2892 vertical_axis_title: None,
2893 pie_hole: None,
2894 anchor: Some("E2".to_string()),
2895 offset_x: None,
2896 offset_y: None,
2897 width: None,
2898 height: None,
2899 new_sheet: false,
2900 }
2901 }
2902
2903 #[test]
2904 fn validate_verb_rejects_add_chart_with_no_series() {
2905 let mut verb = add_chart_verb();
2906 let EmbeddedObjectVerb::AddChart { series, .. } = &mut verb else {
2907 unreachable!() };
2909 series.clear();
2910 let err = validate_verb(&verb).unwrap_err();
2911 assert!(err.contains("at least one --series"), "{err}");
2912 }
2913
2914 #[test]
2915 fn validate_verb_rejects_add_chart_with_neither_anchor_nor_new_sheet() {
2916 let mut verb = add_chart_verb();
2917 let EmbeddedObjectVerb::AddChart { anchor, .. } = &mut verb else {
2918 unreachable!() };
2920 *anchor = None;
2921 let err = validate_verb(&verb).unwrap_err();
2922 assert!(err.contains("--anchor or --new-sheet"), "{err}");
2923 }
2924
2925 #[test]
2926 fn validate_verb_rejects_new_sheet_combined_with_anchor() {
2927 let mut verb = add_chart_verb();
2928 let EmbeddedObjectVerb::AddChart { new_sheet, .. } = &mut verb else {
2929 unreachable!() };
2931 *new_sheet = true;
2932 let err = validate_verb(&verb).unwrap_err();
2933 assert!(err.contains("--new-sheet cannot be combined"), "{err}");
2934 }
2935
2936 #[test]
2937 fn validate_verb_accepts_new_sheet_alone() {
2938 let mut verb = add_chart_verb();
2939 let EmbeddedObjectVerb::AddChart {
2940 anchor, new_sheet, ..
2941 } = &mut verb
2942 else {
2943 unreachable!() };
2945 *anchor = None;
2946 *new_sheet = true;
2947 assert!(validate_verb(&verb).is_ok());
2948 }
2949
2950 #[test]
2951 fn validate_verb_rejects_update_chart_with_nothing_to_change() {
2952 let verb = EmbeddedObjectVerb::UpdateChart {
2953 chart_id: 1,
2954 chart_type: None,
2955 domain: None,
2956 series: Vec::new(),
2957 sheet: None,
2958 title: None,
2959 subtitle: None,
2960 legend: None,
2961 stacked: None,
2962 header_count: None,
2963 horizontal_axis_title: None,
2964 vertical_axis_title: None,
2965 pie_hole: None,
2966 };
2967 let err = validate_verb(&verb).unwrap_err();
2968 assert!(err.contains("nothing to change"), "{err}");
2969 }
2970
2971 #[test]
2972 fn validate_verb_accepts_update_chart_domain_alone() {
2973 let verb = EmbeddedObjectVerb::UpdateChart {
2978 chart_id: 1,
2979 chart_type: None,
2980 domain: Some("A1:A10".to_string()),
2981 series: Vec::new(),
2982 sheet: None,
2983 title: None,
2984 subtitle: None,
2985 legend: None,
2986 stacked: None,
2987 header_count: None,
2988 horizontal_axis_title: None,
2989 vertical_axis_title: None,
2990 pie_hole: None,
2991 };
2992 assert!(validate_verb(&verb).is_ok());
2993 }
2994
2995 #[test]
2996 fn validate_verb_accepts_update_chart_series_alone() {
2997 let verb = EmbeddedObjectVerb::UpdateChart {
3000 chart_id: 1,
3001 chart_type: None,
3002 domain: None,
3003 series: vec!["B1:B10".to_string()],
3004 sheet: None,
3005 title: None,
3006 subtitle: None,
3007 legend: None,
3008 stacked: None,
3009 header_count: None,
3010 horizontal_axis_title: None,
3011 vertical_axis_title: None,
3012 pie_hole: None,
3013 };
3014 assert!(validate_verb(&verb).is_ok());
3015 }
3016
3017 #[test]
3018 fn validate_verb_accepts_update_chart_domain_and_series_together() {
3019 let verb = EmbeddedObjectVerb::UpdateChart {
3020 chart_id: 1,
3021 chart_type: None,
3022 domain: Some("A1:A10".to_string()),
3023 series: vec!["B1:B10".to_string()],
3024 sheet: None,
3025 title: None,
3026 subtitle: None,
3027 legend: None,
3028 stacked: None,
3029 header_count: None,
3030 horizontal_axis_title: None,
3031 vertical_axis_title: None,
3032 pie_hole: None,
3033 };
3034 assert!(validate_verb(&verb).is_ok());
3035 }
3036
3037 #[test]
3038 fn validate_verb_rejects_add_chart_pie_hole_out_of_range() {
3039 let mut verb = add_chart_verb();
3040 let EmbeddedObjectVerb::AddChart {
3041 chart_type,
3042 pie_hole,
3043 ..
3044 } = &mut verb
3045 else {
3046 unreachable!() };
3048 *chart_type = "pie".to_string();
3052 *pie_hole = Some(1.5);
3053 let err = validate_verb(&verb).unwrap_err();
3054 assert!(
3055 err.contains("--pie-hole must be between 0.0 and 1.0"),
3056 "{err}"
3057 );
3058 }
3059
3060 #[test]
3061 fn validate_verb_accepts_add_chart_pie_hole_in_range() {
3062 let mut verb = add_chart_verb();
3063 let EmbeddedObjectVerb::AddChart {
3064 chart_type,
3065 pie_hole,
3066 ..
3067 } = &mut verb
3068 else {
3069 unreachable!() };
3071 *chart_type = "pie".to_string();
3072 *pie_hole = Some(0.5);
3073 assert!(validate_verb(&verb).is_ok());
3074 }
3075
3076 #[test]
3077 fn validate_verb_accepts_add_chart_pie_hole_at_each_inclusive_boundary() {
3078 for boundary in [0.0, 1.0] {
3079 let mut verb = add_chart_verb();
3080 let EmbeddedObjectVerb::AddChart {
3081 chart_type,
3082 pie_hole,
3083 ..
3084 } = &mut verb
3085 else {
3086 unreachable!() };
3088 *chart_type = "pie".to_string();
3089 *pie_hole = Some(boundary);
3090 assert!(
3091 validate_verb(&verb).is_ok(),
3092 "{boundary} should be in range"
3093 );
3094 }
3095 }
3096
3097 #[test]
3098 fn validate_verb_rejects_add_chart_pie_hole_nan_and_infinite() {
3099 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
3100 let mut verb = add_chart_verb();
3101 let EmbeddedObjectVerb::AddChart {
3102 chart_type,
3103 pie_hole,
3104 ..
3105 } = &mut verb
3106 else {
3107 unreachable!() };
3109 *chart_type = "pie".to_string();
3110 *pie_hole = Some(bad);
3111 let err = validate_verb(&verb).unwrap_err();
3112 assert!(
3113 err.contains("--pie-hole must be between 0.0 and 1.0"),
3114 "{bad}: {err}"
3115 );
3116 }
3117 }
3118
3119 #[test]
3120 fn validate_verb_rejects_add_chart_pie_hole_on_a_non_pie_chart_before_the_range_check() {
3121 let mut verb = add_chart_verb();
3127 let EmbeddedObjectVerb::AddChart { pie_hole, .. } = &mut verb else {
3128 unreachable!() };
3130 *pie_hole = Some(1.5);
3131 let err = validate_verb(&verb).unwrap_err();
3132 assert!(err.contains("only applies to a pie chart"), "{err}");
3133 assert!(!err.contains("must be between"), "{err}");
3134 }
3135
3136 #[test]
3137 fn validate_verb_rejects_add_chart_pie_hole_on_a_non_pie_chart_even_in_range() {
3138 let mut verb = add_chart_verb();
3139 let EmbeddedObjectVerb::AddChart { pie_hole, .. } = &mut verb else {
3140 unreachable!() };
3142 *pie_hole = Some(0.5);
3143 let err = validate_verb(&verb).unwrap_err();
3144 assert!(err.contains("only applies to a pie chart"), "{err}");
3145 }
3146
3147 #[test]
3148 fn validate_verb_rejects_update_chart_pie_hole_out_of_range() {
3149 let verb = EmbeddedObjectVerb::UpdateChart {
3150 chart_id: 1,
3151 chart_type: None,
3152 domain: None,
3153 series: Vec::new(),
3154 sheet: None,
3155 title: None,
3156 subtitle: None,
3157 legend: None,
3158 stacked: None,
3159 header_count: None,
3160 horizontal_axis_title: None,
3161 vertical_axis_title: None,
3162 pie_hole: Some(-0.1),
3163 };
3164 let err = validate_verb(&verb).unwrap_err();
3165 assert!(
3166 err.contains("--pie-hole must be between 0.0 and 1.0"),
3167 "{err}"
3168 );
3169 }
3170
3171 #[test]
3172 fn validate_verb_rejects_update_slicer_with_nothing_to_change() {
3173 let verb = EmbeddedObjectVerb::UpdateSlicer {
3174 slicer_id: 1,
3175 sheet: None,
3176 range: None,
3177 column: None,
3178 hide_values: Vec::new(),
3179 clear_criteria: false,
3180 title: None,
3181 apply_to_pivot_tables: None,
3182 };
3183 let err = validate_verb(&verb).unwrap_err();
3184 assert!(err.contains("nothing to change"), "{err}");
3185 }
3186
3187 #[test]
3188 fn validate_verb_accepts_update_slicer_with_only_clear_criteria() {
3189 let verb = EmbeddedObjectVerb::UpdateSlicer {
3190 slicer_id: 1,
3191 sheet: None,
3192 range: None,
3193 column: None,
3194 hide_values: Vec::new(),
3195 clear_criteria: true,
3196 title: None,
3197 apply_to_pivot_tables: None,
3198 };
3199 assert!(validate_verb(&verb).is_ok());
3200 }
3201
3202 #[test]
3203 fn validate_verb_rejects_update_slicer_clear_criteria_with_hide_values() {
3204 let verb = EmbeddedObjectVerb::UpdateSlicer {
3205 slicer_id: 1,
3206 sheet: None,
3207 range: None,
3208 column: None,
3209 hide_values: vec!["foo".to_string()],
3210 clear_criteria: true,
3211 title: None,
3212 apply_to_pivot_tables: None,
3213 };
3214 let err = validate_verb(&verb).unwrap_err();
3215 assert!(
3216 err.contains("--clear-criteria and --hide-values are mutually exclusive"),
3217 "{err}"
3218 );
3219 }
3220
3221 #[test]
3224 fn existing_chart_kind_accepts_a_supported_basic_type() {
3225 let spec = ChartSpec {
3226 basic_chart: Some(BasicChartSpec {
3227 chart_type: "COLUMN".to_string(),
3228 ..Default::default()
3229 }),
3230 ..Default::default()
3231 };
3232 assert!(matches!(
3233 existing_chart_kind(&spec),
3234 Ok(ExistingChartKind::Basic)
3235 ));
3236 }
3237
3238 #[test]
3239 fn existing_chart_kind_accepts_pie() {
3240 let spec = ChartSpec {
3241 pie_chart: Some(PieChartSpec::default()),
3242 ..Default::default()
3243 };
3244 assert!(matches!(
3245 existing_chart_kind(&spec),
3246 Ok(ExistingChartKind::Pie)
3247 ));
3248 }
3249
3250 #[test]
3251 fn existing_chart_kind_refuses_an_unsupported_basic_type() {
3252 let spec = ChartSpec {
3253 basic_chart: Some(BasicChartSpec {
3254 chart_type: "COMBO".to_string(),
3255 ..Default::default()
3256 }),
3257 ..Default::default()
3258 };
3259 let err = existing_chart_kind(&spec).unwrap_err();
3260 assert_eq!(err, "COMBO");
3261 }
3262
3263 #[test]
3264 fn existing_chart_kind_refuses_neither_basic_nor_pie() {
3265 let mut extra = BTreeMap::new();
3266 extra.insert("histogramChart".to_string(), serde_json::json!({}));
3267 let spec = ChartSpec {
3268 extra,
3269 ..Default::default()
3270 };
3271 let err = existing_chart_kind(&spec).unwrap_err();
3272 assert_eq!(err, "histogramChart");
3273 }
3274
3275 #[test]
3276 fn existing_chart_kind_names_an_empty_spec_unknown() {
3277 let err = existing_chart_kind(&ChartSpec::default()).unwrap_err();
3280 assert_eq!(err, "unknown");
3281 }
3282
3283 fn workbook_with_sheet(sheet: Sheet) -> Spreadsheet {
3286 Spreadsheet {
3287 sheets: vec![sheet],
3288 ..Default::default()
3289 }
3290 }
3291
3292 fn basic_chart_sheet(chart_id: i64, chart_type: &str) -> Sheet {
3293 let mut extra = BTreeMap::new();
3294 extra.insert("maximized".to_string(), serde_json::json!(true));
3295 Sheet {
3296 properties: Some(crate::drive::sheets::types::SheetProperties {
3297 sheet_id: Some(0),
3298 title: "Q1".to_string(),
3299 ..Default::default()
3300 }),
3301 charts: vec![EmbeddedChart {
3302 chart_id: Some(chart_id),
3303 spec: Some(ChartSpec {
3304 title: Some("Old title".to_string()),
3305 basic_chart: Some(BasicChartSpec {
3306 chart_type: chart_type.to_string(),
3307 domains: vec![BasicChartDomain {
3308 domain: chart_data(GridRange {
3309 sheet_id: 0,
3310 start_row_index: Some(0),
3311 end_row_index: Some(10),
3312 start_column_index: Some(0),
3313 end_column_index: Some(1),
3314 }),
3315 extra: BTreeMap::new(),
3316 }],
3317 ..Default::default()
3318 }),
3319 extra,
3320 ..Default::default()
3321 }),
3322 position: Some(EmbeddedObjectPosition {
3323 overlay_position: Some(OverlayPosition {
3324 anchor_cell: GridCoordinate {
3325 sheet_id: 0,
3326 row_index: 1,
3327 column_index: 4,
3328 },
3329 ..Default::default()
3330 }),
3331 ..Default::default()
3332 }),
3333 border: None,
3334 }],
3335 ..Default::default()
3336 }
3337 }
3338
3339 fn update_chart_verb(chart_id: i64) -> EmbeddedObjectVerb {
3340 EmbeddedObjectVerb::UpdateChart {
3341 chart_id,
3342 chart_type: None,
3343 domain: None,
3344 series: Vec::new(),
3345 sheet: None,
3346 title: Some("New title".to_string()),
3347 subtitle: None,
3348 legend: None,
3349 stacked: None,
3350 header_count: None,
3351 horizontal_axis_title: None,
3352 vertical_axis_title: None,
3353 pie_hole: None,
3354 }
3355 }
3356
3357 #[test]
3358 fn merge_chart_spec_preserves_unmodelled_extra_fields() {
3359 let sheet = basic_chart_sheet(1, "COLUMN");
3360 let workbook = workbook_with_sheet(sheet);
3361 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3362 let verb = update_chart_verb(1);
3363 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3364 assert_eq!(spec.extra.get("maximized"), Some(&serde_json::json!(true)));
3365 assert_eq!(spec.title.as_deref(), Some("New title"));
3366 assert!(summary.contains("title"));
3367 }
3368
3369 #[test]
3370 fn merge_chart_spec_refuses_an_unsupported_existing_kind() {
3371 let sheet = basic_chart_sheet(1, "COMBO");
3372 let workbook = workbook_with_sheet(sheet);
3373 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3374 let verb = update_chart_verb(1);
3375 let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3376 assert!(matches!(
3377 err,
3378 EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
3379 ));
3380 }
3381
3382 #[test]
3383 fn merge_chart_spec_refuses_a_basic_to_pie_switch() {
3384 let sheet = basic_chart_sheet(1, "COLUMN");
3385 let workbook = workbook_with_sheet(sheet);
3386 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3387 let mut verb = update_chart_verb(1);
3388 let EmbeddedObjectVerb::UpdateChart { chart_type, .. } = &mut verb else {
3389 unreachable!() };
3391 *chart_type = Some("pie".to_string());
3392 let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3393 assert!(matches!(
3394 err,
3395 EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
3396 ));
3397 }
3398
3399 #[test]
3400 fn merge_chart_spec_allows_changing_type_within_the_basic_family() {
3401 let sheet = basic_chart_sheet(1, "COLUMN");
3402 let workbook = workbook_with_sheet(sheet);
3403 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3404 let mut verb = update_chart_verb(1);
3405 let EmbeddedObjectVerb::UpdateChart { chart_type, .. } = &mut verb else {
3406 unreachable!() };
3408 *chart_type = Some("bar".to_string());
3409 let (spec, _) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3410 assert_eq!(spec.basic_chart.unwrap().chart_type, "BAR");
3411 }
3412
3413 #[test]
3414 fn merge_chart_spec_upserts_an_axis_title_on_a_basic_chart() {
3415 let sheet = basic_chart_sheet(1, "COLUMN");
3416 let workbook = workbook_with_sheet(sheet);
3417 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3418 let mut verb = update_chart_verb(1);
3419 let EmbeddedObjectVerb::UpdateChart {
3420 horizontal_axis_title,
3421 ..
3422 } = &mut verb
3423 else {
3424 unreachable!() };
3426 *horizontal_axis_title = Some("Quarter".to_string());
3427 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3428 let axis = spec.basic_chart.unwrap().axis;
3429 assert_eq!(axis.len(), 1);
3430 assert_eq!(axis[0].position, "BOTTOM_AXIS");
3431 assert_eq!(axis[0].title.as_deref(), Some("Quarter"));
3432 assert!(summary.contains("horizontal-axis-title"), "{summary}");
3433 }
3434
3435 fn pie_chart_sheet(chart_id: i64) -> Sheet {
3436 Sheet {
3437 properties: Some(crate::drive::sheets::types::SheetProperties {
3438 sheet_id: Some(0),
3439 title: "Q1".to_string(),
3440 ..Default::default()
3441 }),
3442 charts: vec![EmbeddedChart {
3443 chart_id: Some(chart_id),
3444 spec: Some(ChartSpec {
3445 pie_chart: Some(PieChartSpec {
3446 domain: chart_data(GridRange {
3447 sheet_id: 0,
3448 start_row_index: Some(0),
3449 end_row_index: Some(10),
3450 start_column_index: Some(0),
3451 end_column_index: Some(1),
3452 }),
3453 series: chart_data(GridRange {
3454 sheet_id: 0,
3455 start_row_index: Some(0),
3456 end_row_index: Some(10),
3457 start_column_index: Some(1),
3458 end_column_index: Some(2),
3459 }),
3460 ..Default::default()
3461 }),
3462 ..Default::default()
3463 }),
3464 position: None,
3465 border: None,
3466 }],
3467 ..Default::default()
3468 }
3469 }
3470
3471 #[test]
3472 fn merge_chart_spec_refuses_a_basic_only_flag_on_an_existing_pie_chart() {
3473 let workbook = workbook_with_sheet(pie_chart_sheet(1));
3474 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3475 let mut verb = update_chart_verb(1);
3476 let EmbeddedObjectVerb::UpdateChart { header_count, .. } = &mut verb else {
3477 unreachable!() };
3479 *header_count = Some(1);
3480 let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3481 assert!(matches!(
3482 err,
3483 EmbeddedObjectResult::RefusedInvalidRange { .. }
3484 ));
3485 }
3486
3487 fn basic_chart_sheet_with_series(chart_id: i64) -> Sheet {
3491 let mut sheet = basic_chart_sheet(chart_id, "COLUMN");
3492 let basic = sheet.charts[0]
3493 .spec
3494 .as_mut()
3495 .unwrap()
3496 .basic_chart
3497 .as_mut()
3498 .unwrap();
3499 basic.series = vec![BasicChartSeries {
3500 series: chart_data(GridRange {
3501 sheet_id: 0,
3502 start_row_index: Some(0),
3503 end_row_index: Some(10),
3504 start_column_index: Some(1),
3505 end_column_index: Some(2),
3506 }),
3507 target_axis: None,
3508 extra: BTreeMap::new(),
3509 }];
3510 sheet
3511 }
3512
3513 #[test]
3514 fn merge_chart_spec_updates_series_alone_leaving_domain_untouched() {
3515 let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
3516 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3517 let original_domains = existing.basic_chart.as_ref().unwrap().domains.clone();
3518 let mut verb = update_chart_verb(1);
3519 let EmbeddedObjectVerb::UpdateChart {
3520 title,
3521 sheet,
3522 series,
3523 ..
3524 } = &mut verb
3525 else {
3526 unreachable!() };
3528 *title = None;
3529 *sheet = Some("Q1".to_string());
3530 *series = vec!["C1:C10".to_string()];
3531 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3532 let basic = spec.basic_chart.unwrap();
3533 assert_eq!(
3534 basic.domains, original_domains,
3535 "domain must survive a series-only update"
3536 );
3537 assert_eq!(basic.series.len(), 1);
3538 assert!(summary.contains("series"));
3539 assert!(!summary.contains("domain"));
3540 }
3541
3542 #[test]
3543 fn merge_chart_spec_updates_domain_alone_leaving_series_untouched() {
3544 let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
3545 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3546 let original_series = existing.basic_chart.as_ref().unwrap().series.clone();
3547 let mut verb = update_chart_verb(1);
3548 let EmbeddedObjectVerb::UpdateChart {
3549 title,
3550 sheet,
3551 domain,
3552 ..
3553 } = &mut verb
3554 else {
3555 unreachable!() };
3557 *title = None;
3558 *sheet = Some("Q1".to_string());
3559 *domain = Some("B1:B10".to_string());
3560 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
3561 let basic = spec.basic_chart.unwrap();
3562 assert_eq!(
3563 basic.series, original_series,
3564 "series must survive a domain-only update"
3565 );
3566 assert_eq!(basic.domains.len(), 1);
3567 assert!(summary.contains("domain"));
3568 assert!(!summary.contains("series"));
3569 }
3570
3571 #[test]
3572 fn merge_chart_spec_refuses_pie_hole_on_an_existing_basic_chart() {
3573 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
3574 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
3575 let mut verb = update_chart_verb(1);
3576 let EmbeddedObjectVerb::UpdateChart {
3577 title, pie_hole, ..
3578 } = &mut verb
3579 else {
3580 unreachable!() };
3582 *title = None;
3583 *pie_hole = Some(0.4);
3584 let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
3585 assert!(
3586 matches!(err, EmbeddedObjectResult::RefusedInvalidRange { .. }),
3587 "{err:?}"
3588 );
3589 }
3590
3591 #[test]
3594 fn summarise_chart_reports_overlay_position() {
3595 let sheet = basic_chart_sheet(7, "LINE");
3596 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3597 assert_eq!(summary.object_id, 7);
3598 assert_eq!(summary.kind, "chart");
3599 assert_eq!(summary.chart_type.as_deref(), Some("LINE"));
3600 assert_eq!(summary.title.as_deref(), Some("Old title"));
3601 assert_eq!(summary.position, "Q1!E2");
3602 }
3603
3604 #[test]
3605 fn summarise_chart_reports_own_sheet_for_a_new_sheet_chart() {
3606 let sheet = Sheet {
3607 properties: Some(crate::drive::sheets::types::SheetProperties {
3608 sheet_id: Some(9),
3609 title: "Charts".to_string(),
3610 ..Default::default()
3611 }),
3612 charts: vec![EmbeddedChart {
3613 chart_id: Some(3),
3614 spec: Some(ChartSpec {
3615 pie_chart: Some(PieChartSpec::default()),
3616 ..Default::default()
3617 }),
3618 position: Some(EmbeddedObjectPosition {
3619 sheet_id: Some(9),
3620 new_sheet: Some(true),
3621 ..Default::default()
3622 }),
3623 border: None,
3624 }],
3625 ..Default::default()
3626 };
3627 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3628 assert_eq!(summary.chart_type.as_deref(), Some("PIE"));
3629 assert_eq!(summary.position, "own sheet (id 9)");
3630 }
3631
3632 #[test]
3633 fn summarise_chart_reports_a_non_default_border_colour_as_hex() {
3634 let mut sheet = basic_chart_sheet(7, "LINE");
3635 sheet.charts[0].border = Some(EmbeddedObjectBorder {
3636 color_style: Some(ColorStyle::rgb(parse_hex_color("#4A86E8").unwrap())),
3637 });
3638 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3639 assert_eq!(summary.border.as_deref(), Some("#4A86E8"));
3640 }
3641
3642 #[test]
3643 fn summarise_chart_reports_a_default_border_colour_as_ambiguous() {
3644 let mut sheet = basic_chart_sheet(7, "LINE");
3645 sheet.charts[0].border = Some(EmbeddedObjectBorder {
3646 color_style: Some(ColorStyle::rgb(Color::default())),
3647 });
3648 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3649 assert_eq!(
3650 summary.border.as_deref(),
3651 Some("#000000 (or cleared — indistinguishable on read)")
3652 );
3653 }
3654
3655 #[test]
3656 fn summarise_chart_reports_an_entirely_empty_color_style_as_ambiguous() {
3657 let mut sheet = basic_chart_sheet(7, "LINE");
3662 sheet.charts[0].border = Some(EmbeddedObjectBorder {
3663 color_style: Some(ColorStyle::default()),
3664 });
3665 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3666 assert_eq!(
3667 summary.border.as_deref(),
3668 Some("#000000 (or cleared — indistinguishable on read)")
3669 );
3670 }
3671
3672 #[test]
3673 fn summarise_chart_reports_a_theme_border_colour_by_name() {
3674 let mut sheet = basic_chart_sheet(7, "LINE");
3675 sheet.charts[0].border = Some(EmbeddedObjectBorder {
3676 color_style: Some(ColorStyle::theme("ACCENT1")),
3677 });
3678 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
3679 assert_eq!(summary.border.as_deref(), Some("theme:ACCENT1"));
3680 }
3681
3682 #[test]
3683 fn summarise_slicer_reports_title_and_position() {
3684 let sheet = Sheet {
3685 properties: Some(crate::drive::sheets::types::SheetProperties {
3686 sheet_id: Some(0),
3687 title: "Q1".to_string(),
3688 ..Default::default()
3689 }),
3690 slicers: vec![Slicer {
3691 slicer_id: Some(4),
3692 spec: Some(SlicerSpec {
3693 title: Some("Region".to_string()),
3694 ..Default::default()
3695 }),
3696 position: Some(EmbeddedObjectPosition {
3697 overlay_position: Some(OverlayPosition {
3698 anchor_cell: GridCoordinate {
3699 sheet_id: 0,
3700 row_index: 0,
3701 column_index: 5,
3702 },
3703 ..Default::default()
3704 }),
3705 ..Default::default()
3706 }),
3707 }],
3708 ..Default::default()
3709 };
3710 let summary = summarise_slicer(&sheet, &sheet.slicers[0]).unwrap();
3711 assert_eq!(summary.object_id, 4);
3712 assert_eq!(summary.kind, "slicer");
3713 assert_eq!(summary.chart_type, None);
3714 assert_eq!(summary.title.as_deref(), Some("Region"));
3715 assert_eq!(summary.position, "Q1!F1");
3716 }
3717
3718 #[test]
3721 fn object_preview_names_kind_title_and_position() {
3722 let object = EmbeddedObjectSummary {
3723 object_id: 5,
3724 kind: "chart".to_string(),
3725 chart_type: Some("PIE".to_string()),
3726 title: Some("Sales".to_string()),
3727 position: "Q1!C2".to_string(),
3728 border: None,
3729 };
3730 assert_eq!(
3731 object_preview(&object),
3732 "id 5: chart (PIE) 'Sales', anchored Q1!C2"
3733 );
3734 }
3735
3736 #[test]
3737 fn object_preview_reports_the_chart_border_colour() {
3738 let object = EmbeddedObjectSummary {
3739 object_id: 5,
3740 kind: "chart".to_string(),
3741 chart_type: Some("PIE".to_string()),
3742 title: Some("Sales".to_string()),
3743 position: "Q1!C2".to_string(),
3744 border: Some("#4A86E8".to_string()),
3745 };
3746 assert_eq!(
3747 object_preview(&object),
3748 "id 5: chart (PIE) 'Sales', anchored Q1!C2, border #4A86E8"
3749 );
3750 }
3751
3752 #[test]
3753 fn object_preview_omits_the_chart_type_and_title_a_slicer_lacks() {
3754 let object = EmbeddedObjectSummary {
3755 object_id: 9,
3756 kind: "slicer".to_string(),
3757 chart_type: None,
3758 title: None,
3759 position: "unknown position".to_string(),
3760 border: None,
3761 };
3762 assert_eq!(
3763 object_preview(&object),
3764 "id 9: slicer, anchored unknown position"
3765 );
3766 }
3767
3768 #[test]
3769 fn write_jsonl_emits_one_line_of_json() {
3770 let outcome = EmbeddedObjectOutcome {
3771 spreadsheet_id: "sheet-1".to_string(),
3772 file_name: Some("Budget".to_string()),
3773 resolved_folder_id: None,
3774 sheet_id: None,
3775 verb: EmbeddedObjectVerb::DeleteChart { chart_id: 3 },
3776 result: EmbeddedObjectResult::Changed {
3777 summary: "delete chart 3".to_string(),
3778 object_id: Some(3),
3779 object: None,
3780 },
3781 };
3782 let mut buf = Vec::new();
3783 outcome.write_jsonl(&mut buf).unwrap();
3784 let text = String::from_utf8(buf).unwrap();
3785 assert_eq!(text.matches('\n').count(), 1);
3786 let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
3787 assert_eq!(parsed["result"]["status"], "changed");
3788 }
3789
3790 fn test_credentials() -> DriveCredentials {
3793 DriveCredentials {
3794 client_id: "client-1".to_string(),
3795 client_secret: Secret::new("secret-1"),
3796 refresh_token: Secret::new("refresh-1"),
3797 scope: DriveGrantedScopes::READONLY,
3798 }
3799 }
3800
3801 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
3802 wiremock::Mock::given(wiremock::matchers::method("POST"))
3803 .and(wiremock::matchers::path("/token"))
3804 .respond_with(
3805 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3806 "access_token": "test-token", "expires_in": 3600,
3807 })),
3808 )
3809 .mount(server)
3810 .await;
3811 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
3812 crate::drive::client::test_support::replace_session(
3813 &mut drive,
3814 &test_credentials(),
3815 &format!("{}/token", server.uri()),
3816 );
3817 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
3818 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
3819 (drive, sheets)
3820 }
3821
3822 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
3823 let parents: Vec<&str> = parents.to_vec();
3824 wiremock::Mock::given(wiremock::matchers::method("GET"))
3825 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
3826 .respond_with(
3827 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3828 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
3829 "version": "1",
3830 })),
3831 )
3832 }
3833
3834 fn mount_folder(id: &str) -> wiremock::Mock {
3835 mount_file(id, "application/vnd.google-apps.folder", &[])
3836 }
3837
3838 fn mount_workbook(sheets: serde_json::Value) -> wiremock::Mock {
3839 wiremock::Mock::given(wiremock::matchers::method("GET"))
3840 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
3841 .respond_with(
3842 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3843 "spreadsheetId": "sheet-1",
3844 "properties": {"title": "Budget"},
3845 "sheets": sheets,
3846 })),
3847 )
3848 }
3849
3850 fn leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
3851 let ledger_path = tempfile::tempdir()
3852 .unwrap()
3853 .keep()
3854 .join("lease-ledger.jsonl");
3855 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
3856 (Some(token), ledger_path)
3857 }
3858
3859 fn allow_rule(folder: &str) -> FolderPermissionRule {
3860 FolderPermissionRule {
3861 folder_id: Some(folder.to_string()),
3862 file_id: None,
3863 recursive: true,
3864 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3865 deny: HashSet::default(),
3866 require_lease: true,
3867 }
3868 }
3869
3870 #[tokio::test]
3871 async fn a_denied_gate_blocks_before_any_read_or_batch_update_call() {
3872 let server = wiremock::MockServer::start().await;
3873 let (drive, sheets) = clients(&server).await;
3874 mount_file(
3875 "sheet-1",
3876 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3877 &["folder-1"],
3878 )
3879 .mount(&server)
3880 .await;
3881 mount_folder("folder-1").mount(&server).await;
3882 let rules: Vec<FolderPermissionRule> = Vec::new();
3883 let opts = EmbeddedObjectOptions {
3884 spreadsheet_id: "sheet-1".to_string(),
3885 verb: add_chart_verb(),
3886 dry_run: false,
3887 lease_token: None,
3888 ledger_path: std::path::PathBuf::new(),
3889 };
3890 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3891 assert!(matches!(
3892 outcome.result,
3893 EmbeddedObjectResult::Blocked { .. }
3894 ));
3895 }
3896
3897 #[tokio::test]
3898 async fn add_chart_sends_an_add_chart_request_and_reports_the_server_assigned_id() {
3899 let server = wiremock::MockServer::start().await;
3900 let (drive, sheets) = clients(&server).await;
3901 mount_file(
3902 "sheet-1",
3903 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3904 &["folder-1"],
3905 )
3906 .mount(&server)
3907 .await;
3908 mount_folder("folder-1").mount(&server).await;
3909 mount_workbook(serde_json::json!([
3910 {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
3911 ]))
3912 .mount(&server)
3913 .await;
3914 wiremock::Mock::given(wiremock::matchers::method("POST"))
3915 .and(wiremock::matchers::path(
3916 "/v4/spreadsheets/sheet-1:batchUpdate",
3917 ))
3918 .respond_with(
3919 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3920 "replies": [{"addChart": {"chart": {"chartId": 42}}}]
3921 })),
3922 )
3923 .mount(&server)
3924 .await;
3925 let rules = vec![allow_rule("folder-1")];
3926 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
3927 let opts = EmbeddedObjectOptions {
3928 spreadsheet_id: "sheet-1".to_string(),
3929 verb: add_chart_verb(),
3930 dry_run: false,
3931 lease_token,
3932 ledger_path,
3933 };
3934 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3935 assert!(
3936 matches!(
3937 outcome.result,
3938 EmbeddedObjectResult::Changed {
3939 object_id: Some(42),
3940 ..
3941 }
3942 ),
3943 "{:?}",
3944 outcome.result
3945 );
3946 }
3947
3948 #[tokio::test]
3949 async fn add_chart_pie_refuses_more_than_one_series_before_any_batch_update_call() {
3950 let server = wiremock::MockServer::start().await;
3951 let (drive, sheets) = clients(&server).await;
3952 mount_file(
3953 "sheet-1",
3954 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3955 &["folder-1"],
3956 )
3957 .mount(&server)
3958 .await;
3959 mount_folder("folder-1").mount(&server).await;
3960 mount_workbook(serde_json::json!([
3961 {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
3962 ]))
3963 .mount(&server)
3964 .await;
3965 let rules = vec![allow_rule("folder-1")];
3966 let mut verb = add_chart_verb();
3967 let EmbeddedObjectVerb::AddChart {
3968 chart_type, series, ..
3969 } = &mut verb
3970 else {
3971 unreachable!() };
3973 *chart_type = "pie".to_string();
3974 series.push("C1:C10".to_string());
3975 let opts = EmbeddedObjectOptions {
3976 spreadsheet_id: "sheet-1".to_string(),
3977 verb,
3978 dry_run: true,
3979 lease_token: None,
3980 ledger_path: std::path::PathBuf::new(),
3981 };
3982 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
3983 assert!(
3984 matches!(
3985 outcome.result,
3986 EmbeddedObjectResult::RefusedInvalidRange { .. }
3987 ),
3988 "{:?}",
3989 outcome.result
3990 );
3991 }
3992
3993 #[tokio::test]
3994 async fn update_chart_refuses_an_unsupported_existing_chart_with_no_batch_update_call() {
3995 let server = wiremock::MockServer::start().await;
3996 let (drive, sheets) = clients(&server).await;
3997 mount_file(
3998 "sheet-1",
3999 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4000 &["folder-1"],
4001 )
4002 .mount(&server)
4003 .await;
4004 mount_folder("folder-1").mount(&server).await;
4005 mount_workbook(serde_json::json!([
4006 {
4007 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4008 "charts": [{
4009 "chartId": 1,
4010 "spec": {"basicChart": {"chartType": "COMBO"}},
4011 }],
4012 },
4013 ]))
4014 .mount(&server)
4015 .await;
4016 let rules = vec![allow_rule("folder-1")];
4017 let opts = EmbeddedObjectOptions {
4018 spreadsheet_id: "sheet-1".to_string(),
4019 verb: update_chart_verb(1),
4020 dry_run: false,
4021 lease_token: None,
4022 ledger_path: std::path::PathBuf::new(),
4023 };
4024 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4025 assert!(
4026 matches!(
4027 outcome.result,
4028 EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
4029 ),
4030 "{:?}",
4031 outcome.result
4032 );
4033 }
4034
4035 #[tokio::test]
4036 async fn update_chart_refuses_an_unknown_id() {
4037 let server = wiremock::MockServer::start().await;
4038 let (drive, sheets) = clients(&server).await;
4039 mount_file(
4040 "sheet-1",
4041 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4042 &["folder-1"],
4043 )
4044 .mount(&server)
4045 .await;
4046 mount_folder("folder-1").mount(&server).await;
4047 mount_workbook(serde_json::json!([
4048 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
4049 ]))
4050 .mount(&server)
4051 .await;
4052 let rules = vec![allow_rule("folder-1")];
4053 let opts = EmbeddedObjectOptions {
4054 spreadsheet_id: "sheet-1".to_string(),
4055 verb: update_chart_verb(99),
4056 dry_run: true,
4057 lease_token: None,
4058 ledger_path: std::path::PathBuf::new(),
4059 };
4060 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4061 assert!(matches!(
4062 outcome.result,
4063 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
4064 ));
4065 }
4066
4067 #[tokio::test]
4068 async fn delete_chart_reports_a_preview_under_dry_run_with_no_batch_update_call() {
4069 let server = wiremock::MockServer::start().await;
4070 let (drive, sheets) = clients(&server).await;
4071 mount_file(
4072 "sheet-1",
4073 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4074 &["folder-1"],
4075 )
4076 .mount(&server)
4077 .await;
4078 mount_folder("folder-1").mount(&server).await;
4079 mount_workbook(serde_json::json!([
4080 {
4081 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4082 "charts": [{
4083 "chartId": 5,
4084 "spec": {"title": "Sales", "pieChart": {
4085 "domain": {"sourceRange": {"sources": [{"sheetId": 0}]}},
4086 "series": {"sourceRange": {"sources": [{"sheetId": 0}]}},
4087 }},
4088 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
4089 }],
4090 },
4091 ]))
4092 .mount(&server)
4093 .await;
4094 let rules = vec![allow_rule("folder-1")];
4095 let opts = EmbeddedObjectOptions {
4096 spreadsheet_id: "sheet-1".to_string(),
4097 verb: EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
4098 dry_run: true,
4099 lease_token: None,
4100 ledger_path: std::path::PathBuf::new(),
4101 };
4102 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4103 match outcome.result {
4104 EmbeddedObjectResult::WouldChange { object, .. } => {
4105 let object = object.expect("delete-chart previews the object");
4106 assert_eq!(object.object_id, 5);
4107 assert_eq!(object.title.as_deref(), Some("Sales"));
4108 assert_eq!(object.position, "Q1!C2");
4109 }
4110 other => panic!("expected WouldChange, got {other:?}"),
4111 }
4112 }
4113
4114 #[tokio::test]
4115 async fn add_slicer_sends_an_add_slicer_request_and_reports_the_server_assigned_id() {
4116 let server = wiremock::MockServer::start().await;
4117 let (drive, sheets) = clients(&server).await;
4118 mount_file(
4119 "sheet-1",
4120 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4121 &["folder-1"],
4122 )
4123 .mount(&server)
4124 .await;
4125 mount_folder("folder-1").mount(&server).await;
4126 mount_workbook(serde_json::json!([
4127 {"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
4128 ]))
4129 .mount(&server)
4130 .await;
4131 wiremock::Mock::given(wiremock::matchers::method("POST"))
4132 .and(wiremock::matchers::path(
4133 "/v4/spreadsheets/sheet-1:batchUpdate",
4134 ))
4135 .respond_with(
4136 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4137 "replies": [{"addSlicer": {"slicer": {"slicerId": 8}}}]
4138 })),
4139 )
4140 .mount(&server)
4141 .await;
4142 let rules = vec![allow_rule("folder-1")];
4143 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
4144 let opts = EmbeddedObjectOptions {
4145 spreadsheet_id: "sheet-1".to_string(),
4146 verb: EmbeddedObjectVerb::AddSlicer {
4147 sheet: Some("Q1".to_string()),
4148 range: "A1:D10".to_string(),
4149 column: 1,
4150 hide_values: vec!["Closed".to_string()],
4151 title: Some("Status".to_string()),
4152 apply_to_pivot_tables: None,
4153 anchor: "F2".to_string(),
4154 offset_x: None,
4155 offset_y: None,
4156 width: None,
4157 height: None,
4158 },
4159 dry_run: false,
4160 lease_token,
4161 ledger_path,
4162 };
4163 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4164 assert!(
4165 matches!(
4166 outcome.result,
4167 EmbeddedObjectResult::Changed {
4168 object_id: Some(8),
4169 ..
4170 }
4171 ),
4172 "{:?}",
4173 outcome.result
4174 );
4175 }
4176
4177 #[tokio::test]
4178 async fn update_slicer_names_only_the_changed_fields_in_the_mask() {
4179 let server = wiremock::MockServer::start().await;
4180 let (drive, sheets) = clients(&server).await;
4181 mount_file(
4182 "sheet-1",
4183 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4184 &["folder-1"],
4185 )
4186 .mount(&server)
4187 .await;
4188 mount_folder("folder-1").mount(&server).await;
4189 mount_workbook(serde_json::json!([
4190 {
4191 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4192 "slicers": [{
4193 "slicerId": 4,
4194 "spec": {"title": "Region"},
4195 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
4196 }],
4197 },
4198 ]))
4199 .mount(&server)
4200 .await;
4201 let rules = vec![allow_rule("folder-1")];
4202 let opts = EmbeddedObjectOptions {
4203 spreadsheet_id: "sheet-1".to_string(),
4204 verb: EmbeddedObjectVerb::UpdateSlicer {
4205 slicer_id: 4,
4206 sheet: None,
4207 range: None,
4208 column: None,
4209 hide_values: Vec::new(),
4210 clear_criteria: false,
4211 title: Some("Territory".to_string()),
4212 apply_to_pivot_tables: None,
4213 },
4214 dry_run: true,
4215 lease_token: None,
4216 ledger_path: std::path::PathBuf::new(),
4217 };
4218 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4219 match outcome.result {
4220 EmbeddedObjectResult::WouldChange { summary, .. } => {
4221 assert!(summary.contains("title"), "{summary}");
4222 }
4223 other => panic!("expected WouldChange, got {other:?}"),
4224 }
4225 }
4226
4227 #[tokio::test]
4228 async fn delete_slicer_reports_a_preview_under_dry_run() {
4229 let server = wiremock::MockServer::start().await;
4230 let (drive, sheets) = clients(&server).await;
4231 mount_file(
4232 "sheet-1",
4233 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4234 &["folder-1"],
4235 )
4236 .mount(&server)
4237 .await;
4238 mount_folder("folder-1").mount(&server).await;
4239 mount_workbook(serde_json::json!([
4240 {
4241 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4242 "slicers": [{
4243 "slicerId": 9,
4244 "spec": {"title": "Region"},
4245 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
4246 }],
4247 },
4248 ]))
4249 .mount(&server)
4250 .await;
4251 let rules = vec![allow_rule("folder-1")];
4252 let opts = EmbeddedObjectOptions {
4253 spreadsheet_id: "sheet-1".to_string(),
4254 verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
4255 dry_run: true,
4256 lease_token: None,
4257 ledger_path: std::path::PathBuf::new(),
4258 };
4259 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4260 match outcome.result {
4261 EmbeddedObjectResult::WouldChange { object, .. } => {
4262 let object = object.expect("delete-slicer previews the object");
4263 assert_eq!(object.object_id, 9);
4264 assert_eq!(object.kind, "slicer");
4265 assert_eq!(object.title.as_deref(), Some("Region"));
4266 }
4267 other => panic!("expected WouldChange, got {other:?}"),
4268 }
4269 }
4270
4271 #[tokio::test]
4272 async fn dry_run_makes_no_batch_update_call() {
4273 let server = wiremock::MockServer::start().await;
4274 let (drive, sheets) = clients(&server).await;
4275 mount_file(
4276 "sheet-1",
4277 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4278 &["folder-1"],
4279 )
4280 .mount(&server)
4281 .await;
4282 mount_folder("folder-1").mount(&server).await;
4283 mount_workbook(serde_json::json!([
4284 {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
4285 ]))
4286 .mount(&server)
4287 .await;
4288 let rules = vec![allow_rule("folder-1")];
4291 let opts = EmbeddedObjectOptions {
4292 spreadsheet_id: "sheet-1".to_string(),
4293 verb: add_chart_verb(),
4294 dry_run: true,
4295 lease_token: None,
4296 ledger_path: std::path::PathBuf::new(),
4297 };
4298 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
4299 assert!(
4300 matches!(outcome.result, EmbeddedObjectResult::WouldChange { .. }),
4301 "{:?}",
4302 outcome.result
4303 );
4304 }
4305
4306 async fn run_slicer_column_verb(
4312 verb: EmbeddedObjectVerb,
4313 dry_run: bool,
4314 ) -> EmbeddedObjectResult {
4315 let server = wiremock::MockServer::start().await;
4316 let (drive, sheets) = clients(&server).await;
4317 mount_file(
4318 "sheet-1",
4319 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
4320 &["folder-1"],
4321 )
4322 .mount(&server)
4323 .await;
4324 mount_folder("folder-1").mount(&server).await;
4325 mount_workbook(serde_json::json!([
4326 {
4327 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
4328 "slicers": [{
4329 "slicerId": 4,
4330 "spec": {
4331 "title": "Region",
4332 "columnIndex": 5,
4333 "dataRange": {
4334 "sheetId": 0,
4335 "startRowIndex": 1, "endRowIndex": 8,
4336 "startColumnIndex": 1, "endColumnIndex": 6,
4337 },
4338 },
4339 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 7}}},
4340 }],
4341 },
4342 ]))
4343 .mount(&server)
4344 .await;
4345 wiremock::Mock::given(wiremock::matchers::method("POST"))
4346 .and(wiremock::matchers::path(
4347 "/v4/spreadsheets/sheet-1:batchUpdate",
4348 ))
4349 .respond_with(
4350 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
4351 "replies": [{"addSlicer": {"slicer": {"slicerId": 8}}}]
4352 })),
4353 )
4354 .mount(&server)
4355 .await;
4356 let rules = vec![allow_rule("folder-1")];
4357 let (lease_token, ledger_path) = if dry_run {
4358 (None, std::path::PathBuf::new())
4359 } else {
4360 leased_opts_for("sheet-1")
4361 };
4362 let opts = EmbeddedObjectOptions {
4363 spreadsheet_id: "sheet-1".to_string(),
4364 verb,
4365 dry_run,
4366 lease_token,
4367 ledger_path,
4368 };
4369 embedded_object(&drive, &sheets, &opts, &rules).await.result
4370 }
4371
4372 fn add_slicer_over(range: &str, column: i64) -> EmbeddedObjectVerb {
4373 EmbeddedObjectVerb::AddSlicer {
4374 sheet: Some("Q1".to_string()),
4375 range: range.to_string(),
4376 column,
4377 hide_values: Vec::new(),
4378 title: None,
4379 apply_to_pivot_tables: None,
4380 anchor: "H2".to_string(),
4381 offset_x: None,
4382 offset_y: None,
4383 width: None,
4384 height: None,
4385 }
4386 }
4387
4388 fn update_slicer_4(range: Option<&str>, column: Option<i64>) -> EmbeddedObjectVerb {
4389 EmbeddedObjectVerb::UpdateSlicer {
4390 slicer_id: 4,
4391 sheet: Some("Q1".to_string()),
4392 range: range.map(str::to_string),
4393 column,
4394 hide_values: Vec::new(),
4395 clear_criteria: false,
4396 title: None,
4397 apply_to_pivot_tables: None,
4398 }
4399 }
4400
4401 #[tokio::test]
4402 async fn add_slicer_refuses_a_column_outside_the_range_under_both_dry_run_and_a_real_run() {
4403 for dry_run in [true, false] {
4405 let result = run_slicer_column_verb(add_slicer_over("B2:F8", 0), dry_run).await;
4406 assert_invalid(&result, "0 (column A) is outside the slicer's range");
4407 assert_invalid(&result, "1..=5 (B–F)");
4408 }
4409 }
4410
4411 #[tokio::test]
4412 async fn add_slicer_accepts_an_absolute_column_inside_a_range_not_starting_at_a() {
4413 let preview = run_slicer_column_verb(add_slicer_over("B2:F8", 5), true).await;
4416 assert!(
4417 matches!(preview, EmbeddedObjectResult::WouldChange { .. }),
4418 "{preview:?}"
4419 );
4420 let real = run_slicer_column_verb(add_slicer_over("B2:F8", 5), false).await;
4421 assert!(
4422 matches!(
4423 real,
4424 EmbeddedObjectResult::Changed {
4425 object_id: Some(8),
4426 ..
4427 }
4428 ),
4429 "{real:?}"
4430 );
4431 }
4432
4433 #[tokio::test]
4434 async fn update_slicer_refuses_a_column_outside_the_range_under_both_dry_run_and_a_real_run() {
4435 let cases = [
4436 (
4438 update_slicer_4(None, Some(0)),
4439 "0 (column A)",
4440 "1..=5 (B–F)",
4441 ),
4442 (
4444 update_slicer_4(None, Some(6)),
4445 "6 (column G)",
4446 "1..=5 (B–F)",
4447 ),
4448 (
4450 update_slicer_4(Some("H1:J5"), None),
4451 "5 (column F)",
4452 "7..=9 (H–J)",
4453 ),
4454 (
4456 update_slicer_4(Some("H1:J5"), Some(1)),
4457 "1 (column B)",
4458 "7..=9 (H–J)",
4459 ),
4460 ];
4461 for (verb, column, valid) in cases {
4462 for dry_run in [true, false] {
4463 let result = run_slicer_column_verb(verb.clone(), dry_run).await;
4464 assert_invalid(&result, column);
4465 assert_invalid(&result, valid);
4466 }
4467 }
4468 }
4469
4470 #[tokio::test]
4471 async fn update_slicer_accepts_an_absolute_column_inside_a_range_not_starting_at_a() {
4472 for verb in [
4473 update_slicer_4(None, Some(5)),
4474 update_slicer_4(Some("C2:F8"), None),
4475 update_slicer_4(Some("H1:J5"), Some(9)),
4476 ] {
4477 let result = run_slicer_column_verb(verb, true).await;
4478 assert!(
4479 matches!(result, EmbeddedObjectResult::WouldChange { .. }),
4480 "{result:?}"
4481 );
4482 }
4483 }
4484
4485 #[test]
4486 fn check_slicer_column_in_range_leaves_an_open_ended_edge_unbounded() {
4487 let whole_rows = GridRange {
4488 sheet_id: 0,
4489 start_row_index: Some(1),
4490 end_row_index: Some(5),
4491 start_column_index: None,
4492 end_column_index: None,
4493 };
4494 assert!(check_slicer_column_in_range(0, &whole_rows).is_ok());
4495 assert!(check_slicer_column_in_range(700, &whole_rows).is_ok());
4496 let from_c = GridRange {
4497 start_column_index: Some(2),
4498 ..whole_rows
4499 };
4500 assert!(check_slicer_column_in_range(2, &from_c).is_ok());
4501 assert_invalid(
4502 &check_slicer_column_in_range(1, &from_c).unwrap_err(),
4503 "valid columns are 2 or greater (C onward)",
4504 );
4505 assert_invalid(
4506 &check_slicer_column_in_range(-1, &from_c).unwrap_err(),
4507 "slicer column -1 is outside",
4508 );
4509 let empty = GridRange {
4510 start_column_index: Some(2),
4511 end_column_index: Some(2),
4512 ..whole_rows
4513 };
4514 assert_invalid(
4515 &check_slicer_column_in_range(2, &empty).unwrap_err(),
4516 "the range spans no columns",
4517 );
4518 }
4519
4520 #[test]
4521 fn charts_from_workbook_and_slicers_from_workbook_report_every_object() {
4522 let mut with_slicer = basic_chart_sheet(1, "COLUMN");
4523 with_slicer.slicers.push(Slicer {
4524 slicer_id: Some(2),
4525 spec: Some(SlicerSpec::default()),
4526 position: Some(EmbeddedObjectPosition {
4527 overlay_position: Some(OverlayPosition {
4528 anchor_cell: GridCoordinate {
4529 sheet_id: 0,
4530 row_index: 0,
4531 column_index: 0,
4532 },
4533 ..Default::default()
4534 }),
4535 ..Default::default()
4536 }),
4537 });
4538 let workbook = workbook_with_sheet(with_slicer);
4539 assert_eq!(charts_from_workbook(&workbook).len(), 1);
4540 assert_eq!(slicers_from_workbook(&workbook).len(), 1);
4541 }
4542
4543 #[track_caller]
4548 fn refusal(plan: Result<Plan, EmbeddedObjectResult>) -> EmbeddedObjectResult {
4549 plan.err().expect("expected the plan builder to refuse")
4550 }
4551
4552 #[track_caller]
4558 fn assert_invalid(result: &EmbeddedObjectResult, needle: &str) {
4559 let rendered = format!("{result:?}");
4560 assert!(
4561 matches!(result, EmbeddedObjectResult::RefusedInvalidRange { .. }),
4562 "expected RefusedInvalidRange, got {rendered}"
4563 );
4564 assert!(rendered.contains(needle), "{rendered}");
4565 }
4566
4567 fn plain_workbook(title: &str) -> Spreadsheet {
4569 workbook_with_sheet(Sheet {
4570 properties: Some(crate::drive::sheets::types::SheetProperties {
4571 sheet_id: Some(0),
4572 title: title.to_string(),
4573 ..Default::default()
4574 }),
4575 ..Default::default()
4576 })
4577 }
4578
4579 #[test]
4582 fn resolve_anchor_refuses_an_open_ended_range() {
4583 let workbook = plain_workbook("Sheet1");
4584 let err = resolve_anchor(&workbook, Some("Sheet1"), "A:A")
4585 .expect_err("an open-ended range is not a single cell");
4586 assert_invalid(&err, "not an open-ended range");
4587 }
4588
4589 #[test]
4590 fn resolve_anchor_refuses_a_multi_cell_range() {
4591 let workbook = plain_workbook("Sheet1");
4592 let err = resolve_anchor(&workbook, Some("Sheet1"), "A1:B2")
4593 .expect_err("a bounded 2x2 range is not a single cell");
4594 assert_invalid(&err, "not a range");
4595 }
4596
4597 #[test]
4598 fn resolve_anchor_accepts_a_single_cell() {
4599 let workbook = plain_workbook("Sheet1");
4600 let cell = resolve_anchor(&workbook, Some("Sheet1"), "E2").unwrap();
4601 assert_eq!(cell.sheet_id, 0);
4602 assert_eq!(cell.row_index, 1);
4603 assert_eq!(cell.column_index, 4);
4604 }
4605
4606 fn add_chart_verb_with(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
4611 let mut verb = add_chart_verb();
4612 tweak(&mut verb);
4613 verb
4614 }
4615
4616 fn set_pie(verb: &mut EmbeddedObjectVerb) {
4617 let EmbeddedObjectVerb::AddChart { chart_type, .. } = verb else {
4618 unreachable!() };
4620 *chart_type = "pie".to_string();
4621 }
4622
4623 #[test]
4624 fn build_add_chart_puts_the_chart_on_its_own_new_sheet() {
4625 let workbook = plain_workbook("Sheet1");
4626 let verb = add_chart_verb_with(|verb| {
4627 let EmbeddedObjectVerb::AddChart {
4628 anchor, new_sheet, ..
4629 } = verb
4630 else {
4631 unreachable!() };
4633 *anchor = None;
4634 *new_sheet = true;
4635 });
4636 let plan = build_add_chart(&workbook, &verb).unwrap();
4637 assert_eq!(plan.sheet_id, None, "a new-sheet chart has no host sheet");
4638 let request = serde_json::to_value(&plan.request).unwrap();
4639 assert_eq!(request["addChart"]["chart"]["position"]["newSheet"], true);
4640 assert!(request["addChart"]["chart"]["position"]["overlayPosition"].is_null());
4641 }
4642
4643 #[test]
4644 fn build_add_chart_refuses_neither_anchor_nor_new_sheet() {
4645 let workbook = plain_workbook("Sheet1");
4649 let verb = add_chart_verb_with(|verb| {
4650 let EmbeddedObjectVerb::AddChart { anchor, .. } = verb else {
4651 unreachable!() };
4653 *anchor = None;
4654 });
4655 let err = refusal(build_add_chart(&workbook, &verb));
4656 assert_invalid(&err, "either --anchor or --new-sheet is required");
4657 }
4658
4659 #[test]
4660 fn build_add_chart_propagates_an_unresolvable_anchor() {
4661 let workbook = plain_workbook("Sheet1");
4664 let verb = add_chart_verb_with(|verb| {
4665 let EmbeddedObjectVerb::AddChart { anchor, .. } = verb else {
4666 unreachable!() };
4668 *anchor = Some("E2:F3".to_string());
4669 });
4670 let err = refusal(build_add_chart(&workbook, &verb));
4671 assert_invalid(&err, "must name a single cell, not a range");
4672 }
4673
4674 #[test]
4675 fn build_add_chart_refuses_pie_hole_on_a_basic_chart() {
4676 let workbook = plain_workbook("Sheet1");
4677 let verb = add_chart_verb_with(|verb| {
4678 let EmbeddedObjectVerb::AddChart { pie_hole, .. } = verb else {
4679 unreachable!() };
4681 *pie_hole = Some(0.4);
4682 });
4683 let err = refusal(build_add_chart(&workbook, &verb));
4684 assert_invalid(&err, "--pie-hole only applies to a pie chart");
4685 }
4686
4687 #[test]
4688 fn build_add_chart_refuses_every_basic_only_flag_on_a_pie_chart() {
4689 let workbook = plain_workbook("Sheet1");
4690 for (tweak, needle) in [
4691 (
4692 Box::new(|verb: &mut EmbeddedObjectVerb| {
4693 let EmbeddedObjectVerb::AddChart { header_count, .. } = verb else {
4694 unreachable!() };
4696 *header_count = Some(1);
4697 }) as Box<dyn FnOnce(&mut EmbeddedObjectVerb)>,
4698 "--header-count only applies",
4699 ),
4700 (
4701 Box::new(|verb: &mut EmbeddedObjectVerb| {
4702 let EmbeddedObjectVerb::AddChart { stacked, .. } = verb else {
4703 unreachable!() };
4705 *stacked = Some("stacked".to_string());
4706 }),
4707 "--stacked only applies",
4708 ),
4709 (
4710 Box::new(|verb: &mut EmbeddedObjectVerb| {
4711 let EmbeddedObjectVerb::AddChart {
4712 horizontal_axis_title,
4713 ..
4714 } = verb
4715 else {
4716 unreachable!() };
4718 *horizontal_axis_title = Some("Quarter".to_string());
4719 }),
4720 "--horizontal-axis-title/--vertical-axis-title only apply",
4721 ),
4722 (
4723 Box::new(|verb: &mut EmbeddedObjectVerb| {
4724 let EmbeddedObjectVerb::AddChart {
4725 vertical_axis_title,
4726 ..
4727 } = verb
4728 else {
4729 unreachable!() };
4731 *vertical_axis_title = Some("Revenue".to_string());
4732 }),
4733 "--horizontal-axis-title/--vertical-axis-title only apply",
4734 ),
4735 ] {
4736 let verb = add_chart_verb_with(|verb| {
4737 set_pie(verb);
4738 tweak(verb);
4739 });
4740 let err = refusal(build_add_chart(&workbook, &verb));
4741 assert_invalid(&err, needle);
4742 }
4743 }
4744
4745 #[test]
4746 fn build_add_chart_refuses_a_pie_chart_with_more_than_one_series() {
4747 let workbook = plain_workbook("Sheet1");
4748 let verb = add_chart_verb_with(|verb| {
4749 set_pie(verb);
4750 let EmbeddedObjectVerb::AddChart { series, .. } = verb else {
4751 unreachable!() };
4753 series.push("C1:C10".to_string());
4754 });
4755 let err = refusal(build_add_chart(&workbook, &verb));
4756 assert_invalid(&err, "a pie chart takes exactly one --series");
4757 }
4758
4759 #[test]
4760 fn build_add_chart_builds_a_full_pie_chart_spec() {
4761 let workbook = plain_workbook("Sheet1");
4762 let verb = add_chart_verb_with(|verb| {
4763 set_pie(verb);
4764 let EmbeddedObjectVerb::AddChart {
4765 title,
4766 subtitle,
4767 legend,
4768 pie_hole,
4769 ..
4770 } = verb
4771 else {
4772 unreachable!() };
4774 *title = Some("Share".to_string());
4775 *subtitle = Some("by region".to_string());
4776 *legend = Some("right".to_string());
4777 *pie_hole = Some(0.4);
4778 });
4779 let plan = build_add_chart(&workbook, &verb).unwrap();
4780 assert_eq!(plan.sheet_id, Some(0));
4781 assert_eq!(plan.summary, "add pie chart 'Share'");
4782 assert!(plan.before.is_none());
4783 assert_eq!(plan.existing_id, None);
4784 let spec = &serde_json::to_value(&plan.request).unwrap()["addChart"]["chart"]["spec"];
4785 assert_eq!(spec["title"], "Share");
4786 assert_eq!(spec["subtitle"], "by region");
4787 assert!(
4788 spec["basicChart"].is_null(),
4789 "a pie chart has no basicChart"
4790 );
4791 assert_eq!(spec["pieChart"]["legendPosition"], "RIGHT_LEGEND");
4792 assert_eq!(spec["pieChart"]["pieHole"], 0.4);
4793 assert_eq!(
4794 spec["pieChart"]["domain"]["sourceRange"]["sources"][0]["startColumnIndex"],
4795 0
4796 );
4797 assert_eq!(
4798 spec["pieChart"]["series"]["sourceRange"]["sources"][0]["startColumnIndex"],
4799 1
4800 );
4801 }
4802
4803 #[test]
4806 fn summarise_chart_reports_no_type_for_a_spec_that_is_neither_basic_nor_pie() {
4807 let sheet = Sheet {
4810 properties: Some(crate::drive::sheets::types::SheetProperties {
4811 sheet_id: Some(0),
4812 title: "Q1".to_string(),
4813 ..Default::default()
4814 }),
4815 charts: vec![EmbeddedChart {
4816 chart_id: Some(2),
4817 spec: Some(ChartSpec::default()),
4818 position: None,
4819 border: None,
4820 }],
4821 ..Default::default()
4822 };
4823 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
4824 assert_eq!(summary.chart_type, None);
4825 assert_eq!(summary.object_id, 2);
4826 }
4827
4828 #[test]
4829 fn describe_position_names_an_own_sheet_chart_with_and_without_an_id() {
4830 let sheet = plain_workbook("Q1").sheets.remove(0);
4831 let anonymous = EmbeddedObjectPosition {
4832 new_sheet: Some(true),
4833 ..Default::default()
4834 };
4835 assert_eq!(describe_position(&sheet, Some(&anonymous)), "own sheet");
4836 let identified = EmbeddedObjectPosition {
4837 sheet_id: Some(9),
4838 ..Default::default()
4839 };
4840 assert_eq!(
4841 describe_position(&sheet, Some(&identified)),
4842 "own sheet (id 9)"
4843 );
4844 }
4845
4846 #[test]
4847 fn describe_position_falls_back_to_unknown_without_an_overlay() {
4848 let sheet = plain_workbook("Q1").sheets.remove(0);
4849 let empty = EmbeddedObjectPosition::default();
4850 assert_eq!(
4851 describe_position(&sheet, Some(&empty)),
4852 "unknown position",
4853 "a position carrying neither an overlay nor a sheet is unknown"
4854 );
4855 assert_eq!(describe_position(&sheet, None), "unknown position");
4856 }
4857
4858 #[test]
4859 fn summarise_chart_reports_pie_for_a_pie_spec() {
4860 let sheet = pie_chart_sheet(3);
4861 let summary = summarise_chart(&sheet, &sheet.charts[0]).unwrap();
4862 assert_eq!(summary.chart_type.as_deref(), Some("PIE"));
4863 assert_eq!(
4864 summary.position, "unknown position",
4865 "pie_chart_sheet's chart carries no position at all"
4866 );
4867 }
4868
4869 fn update_chart_verb_with(
4874 chart_id: i64,
4875 tweak: impl FnOnce(&mut EmbeddedObjectVerb),
4876 ) -> EmbeddedObjectVerb {
4877 let mut verb = update_chart_verb(chart_id);
4878 tweak(&mut verb);
4879 verb
4880 }
4881
4882 #[test]
4883 fn merge_chart_spec_sets_subtitle_and_legend_alongside_the_title() {
4884 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
4885 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4886 let verb = update_chart_verb_with(1, |verb| {
4887 let EmbeddedObjectVerb::UpdateChart {
4888 subtitle, legend, ..
4889 } = verb
4890 else {
4891 unreachable!() };
4893 *subtitle = Some("FY25".to_string());
4894 *legend = Some("top".to_string());
4895 });
4896 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
4897 assert_eq!(spec.title.as_deref(), Some("New title"));
4898 assert_eq!(spec.subtitle.as_deref(), Some("FY25"));
4899 assert_eq!(
4900 spec.basic_chart.unwrap().legend_position.as_deref(),
4901 Some("TOP_LEGEND")
4902 );
4903 assert_eq!(summary, "update chart 1 (title, subtitle, legend)");
4904 }
4905
4906 #[test]
4909 fn merge_pie_chart_refuses_stacked_and_axis_titles() {
4910 let workbook = workbook_with_sheet(pie_chart_sheet(1));
4911 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4912
4913 let stacked = update_chart_verb_with(1, |verb| {
4914 let EmbeddedObjectVerb::UpdateChart { stacked, .. } = verb else {
4915 unreachable!() };
4917 *stacked = Some("stacked".to_string());
4918 });
4919 assert_invalid(
4920 &merge_chart_spec(&workbook, existing, &stacked).unwrap_err(),
4921 "--stacked only applies",
4922 );
4923
4924 let axis = update_chart_verb_with(1, |verb| {
4925 let EmbeddedObjectVerb::UpdateChart {
4926 horizontal_axis_title,
4927 ..
4928 } = verb
4929 else {
4930 unreachable!() };
4932 *horizontal_axis_title = Some("Quarter".to_string());
4933 });
4934 assert_invalid(
4935 &merge_chart_spec(&workbook, existing, &axis).unwrap_err(),
4936 "--horizontal-axis-title/--vertical-axis-title only apply",
4937 );
4938 }
4939
4940 #[test]
4941 fn merge_pie_chart_refuses_more_than_one_series() {
4942 let workbook = workbook_with_sheet(pie_chart_sheet(1));
4943 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4944 let verb = update_chart_verb_with(1, |verb| {
4945 let EmbeddedObjectVerb::UpdateChart { sheet, series, .. } = verb else {
4946 unreachable!() };
4948 *sheet = Some("Q1".to_string());
4949 *series = vec!["B1:B10".to_string(), "C1:C10".to_string()];
4950 });
4951 assert_invalid(
4952 &merge_chart_spec(&workbook, existing, &verb).unwrap_err(),
4953 "a pie chart takes exactly one --series",
4954 );
4955 }
4956
4957 #[test]
4958 fn merge_pie_chart_leaves_domain_and_series_alone_when_neither_is_named() {
4959 let workbook = workbook_with_sheet(pie_chart_sheet(1));
4960 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4961 let original = existing.pie_chart.clone().unwrap();
4962 let verb = update_chart_verb_with(1, |verb| {
4963 let EmbeddedObjectVerb::UpdateChart { pie_hole, .. } = verb else {
4964 unreachable!() };
4966 *pie_hole = Some(0.6);
4967 });
4968 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
4969 let pie = spec.pie_chart.unwrap();
4970 assert_eq!(pie.domain, original.domain, "domain must survive untouched");
4971 assert_eq!(pie.series, original.series, "series must survive untouched");
4972 assert_eq!(pie.pie_hole, Some(0.6));
4973 assert_eq!(summary, "update chart 1 (title, pie-hole)");
4974 }
4975
4976 #[test]
4977 fn merge_pie_chart_propagates_an_unresolvable_series_range() {
4978 let workbook = workbook_with_sheet(pie_chart_sheet(1));
4979 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4980 let verb = update_chart_verb_with(1, |verb| {
4981 let EmbeddedObjectVerb::UpdateChart { sheet, series, .. } = verb else {
4982 unreachable!() };
4984 *sheet = Some("Nope".to_string());
4985 *series = vec!["B1:B10".to_string()];
4986 });
4987 let err = merge_chart_spec(&workbook, existing, &verb).unwrap_err();
4988 assert!(
4989 matches!(err, EmbeddedObjectResult::RefusedSheetNotFound { .. }),
4990 "{err:?}"
4991 );
4992 }
4993
4994 #[test]
4995 fn merge_pie_chart_merges_domain_series_legend_and_pie_hole() {
4996 let workbook = workbook_with_sheet(pie_chart_sheet(1));
4997 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
4998 let verb = update_chart_verb_with(1, |verb| {
4999 let EmbeddedObjectVerb::UpdateChart {
5000 title,
5001 sheet,
5002 domain,
5003 series,
5004 legend,
5005 pie_hole,
5006 ..
5007 } = verb
5008 else {
5009 unreachable!() };
5011 *title = None;
5012 *sheet = Some("Q1".to_string());
5013 *domain = Some("C1:C10".to_string());
5014 *series = vec!["D1:D10".to_string()];
5015 *legend = Some("left".to_string());
5016 *pie_hole = Some(0.25);
5017 });
5018 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
5019 assert!(spec.basic_chart.is_none(), "the merge stays a pie chart");
5020 let pie = spec.pie_chart.unwrap();
5021 assert_eq!(
5022 pie.domain.source_range.sources[0].start_column_index,
5023 Some(2)
5024 );
5025 assert_eq!(
5026 pie.series.source_range.sources[0].start_column_index,
5027 Some(3)
5028 );
5029 assert_eq!(pie.legend_position.as_deref(), Some("LEFT_LEGEND"));
5030 assert_eq!(pie.pie_hole, Some(0.25));
5031 assert_eq!(summary, "update chart 1 (legend, domain, series, pie-hole)");
5032 }
5033
5034 #[test]
5037 fn merge_basic_chart_merges_every_basic_flag_at_once() {
5038 let workbook = workbook_with_sheet(basic_chart_sheet_with_series(1));
5039 let existing = workbook.sheets[0].charts[0].spec.as_ref().unwrap();
5040 let verb = update_chart_verb_with(1, |verb| {
5041 let EmbeddedObjectVerb::UpdateChart {
5042 title,
5043 sheet,
5044 domain,
5045 series,
5046 legend,
5047 stacked,
5048 header_count,
5049 horizontal_axis_title,
5050 vertical_axis_title,
5051 ..
5052 } = verb
5053 else {
5054 unreachable!() };
5056 *title = None;
5057 *sheet = Some("Q1".to_string());
5058 *domain = Some("A1:A10".to_string());
5059 *series = vec!["B1:B10".to_string(), "C1:C10".to_string()];
5060 *legend = Some("bottom".to_string());
5061 *stacked = Some("percent".to_string());
5062 *header_count = Some(1);
5063 *horizontal_axis_title = Some("Quarter".to_string());
5064 *vertical_axis_title = Some("Revenue".to_string());
5065 });
5066 let (spec, summary) = merge_chart_spec(&workbook, existing, &verb).unwrap();
5067 assert!(spec.pie_chart.is_none(), "the merge stays a basic chart");
5068 let basic = spec.basic_chart.unwrap();
5069 assert_eq!(basic.domains.len(), 1);
5070 assert_eq!(
5071 basic.series.len(),
5072 2,
5073 "--series replaces the list wholesale"
5074 );
5075 assert_eq!(basic.legend_position.as_deref(), Some("BOTTOM_LEGEND"));
5076 assert_eq!(basic.stacked_type.as_deref(), Some("PERCENT_STACKED"));
5077 assert_eq!(basic.header_count, Some(1));
5078 assert_eq!(basic.axis.len(), 2);
5079 assert_eq!(basic.axis[0].title.as_deref(), Some("Quarter"));
5080 assert_eq!(basic.axis[1].position, "LEFT_AXIS");
5081 assert_eq!(basic.axis[1].title.as_deref(), Some("Revenue"));
5082 assert_eq!(
5083 summary,
5084 "update chart 1 (legend, domain, series, stacked, header-count, \
5085 horizontal-axis-title, vertical-axis-title)"
5086 );
5087 }
5088
5089 #[test]
5092 fn build_update_chart_refuses_a_chart_with_no_spec() {
5093 let sheet = Sheet {
5094 properties: Some(crate::drive::sheets::types::SheetProperties {
5095 sheet_id: Some(0),
5096 title: "Q1".to_string(),
5097 ..Default::default()
5098 }),
5099 charts: vec![EmbeddedChart {
5100 chart_id: Some(1),
5101 spec: None,
5102 position: None,
5103 border: None,
5104 }],
5105 ..Default::default()
5106 };
5107 let workbook = workbook_with_sheet(sheet);
5108 let err = refusal(build_update_chart(&workbook, &update_chart_verb(1), 1));
5109 let rendered = format!("{err:?}");
5110 assert!(
5111 matches!(
5112 err,
5113 EmbeddedObjectResult::RefusedUnsupportedChart { chart_id: 1, .. }
5114 ),
5115 "{rendered}"
5116 );
5117 assert!(rendered.contains("no spec to merge onto"), "{rendered}");
5118 }
5119
5120 #[test]
5121 fn build_update_chart_carries_the_pre_change_object_and_the_existing_id() {
5122 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5123 let plan = build_update_chart(&workbook, &update_chart_verb(1), 1).unwrap();
5124 assert_eq!(plan.existing_id, Some(1));
5125 assert_eq!(plan.sheet_id, Some(0));
5126 let before = plan
5127 .before
5128 .expect("an update previews the pre-change state");
5129 assert_eq!(before.title.as_deref(), Some("Old title"));
5130 assert_eq!(before.position, "Q1!E2");
5131 let request = serde_json::to_value(&plan.request).unwrap();
5132 assert_eq!(request["updateChartSpec"]["chartId"], 1);
5133 assert_eq!(request["updateChartSpec"]["spec"]["title"], "New title");
5134 }
5135
5136 #[test]
5137 fn build_delete_chart_refuses_an_unknown_id() {
5138 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5139 assert!(matches!(
5140 refusal(build_delete_chart(&workbook, 99)),
5141 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5142 ));
5143 }
5144
5145 fn mixed_object_workbook() -> Spreadsheet {
5149 let mut sheet = basic_chart_sheet(1, "COLUMN");
5150 sheet.slicers = slicer_workbook().sheets.remove(0).slicers;
5151 workbook_with_sheet(sheet)
5152 }
5153
5154 #[test]
5155 fn build_update_chart_refuses_a_slicer_id() {
5156 let workbook = mixed_object_workbook();
5157 assert!(matches!(
5158 refusal(build_update_chart(&workbook, &update_chart_verb(4), 4)),
5159 EmbeddedObjectResult::RefusedWrongObjectKind {
5160 object_id: 4,
5161 expected,
5162 found,
5163 } if expected == "chart" && found == "slicer"
5164 ));
5165 }
5166
5167 #[test]
5168 fn build_delete_chart_refuses_a_slicer_id() {
5169 let workbook = mixed_object_workbook();
5170 assert!(matches!(
5171 refusal(build_delete_chart(&workbook, 4)),
5172 EmbeddedObjectResult::RefusedWrongObjectKind {
5173 object_id: 4,
5174 expected,
5175 found,
5176 } if expected == "chart" && found == "slicer"
5177 ));
5178 }
5179
5180 #[test]
5181 fn build_update_slicer_refuses_a_chart_id() {
5182 let workbook = mixed_object_workbook();
5183 let verb = update_slicer_verb(|verb| {
5184 let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5185 unreachable!() };
5187 *title = Some("Territory".to_string());
5188 });
5189 assert!(matches!(
5190 refusal(build_update_slicer(&workbook, &verb, 1)),
5191 EmbeddedObjectResult::RefusedWrongObjectKind {
5192 object_id: 1,
5193 expected,
5194 found,
5195 } if expected == "slicer" && found == "chart"
5196 ));
5197 }
5198
5199 #[test]
5200 fn build_delete_slicer_refuses_a_chart_id() {
5201 let workbook = mixed_object_workbook();
5202 assert!(matches!(
5203 refusal(build_delete_slicer(&workbook, 1)),
5204 EmbeddedObjectResult::RefusedWrongObjectKind {
5205 object_id: 1,
5206 expected,
5207 found,
5208 } if expected == "slicer" && found == "chart"
5209 ));
5210 }
5211
5212 fn add_slicer_verb() -> EmbeddedObjectVerb {
5215 EmbeddedObjectVerb::AddSlicer {
5216 sheet: Some("Q1".to_string()),
5217 range: "A1:D10".to_string(),
5218 column: 1,
5219 hide_values: Vec::new(),
5220 title: Some("Status".to_string()),
5221 apply_to_pivot_tables: None,
5222 anchor: "F2".to_string(),
5223 offset_x: None,
5224 offset_y: None,
5225 width: None,
5226 height: None,
5227 }
5228 }
5229
5230 #[test]
5231 fn build_add_slicer_omits_filter_criteria_when_no_values_are_hidden() {
5232 let workbook = plain_workbook("Q1");
5233 let plan = build_add_slicer(&workbook, &add_slicer_verb()).unwrap();
5234 assert_eq!(plan.summary, "add slicer 'Status'");
5235 assert_eq!(plan.sheet_id, Some(0));
5236 let spec = &serde_json::to_value(&plan.request).unwrap()["addSlicer"]["slicer"]["spec"];
5237 assert!(
5238 spec["filterCriteria"].is_null(),
5239 "no --hide-values means no filterCriteria at all: {spec}"
5240 );
5241 assert_eq!(spec["columnIndex"], 1);
5242 assert_eq!(spec["title"], "Status");
5243 }
5244
5245 #[test]
5246 fn build_add_slicer_propagates_an_unresolvable_anchor() {
5247 let workbook = plain_workbook("Q1");
5248 let EmbeddedObjectVerb::AddSlicer {
5249 sheet,
5250 range,
5251 column,
5252 hide_values,
5253 title,
5254 apply_to_pivot_tables,
5255 offset_x,
5256 offset_y,
5257 width,
5258 height,
5259 ..
5260 } = add_slicer_verb()
5261 else {
5262 unreachable!() };
5264 let verb = EmbeddedObjectVerb::AddSlicer {
5265 sheet,
5266 range,
5267 column,
5268 hide_values,
5269 title,
5270 apply_to_pivot_tables,
5271 anchor: "F2:G3".to_string(),
5272 offset_x,
5273 offset_y,
5274 width,
5275 height,
5276 };
5277 let err = refusal(build_add_slicer(&workbook, &verb));
5278 assert_invalid(&err, "must name a single cell, not a range");
5279 }
5280
5281 #[test]
5282 fn build_add_slicer_carries_hidden_values_when_given() {
5283 let workbook = plain_workbook("Q1");
5284 let EmbeddedObjectVerb::AddSlicer {
5285 sheet,
5286 range,
5287 column,
5288 title,
5289 anchor,
5290 ..
5291 } = add_slicer_verb()
5292 else {
5293 unreachable!() };
5295 let verb = EmbeddedObjectVerb::AddSlicer {
5296 sheet,
5297 range,
5298 column,
5299 hide_values: vec!["Closed".to_string()],
5300 title,
5301 apply_to_pivot_tables: Some(true),
5302 anchor,
5303 offset_x: Some(3),
5304 offset_y: Some(4),
5305 width: Some(300),
5306 height: Some(200),
5307 };
5308 let plan = build_add_slicer(&workbook, &verb).unwrap();
5309 let slicer = &serde_json::to_value(&plan.request).unwrap()["addSlicer"]["slicer"];
5310 assert_eq!(
5311 slicer["spec"]["filterCriteria"]["hiddenValues"][0],
5312 "Closed"
5313 );
5314 assert_eq!(slicer["spec"]["applyToPivotTables"], true);
5315 let overlay = &slicer["position"]["overlayPosition"];
5316 assert_eq!(overlay["offsetXPixels"], 3);
5317 assert_eq!(overlay["offsetYPixels"], 4);
5318 assert_eq!(overlay["widthPixels"], 300);
5319 assert_eq!(overlay["heightPixels"], 200);
5320 }
5321
5322 fn slicer_workbook() -> Spreadsheet {
5323 workbook_with_sheet(Sheet {
5324 properties: Some(crate::drive::sheets::types::SheetProperties {
5325 sheet_id: Some(0),
5326 title: "Q1".to_string(),
5327 ..Default::default()
5328 }),
5329 slicers: vec![Slicer {
5330 slicer_id: Some(4),
5331 spec: Some(SlicerSpec {
5332 title: Some("Region".to_string()),
5333 data_range: Some(GridRange {
5337 sheet_id: 0,
5338 start_row_index: Some(0),
5339 end_row_index: Some(10),
5340 start_column_index: Some(0),
5341 end_column_index: Some(4),
5342 }),
5343 column_index: Some(1),
5344 ..Default::default()
5345 }),
5346 position: Some(EmbeddedObjectPosition {
5347 overlay_position: Some(OverlayPosition {
5348 anchor_cell: GridCoordinate {
5349 sheet_id: 0,
5350 row_index: 0,
5351 column_index: 5,
5352 },
5353 ..Default::default()
5354 }),
5355 ..Default::default()
5356 }),
5357 }],
5358 ..Default::default()
5359 })
5360 }
5361
5362 type VerbTweak = Box<dyn FnOnce(&mut EmbeddedObjectVerb)>;
5364
5365 fn update_slicer_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5366 let mut verb = EmbeddedObjectVerb::UpdateSlicer {
5367 slicer_id: 4,
5368 sheet: Some("Q1".to_string()),
5369 range: None,
5370 column: None,
5371 hide_values: Vec::new(),
5372 clear_criteria: false,
5373 title: None,
5374 apply_to_pivot_tables: None,
5375 };
5376 tweak(&mut verb);
5377 verb
5378 }
5379
5380 #[test]
5381 fn build_update_slicer_names_exactly_the_field_each_flag_changes() {
5382 let workbook = slicer_workbook();
5383 let cases: Vec<(VerbTweak, &str, &str)> = vec![
5384 (
5385 Box::new(|verb: &mut EmbeddedObjectVerb| {
5386 let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5387 unreachable!() };
5389 *range = Some("A1:D20".to_string());
5390 }),
5391 "dataRange",
5392 "range",
5393 ),
5394 (
5395 Box::new(|verb: &mut EmbeddedObjectVerb| {
5396 let EmbeddedObjectVerb::UpdateSlicer { clear_criteria, .. } = verb else {
5397 unreachable!() };
5399 *clear_criteria = true;
5400 }),
5401 "filterCriteria",
5402 "clear criteria",
5403 ),
5404 (
5405 Box::new(|verb: &mut EmbeddedObjectVerb| {
5406 let EmbeddedObjectVerb::UpdateSlicer { hide_values, .. } = verb else {
5407 unreachable!() };
5409 *hide_values = vec!["Closed".to_string()];
5410 }),
5411 "filterCriteria",
5412 "criteria",
5413 ),
5414 (
5415 Box::new(|verb: &mut EmbeddedObjectVerb| {
5416 let EmbeddedObjectVerb::UpdateSlicer { column, .. } = verb else {
5417 unreachable!() };
5419 *column = Some(2);
5420 }),
5421 "columnIndex",
5422 "column",
5423 ),
5424 (
5425 Box::new(|verb: &mut EmbeddedObjectVerb| {
5426 let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5427 unreachable!() };
5429 *title = Some("Territory".to_string());
5430 }),
5431 "title",
5432 "title",
5433 ),
5434 (
5435 Box::new(|verb: &mut EmbeddedObjectVerb| {
5436 let EmbeddedObjectVerb::UpdateSlicer {
5437 apply_to_pivot_tables,
5438 ..
5439 } = verb
5440 else {
5441 unreachable!() };
5443 *apply_to_pivot_tables = Some(true);
5444 }),
5445 "applyToPivotTables",
5446 "apply-to-pivot-tables",
5447 ),
5448 ];
5449 for (tweak, field, changed) in cases {
5450 let verb = update_slicer_verb(tweak);
5451 let plan = build_update_slicer(&workbook, &verb, 4).unwrap();
5452 assert_eq!(plan.existing_id, Some(4));
5453 assert_eq!(plan.sheet_id, Some(0));
5454 let request = serde_json::to_value(&plan.request).unwrap();
5455 assert_eq!(
5456 request["updateSlicerSpec"]["fields"], field,
5457 "one flag must name exactly one field"
5458 );
5459 assert_eq!(plan.summary, format!("update slicer 4 ({changed})"));
5460 }
5461 }
5462
5463 #[test]
5464 fn build_update_slicer_refuses_when_an_existing_spec_has_no_range_or_column() {
5465 let workbook = workbook_with_sheet(Sheet {
5468 properties: Some(crate::drive::sheets::types::SheetProperties {
5469 sheet_id: Some(0),
5470 title: "Q1".to_string(),
5471 ..Default::default()
5472 }),
5473 slicers: vec![Slicer {
5474 slicer_id: Some(4),
5475 spec: Some(SlicerSpec {
5476 title: Some("Region".to_string()),
5477 ..Default::default()
5478 }),
5479 position: Some(EmbeddedObjectPosition {
5480 overlay_position: Some(OverlayPosition {
5481 anchor_cell: GridCoordinate {
5482 sheet_id: 0,
5483 row_index: 0,
5484 column_index: 5,
5485 },
5486 ..Default::default()
5487 }),
5488 ..Default::default()
5489 }),
5490 }],
5491 ..Default::default()
5492 });
5493
5494 let verb = update_slicer_verb(|verb| {
5495 let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5496 unreachable!() };
5498 *range = Some("A1:D20".to_string());
5499 });
5500 let err = refusal(build_update_slicer(&workbook, &verb, 4));
5501 assert_invalid(&err, "has no column on record");
5502
5503 let verb = update_slicer_verb(|verb| {
5504 let EmbeddedObjectVerb::UpdateSlicer { column, .. } = verb else {
5505 unreachable!() };
5507 *column = Some(2);
5508 });
5509 let err = refusal(build_update_slicer(&workbook, &verb, 4));
5510 assert_invalid(&err, "has no range on record");
5511 }
5512
5513 #[test]
5514 fn build_update_slicer_treats_a_stored_column_index_of_none_as_column_zero() {
5515 let workbook = workbook_with_sheet(Sheet {
5524 properties: Some(crate::drive::sheets::types::SheetProperties {
5525 sheet_id: Some(0),
5526 title: "Q1".to_string(),
5527 ..Default::default()
5528 }),
5529 slicers: vec![Slicer {
5530 slicer_id: Some(4),
5531 spec: Some(SlicerSpec {
5532 title: Some("Region".to_string()),
5533 data_range: Some(GridRange {
5534 sheet_id: 0,
5535 start_row_index: Some(0),
5536 end_row_index: Some(10),
5537 start_column_index: Some(0),
5538 end_column_index: Some(4),
5539 }),
5540 column_index: None, ..Default::default()
5542 }),
5543 position: Some(EmbeddedObjectPosition {
5544 overlay_position: Some(OverlayPosition {
5545 anchor_cell: GridCoordinate {
5546 sheet_id: 0,
5547 row_index: 0,
5548 column_index: 5,
5549 },
5550 ..Default::default()
5551 }),
5552 ..Default::default()
5553 }),
5554 }],
5555 ..Default::default()
5556 });
5557
5558 let verb = update_slicer_verb(|verb| {
5559 let EmbeddedObjectVerb::UpdateSlicer { range, .. } = verb else {
5560 unreachable!() };
5562 *range = Some("A1:D20".to_string());
5563 });
5564 let plan = build_update_slicer(&workbook, &verb, 4).unwrap();
5565 let request = serde_json::to_value(&plan.request).unwrap();
5566 assert_eq!(request["updateSlicerSpec"]["fields"], "dataRange");
5567 }
5568
5569 #[test]
5570 fn build_update_slicer_refuses_an_unknown_id() {
5571 let workbook = slicer_workbook();
5572 let verb = update_slicer_verb(|verb| {
5573 let EmbeddedObjectVerb::UpdateSlicer { title, .. } = verb else {
5574 unreachable!() };
5576 *title = Some("Territory".to_string());
5577 });
5578 assert!(matches!(
5579 refusal(build_update_slicer(&workbook, &verb, 99)),
5580 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5581 ));
5582 }
5583
5584 #[test]
5585 fn build_delete_slicer_refuses_an_unknown_id() {
5586 let workbook = slicer_workbook();
5587 assert!(matches!(
5588 refusal(build_delete_slicer(&workbook, 99)),
5589 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5590 ));
5591 }
5592
5593 fn move_chart_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5596 let mut verb = EmbeddedObjectVerb::MoveChart {
5597 chart_id: 1,
5598 sheet: Some("Q1".to_string()),
5599 anchor: None,
5600 offset_x: None,
5601 offset_y: None,
5602 width: None,
5603 height: None,
5604 new_sheet: false,
5605 };
5606 tweak(&mut verb);
5607 verb
5608 }
5609
5610 fn move_slicer_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5611 let mut verb = EmbeddedObjectVerb::MoveSlicer {
5612 slicer_id: 4,
5613 sheet: Some("Q1".to_string()),
5614 anchor: None,
5615 offset_x: None,
5616 offset_y: None,
5617 width: None,
5618 height: None,
5619 };
5620 tweak(&mut verb);
5621 verb
5622 }
5623
5624 fn set_move_chart_anchor(verb: &mut EmbeddedObjectVerb, value: &str) {
5625 let EmbeddedObjectVerb::MoveChart { anchor, .. } = verb else {
5626 unreachable!() };
5628 *anchor = Some(value.to_string());
5629 }
5630
5631 fn set_move_slicer_anchor(verb: &mut EmbeddedObjectVerb, value: &str) {
5632 let EmbeddedObjectVerb::MoveSlicer { anchor, .. } = verb else {
5633 unreachable!() };
5635 *anchor = Some(value.to_string());
5636 }
5637
5638 #[test]
5639 fn move_chart_sends_update_embedded_object_position() {
5640 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5641 let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5642 let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5643 let request = serde_json::to_value(&plan.request).unwrap();
5644 let overlay = &request["updateEmbeddedObjectPosition"]["newPosition"]["overlayPosition"];
5645 assert_eq!(overlay["anchorCell"]["rowIndex"], 1);
5646 assert_eq!(overlay["anchorCell"]["columnIndex"], 5);
5647 assert_eq!(
5648 request["updateEmbeddedObjectPosition"]["fields"],
5649 "anchorCell"
5650 );
5651 assert_eq!(plan.sheet_id, Some(0));
5652 assert_eq!(plan.summary, "move chart 1 to Q1!F2");
5653 }
5654
5655 #[test]
5656 fn position_field_mask_is_relative_to_overlay_position() {
5657 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5662 let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5663 let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5664 let request = serde_json::to_value(&plan.request).unwrap();
5665 let fields = request["updateEmbeddedObjectPosition"]["fields"]
5666 .as_str()
5667 .unwrap();
5668 assert_eq!(fields, "anchorCell");
5669 assert_ne!(fields, "overlayPosition.anchorCell");
5670 }
5671
5672 #[test]
5673 fn move_chart_resize_only_carries_the_existing_anchor_forward() {
5674 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5675 let verb = move_chart_verb(|verb| {
5676 let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5677 unreachable!() };
5679 *width = Some(480);
5680 });
5681 let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5682 let request = serde_json::to_value(&plan.request).unwrap();
5683 let overlay = &request["updateEmbeddedObjectPosition"]["newPosition"]["overlayPosition"];
5684 assert_eq!(overlay["anchorCell"]["rowIndex"], 1);
5687 assert_eq!(overlay["anchorCell"]["columnIndex"], 4);
5688 assert_eq!(overlay["widthPixels"], 480);
5689 assert_eq!(
5690 request["updateEmbeddedObjectPosition"]["fields"],
5691 "widthPixels"
5692 );
5693 assert_eq!(plan.summary, "resize chart 1 (width 480)");
5694 }
5695
5696 #[test]
5697 fn move_chart_masks_exactly_the_flags_given() {
5698 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5699 let verb = move_chart_verb(|verb| {
5700 set_move_chart_anchor(verb, "F2");
5701 let EmbeddedObjectVerb::MoveChart {
5702 offset_x,
5703 offset_y,
5704 width,
5705 height,
5706 ..
5707 } = verb
5708 else {
5709 unreachable!() };
5711 *offset_x = Some(3);
5712 *offset_y = Some(4);
5713 *width = Some(480);
5714 *height = Some(300);
5715 });
5716 let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5717 let request = serde_json::to_value(&plan.request).unwrap();
5718 assert_eq!(
5719 request["updateEmbeddedObjectPosition"]["fields"],
5720 "anchorCell,offsetXPixels,offsetYPixels,widthPixels,heightPixels"
5721 );
5722 assert_eq!(
5723 plan.summary,
5724 "move chart 1 to Q1!F2 (offset-x 3, offset-y 4, width 480, height 300)"
5725 );
5726 }
5727
5728 #[test]
5729 fn move_chart_new_sheet_sends_new_sheet_true_and_no_mask() {
5730 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5731 let verb = move_chart_verb(|verb| {
5732 let EmbeddedObjectVerb::MoveChart { new_sheet, .. } = verb else {
5733 unreachable!() };
5735 *new_sheet = true;
5736 });
5737 let plan = build_move_chart(&workbook, &verb, 1).unwrap();
5738 let request = serde_json::to_value(&plan.request).unwrap();
5739 assert_eq!(
5740 request["updateEmbeddedObjectPosition"]["newPosition"]["newSheet"],
5741 true
5742 );
5743 assert!(
5744 request["updateEmbeddedObjectPosition"]
5745 .get("fields")
5746 .is_none(),
5747 "a new-sheet move sends no fields key at all: {request}"
5748 );
5749 assert_eq!(plan.sheet_id, None);
5750 assert_eq!(plan.summary, "move chart 1 to a new sheet");
5751 }
5752
5753 #[test]
5754 fn move_chart_on_its_own_sheet_without_an_anchor_is_refused() {
5755 let sheet = Sheet {
5756 properties: Some(crate::drive::sheets::types::SheetProperties {
5757 sheet_id: Some(0),
5758 title: "Q1".to_string(),
5759 ..Default::default()
5760 }),
5761 charts: vec![EmbeddedChart {
5762 chart_id: Some(1),
5763 spec: Some(ChartSpec::default()),
5764 position: Some(EmbeddedObjectPosition {
5765 sheet_id: Some(9),
5766 new_sheet: Some(true),
5767 ..Default::default()
5768 }),
5769 border: None,
5770 }],
5771 ..Default::default()
5772 };
5773 let workbook = workbook_with_sheet(sheet);
5774 let verb = move_chart_verb(|verb| {
5775 let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5776 unreachable!() };
5778 *width = Some(480);
5779 });
5780 let err = refusal(build_move_chart(&workbook, &verb, 1));
5781 assert_invalid(&err, "has no current overlay position");
5782 }
5783
5784 #[test]
5785 fn move_chart_refuses_an_empty_flag_set() {
5786 let verb = move_chart_verb(|verb| {
5791 let EmbeddedObjectVerb::MoveChart { sheet, .. } = verb else {
5792 unreachable!() };
5794 *sheet = None;
5795 });
5796 let err = validate_verb(&verb).unwrap_err();
5797 assert!(err.contains("nothing to change"), "{err}");
5798 }
5799
5800 #[test]
5801 fn move_slicer_refuses_an_empty_flag_set() {
5802 let verb = move_slicer_verb(|verb| {
5806 let EmbeddedObjectVerb::MoveSlicer { sheet, .. } = verb else {
5807 unreachable!() };
5809 *sheet = None;
5810 });
5811 let err = validate_verb(&verb).unwrap_err();
5812 assert!(err.contains("nothing to change"), "{err}");
5813 }
5814
5815 #[test]
5816 fn move_chart_refuses_a_sheet_only_move() {
5817 let verb = move_chart_verb(|_| {});
5822 let err = validate_verb(&verb).unwrap_err();
5823 assert!(
5824 err.contains("--sheet has no effect without --anchor"),
5825 "{err}"
5826 );
5827 }
5828
5829 #[test]
5830 fn move_slicer_refuses_a_sheet_only_move() {
5831 let verb = move_slicer_verb(|_| {});
5832 let err = validate_verb(&verb).unwrap_err();
5833 assert!(
5834 err.contains("--sheet has no effect without --anchor"),
5835 "{err}"
5836 );
5837 }
5838
5839 #[test]
5840 fn move_chart_refuses_a_sheet_flag_with_no_anchor() {
5841 let verb = move_chart_verb(|verb| {
5845 let EmbeddedObjectVerb::MoveChart { width, .. } = verb else {
5846 unreachable!() };
5848 *width = Some(480);
5849 });
5850 let err = validate_verb(&verb).unwrap_err();
5851 assert!(
5852 err.contains("--sheet has no effect without --anchor"),
5853 "{err}"
5854 );
5855 }
5856
5857 #[test]
5858 fn move_slicer_refuses_a_sheet_flag_with_no_anchor() {
5859 let verb = move_slicer_verb(|verb| {
5860 let EmbeddedObjectVerb::MoveSlicer { width, .. } = verb else {
5861 unreachable!() };
5863 *width = Some(480);
5864 });
5865 let err = validate_verb(&verb).unwrap_err();
5866 assert!(
5867 err.contains("--sheet has no effect without --anchor"),
5868 "{err}"
5869 );
5870 }
5871
5872 #[test]
5873 fn move_chart_refuses_new_sheet_combined_with_anchor() {
5874 let verb = move_chart_verb(|verb| {
5875 set_move_chart_anchor(verb, "F2");
5876 let EmbeddedObjectVerb::MoveChart { new_sheet, .. } = verb else {
5877 unreachable!() };
5879 *new_sheet = true;
5880 });
5881 let err = validate_verb(&verb).unwrap_err();
5882 assert!(err.contains("--new-sheet cannot be combined"), "{err}");
5883 }
5884
5885 #[test]
5886 fn move_chart_refuses_an_anchor_that_names_a_range() {
5887 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5888 let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2:G3"));
5889 let err = refusal(build_move_chart(&workbook, &verb, 1));
5890 assert_invalid(&err, "not a range");
5891 }
5892
5893 fn two_sheet_slicer_workbook() -> Spreadsheet {
5894 let mut workbook = slicer_workbook();
5895 workbook.sheets.push(Sheet {
5896 properties: Some(crate::drive::sheets::types::SheetProperties {
5897 sheet_id: Some(1),
5898 title: "Q2".to_string(),
5899 ..Default::default()
5900 }),
5901 ..Default::default()
5902 });
5903 workbook
5904 }
5905
5906 #[test]
5907 fn move_slicer_across_sheets_uses_the_destination_anchor_sheet_id() {
5908 let workbook = two_sheet_slicer_workbook();
5909 let verb = move_slicer_verb(|verb| {
5910 let EmbeddedObjectVerb::MoveSlicer { sheet, .. } = verb else {
5911 unreachable!() };
5913 *sheet = Some("Q2".to_string());
5914 set_move_slicer_anchor(verb, "B2");
5915 });
5916 let plan = build_move_slicer(&workbook, &verb, 4).unwrap();
5917 assert_eq!(plan.sheet_id, Some(1));
5918 assert_eq!(plan.summary, "move slicer 4 to Q2!B2");
5919 }
5920
5921 #[test]
5922 fn move_slicer_refuses_a_chart_id() {
5923 let workbook = mixed_object_workbook();
5924 let verb = move_slicer_verb(|verb| set_move_slicer_anchor(verb, "F2"));
5925 assert!(matches!(
5926 refusal(build_move_slicer(&workbook, &verb, 1)),
5927 EmbeddedObjectResult::RefusedWrongObjectKind {
5928 object_id: 1,
5929 expected,
5930 found,
5931 } if expected == "slicer" && found == "chart"
5932 ));
5933 }
5934
5935 #[test]
5936 fn move_chart_refuses_a_slicer_id() {
5937 let workbook = mixed_object_workbook();
5938 let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5939 assert!(matches!(
5940 refusal(build_move_chart(&workbook, &verb, 4)),
5941 EmbeddedObjectResult::RefusedWrongObjectKind {
5942 object_id: 4,
5943 expected,
5944 found,
5945 } if expected == "chart" && found == "slicer"
5946 ));
5947 }
5948
5949 #[test]
5950 fn move_chart_refuses_an_unknown_object_id() {
5951 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5952 let verb = move_chart_verb(|verb| set_move_chart_anchor(verb, "F2"));
5953 assert!(matches!(
5954 refusal(build_move_chart(&workbook, &verb, 99)),
5955 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 99 }
5956 ));
5957 }
5958
5959 fn update_chart_border_verb(tweak: impl FnOnce(&mut EmbeddedObjectVerb)) -> EmbeddedObjectVerb {
5962 let mut verb = EmbeddedObjectVerb::UpdateChartBorder {
5963 chart_id: 1,
5964 color: Some("#4A86E8".to_string()),
5965 clear: false,
5966 };
5967 tweak(&mut verb);
5968 verb
5969 }
5970
5971 #[test]
5972 fn update_chart_border_sets_color_style() {
5973 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5974 let verb = update_chart_border_verb(|_| {});
5975 let plan = build_update_chart_border(&workbook, &verb, 1).unwrap();
5976 let request = serde_json::to_value(&plan.request).unwrap();
5977 let body = &request["updateEmbeddedObjectBorder"];
5978 assert_eq!(body["fields"], "colorStyle");
5979 let rgb = &body["border"]["colorStyle"]["rgbColor"];
5980 let red = rgb["red"].as_f64().unwrap();
5981 assert!((red - f64::from(0x4Au8) / 255.0).abs() < 1e-6, "{rgb}");
5982 assert_eq!(plan.summary, "set chart 1 border to #4A86E8");
5983 }
5984
5985 #[test]
5986 fn update_chart_border_clear_sends_an_empty_border() {
5987 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
5988 let verb = update_chart_border_verb(|verb| {
5989 let EmbeddedObjectVerb::UpdateChartBorder { color, clear, .. } = verb else {
5990 unreachable!() };
5992 *color = None;
5993 *clear = true;
5994 });
5995 let plan = build_update_chart_border(&workbook, &verb, 1).unwrap();
5996 let request = serde_json::to_value(&plan.request).unwrap();
5997 let body = &request["updateEmbeddedObjectBorder"];
5998 assert_eq!(body["fields"], "colorStyle");
5999 assert_eq!(body["border"], serde_json::json!({}));
6000 assert_eq!(plan.summary, "clear chart 1 border");
6001 }
6002
6003 #[test]
6004 fn update_chart_border_refuses_a_bad_hex() {
6005 let workbook = workbook_with_sheet(basic_chart_sheet(1, "COLUMN"));
6006 let verb = update_chart_border_verb(|verb| {
6007 let EmbeddedObjectVerb::UpdateChartBorder { color, .. } = verb else {
6008 unreachable!() };
6010 *color = Some("not-a-color".to_string());
6011 });
6012 let err = refusal(build_update_chart_border(&workbook, &verb, 1));
6013 assert_invalid(&err, "is not a color");
6014 }
6015
6016 #[test]
6017 fn update_chart_border_refuses_an_empty_flag_set() {
6018 let verb = update_chart_border_verb(|verb| {
6019 let EmbeddedObjectVerb::UpdateChartBorder { color, .. } = verb else {
6020 unreachable!() };
6022 *color = None;
6023 });
6024 let err = validate_verb(&verb).unwrap_err();
6025 assert!(err.contains("nothing to change"), "{err}");
6026 }
6027
6028 #[test]
6029 fn update_chart_border_refuses_color_and_clear_together() {
6030 let verb = update_chart_border_verb(|verb| {
6031 let EmbeddedObjectVerb::UpdateChartBorder { clear, .. } = verb else {
6032 unreachable!() };
6034 *clear = true;
6035 });
6036 let err = validate_verb(&verb).unwrap_err();
6037 assert!(err.contains("mutually exclusive"), "{err}");
6038 }
6039
6040 #[test]
6041 fn update_chart_border_refuses_a_slicer_id() {
6042 let workbook = mixed_object_workbook();
6043 let verb = update_chart_border_verb(|_| {});
6044 assert!(matches!(
6045 refusal(build_update_chart_border(&workbook, &verb, 4)),
6046 EmbeddedObjectResult::RefusedWrongObjectKind {
6047 object_id: 4,
6048 expected,
6049 found,
6050 } if expected == "chart" && found == "slicer"
6051 ));
6052 }
6053
6054 #[tokio::test]
6055 async fn move_chart_dry_run_reports_the_object_before_the_move() {
6056 let server = wiremock::MockServer::start().await;
6057 let (drive, sheets) = clients(&server).await;
6058 mount_file(
6059 "sheet-1",
6060 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6061 &["folder-1"],
6062 )
6063 .mount(&server)
6064 .await;
6065 mount_folder("folder-1").mount(&server).await;
6066 mount_workbook(serde_json::json!([
6067 {
6068 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
6069 "charts": [{
6070 "chartId": 5,
6071 "spec": {"title": "Sales", "pieChart": {
6072 "domain": {"sourceRange": {"sources": [{"sheetId": 0}]}},
6073 "series": {"sourceRange": {"sources": [{"sheetId": 0}]}},
6074 }},
6075 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
6076 }],
6077 },
6078 ]))
6079 .mount(&server)
6080 .await;
6081 let rules = vec![allow_rule("folder-1")];
6082 let opts = EmbeddedObjectOptions {
6083 spreadsheet_id: "sheet-1".to_string(),
6084 verb: EmbeddedObjectVerb::MoveChart {
6085 chart_id: 5,
6086 sheet: Some("Q1".to_string()),
6087 anchor: Some("F2".to_string()),
6088 offset_x: None,
6089 offset_y: None,
6090 width: None,
6091 height: None,
6092 new_sheet: false,
6093 },
6094 dry_run: true,
6095 lease_token: None,
6096 ledger_path: std::path::PathBuf::new(),
6097 };
6098 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6099 match outcome.result {
6100 EmbeddedObjectResult::WouldChange { object, summary } => {
6101 let object = object.expect("move-chart previews the object before the move");
6102 assert_eq!(object.object_id, 5);
6103 assert_eq!(object.title.as_deref(), Some("Sales"));
6104 assert_eq!(object.position, "Q1!C2", "previews the position pre-move");
6105 assert_eq!(summary, "move chart 5 to Q1!F2");
6106 }
6107 other => panic!("expected WouldChange, got {other:?}"),
6108 }
6109 }
6110
6111 fn outcome_with(
6114 verb: EmbeddedObjectVerb,
6115 file_name: Option<&str>,
6116 result: EmbeddedObjectResult,
6117 ) -> EmbeddedObjectOutcome {
6118 EmbeddedObjectOutcome {
6119 spreadsheet_id: "sheet-1".to_string(),
6120 file_name: file_name.map(str::to_string),
6121 resolved_folder_id: None,
6122 sheet_id: None,
6123 verb,
6124 result,
6125 }
6126 }
6127
6128 fn sample_object() -> EmbeddedObjectSummary {
6129 EmbeddedObjectSummary {
6130 object_id: 5,
6131 kind: "chart".to_string(),
6132 chart_type: Some("PIE".to_string()),
6133 title: Some("Sales".to_string()),
6134 position: "Q1!C2".to_string(),
6135 border: None,
6136 }
6137 }
6138
6139 #[test]
6140 fn describe_lines_renders_would_change_with_and_without_an_object() {
6141 let bare = outcome_with(
6142 add_chart_verb(),
6143 Some("Budget"),
6144 EmbeddedObjectResult::WouldChange {
6145 summary: "add column chart".to_string(),
6146 object: None,
6147 },
6148 );
6149 assert_eq!(
6150 describe_lines(&bare),
6151 vec!["Would add column chart in 'Budget'"]
6152 );
6153
6154 let previewed = outcome_with(
6155 EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6156 Some("Budget"),
6157 EmbeddedObjectResult::WouldChange {
6158 summary: "delete chart 5".to_string(),
6159 object: Some(Box::new(sample_object())),
6160 },
6161 );
6162 let lines = describe_lines(&previewed);
6163 assert_eq!(lines.len(), 2);
6164 assert_eq!(lines[1], " id 5: chart (PIE) 'Sales', anchored Q1!C2");
6165 }
6166
6167 #[test]
6168 fn describe_lines_falls_back_to_the_spreadsheet_id_without_a_file_name() {
6169 let out = outcome_with(
6170 add_chart_verb(),
6171 None,
6172 EmbeddedObjectResult::RefusedNotASpreadsheet {
6173 mime_type: "application/vnd.google-apps.document".to_string(),
6174 },
6175 );
6176 let text = describe(&out);
6177 assert!(text.contains("'sheet-1' is not a Google Sheet"), "{text}");
6178 assert!(text.contains("drive sheets add-chart"), "{text}");
6179 }
6180
6181 #[test]
6182 fn describe_lines_renders_shortcut_and_no_visible_parents() {
6183 let shortcut = outcome_with(
6184 EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6185 Some("Budget"),
6186 EmbeddedObjectResult::RefusedShortcut,
6187 );
6188 let text = describe(&shortcut);
6189 assert!(text.contains("is a shortcut"), "{text}");
6190 assert!(text.contains("delete-slicer"), "{text}");
6191
6192 let orphan = outcome_with(
6193 add_chart_verb(),
6194 Some("Budget"),
6195 EmbeddedObjectResult::RefusedNoVisibleParents,
6196 );
6197 assert!(describe(&orphan).contains("sheets-structure"));
6198 }
6199
6200 #[test]
6201 fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
6202 let none = outcome_with(
6203 add_chart_verb(),
6204 Some("Budget"),
6205 EmbeddedObjectResult::RefusedSheetNotFound {
6206 title: "Q9".to_string(),
6207 available: Vec::new(),
6208 },
6209 );
6210 assert!(
6211 describe(&none).contains("Available: none"),
6212 "{}",
6213 describe(&none)
6214 );
6215
6216 let some = outcome_with(
6217 add_chart_verb(),
6218 Some("Budget"),
6219 EmbeddedObjectResult::RefusedSheetNotFound {
6220 title: "Q9".to_string(),
6221 available: vec!["Q1".to_string(), "Q3".to_string()],
6222 },
6223 );
6224 assert!(
6225 describe(&some).contains("'Q1', 'Q3'"),
6226 "{}",
6227 describe(&some)
6228 );
6229 }
6230
6231 #[test]
6232 fn describe_lines_renders_invalid_range_object_not_found_and_unsupported_chart() {
6233 let invalid = outcome_with(
6234 add_chart_verb(),
6235 Some("Budget"),
6236 EmbeddedObjectResult::RefusedInvalidRange {
6237 detail: "bad anchor".to_string(),
6238 },
6239 );
6240 assert_eq!(describe(&invalid), "Refused: bad anchor");
6241
6242 let missing = outcome_with(
6243 EmbeddedObjectVerb::DeleteChart { chart_id: 7 },
6244 Some("Budget"),
6245 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 7 },
6246 );
6247 let text = describe(&missing);
6248 assert!(text.contains("no chart or slicer with id 7"), "{text}");
6249 assert!(text.contains("list-charts"), "{text}");
6250
6251 let unsupported = outcome_with(
6252 update_chart_verb(7),
6253 Some("Budget"),
6254 EmbeddedObjectResult::RefusedUnsupportedChart {
6255 chart_id: 7,
6256 detail: "chart has no spec to merge onto".to_string(),
6257 },
6258 );
6259 assert_eq!(
6260 describe(&unsupported),
6261 "Refused: chart 7 in 'Budget': chart has no spec to merge onto"
6262 );
6263 }
6264
6265 #[test]
6266 fn describe_lines_renders_wrong_object_kind() {
6267 let out = outcome_with(
6268 update_chart_verb(4),
6269 Some("Budget"),
6270 EmbeddedObjectResult::RefusedWrongObjectKind {
6271 object_id: 4,
6272 expected: "chart".to_string(),
6273 found: "slicer".to_string(),
6274 },
6275 );
6276 assert_eq!(
6277 describe(&out),
6278 "Refused: id 4 in 'Budget' is a slicer, not a chart; run `drive sheets \
6279 list-slicers` to see what exists"
6280 );
6281 }
6282
6283 #[test]
6284 fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
6285 let by_rule = outcome_with(
6286 add_chart_verb(),
6287 Some("Budget"),
6288 EmbeddedObjectResult::Blocked {
6289 decided_by: Some(DecidingRule::Folder {
6290 folder_id: "folder-1".to_string(),
6291 depth: 2,
6292 }),
6293 },
6294 );
6295 let text = describe(&by_rule);
6296 assert!(text.contains("add-chart on 'Budget'"), "{text}");
6297 assert!(text.contains("folder-1"), "{text}");
6298
6299 let by_default = outcome_with(
6300 add_chart_verb(),
6301 Some("Budget"),
6302 EmbeddedObjectResult::Blocked { decided_by: None },
6303 );
6304 let text = describe(&by_default);
6305 assert!(text.contains("default policy"), "{text}");
6306 assert!(text.contains("sheets-structure"), "{text}");
6307 }
6308
6309 #[test]
6310 fn describe_lines_renders_every_lease_refusal_with_the_lease_acquire_hint() {
6311 for (result, phrase) in [
6312 (
6313 EmbeddedObjectResult::RefusedNoLease,
6314 "requires a Drive write lease",
6315 ),
6316 (
6317 EmbeddedObjectResult::RefusedLeaseExpired,
6318 "expired, released, or unknown",
6319 ),
6320 (
6321 EmbeddedObjectResult::RefusedLeaseWrongFile,
6322 "acquired for a different file",
6323 ),
6324 (
6325 EmbeddedObjectResult::RefusedLeaseStale,
6326 "changed since the lease was acquired",
6327 ),
6328 ] {
6329 let out = outcome_with(add_chart_verb(), Some("Budget"), result);
6330 let text = describe(&out);
6331 assert!(text.contains(phrase), "{text}");
6332 assert!(text.contains("drive lease acquire sheet-1"), "{text}");
6333 }
6334 }
6335
6336 #[test]
6337 fn describe_lines_renders_changed_with_and_without_an_id_and_an_object() {
6338 let added = outcome_with(
6339 add_chart_verb(),
6340 Some("Budget"),
6341 EmbeddedObjectResult::Changed {
6342 summary: "add column chart".to_string(),
6343 object_id: Some(42),
6344 object: None,
6345 },
6346 );
6347 assert_eq!(
6348 describe(&added),
6349 "Applied: add column chart (id 42) in 'Budget'"
6350 );
6351
6352 let deleted = outcome_with(
6353 EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6354 Some("Budget"),
6355 EmbeddedObjectResult::Changed {
6356 summary: "delete chart 5".to_string(),
6357 object_id: None,
6358 object: Some(Box::new(sample_object())),
6359 },
6360 );
6361 assert_eq!(
6362 describe_lines(&deleted),
6363 vec![
6364 "Applied: delete chart 5 in 'Budget'".to_string(),
6365 " id 5: chart (PIE) 'Sales', anchored Q1!C2".to_string(),
6366 ]
6367 );
6368 }
6369
6370 #[test]
6371 fn describe_lines_renders_failed() {
6372 let out = outcome_with(
6373 add_chart_verb(),
6374 Some("Budget"),
6375 EmbeddedObjectResult::Failed {
6376 detail: "boom".to_string(),
6377 },
6378 );
6379 assert_eq!(describe(&out), "Failed: boom");
6380 }
6381
6382 #[test]
6383 fn every_result_variant_has_a_distinct_log_status() {
6384 let statuses: Vec<&str> = [
6385 EmbeddedObjectResult::WouldChange {
6386 summary: String::new(),
6387 object: None,
6388 },
6389 EmbeddedObjectResult::RefusedNotASpreadsheet {
6390 mime_type: String::new(),
6391 },
6392 EmbeddedObjectResult::RefusedShortcut,
6393 EmbeddedObjectResult::RefusedNoVisibleParents,
6394 EmbeddedObjectResult::RefusedSheetNotFound {
6395 title: String::new(),
6396 available: Vec::new(),
6397 },
6398 EmbeddedObjectResult::RefusedInvalidRange {
6399 detail: String::new(),
6400 },
6401 EmbeddedObjectResult::RefusedObjectNotFound { object_id: 1 },
6402 EmbeddedObjectResult::RefusedWrongObjectKind {
6403 object_id: 1,
6404 expected: String::new(),
6405 found: String::new(),
6406 },
6407 EmbeddedObjectResult::RefusedUnsupportedChart {
6408 chart_id: 1,
6409 detail: String::new(),
6410 },
6411 EmbeddedObjectResult::Blocked { decided_by: None },
6412 EmbeddedObjectResult::RefusedNoLease,
6413 EmbeddedObjectResult::RefusedLeaseExpired,
6414 EmbeddedObjectResult::RefusedLeaseWrongFile,
6415 EmbeddedObjectResult::RefusedLeaseStale,
6416 EmbeddedObjectResult::Changed {
6417 summary: String::new(),
6418 object_id: None,
6419 object: None,
6420 },
6421 EmbeddedObjectResult::Failed {
6422 detail: String::new(),
6423 },
6424 ]
6425 .iter()
6426 .map(EmbeddedObjectResult::log_status)
6427 .collect();
6428 let distinct: HashSet<&str> = statuses.iter().copied().collect();
6429 assert_eq!(distinct.len(), statuses.len(), "{statuses:?}");
6430 }
6431
6432 #[test]
6433 fn from_lease_refusal_maps_each_refusal_onto_its_own_variant() {
6434 assert!(matches!(
6435 EmbeddedObjectResult::from_no_lease(),
6436 EmbeddedObjectResult::RefusedNoLease
6437 ));
6438 assert!(matches!(
6439 EmbeddedObjectResult::from_lease_expired(),
6440 EmbeddedObjectResult::RefusedLeaseExpired
6441 ));
6442 assert!(matches!(
6443 EmbeddedObjectResult::from_lease_wrong_file(),
6444 EmbeddedObjectResult::RefusedLeaseWrongFile
6445 ));
6446 assert!(matches!(
6447 EmbeddedObjectResult::from_lease_stale(),
6448 EmbeddedObjectResult::RefusedLeaseStale
6449 ));
6450 assert_eq!(
6451 EmbeddedObjectResult::from_lease_failed("boom".to_string()),
6452 EmbeddedObjectResult::Failed {
6453 detail: "boom".to_string()
6454 }
6455 );
6456 }
6457
6458 fn unleased_opts(verb: EmbeddedObjectVerb) -> EmbeddedObjectOptions {
6461 EmbeddedObjectOptions {
6462 spreadsheet_id: "sheet-1".to_string(),
6463 verb,
6464 dry_run: false,
6465 lease_token: None,
6466 ledger_path: std::path::PathBuf::new(),
6467 }
6468 }
6469
6470 #[tokio::test]
6471 async fn an_invalid_verb_is_refused_before_any_call() {
6472 let server = wiremock::MockServer::start().await;
6475 let (drive, sheets) = clients(&server).await;
6476 let verb = add_chart_verb_with(|verb| {
6477 let EmbeddedObjectVerb::AddChart { series, .. } = verb else {
6478 unreachable!() };
6480 series.clear();
6481 });
6482 let rules = vec![allow_rule("folder-1")];
6483 let outcome = embedded_object(&drive, &sheets, &unleased_opts(verb), &rules).await;
6484 assert_eq!(outcome.file_name, None);
6485 assert_invalid(&outcome.result, "at least one --series");
6486 }
6487
6488 #[tokio::test]
6489 async fn a_metadata_fetch_failure_surfaces_as_failed() {
6490 let server = wiremock::MockServer::start().await;
6491 let (drive, sheets) = clients(&server).await;
6492 wiremock::Mock::given(wiremock::matchers::method("GET"))
6493 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
6494 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
6495 .mount(&server)
6496 .await;
6497 let rules: Vec<FolderPermissionRule> = Vec::new();
6498 let outcome =
6499 embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6500 assert!(
6501 matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6502 "{:?}",
6503 outcome.result
6504 );
6505 assert_eq!(outcome.file_name, None);
6506 }
6507
6508 #[tokio::test]
6509 async fn a_shortcut_target_is_refused() {
6510 let server = wiremock::MockServer::start().await;
6511 let (drive, sheets) = clients(&server).await;
6512 mount_file(
6513 "sheet-1",
6514 "application/vnd.google-apps.shortcut",
6515 &["folder-1"],
6516 )
6517 .mount(&server)
6518 .await;
6519 let rules = vec![allow_rule("folder-1")];
6520 let outcome =
6521 embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6522 assert!(matches!(
6523 outcome.result,
6524 EmbeddedObjectResult::RefusedShortcut
6525 ));
6526 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
6527 }
6528
6529 #[tokio::test]
6530 async fn a_non_spreadsheet_target_is_refused() {
6531 let server = wiremock::MockServer::start().await;
6532 let (drive, sheets) = clients(&server).await;
6533 mount_file(
6534 "sheet-1",
6535 "application/vnd.google-apps.document",
6536 &["folder-1"],
6537 )
6538 .mount(&server)
6539 .await;
6540 let rules = vec![allow_rule("folder-1")];
6541 let outcome = embedded_object(
6542 &drive,
6543 &sheets,
6544 &unleased_opts(EmbeddedObjectVerb::DeleteChart { chart_id: 5 }),
6545 &rules,
6546 )
6547 .await;
6548 let rendered = format!("{:?}", outcome.result);
6549 assert!(
6550 matches!(
6551 outcome.result,
6552 EmbeddedObjectResult::RefusedNotASpreadsheet { .. }
6553 ),
6554 "{rendered}"
6555 );
6556 assert!(
6557 rendered.contains("application/vnd.google-apps.document"),
6558 "{rendered}"
6559 );
6560 }
6561
6562 #[tokio::test]
6563 async fn a_target_with_no_visible_parents_is_refused() {
6564 let server = wiremock::MockServer::start().await;
6565 let (drive, sheets) = clients(&server).await;
6566 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
6567 .mount(&server)
6568 .await;
6569 let rules: Vec<FolderPermissionRule> = Vec::new();
6570 let outcome =
6571 embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6572 assert!(matches!(
6573 outcome.result,
6574 EmbeddedObjectResult::RefusedNoVisibleParents
6575 ));
6576 }
6577
6578 #[tokio::test]
6579 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
6580 let server = wiremock::MockServer::start().await;
6581 let (drive, sheets) = clients(&server).await;
6582 mount_file(
6583 "sheet-1",
6584 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6585 &["folder-1"],
6586 )
6587 .mount(&server)
6588 .await;
6589 wiremock::Mock::given(wiremock::matchers::method("GET"))
6590 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
6591 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6592 .mount(&server)
6593 .await;
6594 let rules = vec![allow_rule("folder-1")];
6595 let outcome =
6596 embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6597 assert!(
6598 matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6599 "{:?}",
6600 outcome.result
6601 );
6602 assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
6603 }
6604
6605 #[tokio::test]
6606 async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
6607 let server = wiremock::MockServer::start().await;
6608 let (drive, sheets) = clients(&server).await;
6609 mount_file(
6610 "sheet-1",
6611 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6612 &["folder-1"],
6613 )
6614 .mount(&server)
6615 .await;
6616 mount_folder("folder-1").mount(&server).await;
6617 wiremock::Mock::given(wiremock::matchers::method("GET"))
6618 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6619 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6620 .mount(&server)
6621 .await;
6622 let rules = vec![allow_rule("folder-1")];
6623 let outcome =
6624 embedded_object(&drive, &sheets, &unleased_opts(add_chart_verb()), &rules).await;
6625 assert!(
6626 matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6627 "{:?}",
6628 outcome.result
6629 );
6630 assert_eq!(outcome.sheet_id, None);
6631 }
6632
6633 #[tokio::test]
6634 async fn add_chart_refuses_an_unknown_sheet() {
6635 let server = wiremock::MockServer::start().await;
6636 let (drive, sheets) = clients(&server).await;
6637 mount_file(
6638 "sheet-1",
6639 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6640 &["folder-1"],
6641 )
6642 .mount(&server)
6643 .await;
6644 mount_folder("folder-1").mount(&server).await;
6645 mount_workbook(serde_json::json!([
6646 {"properties": {"sheetId": 0, "title": "Q2", "index": 0}},
6647 ]))
6648 .mount(&server)
6649 .await;
6650 let rules = vec![allow_rule("folder-1")];
6651 let verb = add_chart_verb_with(|verb| {
6652 let EmbeddedObjectVerb::AddChart { sheet, .. } = verb else {
6653 unreachable!() };
6655 *sheet = Some("Nope".to_string());
6656 });
6657 let outcome = embedded_object(&drive, &sheets, &unleased_opts(verb), &rules).await;
6658 let rendered = format!("{:?}", outcome.result);
6659 assert!(
6660 matches!(
6661 outcome.result,
6662 EmbeddedObjectResult::RefusedSheetNotFound { .. }
6663 ),
6664 "{rendered}"
6665 );
6666 assert!(rendered.contains("Nope"), "{rendered}");
6667 assert!(rendered.contains("Q2"), "{rendered}");
6668 }
6669
6670 fn mount_workbook_with_chart_and_slicer() -> wiremock::Mock {
6676 mount_workbook(serde_json::json!([
6677 {
6678 "properties": {"sheetId": 0, "title": "Q1", "index": 0},
6679 "charts": [{
6680 "chartId": 5,
6681 "spec": {"title": "Sales", "basicChart": {"chartType": "COLUMN"}},
6682 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 1, "columnIndex": 2}}},
6683 }],
6684 "slicers": [{
6685 "slicerId": 9,
6686 "spec": {"title": "Region"},
6687 "position": {"overlayPosition": {"anchorCell": {"sheetId": 0, "rowIndex": 0, "columnIndex": 5}}},
6688 }],
6689 },
6690 ]))
6691 }
6692
6693 async fn mount_granted_gate_with_objects(server: &wiremock::MockServer) {
6694 mount_file(
6695 "sheet-1",
6696 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6697 &["folder-1"],
6698 )
6699 .mount(server)
6700 .await;
6701 mount_folder("folder-1").mount(server).await;
6702 mount_workbook_with_chart_and_slicer().mount(server).await;
6703 }
6704
6705 fn fresh_ledger_path() -> std::path::PathBuf {
6706 tempfile::tempdir()
6707 .unwrap()
6708 .keep()
6709 .join("lease-ledger.jsonl")
6710 }
6711
6712 #[tokio::test]
6713 async fn refuses_without_a_lease_when_the_rule_requires_one() {
6714 let server = wiremock::MockServer::start().await;
6715 let (drive, sheets) = clients(&server).await;
6716 mount_granted_gate_with_objects(&server).await;
6717 let rules = vec![allow_rule("folder-1")];
6720 let opts = unleased_opts(EmbeddedObjectVerb::DeleteChart { chart_id: 5 });
6721 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6722 assert!(matches!(
6723 outcome.result,
6724 EmbeddedObjectResult::RefusedNoLease
6725 ));
6726 assert_eq!(outcome.result.log_status(), "refused-no-lease");
6727 }
6728
6729 #[tokio::test]
6730 async fn refuses_an_unknown_lease_token() {
6731 let server = wiremock::MockServer::start().await;
6732 let (drive, sheets) = clients(&server).await;
6733 mount_granted_gate_with_objects(&server).await;
6734 let rules = vec![allow_rule("folder-1")];
6735 let opts = EmbeddedObjectOptions {
6737 spreadsheet_id: "sheet-1".to_string(),
6738 verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6739 dry_run: false,
6740 lease_token: Some("bogus-token".to_string()),
6741 ledger_path: fresh_ledger_path(),
6742 };
6743 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6744 assert!(matches!(
6745 outcome.result,
6746 EmbeddedObjectResult::RefusedLeaseExpired
6747 ));
6748 assert_eq!(outcome.result.log_status(), "refused-lease-expired");
6749 }
6750
6751 #[tokio::test]
6752 async fn refuses_a_lease_bound_to_a_different_file() {
6753 let server = wiremock::MockServer::start().await;
6754 let (drive, sheets) = clients(&server).await;
6755 mount_granted_gate_with_objects(&server).await;
6756 let rules = vec![allow_rule("folder-1")];
6757 let ledger_path = fresh_ledger_path();
6758 let token = seed_lease(&ledger_path, "some-other-sheet", "1");
6760 let opts = EmbeddedObjectOptions {
6761 spreadsheet_id: "sheet-1".to_string(),
6762 verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6763 dry_run: false,
6764 lease_token: Some(token),
6765 ledger_path,
6766 };
6767 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6768 assert!(matches!(
6769 outcome.result,
6770 EmbeddedObjectResult::RefusedLeaseWrongFile
6771 ));
6772 assert_eq!(outcome.result.log_status(), "refused-lease-wrong-file");
6773 }
6774
6775 #[tokio::test]
6776 async fn refuses_a_stale_lease_when_the_file_has_moved() {
6777 let server = wiremock::MockServer::start().await;
6778 let (drive, sheets) = clients(&server).await;
6779 mount_granted_gate_with_objects(&server).await;
6782 let rules = vec![allow_rule("folder-1")];
6783 let ledger_path = fresh_ledger_path();
6784 let token = seed_lease(&ledger_path, "sheet-1", "0");
6785 let opts = EmbeddedObjectOptions {
6786 spreadsheet_id: "sheet-1".to_string(),
6787 verb: EmbeddedObjectVerb::DeleteSlicer { slicer_id: 9 },
6788 dry_run: false,
6789 lease_token: Some(token),
6790 ledger_path,
6791 };
6792 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6793 assert!(matches!(
6794 outcome.result,
6795 EmbeddedObjectResult::RefusedLeaseStale
6796 ));
6797 assert_eq!(outcome.result.log_status(), "refused-lease-stale");
6798 }
6799
6800 #[tokio::test]
6801 async fn a_batch_update_failure_surfaces_as_failed() {
6802 let server = wiremock::MockServer::start().await;
6803 let (drive, sheets) = clients(&server).await;
6804 mount_granted_gate_with_objects(&server).await;
6805 wiremock::Mock::given(wiremock::matchers::method("POST"))
6806 .and(wiremock::matchers::path(
6807 "/v4/spreadsheets/sheet-1:batchUpdate",
6808 ))
6809 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
6810 .mount(&server)
6811 .await;
6812 let rules = vec![allow_rule("folder-1")];
6813 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6814 let opts = EmbeddedObjectOptions {
6815 spreadsheet_id: "sheet-1".to_string(),
6816 verb: update_chart_verb(5),
6817 dry_run: false,
6818 lease_token,
6819 ledger_path,
6820 };
6821 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6822 let rendered = format!("{:?}", outcome.result);
6823 assert!(
6824 matches!(outcome.result, EmbeddedObjectResult::Failed { .. }),
6825 "{rendered}"
6826 );
6827 assert!(rendered.contains("500"), "{rendered}");
6828 }
6829
6830 #[tokio::test]
6831 async fn a_real_delete_chart_reports_the_object_it_discarded() {
6832 let server = wiremock::MockServer::start().await;
6833 let (drive, sheets) = clients(&server).await;
6834 mount_granted_gate_with_objects(&server).await;
6835 wiremock::Mock::given(wiremock::matchers::method("POST"))
6836 .and(wiremock::matchers::path(
6837 "/v4/spreadsheets/sheet-1:batchUpdate",
6838 ))
6839 .respond_with(
6840 wiremock::ResponseTemplate::new(200)
6841 .set_body_json(serde_json::json!({"replies": [{}]})),
6842 )
6843 .mount(&server)
6844 .await;
6845 let rules = vec![allow_rule("folder-1")];
6846 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6847 let opts = EmbeddedObjectOptions {
6848 spreadsheet_id: "sheet-1".to_string(),
6849 verb: EmbeddedObjectVerb::DeleteChart { chart_id: 5 },
6850 dry_run: false,
6851 lease_token,
6852 ledger_path,
6853 };
6854 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6855 let rendered = format!("{:?}", outcome.result);
6856 assert!(
6860 matches!(
6861 outcome.result,
6862 EmbeddedObjectResult::Changed {
6863 object_id: Some(5),
6864 object: Some(_),
6865 ..
6866 }
6867 ),
6868 "{rendered}"
6869 );
6870 assert!(rendered.contains("Sales"), "{rendered}");
6871 assert_eq!(describe_lines(&outcome).len(), 2);
6872 }
6873
6874 #[tokio::test]
6875 async fn a_real_add_chart_parses_a_reply_anchored_at_a1_on_sheet_0() {
6876 let server = wiremock::MockServer::start().await;
6885 let (drive, sheets) = clients(&server).await;
6886 mount_file(
6887 "sheet-1",
6888 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
6889 &["folder-1"],
6890 )
6891 .mount(&server)
6892 .await;
6893 mount_folder("folder-1").mount(&server).await;
6894 mount_workbook(serde_json::json!([
6895 {"properties": {"sheetId": 0, "title": "Sheet1", "index": 0}},
6896 ]))
6897 .mount(&server)
6898 .await;
6899 wiremock::Mock::given(wiremock::matchers::method("POST"))
6900 .and(wiremock::matchers::path(
6901 "/v4/spreadsheets/sheet-1:batchUpdate",
6902 ))
6903 .respond_with(
6904 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6905 "replies": [{
6906 "addChart": {
6907 "chart": {
6908 "chartId": 7,
6909 "spec": {"basicChart": {"chartType": "COLUMN"}},
6910 "position": {"overlayPosition": {"anchorCell": {}}},
6911 },
6912 },
6913 }],
6914 })),
6915 )
6916 .mount(&server)
6917 .await;
6918 let rules = vec![allow_rule("folder-1")];
6919 let (lease_token, ledger_path) = leased_opts_for("sheet-1");
6920 let mut verb = add_chart_verb();
6921 let EmbeddedObjectVerb::AddChart { anchor, .. } = &mut verb else {
6922 unreachable!() };
6924 *anchor = Some("A1".to_string());
6925 let opts = EmbeddedObjectOptions {
6926 spreadsheet_id: "sheet-1".to_string(),
6927 verb,
6928 dry_run: false,
6929 lease_token,
6930 ledger_path,
6931 };
6932 let outcome = embedded_object(&drive, &sheets, &opts, &rules).await;
6933 let rendered = format!("{:?}", outcome.result);
6934 assert!(
6935 matches!(
6936 outcome.result,
6937 EmbeddedObjectResult::Changed {
6938 object_id: Some(7),
6939 ..
6940 }
6941 ),
6942 "{rendered}"
6943 );
6944 }
6945}