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