1use std::path::PathBuf;
73use std::time::{Duration, Instant};
74
75use serde::Serialize;
76
77use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
78use crate::drive::client::DriveClient;
79use crate::drive::files_api::FilesApi;
80use crate::drive::lease::check::{
81 conclude_native_leased_write, gate_optional_leased_write, recovery_note, FromLeaseRefusal,
82 LeaseGateRefusal, LeasedWrite,
83};
84use crate::drive::lease::ledger::LeaseBackup;
85use crate::drive::sheets::api::{
86 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
87};
88use crate::drive::sheets::client::SheetsClient;
89use crate::drive::sheets::format::{normalize_hex_for_display, parse_hex_color};
90use crate::drive::sheets::grid_range;
91use crate::drive::sheets::target_gate;
92use crate::drive::sheets::types::{
93 AddSheetRequest, BatchUpdateRequestItem, BatchUpdateResponse, ColorStyle,
94 DeleteDimensionRequest, DeleteRangeRequest, DeleteSheetRequest, Dimension, DimensionRange,
95 DuplicateSheetRequest, GridProperties, GridPropertiesUpdate, GridRange, InsertDimensionRequest,
96 InsertRangeRequest, IterativeCalculationSettings, MoveDimensionRequest, NewSheetProperties,
97 RecalculationInterval, SheetProperties, SheetPropertiesUpdate, ShiftDimension, Spreadsheet,
98 SpreadsheetPropertiesUpdate, UpdateSheetPropertiesRequest, UpdateSpreadsheetPropertiesRequest,
99};
100use crate::drive::types::SheetTargetRefusal;
101use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
102use crate::request_log::{self, DriveMutationOutcome};
103
104#[derive(Debug, Clone, PartialEq)]
116pub enum StructureVerb {
117 AddSheet {
119 title: String,
121 index: Option<i64>,
123 rows: Option<i64>,
125 columns: Option<i64>,
127 },
128 RenameSheet {
130 sheet: String,
132 new_title: String,
134 },
135 InsertRows {
137 sheet: String,
139 at: i64,
141 count: i64,
143 },
144 InsertColumns {
146 sheet: String,
148 at: i64,
150 count: i64,
152 },
153 InsertRange {
160 sheet: String,
162 start_row: i64,
164 end_row: i64,
166 start_column: i64,
168 end_column: i64,
170 shift: ShiftDimension,
172 },
173 MoveRows {
176 sheet: String,
178 at: i64,
180 count: i64,
182 before: i64,
186 },
187 MoveColumns {
191 sheet: String,
193 at: i64,
195 count: i64,
197 before: i64,
200 },
201 DeleteSheet {
203 sheet: String,
205 },
206 DeleteRows {
208 sheet: String,
210 at: i64,
212 count: i64,
214 },
215 DeleteColumns {
217 sheet: String,
219 at: i64,
221 count: i64,
223 },
224 DeleteRange {
232 sheet: String,
234 start_row: i64,
236 end_row: i64,
238 start_column: i64,
240 end_column: i64,
242 shift: ShiftDimension,
244 },
245 DuplicateSheet {
247 sheet: String,
249 title: Option<String>,
251 index: Option<i64>,
253 },
254 ReorderSheet {
256 sheet: String,
258 index: i64,
260 },
261 SetSheetVisibility {
263 sheet: String,
265 hidden: bool,
267 },
268 UpdateSheetProperties {
273 sheet: String,
275 freeze_rows: Option<i64>,
277 freeze_columns: Option<i64>,
279 tab_color: Option<String>,
281 clear_tab_color: bool,
285 right_to_left: Option<bool>,
287 hide_gridlines: Option<bool>,
289 },
290 UpdateWorkbookProperties {
294 locale: Option<String>,
296 time_zone: Option<String>,
299 auto_recalc: Option<RecalculationInterval>,
301 iterative_calculation: Option<IterativeCalculationToggle>,
306 iterative_calculation_max_iterations: Option<i64>,
309 iterative_calculation_convergence_threshold: Option<f64>,
312 },
313}
314
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum IterativeCalculationToggle {
327 On,
330 Off,
332}
333
334impl StructureVerb {
335 const fn log_operation(&self) -> &'static str {
341 match self {
342 Self::AddSheet { .. } => "sheets-add-sheet",
343 Self::RenameSheet { .. } => "sheets-rename-sheet",
344 Self::InsertRows { .. } => "sheets-insert-rows",
345 Self::InsertColumns { .. } => "sheets-insert-columns",
346 Self::InsertRange { .. } => "sheets-insert-range",
347 Self::MoveRows { .. } => "sheets-move-rows",
348 Self::MoveColumns { .. } => "sheets-move-columns",
349 Self::DeleteSheet { .. } => "sheets-delete-sheet",
350 Self::DeleteRows { .. } => "sheets-delete-rows",
351 Self::DeleteColumns { .. } => "sheets-delete-columns",
352 Self::DeleteRange { .. } => "sheets-delete-range",
353 Self::DuplicateSheet { .. } => "sheets-duplicate-sheet",
354 Self::ReorderSheet { .. } => "sheets-reorder-sheet",
355 Self::SetSheetVisibility { hidden: true, .. } => "sheets-hide-sheet",
356 Self::SetSheetVisibility { hidden: false, .. } => "sheets-show-sheet",
357 Self::UpdateSheetProperties { .. } => "sheets-update-sheet-properties",
358 Self::UpdateWorkbookProperties { .. } => "sheets-update-workbook-properties",
359 }
360 }
361
362 const fn gate_operation(&self) -> DriveOperation {
368 match self {
369 Self::AddSheet { .. }
370 | Self::RenameSheet { .. }
371 | Self::InsertRows { .. }
372 | Self::InsertColumns { .. }
373 | Self::InsertRange { .. }
374 | Self::MoveRows { .. }
375 | Self::MoveColumns { .. }
376 | Self::DuplicateSheet { .. }
377 | Self::ReorderSheet { .. }
378 | Self::SetSheetVisibility { .. }
379 | Self::UpdateSheetProperties { .. }
380 | Self::UpdateWorkbookProperties { .. } => DriveOperation::SheetsStructure,
381 Self::DeleteSheet { .. }
382 | Self::DeleteRows { .. }
383 | Self::DeleteColumns { .. }
384 | Self::DeleteRange { .. } => DriveOperation::SheetsDelete,
385 }
386 }
387
388 const fn label(&self) -> &'static str {
392 match self {
393 Self::AddSheet { .. } => "add-sheet",
394 Self::RenameSheet { .. } => "rename-sheet",
395 Self::InsertRows { .. } => "insert-rows",
396 Self::InsertColumns { .. } => "insert-columns",
397 Self::InsertRange { .. } => "insert-range",
398 Self::MoveRows { .. } => "move-rows",
399 Self::MoveColumns { .. } => "move-columns",
400 Self::DeleteSheet { .. } => "delete-sheet",
401 Self::DeleteRows { .. } => "delete-rows",
402 Self::DeleteColumns { .. } => "delete-columns",
403 Self::DeleteRange { .. } => "delete-range",
404 Self::DuplicateSheet { .. } => "duplicate-sheet",
405 Self::ReorderSheet { .. } => "reorder-sheet",
406 Self::SetSheetVisibility { hidden: true, .. } => "hide-sheet",
407 Self::SetSheetVisibility { hidden: false, .. } => "show-sheet",
408 Self::UpdateSheetProperties { .. } => "update-sheet-properties",
409 Self::UpdateWorkbookProperties { .. } => "update-workbook-properties",
410 }
411 }
412
413 fn sheet_title(&self) -> Option<&str> {
418 match self {
419 Self::AddSheet { title, .. } => Some(title),
420 Self::RenameSheet { sheet, .. }
421 | Self::InsertRows { sheet, .. }
422 | Self::InsertColumns { sheet, .. }
423 | Self::InsertRange { sheet, .. }
424 | Self::MoveRows { sheet, .. }
425 | Self::MoveColumns { sheet, .. }
426 | Self::DeleteSheet { sheet }
427 | Self::DeleteRows { sheet, .. }
428 | Self::DeleteColumns { sheet, .. }
429 | Self::DeleteRange { sheet, .. }
430 | Self::DuplicateSheet { sheet, .. }
431 | Self::ReorderSheet { sheet, .. }
432 | Self::SetSheetVisibility { sheet, .. }
433 | Self::UpdateSheetProperties { sheet, .. } => Some(sheet),
434 Self::UpdateWorkbookProperties { .. } => None,
435 }
436 }
437
438 fn new_sheet_title(&self) -> Option<&String> {
449 match self {
450 Self::RenameSheet { new_title, .. } => Some(new_title),
451 Self::DuplicateSheet { title, .. } => title.as_ref(),
452 Self::AddSheet { .. }
453 | Self::InsertRows { .. }
454 | Self::InsertColumns { .. }
455 | Self::InsertRange { .. }
456 | Self::MoveRows { .. }
457 | Self::MoveColumns { .. }
458 | Self::DeleteSheet { .. }
459 | Self::DeleteRows { .. }
460 | Self::DeleteColumns { .. }
461 | Self::DeleteRange { .. }
462 | Self::ReorderSheet { .. }
463 | Self::SetSheetVisibility { .. }
464 | Self::UpdateSheetProperties { .. }
465 | Self::UpdateWorkbookProperties { .. } => None,
466 }
467 }
468
469 const fn dimension(&self) -> Option<Dimension> {
473 match self {
474 Self::InsertRows { .. } | Self::DeleteRows { .. } | Self::MoveRows { .. } => {
475 Some(Dimension::Rows)
476 }
477 Self::InsertColumns { .. } | Self::DeleteColumns { .. } | Self::MoveColumns { .. } => {
478 Some(Dimension::Columns)
479 }
480 Self::AddSheet { .. }
481 | Self::RenameSheet { .. }
482 | Self::DeleteSheet { .. }
483 | Self::InsertRange { .. }
484 | Self::DeleteRange { .. }
485 | Self::DuplicateSheet { .. }
486 | Self::ReorderSheet { .. }
487 | Self::SetSheetVisibility { .. }
488 | Self::UpdateSheetProperties { .. }
489 | Self::UpdateWorkbookProperties { .. } => None,
490 }
491 }
492}
493
494#[derive(Debug, Clone)]
496pub struct StructureOptions {
497 pub spreadsheet_id: String,
499 pub verb: StructureVerb,
501 pub dry_run: bool,
503 pub lease_token: Option<String>,
508 pub ledger_path: PathBuf,
512}
513
514#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
520pub struct SheetSnapshot {
521 #[serde(skip_serializing_if = "Option::is_none")]
523 pub sheet_id: Option<i64>,
524 pub title: String,
526 #[serde(skip_serializing_if = "Option::is_none")]
528 pub row_count: Option<i64>,
529 #[serde(skip_serializing_if = "Option::is_none")]
531 pub column_count: Option<i64>,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 pub frozen_row_count: Option<i64>,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub frozen_column_count: Option<i64>,
538 #[serde(skip_serializing_if = "Option::is_none")]
541 pub right_to_left: Option<bool>,
542 #[serde(skip_serializing_if = "Option::is_none")]
544 pub hide_gridlines: Option<bool>,
545}
546
547impl SheetSnapshot {
548 fn from_properties(props: &SheetProperties) -> Self {
549 let grid = props.grid_properties.as_ref();
550 Self {
551 sheet_id: props.sheet_id,
552 title: props.title.clone(),
553 row_count: grid.and_then(|g| g.row_count),
554 column_count: grid.and_then(|g| g.column_count),
555 frozen_row_count: grid.and_then(|g| g.frozen_row_count),
556 frozen_column_count: grid.and_then(|g| g.frozen_column_count),
557 right_to_left: props.right_to_left,
558 hide_gridlines: grid.and_then(|g| g.hide_gridlines),
559 }
560 }
561}
562
563#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
565#[serde(tag = "status", rename_all = "kebab-case")]
566pub enum StructureResult {
567 WouldChange {
569 #[serde(skip_serializing_if = "Option::is_none")]
578 sheet: Option<Box<SheetSnapshot>>,
579 sheet_count: usize,
581 },
582 RefusedNotASpreadsheet {
585 mime_type: String,
587 },
588 RefusedShortcut,
590 RefusedNoVisibleParents,
594 RefusedSheetNotFound {
601 title: String,
603 available: Vec<String>,
605 },
606 RefusedSheetExists {
611 title: String,
613 },
614 RefusedInvalidRange {
627 detail: String,
629 },
630 Blocked {
632 decided_by: Option<DecidingRule>,
635 },
636 RefusedNoLease,
639 RefusedLeaseExpired,
642 RefusedLeaseWrongFile,
644 RefusedLeaseStale,
647 Unchanged {
652 detail: String,
654 },
655 Changed {
657 #[serde(skip_serializing_if = "Option::is_none")]
662 sheet: Option<Box<SheetSnapshot>>,
663 #[serde(skip_serializing_if = "Option::is_none")]
666 sheet_id: Option<i64>,
667 #[serde(skip_serializing_if = "Option::is_none")]
681 backup: Option<Box<LeaseBackup>>,
682 },
683 AppliedReplyUnreadable {
689 summary: String,
691 detail: String,
693 #[serde(skip_serializing_if = "Option::is_none")]
697 sheet: Option<Box<SheetSnapshot>>,
698 #[serde(skip_serializing_if = "Option::is_none")]
702 sheet_id: Option<i64>,
703 #[serde(skip_serializing_if = "Option::is_none")]
707 backup: Option<Box<LeaseBackup>>,
708 },
709 Failed {
711 detail: String,
713 },
714}
715
716impl FromLeaseRefusal for StructureResult {
717 fn from_no_lease() -> Self {
718 Self::RefusedNoLease
719 }
720 fn from_lease_expired() -> Self {
721 Self::RefusedLeaseExpired
722 }
723 fn from_lease_wrong_file() -> Self {
724 Self::RefusedLeaseWrongFile
725 }
726 fn from_lease_stale() -> Self {
727 Self::RefusedLeaseStale
728 }
729 fn from_lease_failed(detail: String) -> Self {
730 Self::Failed { detail }
731 }
732}
733
734impl StructureResult {
735 fn log_status(&self) -> &'static str {
741 match self {
742 Self::WouldChange { .. } => "would-change",
743 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
744 Self::RefusedShortcut => "refused-shortcut",
745 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
746 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
747 Self::RefusedSheetExists { .. } => "refused-sheet-exists",
748 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
749 Self::Blocked { .. } => "blocked",
750 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
751 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
752 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
753 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
754 Self::Unchanged { .. } => "unchanged",
755 Self::Changed { .. } => "changed",
756 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
757 Self::Failed { .. } => "failed",
758 }
759 }
760}
761
762#[derive(Debug, Clone, Serialize, PartialEq)]
767pub struct StructureOutcome {
768 pub spreadsheet_id: String,
770 #[serde(skip_serializing_if = "Option::is_none")]
772 pub file_name: Option<String>,
773 #[serde(skip_serializing_if = "Option::is_none")]
776 pub resolved_folder_id: Option<String>,
777 #[serde(skip)]
784 pub verb: StructureVerb,
785 pub result: StructureResult,
787}
788
789impl JsonlSerialize for StructureOutcome {
790 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
791 write_scalar_jsonl(self, out)
792 }
793}
794
795fn dimension_range(sheet_id: i64, dimension: Dimension, at: i64, count: i64) -> DimensionRange {
803 let start = at - 1;
804 DimensionRange {
805 sheet_id,
806 dimension,
807 start_index: start,
808 end_index: start + count,
809 }
810}
811
812const fn move_destination_index(before: i64) -> i64 {
821 before - 1
822}
823
824const fn move_landing_span(before: i64, count: i64, last: i64) -> (i64, i64) {
834 if before > last {
835 (before - count, before - 1)
840 } else {
841 (before, before + count - 1)
844 }
845}
846
847pub async fn structure(
853 drive: &DriveClient,
854 sheets: &SheetsClient,
855 opts: &StructureOptions,
856 rules: &[FolderPermissionRule],
857) -> StructureOutcome {
858 let started = Instant::now();
859 let outcome = structure_inner(drive, sheets, opts, rules).await;
860 if !opts.dry_run {
863 record_attempt(&outcome, opts, started.elapsed());
864 }
865 outcome
866}
867
868async fn structure_inner(
869 drive: &DriveClient,
870 sheets: &SheetsClient,
871 opts: &StructureOptions,
872 rules: &[FolderPermissionRule],
873) -> StructureOutcome {
874 let bare = |result| StructureOutcome {
875 spreadsheet_id: opts.spreadsheet_id.clone(),
876 file_name: None,
877 resolved_folder_id: None,
878 verb: opts.verb.clone(),
879 result,
880 };
881
882 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
888 drive,
889 &opts.spreadsheet_id,
890 opts.verb.gate_operation(),
891 rules,
892 )
893 .await
894 {
895 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
896 return bare(StructureResult::Failed { detail })
897 }
898 target_gate::TargetGateOutcome::Refused { target, refusal } => {
899 let result = match refusal {
900 SheetTargetRefusal::Shortcut => StructureResult::RefusedShortcut,
901 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
902 StructureResult::RefusedNotASpreadsheet { mime_type }
903 }
904 SheetTargetRefusal::NoVisibleParents => StructureResult::RefusedNoVisibleParents,
905 };
906 return StructureOutcome {
907 spreadsheet_id: opts.spreadsheet_id.clone(),
908 file_name: Some(target.name),
909 resolved_folder_id: None,
910 verb: opts.verb.clone(),
911 result,
912 };
913 }
914 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
917 return StructureOutcome {
918 spreadsheet_id: opts.spreadsheet_id.clone(),
919 file_name: Some(target.name),
920 resolved_folder_id: None,
921 verb: opts.verb.clone(),
922 result: StructureResult::Failed { detail },
923 };
924 }
925 target_gate::TargetGateOutcome::Gated {
926 target,
927 decision,
928 resolved_folder_id,
929 requires_lease,
930 } => (target, decision, resolved_folder_id, requires_lease),
931 };
932
933 let gated = |result| StructureOutcome {
934 spreadsheet_id: opts.spreadsheet_id.clone(),
935 file_name: Some(target.name.clone()),
936 resolved_folder_id: resolved_folder_id.clone(),
937 verb: opts.verb.clone(),
938 result,
939 };
940
941 if decision.verdict == write_gate::Verdict::Deny {
942 return gated(StructureResult::Blocked {
943 decided_by: decision.decided_by,
944 });
945 }
946
947 let api = SheetsApi::new(sheets);
953 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
954 Ok(workbook) => workbook,
955 Err(err) => {
956 return gated(StructureResult::Failed {
957 detail: format!("{err:#}"),
958 })
959 }
960 };
961
962 let sheet = match resolve_sheet(&workbook, &opts.verb) {
963 Ok(sheet) => sheet,
964 Err(result) => return gated(result),
965 };
966 let sheet_count = workbook.sheets.len();
967
968 if let Err(result) = validate_verb_args(&workbook, &opts.verb, sheet.as_ref()) {
969 return gated(result);
970 }
971
972 if let Some(detail) = already_applied(&workbook, &opts.verb, sheet.as_ref()) {
975 return gated(StructureResult::Unchanged { detail });
976 }
977
978 if opts.dry_run {
979 return gated(StructureResult::WouldChange {
983 sheet: sheet.map(Box::new),
984 sheet_count,
985 });
986 }
987
988 let request = match build_request(&opts.verb, sheet.as_ref()) {
997 Ok(request) => request,
998 Err(detail) => return gated(StructureResult::Failed { detail }),
999 };
1000
1001 let files_api = FilesApi::new(drive);
1010 let leased = LeasedWrite {
1011 log_prefix: "drive sheets structure",
1012 operation: opts.verb.log_operation(),
1013 ledger_path: &opts.ledger_path,
1014 file_id: &opts.spreadsheet_id,
1015 };
1016 let lease_grant = match gate_optional_leased_write(
1017 leased,
1018 &files_api,
1019 requires_lease,
1020 opts.lease_token.as_deref(),
1021 )
1022 .await
1023 {
1024 Ok(grant) => grant,
1025 Err(err) => return gated(err.into_result()),
1026 };
1027
1028 let result = match conclude_native_leased_write(
1029 leased,
1030 &lease_grant,
1031 &files_api,
1032 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
1033 |err| format!("{err:#}"),
1034 )
1035 .await
1036 {
1037 Ok(BatchUpdateOutcome::Applied(response)) => StructureResult::Changed {
1038 sheet_id: added_sheet_id(&response).or_else(|| sheet.as_ref().and_then(|s| s.sheet_id)),
1039 sheet: sheet.map(Box::new),
1040 backup: lease_grant
1041 .as_ref()
1042 .map(|grant| Box::new(grant.backup.clone())),
1043 },
1044 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
1046 StructureResult::AppliedReplyUnreadable {
1047 summary: applied_summary(&opts.verb),
1048 detail,
1049 sheet_id: if adds_a_sheet(&opts.verb) {
1053 None
1054 } else {
1055 sheet.as_ref().and_then(|s| s.sheet_id)
1056 },
1057 sheet: sheet.map(Box::new),
1058 backup: lease_grant
1059 .as_ref()
1060 .map(|grant| Box::new(grant.backup.clone())),
1061 }
1062 }
1063 Err(err) => StructureResult::Failed {
1064 detail: format!("{err:#}"),
1065 },
1066 };
1067 drop(lease_grant);
1068 gated(result)
1069}
1070
1071fn resolve_sheet(
1086 workbook: &Spreadsheet,
1087 verb: &StructureVerb,
1088) -> Result<Option<SheetSnapshot>, StructureResult> {
1089 let Some(wanted) = verb.sheet_title() else {
1090 return Ok(None);
1091 };
1092 let found = workbook
1093 .sheets
1094 .iter()
1095 .filter_map(|sheet| sheet.properties.as_ref())
1096 .find(|props| props.title == wanted);
1097
1098 match verb {
1099 StructureVerb::AddSheet { .. } => match found {
1100 Some(_) => Err(StructureResult::RefusedSheetExists {
1101 title: wanted.to_string(),
1102 }),
1103 None => Ok(None),
1104 },
1105 StructureVerb::RenameSheet { new_title, .. } => match found {
1106 Some(props) => {
1107 let collides = new_title != wanted && workbook.has_sheet_titled(new_title);
1108 if collides {
1109 return Err(StructureResult::RefusedSheetExists {
1110 title: new_title.clone(),
1111 });
1112 }
1113 Ok(Some(SheetSnapshot::from_properties(props)))
1114 }
1115 None => Err(StructureResult::RefusedSheetNotFound {
1116 title: wanted.to_string(),
1117 available: workbook.sheet_titles(),
1118 }),
1119 },
1120 StructureVerb::DuplicateSheet { title, .. } => match found {
1126 Some(props) => {
1127 if let Some(new_title) = title {
1128 let collides = workbook.has_sheet_titled(new_title);
1129 if collides {
1130 return Err(StructureResult::RefusedSheetExists {
1131 title: new_title.clone(),
1132 });
1133 }
1134 }
1135 Ok(Some(SheetSnapshot::from_properties(props)))
1136 }
1137 None => Err(StructureResult::RefusedSheetNotFound {
1138 title: wanted.to_string(),
1139 available: workbook.sheet_titles(),
1140 }),
1141 },
1142 _ => match found {
1143 Some(props) => Ok(Some(SheetSnapshot::from_properties(props))),
1144 None => Err(StructureResult::RefusedSheetNotFound {
1145 title: wanted.to_string(),
1146 available: workbook.sheet_titles(),
1147 }),
1148 },
1149 }
1150}
1151
1152fn already_applied(
1156 workbook: &Spreadsheet,
1157 verb: &StructureVerb,
1158 sheet: Option<&SheetSnapshot>,
1159) -> Option<String> {
1160 let StructureVerb::SetSheetVisibility {
1161 sheet: title,
1162 hidden,
1163 } = verb
1164 else {
1165 return None;
1166 };
1167 let target_id = sheet.and_then(|s| s.sheet_id)?;
1168 let currently_hidden = workbook
1169 .sheets
1170 .iter()
1171 .filter_map(|s| s.properties.as_ref())
1172 .find(|props| props.sheet_id == Some(target_id))?
1173 .hidden
1174 .unwrap_or(false);
1175 (currently_hidden == *hidden).then(|| {
1176 let state = if *hidden { "hidden" } else { "shown" };
1177 format!("sheet '{title}' is already {state}")
1178 })
1179}
1180
1181fn validate_verb_args(
1187 workbook: &Spreadsheet,
1188 verb: &StructureVerb,
1189 sheet: Option<&SheetSnapshot>,
1190) -> Result<(), StructureResult> {
1191 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1192
1193 match verb {
1194 StructureVerb::AddSheet {
1195 index,
1196 rows,
1197 columns,
1198 ..
1199 } => {
1200 if let Some(rows) = rows {
1201 if *rows < 1 {
1202 return invalid(format!("--rows must be at least 1, got {rows}"));
1203 }
1204 }
1205 if let Some(columns) = columns {
1206 if *columns < 1 {
1207 return invalid(format!("--columns must be at least 1, got {columns}"));
1208 }
1209 }
1210 if let Some(index) = index {
1211 let max = workbook.sheets.len() as i64;
1212 if *index < 0 || *index > max {
1213 return invalid(format!(
1214 "--index must be between 0 and {max} inclusive (the workbook has \
1215 {max} sheet(s)), got {index}"
1216 ));
1217 }
1218 }
1219 Ok(())
1220 }
1221 StructureVerb::RenameSheet { .. }
1222 | StructureVerb::DeleteSheet { .. }
1223 | StructureVerb::DuplicateSheet { index: None, .. }
1224 | StructureVerb::SetSheetVisibility { hidden: false, .. } => Ok(()),
1225 StructureVerb::InsertRows { at, count, .. } => {
1226 validate_insert_bounds(Dimension::Rows, *at, *count, sheet)
1227 }
1228 StructureVerb::InsertColumns { at, count, .. } => {
1229 validate_insert_bounds(Dimension::Columns, *at, *count, sheet)
1230 }
1231 StructureVerb::MoveRows {
1232 at, count, before, ..
1233 } => validate_move_bounds(Dimension::Rows, *at, *count, *before, sheet),
1234 StructureVerb::MoveColumns {
1235 at, count, before, ..
1236 } => validate_move_bounds(Dimension::Columns, *at, *count, *before, sheet),
1237 StructureVerb::DeleteRows { at, count, .. } => {
1238 validate_delete_dimension_bounds(Dimension::Rows, *at, *count, sheet)
1239 }
1240 StructureVerb::DeleteColumns { at, count, .. } => {
1241 validate_delete_dimension_bounds(Dimension::Columns, *at, *count, sheet)
1242 }
1243 StructureVerb::InsertRange {
1244 start_row,
1245 end_row,
1246 start_column,
1247 end_column,
1248 ..
1249 }
1250 | StructureVerb::DeleteRange {
1251 start_row,
1252 end_row,
1253 start_column,
1254 end_column,
1255 ..
1256 } => validate_range_bounds(*start_row, *end_row, *start_column, *end_column, sheet),
1257 StructureVerb::DuplicateSheet {
1258 index: Some(index), ..
1259 } => {
1260 let max = workbook.sheets.len() as i64;
1263 if *index < 0 || *index > max {
1264 return invalid(format!(
1265 "--index must be between 0 and {max} inclusive (the workbook has \
1266 {max} sheet(s)), got {index}"
1267 ));
1268 }
1269 Ok(())
1270 }
1271 StructureVerb::ReorderSheet { index, .. } => {
1272 let len = workbook.sheets.len() as i64;
1276 let max = len - 1;
1277 if *index < 0 || *index > max {
1278 return invalid(format!(
1279 "--index must be between 0 and {max} inclusive (the workbook has \
1280 {len} sheet(s)), got {index}"
1281 ));
1282 }
1283 Ok(())
1284 }
1285 StructureVerb::SetSheetVisibility {
1286 sheet: title,
1287 hidden: true,
1288 } => {
1289 let target_id = sheet.and_then(|s| s.sheet_id);
1290 let another_stays_visible = workbook
1291 .sheets
1292 .iter()
1293 .filter_map(|s| s.properties.as_ref())
1294 .filter(|props| props.sheet_id != target_id)
1295 .any(|props| !props.hidden.unwrap_or(false));
1296 if !another_stays_visible {
1297 return invalid(format!(
1298 "hiding '{title}' would leave the workbook with no visible sheets; \
1299 Sheets requires at least one"
1300 ));
1301 }
1302 Ok(())
1303 }
1304 StructureVerb::UpdateSheetProperties {
1305 sheet: _title,
1306 freeze_rows,
1307 freeze_columns,
1308 tab_color,
1309 clear_tab_color,
1310 right_to_left,
1311 hide_gridlines,
1312 } => {
1313 if freeze_rows.is_none()
1314 && freeze_columns.is_none()
1315 && tab_color.is_none()
1316 && !clear_tab_color
1317 && right_to_left.is_none()
1318 && hide_gridlines.is_none()
1319 {
1320 return invalid(
1321 "update-sheet-properties requires at least one property to change".to_string(),
1322 );
1323 }
1324 if tab_color.is_some() && *clear_tab_color {
1325 return invalid(
1326 "--tab-color and --clear-tab-color are mutually exclusive".to_string(),
1327 );
1328 }
1329 if let Some(hex) = tab_color {
1330 if let Err(detail) = parse_hex_color(hex) {
1331 return invalid(detail);
1332 }
1333 }
1334 if let Some(rows) = freeze_rows {
1335 check_freeze_bound(
1336 "--freeze-rows",
1337 "row",
1338 *rows,
1339 sheet.and_then(|s| s.row_count),
1340 )?;
1341 }
1342 if let Some(columns) = freeze_columns {
1343 check_freeze_bound(
1344 "--freeze-columns",
1345 "column",
1346 *columns,
1347 sheet.and_then(|s| s.column_count),
1348 )?;
1349 }
1350 Ok(())
1351 }
1352 StructureVerb::UpdateWorkbookProperties {
1353 locale,
1354 time_zone,
1355 auto_recalc,
1356 iterative_calculation,
1357 iterative_calculation_max_iterations,
1358 iterative_calculation_convergence_threshold,
1359 } => {
1360 let touches_iterative_settings = iterative_calculation_max_iterations.is_some()
1361 || iterative_calculation_convergence_threshold.is_some();
1362 if touches_iterative_settings
1363 && *iterative_calculation != Some(IterativeCalculationToggle::On)
1364 {
1365 return invalid(
1366 "--iterative-calculation-max-iterations/\
1367 --iterative-calculation-convergence-threshold require \
1368 --iterative-calculation on"
1369 .to_string(),
1370 );
1371 }
1372 if let Some(max_iterations) = iterative_calculation_max_iterations {
1373 if *max_iterations < 1 {
1374 return invalid(format!(
1375 "--iterative-calculation-max-iterations must be at least 1, got \
1376 {max_iterations}"
1377 ));
1378 }
1379 }
1380 if let Some(threshold) = iterative_calculation_convergence_threshold {
1381 if !threshold.is_finite() || *threshold <= 0.0 {
1382 return invalid(format!(
1383 "--iterative-calculation-convergence-threshold must be a positive, \
1384 finite number, got {threshold}"
1385 ));
1386 }
1387 }
1388 if let Some(tz) = time_zone {
1393 if tz.parse::<chrono_tz::Tz>().is_err() {
1394 return invalid(format!(
1395 "--time-zone '{tz}' is not a recognised IANA time zone name \
1396 (for example 'America/New_York' or 'Europe/London'); the \
1397 Sheets API would silently reset the workbook to 'Etc/GMT' \
1398 instead of rejecting it"
1399 ));
1400 }
1401 }
1402 if locale.is_none()
1403 && time_zone.is_none()
1404 && auto_recalc.is_none()
1405 && iterative_calculation.is_none()
1406 {
1407 return invalid(
1408 "update-workbook-properties needs at least one property to set \
1409 (--locale, --time-zone, --auto-recalc, --iterative-calculation)"
1410 .to_string(),
1411 );
1412 }
1413 Ok(())
1414 }
1415 }
1416}
1417
1418fn validate_span_start(dimension: Dimension, at: i64, count: i64) -> Result<i64, StructureResult> {
1433 let invalid = |detail: String| StructureResult::RefusedInvalidRange { detail };
1434
1435 if count < 1 {
1436 return Err(invalid(format!("--count must be at least 1, got {count}")));
1437 }
1438 if at < 1 {
1439 return Err(invalid(format!("--at must be at least 1, got {at}")));
1440 }
1441 at.checked_sub(1)
1442 .and_then(|start| start.checked_add(count))
1443 .ok_or_else(|| {
1444 invalid(format!(
1445 "--at {at} with --count {count} overflows the {noun} index space",
1446 noun = dimension.noun(),
1447 ))
1448 })
1449}
1450
1451fn check_freeze_bound(
1458 flag: &str,
1459 noun: &str,
1460 value: i64,
1461 current: Option<i64>,
1462) -> Result<(), StructureResult> {
1463 if value < 0 {
1464 return Err(StructureResult::RefusedInvalidRange {
1465 detail: format!("{flag} must be at least 0, got {value}"),
1466 });
1467 }
1468 if let Some(current) = current {
1469 if value >= current {
1470 return Err(StructureResult::RefusedInvalidRange {
1471 detail: format!(
1472 "{flag} {value} would freeze every {noun}; the sheet has {current} \
1473 {noun}(s), so the most that can be frozen is {}",
1474 current - 1
1475 ),
1476 });
1477 }
1478 }
1479 Ok(())
1480}
1481
1482fn validate_insert_bounds(
1486 dimension: Dimension,
1487 at: i64,
1488 count: i64,
1489 sheet: Option<&SheetSnapshot>,
1490) -> Result<(), StructureResult> {
1491 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1492
1493 validate_span_start(dimension, at, count)?;
1494 let current = match dimension {
1495 Dimension::Rows => sheet.and_then(|s| s.row_count),
1496 Dimension::Columns => sheet.and_then(|s| s.column_count),
1497 };
1498 if let Some(current) = current {
1499 let max_at = current + 1;
1503 if at > max_at {
1504 return invalid(format!(
1505 "--at {at} is past the end of the sheet, which has {current} {noun}(s); \
1506 the furthest valid position is {max_at}",
1507 noun = dimension.noun(),
1508 ));
1509 }
1510 }
1511 Ok(())
1512}
1513
1514fn validate_move_bounds(
1527 dimension: Dimension,
1528 at: i64,
1529 count: i64,
1530 before: i64,
1531 sheet: Option<&SheetSnapshot>,
1532) -> Result<(), StructureResult> {
1533 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1534
1535 let last = validate_span_start(dimension, at, count)?;
1536 if before < 1 {
1537 return invalid(format!("--before must be at least 1, got {before}"));
1538 }
1539 if before >= at && before - 1 <= last {
1547 return invalid(format!(
1548 "--before {before} is inside or immediately after the block being moved \
1549 ({noun}s {at}-{last}), so nothing would move",
1550 noun = dimension.noun(),
1551 ));
1552 }
1553 let current = match dimension {
1554 Dimension::Rows => sheet.and_then(|s| s.row_count),
1555 Dimension::Columns => sheet.and_then(|s| s.column_count),
1556 };
1557 if let Some(current) = current {
1558 if last > current {
1559 return invalid(format!(
1560 "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1561 sheet, which has {current} {noun}(s)",
1562 noun = dimension.noun(),
1563 ));
1564 }
1565 let max_before = current + 1;
1568 if before > max_before {
1569 return invalid(format!(
1570 "--before {before} is past the end of the sheet, which has {current} \
1571 {noun}(s); the furthest valid position is {max_before}",
1572 noun = dimension.noun(),
1573 ));
1574 }
1575 }
1576 Ok(())
1577}
1578
1579fn validate_delete_dimension_bounds(
1585 dimension: Dimension,
1586 at: i64,
1587 count: i64,
1588 sheet: Option<&SheetSnapshot>,
1589) -> Result<(), StructureResult> {
1590 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1591
1592 let last = validate_span_start(dimension, at, count)?;
1593 let current = match dimension {
1594 Dimension::Rows => sheet.and_then(|s| s.row_count),
1595 Dimension::Columns => sheet.and_then(|s| s.column_count),
1596 };
1597 if let Some(current) = current {
1598 if last > current {
1599 return invalid(format!(
1600 "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1601 sheet, which has {current} {noun}(s)",
1602 noun = dimension.noun(),
1603 ));
1604 }
1605 }
1606 Ok(())
1607}
1608
1609fn validate_range_bounds(
1615 start_row: i64,
1616 end_row: i64,
1617 start_column: i64,
1618 end_column: i64,
1619 sheet: Option<&SheetSnapshot>,
1620) -> Result<(), StructureResult> {
1621 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1622
1623 if start_row < 1 {
1624 return invalid(format!("--start-row must be at least 1, got {start_row}"));
1625 }
1626 if start_column < 1 {
1627 return invalid(format!(
1628 "--start-column must be at least 1, got {start_column}"
1629 ));
1630 }
1631 if end_row < start_row {
1632 return invalid(format!(
1633 "--end-row ({end_row}) must be at or after --start-row ({start_row})"
1634 ));
1635 }
1636 if end_column < start_column {
1637 return invalid(format!(
1638 "--end-column ({end_column}) must be at or after --start-column ({start_column})"
1639 ));
1640 }
1641 if let Some(current) = sheet.and_then(|s| s.row_count) {
1642 if end_row > current {
1643 return invalid(format!(
1644 "--end-row {end_row} is past the end of the sheet, which has {current} row(s)"
1645 ));
1646 }
1647 }
1648 if let Some(current) = sheet.and_then(|s| s.column_count) {
1649 if end_column > current {
1650 return invalid(format!(
1651 "--end-column {end_column} is past the end of the sheet, which has {current} \
1652 column(s)"
1653 ));
1654 }
1655 }
1656 Ok(())
1657}
1658
1659fn build_request(
1665 verb: &StructureVerb,
1666 sheet: Option<&SheetSnapshot>,
1667) -> Result<BatchUpdateRequestItem, String> {
1668 let sheet_id = |verb_name: &str| -> Result<i64, String> {
1674 sheet.and_then(|s| s.sheet_id).ok_or_else(|| {
1675 format!("Sheets did not report a sheetId for the target sheet, so {verb_name} cannot address it")
1676 })
1677 };
1678
1679 match verb {
1680 StructureVerb::AddSheet {
1681 title,
1682 index,
1683 rows,
1684 columns,
1685 } => {
1686 let grid_properties = (rows.is_some() || columns.is_some()).then_some(GridProperties {
1687 row_count: *rows,
1688 column_count: *columns,
1689 ..Default::default()
1690 });
1691 Ok(BatchUpdateRequestItem::AddSheet(AddSheetRequest {
1692 properties: NewSheetProperties {
1693 title: title.clone(),
1694 index: *index,
1695 grid_properties,
1696 },
1697 }))
1698 }
1699 StructureVerb::RenameSheet { new_title, .. } => Ok(
1700 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1701 properties: SheetPropertiesUpdate {
1702 sheet_id: sheet_id("rename-sheet")?,
1703 title: Some(new_title.clone()),
1704 ..Default::default()
1705 },
1706 fields: "title".to_string(),
1709 }),
1710 ),
1711 StructureVerb::InsertRows { at, count, .. } => Ok(BatchUpdateRequestItem::InsertDimension(
1712 InsertDimensionRequest {
1713 range: dimension_range(sheet_id("insert-rows")?, Dimension::Rows, *at, *count),
1714 inherit_from_before: false,
1715 },
1716 )),
1717 StructureVerb::InsertColumns { at, count, .. } => Ok(
1718 BatchUpdateRequestItem::InsertDimension(InsertDimensionRequest {
1719 range: dimension_range(
1720 sheet_id("insert-columns")?,
1721 Dimension::Columns,
1722 *at,
1723 *count,
1724 ),
1725 inherit_from_before: false,
1726 }),
1727 ),
1728 StructureVerb::InsertRange {
1729 start_row,
1730 end_row,
1731 start_column,
1732 end_column,
1733 shift,
1734 ..
1735 } => Ok(BatchUpdateRequestItem::InsertRange(InsertRangeRequest {
1736 range: grid_range(
1737 sheet_id("insert-range")?,
1738 *start_row,
1739 *end_row,
1740 *start_column,
1741 *end_column,
1742 ),
1743 shift_dimension: *shift,
1744 })),
1745 StructureVerb::MoveRows {
1746 at, count, before, ..
1747 } => Ok(BatchUpdateRequestItem::MoveDimension(
1748 MoveDimensionRequest {
1749 source: dimension_range(sheet_id("move-rows")?, Dimension::Rows, *at, *count),
1750 destination_index: move_destination_index(*before),
1751 },
1752 )),
1753 StructureVerb::MoveColumns {
1754 at, count, before, ..
1755 } => Ok(BatchUpdateRequestItem::MoveDimension(
1756 MoveDimensionRequest {
1757 source: dimension_range(sheet_id("move-columns")?, Dimension::Columns, *at, *count),
1758 destination_index: move_destination_index(*before),
1759 },
1760 )),
1761 StructureVerb::DeleteSheet { .. } => {
1762 Ok(BatchUpdateRequestItem::DeleteSheet(DeleteSheetRequest {
1763 sheet_id: sheet_id("delete-sheet")?,
1764 }))
1765 }
1766 StructureVerb::DeleteRows { at, count, .. } => Ok(BatchUpdateRequestItem::DeleteDimension(
1767 DeleteDimensionRequest {
1768 range: dimension_range(sheet_id("delete-rows")?, Dimension::Rows, *at, *count),
1769 },
1770 )),
1771 StructureVerb::DeleteColumns { at, count, .. } => Ok(
1772 BatchUpdateRequestItem::DeleteDimension(DeleteDimensionRequest {
1773 range: dimension_range(
1774 sheet_id("delete-columns")?,
1775 Dimension::Columns,
1776 *at,
1777 *count,
1778 ),
1779 }),
1780 ),
1781 StructureVerb::DeleteRange {
1782 start_row,
1783 end_row,
1784 start_column,
1785 end_column,
1786 shift,
1787 ..
1788 } => Ok(BatchUpdateRequestItem::DeleteRange(DeleteRangeRequest {
1789 range: grid_range(
1790 sheet_id("delete-range")?,
1791 *start_row,
1792 *end_row,
1793 *start_column,
1794 *end_column,
1795 ),
1796 shift_dimension: *shift,
1797 })),
1798 StructureVerb::DuplicateSheet { title, index, .. } => Ok(
1799 BatchUpdateRequestItem::DuplicateSheet(DuplicateSheetRequest {
1800 source_sheet_id: sheet_id("duplicate-sheet")?,
1801 insert_sheet_index: *index,
1802 new_sheet_name: title.clone(),
1803 }),
1804 ),
1805 StructureVerb::ReorderSheet { index, .. } => Ok(
1806 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1807 properties: SheetPropertiesUpdate {
1808 sheet_id: sheet_id("reorder-sheet")?,
1809 index: Some(*index),
1810 ..Default::default()
1811 },
1812 fields: "index".to_string(),
1813 }),
1814 ),
1815 StructureVerb::SetSheetVisibility { hidden, .. } => Ok(
1816 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1817 properties: SheetPropertiesUpdate {
1818 sheet_id: sheet_id(if *hidden { "hide-sheet" } else { "show-sheet" })?,
1819 hidden: Some(*hidden),
1820 ..Default::default()
1821 },
1822 fields: "hidden".to_string(),
1823 }),
1824 ),
1825 StructureVerb::UpdateSheetProperties {
1826 freeze_rows,
1827 freeze_columns,
1828 tab_color,
1829 clear_tab_color,
1830 right_to_left,
1831 hide_gridlines,
1832 ..
1833 } => {
1834 let mut fields = Vec::new();
1835 let mut grid = GridPropertiesUpdate::default();
1836 if let Some(rows) = freeze_rows {
1837 grid.frozen_row_count = Some(*rows);
1838 fields.push("gridProperties.frozenRowCount");
1839 }
1840 if let Some(columns) = freeze_columns {
1841 grid.frozen_column_count = Some(*columns);
1842 fields.push("gridProperties.frozenColumnCount");
1843 }
1844 if let Some(hide) = hide_gridlines {
1845 grid.hide_gridlines = Some(*hide);
1846 fields.push("gridProperties.hideGridlines");
1847 }
1848 let mut tab_color_style = None;
1849 if let Some(hex) = tab_color {
1850 let color = parse_hex_color(hex)
1855 .map_err(|detail| format!("update-sheet-properties: {detail}"))?;
1856 tab_color_style = Some(ColorStyle::rgb(color));
1857 fields.push("tabColorStyle");
1858 } else if *clear_tab_color {
1859 fields.push("tabColorStyle");
1860 }
1861 if right_to_left.is_some() {
1862 fields.push("rightToLeft");
1863 }
1864 if fields.is_empty() {
1865 return Err("update-sheet-properties: no property was set".to_string());
1870 }
1872 Ok(BatchUpdateRequestItem::UpdateSheetProperties(
1873 UpdateSheetPropertiesRequest {
1874 properties: SheetPropertiesUpdate {
1875 sheet_id: sheet_id("update-sheet-properties")?,
1876 grid_properties: (grid != GridPropertiesUpdate::default()).then_some(grid),
1877 tab_color_style,
1878 right_to_left: *right_to_left,
1879 ..Default::default()
1880 },
1881 fields: fields.join(","),
1882 },
1883 ))
1884 }
1885 StructureVerb::UpdateWorkbookProperties {
1886 locale,
1887 time_zone,
1888 auto_recalc,
1889 iterative_calculation,
1890 iterative_calculation_max_iterations,
1891 iterative_calculation_convergence_threshold,
1892 } => {
1893 let mut properties = SpreadsheetPropertiesUpdate::default();
1898 let mut fields = Vec::new();
1899 if let Some(locale) = locale {
1900 properties.locale = Some(locale.clone());
1901 fields.push("locale");
1902 }
1903 if let Some(time_zone) = time_zone {
1904 properties.time_zone = Some(time_zone.clone());
1905 fields.push("timeZone");
1906 }
1907 if let Some(auto_recalc) = auto_recalc {
1908 properties.auto_recalc = Some(*auto_recalc);
1909 fields.push("autoRecalc");
1910 }
1911 match iterative_calculation {
1912 Some(IterativeCalculationToggle::On) => {
1913 properties.iterative_calculation_settings =
1914 Some(IterativeCalculationSettings {
1915 max_iterations: *iterative_calculation_max_iterations,
1916 convergence_threshold: *iterative_calculation_convergence_threshold,
1917 });
1918 fields.push("iterativeCalculationSettings");
1919 }
1920 Some(IterativeCalculationToggle::Off) => {
1926 fields.push("iterativeCalculationSettings");
1927 }
1928 None => {}
1929 }
1930 Ok(BatchUpdateRequestItem::UpdateSpreadsheetProperties(
1931 UpdateSpreadsheetPropertiesRequest {
1932 properties,
1933 fields: fields.join(","),
1934 },
1935 ))
1936 }
1937 }
1938}
1939
1940fn workbook_properties_summary(verb: &StructureVerb) -> String {
1954 let StructureVerb::UpdateWorkbookProperties {
1955 locale,
1956 time_zone,
1957 auto_recalc,
1958 iterative_calculation,
1959 iterative_calculation_max_iterations,
1960 iterative_calculation_convergence_threshold,
1961 } = verb
1962 else {
1964 unreachable!("workbook_properties_summary called on a non-UpdateWorkbookProperties verb")
1965 };
1966 let mut parts = Vec::new();
1969 if let Some(locale) = locale {
1970 parts.push(format!("locale -> '{locale}'"));
1971 }
1972 if let Some(time_zone) = time_zone {
1973 parts.push(format!("time zone -> '{time_zone}'"));
1974 }
1975 if let Some(auto_recalc) = auto_recalc {
1976 parts.push(format!("auto-recalc -> {}", auto_recalc.as_str()));
1977 }
1978 match iterative_calculation {
1979 Some(IterativeCalculationToggle::On) => {
1980 let bounds = IterativeCalculationSettings {
1981 max_iterations: *iterative_calculation_max_iterations,
1982 convergence_threshold: *iterative_calculation_convergence_threshold,
1983 }
1984 .describe_bounds();
1985 parts.push(format!("iterative calculation -> on{bounds}"));
1986 }
1987 Some(IterativeCalculationToggle::Off) => {
1988 parts.push("iterative calculation -> off".to_string());
1989 }
1990 None => {}
1991 }
1992 parts.join(", ")
1993}
1994
1995fn grid_range(
1999 sheet_id: i64,
2000 start_row: i64,
2001 end_row: i64,
2002 start_column: i64,
2003 end_column: i64,
2004) -> GridRange {
2005 GridRange {
2006 sheet_id,
2007 start_row_index: Some(start_row - 1),
2008 end_row_index: Some(end_row),
2009 start_column_index: Some(start_column - 1),
2010 end_column_index: Some(end_column),
2011 }
2012}
2013
2014fn added_sheet_id(response: &BatchUpdateResponse) -> Option<i64> {
2019 response
2020 .replies
2021 .iter()
2022 .find_map(|reply| reply.add_sheet.as_ref().or(reply.duplicate_sheet.as_ref()))
2023 .and_then(|added| added.properties.as_ref())
2024 .and_then(|props| props.sheet_id)
2025}
2026
2027const fn adds_a_sheet(verb: &StructureVerb) -> bool {
2030 matches!(
2031 verb,
2032 StructureVerb::AddSheet { .. } | StructureVerb::DuplicateSheet { .. }
2033 )
2034}
2035
2036fn applied_summary(verb: &StructureVerb) -> String {
2041 let mut summary = verb.label().to_string();
2042 if let Some(title) = verb.sheet_title() {
2043 summary.push_str(&format!(" '{title}'"));
2044 }
2045 if let Some(new_title) = verb.new_sheet_title() {
2046 summary.push_str(&format!(" to '{new_title}'"));
2047 }
2048 if let Some(span) = dimension_range_label(verb).or_else(|| grid_range_label(verb)) {
2049 summary.push_str(&format!(" ({span})"));
2050 }
2051 summary
2052}
2053
2054fn dimension_range_label(verb: &StructureVerb) -> Option<String> {
2067 let dimension = verb.dimension()?;
2068 let (at, count) = match verb {
2069 StructureVerb::InsertRows { at, count, .. }
2070 | StructureVerb::InsertColumns { at, count, .. }
2071 | StructureVerb::DeleteRows { at, count, .. }
2072 | StructureVerb::DeleteColumns { at, count, .. }
2073 | StructureVerb::MoveRows { at, count, .. }
2074 | StructureVerb::MoveColumns { at, count, .. } => (*at, *count),
2075 _ => return None,
2076 };
2077 if count < 1 {
2083 return None;
2084 }
2085 let end = at.checked_add(count).and_then(|end| end.checked_sub(1))?;
2086 Some(dimension.span_label(at, end))
2087}
2088
2089fn grid_range_label(verb: &StructureVerb) -> Option<String> {
2094 match verb {
2095 StructureVerb::InsertRange {
2096 start_row,
2097 end_row,
2098 start_column,
2099 end_column,
2100 ..
2101 }
2102 | StructureVerb::DeleteRange {
2103 start_row,
2104 end_row,
2105 start_column,
2106 end_column,
2107 ..
2108 } => Some(format!(
2109 "rows {start_row}-{end_row}, columns {start_column}-{end_column}"
2110 )),
2111 _ => None,
2112 }
2113}
2114
2115fn record_attempt(outcome: &StructureOutcome, opts: &StructureOptions, duration: Duration) {
2121 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
2122}
2123
2124fn mutation_record(
2127 outcome: &StructureOutcome,
2128 opts: &StructureOptions,
2129 duration: Duration,
2130) -> DriveMutationOutcome {
2131 let error = match &outcome.result {
2132 StructureResult::Failed { detail }
2133 | StructureResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
2134 _ => None,
2135 };
2136 let decided_by = match &outcome.result {
2137 StructureResult::Blocked { decided_by } => decided_by.as_ref(),
2138 _ => None,
2139 };
2140 let decided_by = write_gate::decided_by_log_fields(decided_by);
2141 let sheet_id = match &outcome.result {
2142 StructureResult::Changed { sheet_id, .. }
2143 | StructureResult::AppliedReplyUnreadable { sheet_id, .. } => *sheet_id,
2144 StructureResult::WouldChange { sheet, .. } => sheet.as_ref().and_then(|s| s.sheet_id),
2145 _ => None,
2146 };
2147 let move_to = match &opts.verb {
2150 StructureVerb::MoveRows { before, .. } | StructureVerb::MoveColumns { before, .. } => {
2151 Some(*before)
2152 }
2153 _ => None,
2154 };
2155 let fields_changed = match &opts.verb {
2164 StructureVerb::UpdateWorkbookProperties { .. } => {
2165 Some(workbook_properties_summary(&opts.verb))
2166 }
2167 _ => fields_changed_label(&opts.verb),
2168 }
2169 .or_else(|| match &outcome.result {
2170 StructureResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
2171 _ => None,
2172 });
2173
2174 DriveMutationOutcome {
2175 operation: opts.verb.log_operation(),
2176 file_id: outcome.spreadsheet_id.clone(),
2177 file_name: outcome.file_name.clone().unwrap_or_default(),
2178 status: outcome.result.log_status().to_string(),
2179 resolved_folder_id: outcome.resolved_folder_id.clone(),
2180 decided_by_folder_id: decided_by.folder_id,
2181 decided_by_depth: decided_by.depth,
2182 decided_by_file_id: decided_by.file_id,
2183 sheet_id,
2184 sheet_title: opts.verb.sheet_title().map(ToString::to_string),
2185 sheet_new_title: opts.verb.new_sheet_title().map(ToString::to_string),
2186 dimension_range: dimension_range_label(&opts.verb),
2187 move_to,
2188 grid_range: grid_range_label(&opts.verb),
2189 fields_changed,
2190 error,
2191 duration,
2192 ..Default::default()
2193 }
2194}
2195
2196fn fields_changed_label(verb: &StructureVerb) -> Option<String> {
2208 let StructureVerb::UpdateSheetProperties {
2209 freeze_rows,
2210 freeze_columns,
2211 tab_color,
2212 clear_tab_color,
2213 right_to_left,
2214 hide_gridlines,
2215 ..
2216 } = verb
2217 else {
2218 return None;
2219 };
2220 let mut parts = Vec::new();
2221 if let Some(rows) = freeze_rows {
2222 parts.push(format!("frozenRowCount={rows}"));
2223 }
2224 if let Some(columns) = freeze_columns {
2225 parts.push(format!("frozenColumnCount={columns}"));
2226 }
2227 if let Some(hide) = hide_gridlines {
2228 parts.push(format!("hideGridlines={hide}"));
2229 }
2230 if let Some(hex) = tab_color {
2231 parts.push(format!("tabColorStyle={hex}"));
2232 } else if *clear_tab_color {
2233 parts.push("tabColorStyle=cleared".to_string());
2234 }
2235 if let Some(rtl) = right_to_left {
2236 parts.push(format!("rightToLeft={rtl}"));
2237 }
2238 if parts.is_empty() {
2239 return None;
2240 }
2241 Some(parts.join(", "))
2242}
2243
2244#[must_use]
2249pub fn describe(outcome: &StructureOutcome) -> String {
2250 describe_lines(outcome).join("\n")
2251}
2252
2253#[must_use]
2274pub fn describe_lines(outcome: &StructureOutcome) -> Vec<String> {
2275 let verb = &outcome.verb;
2276 let book = outcome.file_name.as_deref().map_or_else(
2277 || format!("'{}'", outcome.spreadsheet_id),
2278 |n| format!("'{n}'"),
2279 );
2280
2281 match &outcome.result {
2282 StructureResult::WouldChange { sheet, sheet_count } => {
2283 describe_would_change(verb, sheet.as_deref(), *sheet_count, &book)
2284 }
2285 StructureResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
2286 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
2287 `drive sheets {}` only works on spreadsheets",
2288 verb.label()
2289 )],
2290 StructureResult::RefusedShortcut => vec![format!(
2291 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
2292 resolve the target spreadsheet's id and use that instead",
2293 verb.label()
2294 )],
2295 StructureResult::RefusedNoVisibleParents => {
2296 let op = verb.gate_operation();
2297 vec![format!(
2298 "Refused: {book} has no parent folder visible to this account, so no folder \
2299 rule can apply to it. This is normal for a Sheet shared by link or email. \
2300 Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
2301 [\"{op}\"]}} to write_permissions.rules. (Adding it to a folder in your own \
2302 Drive and granting that folder `{op}` also works.)"
2303 )]
2304 }
2305 StructureResult::RefusedSheetNotFound { title, available } => {
2306 vec![grid_range::no_sheet_refusal(&book, title, available)]
2307 }
2308 StructureResult::RefusedSheetExists { title } => {
2309 vec![format!(
2310 "Refused: {book} already has a sheet titled '{title}'"
2311 )]
2312 }
2313 StructureResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
2314 StructureResult::Blocked { decided_by } => {
2315 let op = verb.gate_operation();
2316 let action = if matches!(op, DriveOperation::SheetsDelete) {
2317 "destructive edits"
2318 } else {
2319 "structural edits"
2320 };
2321 vec![match decided_by {
2322 Some(rule) => format!(
2323 "Blocked: {action} to {book} refused by rule on {} {}{}",
2324 rule.kind_label(),
2325 rule.id(),
2326 rule.depth_suffix()
2327 ),
2328 None => format!(
2329 "Blocked: {action} to {book} refused by default policy (no matching rule \
2330 for {op})"
2331 ),
2332 }]
2333 }
2334 StructureResult::RefusedNoLease => LeaseGateRefusal::NoLease
2335 .describe_line(&outcome.spreadsheet_id, &book)
2336 .into_iter()
2337 .collect(),
2338 StructureResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
2339 .describe_line(&outcome.spreadsheet_id, &book)
2340 .into_iter()
2341 .collect(),
2342 StructureResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
2343 .describe_line(&outcome.spreadsheet_id, &book)
2344 .into_iter()
2345 .collect(),
2346 StructureResult::RefusedLeaseStale => LeaseGateRefusal::Stale
2347 .describe_line(&outcome.spreadsheet_id, &book)
2348 .into_iter()
2349 .collect(),
2350 StructureResult::Changed {
2351 sheet,
2352 sheet_id,
2353 backup,
2354 } => {
2355 vec![describe_changed(
2356 verb,
2357 sheet.as_deref(),
2358 *sheet_id,
2359 backup.as_deref(),
2360 &book,
2361 )]
2362 }
2363 StructureResult::AppliedReplyUnreadable {
2364 summary,
2365 detail,
2366 sheet: _,
2367 sheet_id,
2368 backup,
2369 } => {
2370 let summary =
2375 sheet_id.map_or_else(|| summary.clone(), |id| format!("{summary} (sheetId {id})"));
2376 let mut first =
2377 applied_reply_unreadable_line(&summary, &book, RetryHint::NotIdempotent, detail);
2378 if matches!(verb.gate_operation(), DriveOperation::SheetsDelete) {
2381 let recovery = recovery_note(
2382 backup.as_deref(),
2383 matches!(verb, StructureVerb::DeleteSheet { .. }),
2384 );
2385 first.push_str(&format!("; {recovery}"));
2386 }
2387 let mut lines = vec![first];
2388 if adds_a_sheet(verb) {
2389 lines.push(
2390 " the new sheet's id was in the unreadable reply; check the spreadsheet's \
2391 tabs to find it"
2392 .to_string(),
2393 );
2394 }
2395 lines
2396 }
2397 StructureResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
2398 StructureResult::Failed { detail } => vec![format!("Failed: {detail}")],
2399 }
2400}
2401
2402fn describe_would_change(
2403 verb: &StructureVerb,
2404 sheet: Option<&SheetSnapshot>,
2405 sheet_count: usize,
2406 book: &str,
2407) -> Vec<String> {
2408 let id = sheet
2409 .and_then(|s| s.sheet_id)
2410 .map_or_else(String::new, |id| format!(" (sheetId {id})"));
2411
2412 match verb {
2413 StructureVerb::AddSheet {
2414 title,
2415 index,
2416 rows,
2417 columns,
2418 } => {
2419 let size = match (rows, columns) {
2420 (Some(r), Some(c)) => format!(" ({r} x {c})"),
2421 (Some(r), None) => format!(" ({r} rows)"),
2422 (None, Some(c)) => format!(" ({c} columns)"),
2423 (None, None) => String::new(),
2424 };
2425 let position = index.map_or_else(
2426 || " at the end".to_string(),
2427 |index| format!(" at index {index}"),
2428 );
2429 vec![format!(
2430 "Would add sheet '{title}'{size}{position} of {book} \
2431 ({sheet_count} sheet(s) -> {})",
2432 sheet_count + 1
2433 )]
2434 }
2435 StructureVerb::RenameSheet {
2436 sheet: from,
2437 new_title,
2438 } => {
2439 vec![format!(
2440 "Would rename sheet '{from}'{id} to '{new_title}' in {book}"
2441 )]
2442 }
2443 StructureVerb::InsertRows {
2444 sheet: from,
2445 at,
2446 count,
2447 } => describe_would_insert(Dimension::Rows, from, &id, *at, *count, sheet, book),
2448 StructureVerb::InsertColumns {
2449 sheet: from,
2450 at,
2451 count,
2452 } => describe_would_insert(Dimension::Columns, from, &id, *at, *count, sheet, book),
2453 StructureVerb::InsertRange {
2454 sheet: from,
2455 start_row,
2456 end_row,
2457 start_column,
2458 end_column,
2459 shift,
2460 } => vec![describe_would_insert_range(
2461 from,
2462 &id,
2463 *start_row,
2464 *end_row,
2465 *start_column,
2466 *end_column,
2467 *shift,
2468 book,
2469 )],
2470 StructureVerb::MoveRows {
2471 sheet: from,
2472 at,
2473 count,
2474 before,
2475 } => describe_would_move(
2476 Dimension::Rows,
2477 from,
2478 &id,
2479 *at,
2480 *count,
2481 *before,
2482 sheet,
2483 book,
2484 ),
2485 StructureVerb::MoveColumns {
2486 sheet: from,
2487 at,
2488 count,
2489 before,
2490 } => describe_would_move(
2491 Dimension::Columns,
2492 from,
2493 &id,
2494 *at,
2495 *count,
2496 *before,
2497 sheet,
2498 book,
2499 ),
2500 StructureVerb::DeleteSheet { sheet: from } => {
2501 vec![format!(
2502 "Would delete sheet '{from}'{id} from {book} ({sheet_count} sheet(s) -> {}); \
2503 {FORMULA_CAVEAT}",
2504 sheet_count.saturating_sub(1)
2505 )]
2506 }
2507 StructureVerb::DeleteRows {
2508 sheet: from,
2509 at,
2510 count,
2511 } => describe_would_delete_dimension(Dimension::Rows, from, &id, *at, *count, sheet, book),
2512 StructureVerb::DeleteColumns {
2513 sheet: from,
2514 at,
2515 count,
2516 } => {
2517 describe_would_delete_dimension(Dimension::Columns, from, &id, *at, *count, sheet, book)
2518 }
2519 StructureVerb::DeleteRange {
2520 sheet: from,
2521 start_row,
2522 end_row,
2523 start_column,
2524 end_column,
2525 shift,
2526 } => vec![describe_would_delete_range(
2527 from,
2528 &id,
2529 *start_row,
2530 *end_row,
2531 *start_column,
2532 *end_column,
2533 *shift,
2534 book,
2535 )],
2536 StructureVerb::DuplicateSheet {
2537 sheet: from,
2538 title,
2539 index,
2540 } => {
2541 let to = title.as_deref().map_or_else(
2542 || " as a copy Sheets names automatically".to_string(),
2543 |title| format!(" as '{title}'"),
2544 );
2545 let position = index.map_or_else(
2546 || " at the front (Sheets' default; not the end)".to_string(),
2550 |index| format!(" at index {index}"),
2551 );
2552 vec![format!(
2553 "Would duplicate sheet '{from}'{id}{to}{position} of {book} \
2554 ({sheet_count} sheet(s) -> {})",
2555 sheet_count + 1
2556 )]
2557 }
2558 StructureVerb::ReorderSheet { sheet: from, index } => {
2559 vec![format!(
2560 "Would move sheet '{from}'{id} to index {index} in {book}"
2561 )]
2562 }
2563 StructureVerb::SetSheetVisibility {
2564 sheet: from,
2565 hidden,
2566 } => {
2567 let verb_word = if *hidden { "hide" } else { "show" };
2568 vec![format!("Would {verb_word} sheet '{from}'{id} in {book}")]
2569 }
2570 StructureVerb::UpdateSheetProperties {
2571 sheet: from,
2572 freeze_rows,
2573 freeze_columns,
2574 tab_color,
2575 clear_tab_color,
2576 right_to_left,
2577 hide_gridlines,
2578 } => {
2579 let fragments = update_sheet_properties_fragments(
2580 freeze_rows,
2581 freeze_columns,
2582 tab_color,
2583 *clear_tab_color,
2584 *right_to_left,
2585 *hide_gridlines,
2586 sheet,
2587 );
2588 vec![format!(
2589 "Would update sheet '{from}'{id} in {book}: {}",
2590 fragments.join(", ")
2591 )]
2592 }
2593 StructureVerb::UpdateWorkbookProperties { .. } => vec![format!(
2594 "Would update workbook properties of {book}: {}",
2595 workbook_properties_summary(verb)
2596 )],
2597 }
2598}
2599
2600fn before_after_fragment(
2606 label: &str,
2607 before: Option<impl std::fmt::Display>,
2608 after: impl std::fmt::Display,
2609) -> String {
2610 match before {
2611 Some(before) => format!("{label} {before} -> {after}"),
2612 None => format!("{label} -> {after}"),
2613 }
2614}
2615
2616fn gridlines_label(hidden: bool) -> &'static str {
2619 if hidden {
2620 "hidden"
2621 } else {
2622 "shown"
2623 }
2624}
2625
2626fn update_sheet_properties_fragments(
2633 freeze_rows: &Option<i64>,
2634 freeze_columns: &Option<i64>,
2635 tab_color: &Option<String>,
2636 clear_tab_color: bool,
2637 right_to_left: Option<bool>,
2638 hide_gridlines: Option<bool>,
2639 sheet: Option<&SheetSnapshot>,
2640) -> Vec<String> {
2641 let mut fragments = Vec::new();
2642 if let Some(rows) = freeze_rows {
2643 fragments.push(before_after_fragment(
2644 "frozen rows",
2645 sheet.and_then(|s| s.frozen_row_count),
2646 rows,
2647 ));
2648 }
2649 if let Some(columns) = freeze_columns {
2650 fragments.push(before_after_fragment(
2651 "frozen columns",
2652 sheet.and_then(|s| s.frozen_column_count),
2653 columns,
2654 ));
2655 }
2656 if let Some(hex) = tab_color {
2657 fragments.push(format!("tab color -> {}", normalize_hex_for_display(hex)));
2658 } else if clear_tab_color {
2659 fragments.push("tab color cleared".to_string());
2660 }
2661 if let Some(rtl) = right_to_left {
2662 fragments.push(before_after_fragment(
2663 "right-to-left",
2664 sheet.and_then(|s| s.right_to_left),
2665 rtl,
2666 ));
2667 }
2668 if let Some(hide) = hide_gridlines {
2669 fragments.push(before_after_fragment(
2670 "gridlines",
2671 sheet.and_then(|s| s.hide_gridlines).map(gridlines_label),
2672 gridlines_label(hide),
2673 ));
2674 }
2675 fragments
2676}
2677
2678#[allow(clippy::too_many_arguments)]
2689fn describe_would_insert(
2690 dimension: Dimension,
2691 from: &str,
2692 id: &str,
2693 at: i64,
2694 count: i64,
2695 sheet: Option<&SheetSnapshot>,
2696 book: &str,
2697) -> Vec<String> {
2698 let summary = format!(
2699 "Would insert {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}",
2700 noun = dimension.noun(),
2701 );
2702 let before = match dimension {
2703 Dimension::Rows => sheet.and_then(|s| s.row_count),
2704 Dimension::Columns => sheet.and_then(|s| s.column_count),
2705 };
2706 let Some(before) = before else {
2711 return vec![summary];
2712 };
2713 let Some(after) = before.checked_add(count) else {
2714 return vec![summary];
2715 };
2716 let existing = if at <= before {
2721 format!(
2722 "; existing {plural} {at}-{before} shift {direction}",
2723 plural = plural(dimension),
2724 direction = shift_word(dimension, true),
2726 )
2727 } else {
2728 format!(
2729 "; appended at the end, no existing {plural} shift",
2730 plural = plural(dimension)
2731 )
2732 };
2733 vec![
2734 summary,
2735 format!(
2736 " ({before} {plural} -> {after}{existing})",
2737 plural = plural(dimension),
2738 ),
2739 ]
2740}
2741
2742#[allow(clippy::too_many_arguments)]
2754fn describe_would_move(
2755 dimension: Dimension,
2756 from: &str,
2757 id: &str,
2758 at: i64,
2759 count: i64,
2760 before: i64,
2761 sheet: Option<&SheetSnapshot>,
2762 book: &str,
2763) -> Vec<String> {
2764 let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2765 return vec![format!(
2766 "Would move {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2767 noun = dimension.noun(),
2768 )];
2769 };
2770 let summary = format!(
2771 "Would move {count} {noun}(s) {at}-{last} of '{from}'{id} in {book} to before \
2772 {noun} {before}",
2773 noun = dimension.noun(),
2774 );
2775 let current = match dimension {
2776 Dimension::Rows => sheet.and_then(|s| s.row_count),
2777 Dimension::Columns => sheet.and_then(|s| s.column_count),
2778 };
2779 let Some(current) = current else {
2783 return vec![summary];
2784 };
2785 let (land_first, land_last) = move_landing_span(before, count, last);
2790 let (shift_first, shift_last, shifted_first, shifted_last, forward) = if before > last {
2791 (last + 1, before - 1, at, before - 1 - count, false)
2796 } else {
2797 (before, at - 1, before + count, at - 1 + count, true)
2801 };
2802 let detail = format!(
2803 " ({current} {plural} unchanged; {plural} {shift_first}-{shift_last} shift \
2804 {direction} to {shifted_first}-{shifted_last}; moved {plural} land at {land_first}-\
2805 {land_last})",
2806 plural = plural(dimension),
2807 direction = shift_word(dimension, forward),
2808 );
2809 vec![summary, detail]
2810}
2811
2812const FORMULA_CAVEAT: &str =
2819 "formulas elsewhere in the workbook that reference this may break, which cannot be \
2820 checked automatically";
2821
2822const INSERT_RANGE_EDGE_CAVEAT_DRY_RUN: &str =
2827 "cells pushed past the sheet's grid extent may be dropped by Sheets";
2828const INSERT_RANGE_EDGE_CAVEAT: &str =
2829 "cells pushed past the sheet's grid extent may have been dropped by Sheets";
2830
2831#[allow(clippy::too_many_arguments)]
2839fn describe_would_delete_dimension(
2840 dimension: Dimension,
2841 from: &str,
2842 id: &str,
2843 at: i64,
2844 count: i64,
2845 sheet: Option<&SheetSnapshot>,
2846 book: &str,
2847) -> Vec<String> {
2848 let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2849 return vec![format!(
2850 "Would delete {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2851 noun = dimension.noun(),
2852 )];
2853 };
2854 let summary = format!(
2855 "Would delete {count} {noun}(s) {at}-{last} of '{from}'{id} in {book}",
2856 noun = dimension.noun(),
2857 );
2858 let before = match dimension {
2859 Dimension::Rows => sheet.and_then(|s| s.row_count),
2860 Dimension::Columns => sheet.and_then(|s| s.column_count),
2861 };
2862 let Some(before) = before else {
2863 return vec![summary];
2864 };
2865 let Some(after) = before.checked_sub(count) else {
2866 return vec![summary];
2867 };
2868 let existing = if last < before {
2869 format!(
2870 "; existing {plural} {next}-{before} shift {direction}",
2871 plural = plural(dimension),
2872 next = last + 1,
2873 direction = shift_word(dimension, false),
2874 )
2875 } else {
2876 format!(
2877 "; nothing remains after the deleted {plural}",
2878 plural = plural(dimension)
2879 )
2880 };
2881 vec![
2882 summary,
2883 format!(
2884 " ({before} {plural} -> {after}{existing}; {FORMULA_CAVEAT})",
2885 plural = plural(dimension),
2886 ),
2887 ]
2888}
2889
2890#[allow(clippy::too_many_arguments)]
2897fn describe_would_delete_range(
2898 from: &str,
2899 id: &str,
2900 start_row: i64,
2901 end_row: i64,
2902 start_column: i64,
2903 end_column: i64,
2904 shift: ShiftDimension,
2905 book: &str,
2906) -> String {
2907 let direction = shift_word(shift.into(), false);
2908 format!(
2909 "Would delete rows {start_row}-{end_row}, columns {start_column}-{end_column} of \
2910 '{from}'{id} in {book}, shifting remaining cells {direction} to close the gap; \
2911 {FORMULA_CAVEAT}"
2912 )
2913}
2914
2915#[allow(clippy::too_many_arguments)]
2917fn describe_would_insert_range(
2918 from: &str,
2919 id: &str,
2920 start_row: i64,
2921 end_row: i64,
2922 start_column: i64,
2923 end_column: i64,
2924 shift: ShiftDimension,
2925 book: &str,
2926) -> String {
2927 let direction = shift_word(shift.into(), true);
2928 format!(
2929 "Would insert empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
2930 of '{from}'{id} in {book}, shifting existing cells {direction}; \
2931 {INSERT_RANGE_EDGE_CAVEAT_DRY_RUN}"
2932 )
2933}
2934
2935fn describe_changed(
2936 verb: &StructureVerb,
2937 sheet: Option<&SheetSnapshot>,
2938 sheet_id: Option<i64>,
2939 backup: Option<&LeaseBackup>,
2940 book: &str,
2941) -> String {
2942 let id = sheet_id.map_or_else(String::new, |id| format!(" (sheetId {id})"));
2943 let recovery = recovery_note(backup, matches!(verb, StructureVerb::DeleteSheet { .. }));
2944 match verb {
2945 StructureVerb::AddSheet { title, .. } => {
2946 format!("Added sheet '{title}'{id} to {book}")
2947 }
2948 StructureVerb::RenameSheet {
2949 sheet: from,
2950 new_title,
2951 } => {
2952 format!("Renamed sheet '{from}' to '{new_title}'{id} in {book}")
2953 }
2954 StructureVerb::InsertRows {
2955 sheet: from,
2956 at,
2957 count,
2958 } => describe_inserted(Dimension::Rows, from, &id, *at, *count, sheet, book),
2959 StructureVerb::InsertColumns {
2960 sheet: from,
2961 at,
2962 count,
2963 } => describe_inserted(Dimension::Columns, from, &id, *at, *count, sheet, book),
2964 StructureVerb::InsertRange {
2965 sheet: from,
2966 start_row,
2967 end_row,
2968 start_column,
2969 end_column,
2970 shift,
2971 } => describe_inserted_range(
2972 from,
2973 &id,
2974 *start_row,
2975 *end_row,
2976 *start_column,
2977 *end_column,
2978 *shift,
2979 book,
2980 ),
2981 StructureVerb::MoveRows {
2982 sheet: from,
2983 at,
2984 count,
2985 before,
2986 } => describe_moved(Dimension::Rows, from, &id, *at, *count, *before, book),
2987 StructureVerb::MoveColumns {
2988 sheet: from,
2989 at,
2990 count,
2991 before,
2992 } => describe_moved(Dimension::Columns, from, &id, *at, *count, *before, book),
2993 StructureVerb::DeleteSheet { sheet: from } => {
2994 format!("Deleted sheet '{from}'{id} from {book}; {recovery}")
2995 }
2996 StructureVerb::DeleteRows {
2997 sheet: from,
2998 at,
2999 count,
3000 } => describe_deleted_dimension(
3001 Dimension::Rows,
3002 from,
3003 &id,
3004 *at,
3005 *count,
3006 sheet,
3007 book,
3008 &recovery,
3009 ),
3010 StructureVerb::DeleteColumns {
3011 sheet: from,
3012 at,
3013 count,
3014 } => describe_deleted_dimension(
3015 Dimension::Columns,
3016 from,
3017 &id,
3018 *at,
3019 *count,
3020 sheet,
3021 book,
3022 &recovery,
3023 ),
3024 StructureVerb::DeleteRange {
3025 sheet: from,
3026 start_row,
3027 end_row,
3028 start_column,
3029 end_column,
3030 shift,
3031 } => describe_deleted_range(
3032 from,
3033 &id,
3034 *start_row,
3035 *end_row,
3036 *start_column,
3037 *end_column,
3038 *shift,
3039 book,
3040 &recovery,
3041 ),
3042 StructureVerb::DuplicateSheet {
3043 sheet: from, title, ..
3044 } => {
3045 let to = title
3046 .as_deref()
3047 .map_or_else(String::new, |title| format!(" as '{title}'"));
3048 format!("Duplicated sheet '{from}'{to}{id} in {book}")
3049 }
3050 StructureVerb::ReorderSheet { sheet: from, index } => {
3051 format!("Moved sheet '{from}'{id} to index {index} in {book}")
3052 }
3053 StructureVerb::SetSheetVisibility {
3054 sheet: from,
3055 hidden,
3056 } => {
3057 let verb_word = if *hidden { "Hid" } else { "Showed" };
3058 format!("{verb_word} sheet '{from}'{id} in {book}")
3059 }
3060 StructureVerb::UpdateSheetProperties {
3061 sheet: from,
3062 freeze_rows,
3063 freeze_columns,
3064 tab_color,
3065 clear_tab_color,
3066 right_to_left,
3067 hide_gridlines,
3068 } => {
3069 let fragments = update_sheet_properties_fragments(
3070 freeze_rows,
3071 freeze_columns,
3072 tab_color,
3073 *clear_tab_color,
3074 *right_to_left,
3075 *hide_gridlines,
3076 sheet,
3077 );
3078 format!(
3079 "Updated sheet '{from}'{id} in {book}: {}",
3080 fragments.join(", ")
3081 )
3082 }
3083 StructureVerb::UpdateWorkbookProperties { .. } => format!(
3084 "Updated workbook properties of {book}: {}",
3085 workbook_properties_summary(verb)
3086 ),
3087 }
3088}
3089
3090#[allow(clippy::too_many_arguments)]
3094fn describe_inserted(
3095 dimension: Dimension,
3096 from: &str,
3097 id: &str,
3098 at: i64,
3099 count: i64,
3100 sheet: Option<&SheetSnapshot>,
3101 book: &str,
3102) -> String {
3103 let now = match dimension {
3104 Dimension::Rows => sheet.and_then(|s| s.row_count),
3105 Dimension::Columns => sheet.and_then(|s| s.column_count),
3106 }
3107 .and_then(|before| before.checked_add(count))
3108 .map_or_else(String::new, |after| {
3109 format!(" ({after} {} now)", plural(dimension))
3110 });
3111 format!(
3112 "Inserted {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}{now}",
3113 noun = dimension.noun(),
3114 )
3115}
3116
3117fn describe_moved(
3128 dimension: Dimension,
3129 from: &str,
3130 id: &str,
3131 at: i64,
3132 count: i64,
3133 before: i64,
3134 book: &str,
3135) -> String {
3136 let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3137 let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3138 let now = last.map_or_else(String::new, |last| {
3139 let (land_first, land_last) = move_landing_span(before, count, last);
3140 format!(" (now {} {land_first}-{land_last})", plural(dimension))
3141 });
3142 format!(
3143 "Moved {count} {noun}(s) {range} of '{from}'{id} in {book} to before {noun} \
3144 {before}{now}",
3145 noun = dimension.noun(),
3146 )
3147}
3148
3149#[allow(clippy::too_many_arguments)]
3151fn describe_deleted_dimension(
3152 dimension: Dimension,
3153 from: &str,
3154 id: &str,
3155 at: i64,
3156 count: i64,
3157 sheet: Option<&SheetSnapshot>,
3158 book: &str,
3159 recovery: &str,
3160) -> String {
3161 let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3162 let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3163 let now = match dimension {
3164 Dimension::Rows => sheet.and_then(|s| s.row_count),
3165 Dimension::Columns => sheet.and_then(|s| s.column_count),
3166 }
3167 .and_then(|before| before.checked_sub(count))
3168 .map_or_else(String::new, |after| {
3169 format!(" ({after} {} now)", plural(dimension))
3170 });
3171 format!(
3172 "Deleted {count} {noun}(s) {range} of '{from}'{id} in {book}{now}; {recovery}",
3173 noun = dimension.noun(),
3174 )
3175}
3176
3177#[allow(clippy::too_many_arguments)]
3179fn describe_deleted_range(
3180 from: &str,
3181 id: &str,
3182 start_row: i64,
3183 end_row: i64,
3184 start_column: i64,
3185 end_column: i64,
3186 shift: ShiftDimension,
3187 book: &str,
3188 recovery: &str,
3189) -> String {
3190 let direction = shift_word(shift.into(), false);
3191 format!(
3192 "Deleted rows {start_row}-{end_row}, columns {start_column}-{end_column} of '{from}'{id} \
3193 in {book}, shifted remaining cells {direction}; {recovery}"
3194 )
3195}
3196
3197#[allow(clippy::too_many_arguments)]
3199fn describe_inserted_range(
3200 from: &str,
3201 id: &str,
3202 start_row: i64,
3203 end_row: i64,
3204 start_column: i64,
3205 end_column: i64,
3206 shift: ShiftDimension,
3207 book: &str,
3208) -> String {
3209 let direction = shift_word(shift.into(), true);
3210 format!(
3211 "Inserted empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
3212 of '{from}'{id} in {book}, shifting existing cells {direction}; \
3213 {INSERT_RANGE_EDGE_CAVEAT}"
3214 )
3215}
3216
3217const fn plural(dimension: Dimension) -> &'static str {
3218 match dimension {
3219 Dimension::Rows => "rows",
3220 Dimension::Columns => "columns",
3221 }
3222}
3223
3224const fn shift_word(dimension: Dimension, forward: bool) -> &'static str {
3233 match (dimension, forward) {
3234 (Dimension::Rows, true) => "down",
3235 (Dimension::Rows, false) => "up",
3236 (Dimension::Columns, true) => "right",
3237 (Dimension::Columns, false) => "left",
3238 }
3239}
3240
3241#[cfg(test)]
3242#[allow(clippy::unwrap_used, clippy::expect_used)]
3243mod tests {
3244 use super::*;
3245 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
3246 use crate::drive::sheets::client::SHEETS_API_URL;
3247 use crate::drive::test_support::{seed_lease, seed_lease_with_backup};
3248 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
3249 use crate::drive::write_gate::Verdict;
3250 use crate::test_support::env::MapEnv;
3251 use crate::utils::secret::Secret;
3252 use std::collections::HashSet;
3253
3254 fn test_credentials() -> DriveCredentials {
3255 DriveCredentials {
3256 client_id: "client-1".to_string(),
3257 client_secret: Secret::new("secret-1"),
3258 refresh_token: Secret::new("refresh-1"),
3259 scope: DriveGrantedScopes::READONLY,
3260 }
3261 }
3262
3263 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
3269 wiremock::Mock::given(wiremock::matchers::method("POST"))
3270 .and(wiremock::matchers::path("/token"))
3271 .respond_with(
3272 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3273 "access_token": "test-token", "expires_in": 3600,
3274 })),
3275 )
3276 .mount(server)
3277 .await;
3278 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
3279 crate::drive::client::test_support::replace_session(
3280 &mut drive,
3281 &test_credentials(),
3282 &format!("{}/token", server.uri()),
3283 );
3284 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
3285 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
3286 (drive, sheets)
3287 }
3288
3289 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
3293 let parents: Vec<&str> = parents.to_vec();
3294 wiremock::Mock::given(wiremock::matchers::method("GET"))
3295 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
3296 .respond_with(
3297 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3298 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
3299 "version": "1",
3300 })),
3301 )
3302 }
3303
3304 fn mount_folder(id: &str) -> wiremock::Mock {
3305 mount_file(id, "application/vnd.google-apps.folder", &[])
3306 }
3307
3308 fn allow_rule(folder: &str) -> FolderPermissionRule {
3309 FolderPermissionRule {
3310 folder_id: Some(folder.to_string()),
3311 file_id: None,
3312 recursive: true,
3313 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3314 deny: HashSet::default(),
3315 require_lease: true,
3316 }
3317 }
3318
3319 fn workbook_body(q2: serde_json::Value) -> serde_json::Value {
3323 serde_json::json!({
3324 "spreadsheetId": "sheet-1",
3325 "properties": {"title": "Budget"},
3326 "sheets": [
3327 {"properties": {
3328 "sheetId": 0, "title": "Q1", "index": 0,
3329 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
3330 q2,
3331 ],
3332 })
3333 }
3334
3335 fn default_q2() -> serde_json::Value {
3338 serde_json::json!({"properties": {
3339 "sheetId": 118_293, "title": "Q2", "index": 1,
3340 "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3341 }
3342
3343 fn hidden_q2() -> serde_json::Value {
3346 serde_json::json!({"properties": {
3347 "sheetId": 118_293, "title": "Q2", "index": 1, "hidden": true,
3348 "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3349 }
3350
3351 fn mount_workbook() -> wiremock::Mock {
3354 mount_workbook_with_q2(default_q2())
3355 }
3356
3357 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
3358 wiremock::Mock::given(wiremock::matchers::method("POST"))
3359 .and(wiremock::matchers::path(
3360 "/v4/spreadsheets/sheet-1:batchUpdate",
3361 ))
3362 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
3363 }
3364
3365 fn opts(verb: StructureVerb, dry_run: bool) -> StructureOptions {
3374 opts_for("sheet-1", verb, dry_run)
3375 }
3376
3377 fn opts_for(spreadsheet_id: &str, verb: StructureVerb, dry_run: bool) -> StructureOptions {
3387 let ledger_path = tempfile::tempdir()
3388 .unwrap()
3389 .keep()
3390 .join("lease-ledger.jsonl");
3391 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
3392 StructureOptions {
3393 spreadsheet_id: spreadsheet_id.to_string(),
3394 verb,
3395 dry_run,
3396 lease_token: Some(token),
3397 ledger_path,
3398 }
3399 }
3400
3401 fn rename() -> StructureVerb {
3402 StructureVerb::RenameSheet {
3403 sheet: "Q2".to_string(),
3404 new_title: "Q3".to_string(),
3405 }
3406 }
3407
3408 fn insert_rows() -> StructureVerb {
3409 StructureVerb::InsertRows {
3410 sheet: "Q2".to_string(),
3411 at: 5,
3412 count: 3,
3413 }
3414 }
3415
3416 fn move_rows() -> StructureVerb {
3420 StructureVerb::MoveRows {
3421 sheet: "Q2".to_string(),
3422 at: 2,
3423 count: 2,
3424 before: 6,
3425 }
3426 }
3427
3428 fn add_sheet() -> StructureVerb {
3429 StructureVerb::AddSheet {
3430 title: "Q3".to_string(),
3431 index: None,
3432 rows: None,
3433 columns: None,
3434 }
3435 }
3436
3437 fn delete_sheet() -> StructureVerb {
3438 StructureVerb::DeleteSheet {
3439 sheet: "Q2".to_string(),
3440 }
3441 }
3442
3443 fn delete_rows() -> StructureVerb {
3444 StructureVerb::DeleteRows {
3445 sheet: "Q2".to_string(),
3446 at: 5,
3447 count: 3,
3448 }
3449 }
3450
3451 fn delete_columns() -> StructureVerb {
3452 StructureVerb::DeleteColumns {
3453 sheet: "Q2".to_string(),
3454 at: 3,
3455 count: 2,
3456 }
3457 }
3458
3459 fn insert_range() -> StructureVerb {
3460 StructureVerb::InsertRange {
3461 sheet: "Q2".to_string(),
3462 start_row: 2,
3463 end_row: 4,
3464 start_column: 2,
3465 end_column: 3,
3466 shift: ShiftDimension::Rows,
3467 }
3468 }
3469
3470 fn insert_range_columns_shift() -> StructureVerb {
3475 StructureVerb::InsertRange {
3476 sheet: "Q2".to_string(),
3477 start_row: 2,
3478 end_row: 4,
3479 start_column: 2,
3480 end_column: 3,
3481 shift: ShiftDimension::Columns,
3482 }
3483 }
3484
3485 fn delete_range() -> StructureVerb {
3486 StructureVerb::DeleteRange {
3487 sheet: "Q2".to_string(),
3488 start_row: 2,
3489 end_row: 4,
3490 start_column: 2,
3491 end_column: 3,
3492 shift: ShiftDimension::Rows,
3493 }
3494 }
3495
3496 fn delete_range_columns_shift() -> StructureVerb {
3500 StructureVerb::DeleteRange {
3501 sheet: "Q2".to_string(),
3502 start_row: 2,
3503 end_row: 4,
3504 start_column: 2,
3505 end_column: 3,
3506 shift: ShiftDimension::Columns,
3507 }
3508 }
3509
3510 fn duplicate_sheet() -> StructureVerb {
3511 StructureVerb::DuplicateSheet {
3512 sheet: "Q2".to_string(),
3513 title: Some("Q2 Copy".to_string()),
3514 index: Some(1),
3515 }
3516 }
3517
3518 fn duplicate_sheet_default() -> StructureVerb {
3519 StructureVerb::DuplicateSheet {
3520 sheet: "Q2".to_string(),
3521 title: None,
3522 index: None,
3523 }
3524 }
3525
3526 fn reorder_sheet() -> StructureVerb {
3527 StructureVerb::ReorderSheet {
3528 sheet: "Q2".to_string(),
3529 index: 0,
3530 }
3531 }
3532
3533 fn hide_sheet() -> StructureVerb {
3534 StructureVerb::SetSheetVisibility {
3535 sheet: "Q2".to_string(),
3536 hidden: true,
3537 }
3538 }
3539
3540 fn show_sheet() -> StructureVerb {
3541 StructureVerb::SetSheetVisibility {
3542 sheet: "Q2".to_string(),
3543 hidden: false,
3544 }
3545 }
3546
3547 fn update_workbook_properties() -> StructureVerb {
3548 StructureVerb::UpdateWorkbookProperties {
3549 locale: Some("en_US".to_string()),
3550 time_zone: Some("America/New_York".to_string()),
3551 auto_recalc: Some(RecalculationInterval::OnChange),
3552 iterative_calculation: None,
3553 iterative_calculation_max_iterations: None,
3554 iterative_calculation_convergence_threshold: None,
3555 }
3556 }
3557
3558 fn delete_allow_rule(folder: &str) -> FolderPermissionRule {
3559 FolderPermissionRule {
3560 folder_id: Some(folder.to_string()),
3561 file_id: None,
3562 recursive: true,
3563 allow: std::iter::once(DriveOperation::SheetsDelete).collect(),
3564 deny: HashSet::default(),
3565 require_lease: true,
3566 }
3567 }
3568
3569 #[test]
3582 fn every_delete_verb_gates_on_sheets_delete_not_sheets_structure() {
3583 for verb in [
3584 StructureVerb::DeleteSheet {
3585 sheet: "Q1".to_string(),
3586 },
3587 StructureVerb::DeleteRows {
3588 sheet: "Q1".to_string(),
3589 at: 1,
3590 count: 1,
3591 },
3592 StructureVerb::DeleteColumns {
3593 sheet: "Q1".to_string(),
3594 at: 1,
3595 count: 1,
3596 },
3597 StructureVerb::DeleteRange {
3598 sheet: "Q1".to_string(),
3599 start_row: 1,
3600 end_row: 2,
3601 start_column: 1,
3602 end_column: 2,
3603 shift: ShiftDimension::Rows,
3604 },
3605 ] {
3606 assert_eq!(verb.gate_operation(), DriveOperation::SheetsDelete);
3607 }
3608 }
3609
3610 #[test]
3611 fn every_additive_verb_still_gates_on_sheets_structure() {
3612 for verb in [
3613 add_sheet(),
3614 StructureVerb::RenameSheet {
3615 sheet: "Q1".to_string(),
3616 new_title: "Q2".to_string(),
3617 },
3618 StructureVerb::InsertRows {
3619 sheet: "Q1".to_string(),
3620 at: 1,
3621 count: 1,
3622 },
3623 StructureVerb::InsertColumns {
3624 sheet: "Q1".to_string(),
3625 at: 1,
3626 count: 1,
3627 },
3628 StructureVerb::InsertRange {
3629 sheet: "Q1".to_string(),
3630 start_row: 1,
3631 end_row: 2,
3632 start_column: 1,
3633 end_column: 2,
3634 shift: ShiftDimension::Rows,
3635 },
3636 StructureVerb::MoveRows {
3637 sheet: "Q1".to_string(),
3638 at: 1,
3639 count: 1,
3640 before: 3,
3641 },
3642 StructureVerb::MoveColumns {
3643 sheet: "Q1".to_string(),
3644 at: 1,
3645 count: 1,
3646 before: 3,
3647 },
3648 StructureVerb::DuplicateSheet {
3649 sheet: "Q1".to_string(),
3650 title: None,
3651 index: None,
3652 },
3653 StructureVerb::ReorderSheet {
3654 sheet: "Q1".to_string(),
3655 index: 0,
3656 },
3657 StructureVerb::SetSheetVisibility {
3658 sheet: "Q1".to_string(),
3659 hidden: true,
3660 },
3661 StructureVerb::UpdateSheetProperties {
3662 sheet: "Q1".to_string(),
3663 freeze_rows: None,
3664 freeze_columns: None,
3665 tab_color: None,
3666 clear_tab_color: false,
3667 right_to_left: Some(true),
3668 hide_gridlines: None,
3669 },
3670 StructureVerb::UpdateWorkbookProperties {
3671 locale: Some("en_US".to_string()),
3672 time_zone: None,
3673 auto_recalc: None,
3674 iterative_calculation: None,
3675 iterative_calculation_max_iterations: None,
3676 iterative_calculation_convergence_threshold: None,
3677 },
3678 ] {
3679 assert_eq!(verb.gate_operation(), DriveOperation::SheetsStructure);
3680 }
3681 }
3682
3683 #[test]
3686 fn at_is_one_based_inclusive_and_becomes_zero_based_half_open() {
3687 let range = dimension_range(7, Dimension::Rows, 5, 3);
3689 assert_eq!(range.start_index, 4);
3690 assert_eq!(range.end_index, 7);
3691 assert_eq!(range.sheet_id, 7);
3692 assert_eq!(range.dimension, Dimension::Rows);
3693 }
3694
3695 #[test]
3696 fn inserting_at_row_one_starts_at_index_zero() {
3697 let range = dimension_range(0, Dimension::Rows, 1, 1);
3700 assert_eq!(range.start_index, 0);
3701 assert_eq!(range.end_index, 1);
3702 }
3703
3704 #[test]
3705 fn a_single_insert_spans_exactly_one_index() {
3706 let range = dimension_range(0, Dimension::Columns, 3, 1);
3707 assert_eq!((range.start_index, range.end_index), (2, 3));
3708 }
3709
3710 #[test]
3711 fn dimension_range_label_is_one_based_inclusive_like_the_flag() {
3712 assert_eq!(
3714 dimension_range_label(&insert_rows()).as_deref(),
3715 Some("ROWS 5:7")
3716 );
3717 assert_eq!(
3718 dimension_range_label(&StructureVerb::InsertColumns {
3719 sheet: "Q2".to_string(),
3720 at: 2,
3721 count: 1,
3722 })
3723 .as_deref(),
3724 Some("COLUMNS 2:2")
3725 );
3726 assert_eq!(dimension_range_label(&rename()), None);
3728 assert_eq!(dimension_range_label(&add_sheet()), None);
3729 }
3730
3731 #[test]
3732 fn dimension_range_label_omits_a_span_it_cannot_compute() {
3733 for (at, count) in [(i64::MAX, 5), (1, i64::MIN), (-1, i64::MIN)] {
3739 assert_eq!(
3740 dimension_range_label(&StructureVerb::InsertRows {
3741 sheet: "Q2".to_string(),
3742 at,
3743 count,
3744 }),
3745 None,
3746 "--at {at} --count {count} must not produce a span"
3747 );
3748 }
3749 }
3750
3751 #[tokio::test]
3754 async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
3755 let server = wiremock::MockServer::start().await;
3756 let (drive, sheets) = clients(&server).await;
3757 mount_file("sheet-1", "application/pdf", &["parent-1"])
3758 .mount(&server)
3759 .await;
3760 let outcome = structure(
3763 &drive,
3764 &sheets,
3765 &opts(rename(), false),
3766 &[allow_rule("parent-1")],
3767 )
3768 .await;
3769 assert!(matches!(
3770 outcome.result,
3771 StructureResult::RefusedNotASpreadsheet { .. }
3772 ));
3773 let text = describe(&outcome);
3774 assert!(text.contains("is not a Google Sheet"), "{text}");
3775 assert!(text.contains("drive sheets rename-sheet"), "{text}");
3776 }
3777
3778 #[tokio::test]
3779 async fn shortcut_is_refused_with_its_own_message() {
3780 let server = wiremock::MockServer::start().await;
3781 let (drive, sheets) = clients(&server).await;
3782 mount_file(
3783 "sheet-1",
3784 "application/vnd.google-apps.shortcut",
3785 &["parent-1"],
3786 )
3787 .mount(&server)
3788 .await;
3789 let outcome = structure(
3790 &drive,
3791 &sheets,
3792 &opts(rename(), false),
3793 &[allow_rule("parent-1")],
3794 )
3795 .await;
3796 assert!(matches!(outcome.result, StructureResult::RefusedShortcut));
3797 let text = describe(&outcome);
3798 assert!(text.contains("is a shortcut"), "{text}");
3799 }
3800
3801 #[tokio::test]
3802 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_blocked() {
3803 let server = wiremock::MockServer::start().await;
3804 let (drive, sheets) = clients(&server).await;
3805 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3806 .mount(&server)
3807 .await;
3808 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3809 assert!(matches!(
3810 outcome.result,
3811 StructureResult::RefusedNoVisibleParents
3812 ));
3813 let text = describe(&outcome);
3814 assert!(text.contains("no parent folder visible"), "{text}");
3815 assert!(text.contains("sheets-structure"), "{text}");
3818 }
3819
3820 #[tokio::test]
3825 async fn a_delete_verbs_no_visible_parents_message_names_sheets_delete() {
3826 let server = wiremock::MockServer::start().await;
3827 let (drive, sheets) = clients(&server).await;
3828 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3829 .mount(&server)
3830 .await;
3831 let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3832 let text = describe(&outcome);
3833 assert!(text.contains("sheets-delete"), "{text}");
3834 assert!(!text.contains("sheets-structure"), "{text}");
3835 }
3836
3837 #[tokio::test]
3840 async fn denied_target_makes_zero_sheets_calls() {
3841 let server = wiremock::MockServer::start().await;
3842 let (drive, sheets) = clients(&server).await;
3843 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3844 .mount(&server)
3845 .await;
3846 mount_folder("parent-1").mount(&server).await;
3847 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3851 assert!(matches!(
3852 outcome.result,
3853 StructureResult::Blocked { decided_by: None }
3854 ));
3855 let text = describe(&outcome);
3856 assert!(text.contains("default policy"), "{text}");
3857 }
3858
3859 #[tokio::test]
3860 async fn a_blocked_delete_names_destructive_edits_and_sheets_delete() {
3861 let server = wiremock::MockServer::start().await;
3862 let (drive, sheets) = clients(&server).await;
3863 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3864 .mount(&server)
3865 .await;
3866 mount_folder("parent-1").mount(&server).await;
3867 let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3868 assert!(matches!(
3869 outcome.result,
3870 StructureResult::Blocked { decided_by: None }
3871 ));
3872 let text = describe(&outcome);
3873 assert!(text.contains("destructive edits"), "{text}");
3874 assert!(text.contains("default policy"), "{text}");
3875 assert!(text.contains("sheets-delete"), "{text}");
3876 assert!(!text.contains("structural edits"), "{text}");
3877 }
3878
3879 #[tokio::test]
3880 async fn a_sheets_write_rule_alone_does_not_permit_a_structural_edit() {
3881 let server = wiremock::MockServer::start().await;
3882 let (drive, sheets) = clients(&server).await;
3883 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3884 .mount(&server)
3885 .await;
3886 mount_folder("parent-1").mount(&server).await;
3887 let rules = [FolderPermissionRule {
3891 folder_id: Some("parent-1".to_string()),
3892 file_id: None,
3893 recursive: true,
3894 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
3895 deny: HashSet::default(),
3896 require_lease: true,
3897 }];
3898 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3899 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3900 }
3901
3902 #[tokio::test]
3906 async fn a_sheets_structure_rule_alone_does_not_permit_deletion() {
3907 let server = wiremock::MockServer::start().await;
3908 let (drive, sheets) = clients(&server).await;
3909 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3910 .mount(&server)
3911 .await;
3912 mount_folder("parent-1").mount(&server).await;
3913 let rules = [allow_rule("parent-1")]; for verb in [delete_sheet(), delete_rows(), delete_range()] {
3915 let outcome = structure(&drive, &sheets, &opts(verb, false), &rules).await;
3916 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3917 }
3918 }
3919
3920 #[tokio::test]
3923 async fn a_sheets_delete_rule_alone_does_not_permit_a_structural_edit() {
3924 let server = wiremock::MockServer::start().await;
3925 let (drive, sheets) = clients(&server).await;
3926 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3927 .mount(&server)
3928 .await;
3929 mount_folder("parent-1").mount(&server).await;
3930 let rules = [delete_allow_rule("parent-1")];
3931 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3932 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3933 }
3934
3935 #[tokio::test]
3936 async fn an_explicit_deny_is_reported_with_the_deciding_rule() {
3937 let server = wiremock::MockServer::start().await;
3938 let (drive, sheets) = clients(&server).await;
3939 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3940 .mount(&server)
3941 .await;
3942 mount_folder("parent-1").mount(&server).await;
3943 let rules = [FolderPermissionRule {
3944 folder_id: Some("parent-1".to_string()),
3945 file_id: None,
3946 recursive: true,
3947 allow: HashSet::default(),
3948 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
3949 require_lease: true,
3950 }];
3951 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3952 let StructureResult::Blocked { decided_by } = &outcome.result else {
3953 panic!("expected Blocked, got {:?}", outcome.result);
3954 };
3955 let rule = decided_by.as_ref().expect("an explicit rule decided this");
3956 assert_eq!(rule.kind_label(), "folder");
3957 assert_eq!(rule.id(), "parent-1");
3958 assert_eq!(rule.depth_suffix(), " (depth 0)");
3959 }
3960
3961 #[tokio::test]
3962 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
3963 let server = wiremock::MockServer::start().await;
3964 let (drive, sheets) = clients(&server).await;
3965 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3966 .mount(&server)
3967 .await;
3968 wiremock::Mock::given(wiremock::matchers::method("GET"))
3969 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
3970 .respond_with(wiremock::ResponseTemplate::new(500))
3971 .mount(&server)
3972 .await;
3973 let outcome = structure(
3974 &drive,
3975 &sheets,
3976 &opts(rename(), false),
3977 &[allow_rule("parent-1")],
3978 )
3979 .await;
3980 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
3981 }
3982
3983 #[tokio::test]
3993 async fn dry_run_reads_the_workbook_but_never_calls_batch_update() {
3994 let server = wiremock::MockServer::start().await;
3995 let (drive, sheets) = clients(&server).await;
3996 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3997 .mount(&server)
3998 .await;
3999 mount_folder("parent-1").mount(&server).await;
4000 mount_workbook().expect(1).mount(&server).await;
4001 let outcome = structure(
4002 &drive,
4003 &sheets,
4004 &opts(insert_rows(), true),
4005 &[allow_rule("parent-1")],
4006 )
4007 .await;
4008 assert!(matches!(
4009 outcome.result,
4010 StructureResult::WouldChange { .. }
4011 ));
4012 }
4013
4014 #[tokio::test]
4015 async fn dry_run_names_the_resulting_dimensions_and_the_shift() {
4016 let server = wiremock::MockServer::start().await;
4017 let (drive, sheets) = clients(&server).await;
4018 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4019 .mount(&server)
4020 .await;
4021 mount_folder("parent-1").mount(&server).await;
4022 mount_workbook().mount(&server).await;
4023 let outcome = structure(
4024 &drive,
4025 &sheets,
4026 &opts(insert_rows(), true),
4027 &[allow_rule("parent-1")],
4028 )
4029 .await;
4030 let text = describe(&outcome);
4031 assert!(
4034 text.contains("Would insert 3 row(s) before row 5"),
4035 "{text}"
4036 );
4037 assert!(text.contains("500 rows -> 503"), "{text}");
4038 assert!(text.contains("shift down"), "{text}");
4039 assert!(text.contains("sheetId 118293"), "{text}");
4040 }
4041
4042 #[tokio::test]
4046 async fn dry_run_never_calls_batch_update_for_a_delete_verb() {
4047 let server = wiremock::MockServer::start().await;
4048 let (drive, sheets) = clients(&server).await;
4049 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4050 .mount(&server)
4051 .await;
4052 mount_folder("parent-1").mount(&server).await;
4053 mount_workbook().mount(&server).await;
4054 for verb in [delete_sheet(), delete_rows(), delete_range()] {
4055 let outcome = structure(
4056 &drive,
4057 &sheets,
4058 &opts(verb, true),
4059 &[delete_allow_rule("parent-1")],
4060 )
4061 .await;
4062 assert!(matches!(
4063 outcome.result,
4064 StructureResult::WouldChange { .. }
4065 ));
4066 }
4067 }
4068
4069 #[tokio::test]
4070 async fn dry_run_names_the_deleted_dimensions_and_the_shift_plus_the_caveat() {
4071 let server = wiremock::MockServer::start().await;
4072 let (drive, sheets) = clients(&server).await;
4073 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4074 .mount(&server)
4075 .await;
4076 mount_folder("parent-1").mount(&server).await;
4077 mount_workbook().mount(&server).await;
4078 let outcome = structure(
4079 &drive,
4080 &sheets,
4081 &opts(delete_rows(), true),
4082 &[delete_allow_rule("parent-1")],
4083 )
4084 .await;
4085 let text = describe(&outcome);
4086 assert!(text.contains("Would delete 3 row(s) 5-7"), "{text}");
4087 assert!(text.contains("500 rows -> 497"), "{text}");
4088 assert!(text.contains("shift up"), "{text}");
4089 assert!(
4090 text.contains("formulas elsewhere in the workbook"),
4091 "{text}"
4092 );
4093 }
4094
4095 #[tokio::test]
4096 async fn dry_run_names_the_deleted_sheet_and_the_caveat() {
4097 let server = wiremock::MockServer::start().await;
4098 let (drive, sheets) = clients(&server).await;
4099 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4100 .mount(&server)
4101 .await;
4102 mount_folder("parent-1").mount(&server).await;
4103 mount_workbook().mount(&server).await;
4104 let outcome = structure(
4105 &drive,
4106 &sheets,
4107 &opts(delete_sheet(), true),
4108 &[delete_allow_rule("parent-1")],
4109 )
4110 .await;
4111 let text = describe(&outcome);
4112 assert!(text.contains("Would delete sheet 'Q2'"), "{text}");
4113 assert!(text.contains("2 sheet(s) -> 1"), "{text}");
4114 assert!(
4115 text.contains("formulas elsewhere in the workbook"),
4116 "{text}"
4117 );
4118 }
4119
4120 #[tokio::test]
4121 async fn dry_run_names_the_deleted_range_and_the_shift() {
4122 let server = wiremock::MockServer::start().await;
4123 let (drive, sheets) = clients(&server).await;
4124 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4125 .mount(&server)
4126 .await;
4127 mount_folder("parent-1").mount(&server).await;
4128 mount_workbook().mount(&server).await;
4129 let outcome = structure(
4130 &drive,
4131 &sheets,
4132 &opts(delete_range(), true),
4133 &[delete_allow_rule("parent-1")],
4134 )
4135 .await;
4136 let text = describe(&outcome);
4137 assert!(
4138 text.contains("Would delete rows 2-4, columns 2-3"),
4139 "{text}"
4140 );
4141 assert!(text.contains("shifting remaining cells up"), "{text}");
4142 }
4143
4144 #[tokio::test]
4145 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
4146 let server = wiremock::MockServer::start().await;
4147 let (drive, sheets) = clients(&server).await;
4148 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4149 .mount(&server)
4150 .await;
4151 mount_folder("parent-1").mount(&server).await;
4152 let dry = structure(&drive, &sheets, &opts(rename(), true), &[]).await;
4155 let wet = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
4156 assert_eq!(dry.result, wet.result);
4157 assert!(matches!(dry.result, StructureResult::Blocked { .. }));
4158 }
4159
4160 #[tokio::test]
4163 async fn a_missing_sheet_is_refused_and_lists_what_does_exist() {
4164 let server = wiremock::MockServer::start().await;
4165 let (drive, sheets) = clients(&server).await;
4166 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4167 .mount(&server)
4168 .await;
4169 mount_folder("parent-1").mount(&server).await;
4170 mount_workbook().mount(&server).await;
4171 let verb = StructureVerb::RenameSheet {
4172 sheet: "Nope".to_string(),
4173 new_title: "Q3".to_string(),
4174 };
4175 let outcome = structure(
4176 &drive,
4177 &sheets,
4178 &opts(verb.clone(), false),
4179 &[allow_rule("parent-1")],
4180 )
4181 .await;
4182 assert!(matches!(
4183 outcome.result,
4184 StructureResult::RefusedSheetNotFound { .. }
4185 ));
4186 let text = describe(&outcome);
4187 assert!(text.contains("no sheet titled 'Nope'"), "{text}");
4188 assert!(text.contains("'Q1'"), "{text}");
4189 assert!(text.contains("'Q2'"), "{text}");
4190 }
4191
4192 #[tokio::test]
4195 async fn add_sheet_refuses_a_title_the_workbook_already_uses() {
4196 let server = wiremock::MockServer::start().await;
4197 let (drive, sheets) = clients(&server).await;
4198 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4199 .mount(&server)
4200 .await;
4201 mount_folder("parent-1").mount(&server).await;
4202 mount_workbook().mount(&server).await;
4203 let verb = StructureVerb::AddSheet {
4204 title: "Q1".to_string(),
4205 index: None,
4206 rows: None,
4207 columns: None,
4208 };
4209 let outcome = structure(
4211 &drive,
4212 &sheets,
4213 &opts(verb.clone(), false),
4214 &[allow_rule("parent-1")],
4215 )
4216 .await;
4217 assert!(matches!(
4218 outcome.result,
4219 StructureResult::RefusedSheetExists { .. }
4220 ));
4221 assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4222 }
4223
4224 #[tokio::test]
4228 async fn rename_sheet_refuses_a_title_another_sheet_already_uses() {
4229 let server = wiremock::MockServer::start().await;
4230 let (drive, sheets) = clients(&server).await;
4231 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4232 .mount(&server)
4233 .await;
4234 mount_folder("parent-1").mount(&server).await;
4235 mount_workbook().mount(&server).await;
4236 let verb = StructureVerb::RenameSheet {
4237 sheet: "Q2".to_string(),
4238 new_title: "Q1".to_string(),
4239 };
4240 let outcome = structure(
4243 &drive,
4244 &sheets,
4245 &opts(verb.clone(), false),
4246 &[allow_rule("parent-1")],
4247 )
4248 .await;
4249 assert!(matches!(
4250 outcome.result,
4251 StructureResult::RefusedSheetExists { .. }
4252 ));
4253 assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4254
4255 let dry = structure(
4256 &drive,
4257 &sheets,
4258 &opts(verb.clone(), true),
4259 &[allow_rule("parent-1")],
4260 )
4261 .await;
4262 assert_eq!(dry.result, outcome.result);
4263 }
4264
4265 #[tokio::test]
4268 async fn rename_sheet_to_its_own_current_title_is_not_a_duplicate() {
4269 let server = wiremock::MockServer::start().await;
4270 let (drive, sheets) = clients(&server).await;
4271 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4272 .mount(&server)
4273 .await;
4274 mount_folder("parent-1").mount(&server).await;
4275 mount_workbook().mount(&server).await;
4276 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4277 .mount(&server)
4278 .await;
4279 let verb = StructureVerb::RenameSheet {
4280 sheet: "Q2".to_string(),
4281 new_title: "Q2".to_string(),
4282 };
4283 let outcome = structure(
4284 &drive,
4285 &sheets,
4286 &opts(verb, false),
4287 &[allow_rule("parent-1")],
4288 )
4289 .await;
4290 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4291 }
4292
4293 #[tokio::test]
4296 async fn insert_rows_refuses_a_count_below_one() {
4297 let server = wiremock::MockServer::start().await;
4298 let (drive, sheets) = clients(&server).await;
4299 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4300 .mount(&server)
4301 .await;
4302 mount_folder("parent-1").mount(&server).await;
4303 mount_workbook().mount(&server).await;
4304 let verb = StructureVerb::InsertRows {
4305 sheet: "Q2".to_string(),
4306 at: 5,
4307 count: 0,
4308 };
4309 let outcome = structure(
4311 &drive,
4312 &sheets,
4313 &opts(verb.clone(), false),
4314 &[allow_rule("parent-1")],
4315 )
4316 .await;
4317 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4318 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4319 };
4320 assert!(detail.contains("--count"), "{detail}");
4321 assert!(describe(&outcome).starts_with("Refused: --count"));
4322 }
4323
4324 #[tokio::test]
4325 async fn insert_columns_refuses_an_at_below_one() {
4326 let server = wiremock::MockServer::start().await;
4327 let (drive, sheets) = clients(&server).await;
4328 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4329 .mount(&server)
4330 .await;
4331 mount_folder("parent-1").mount(&server).await;
4332 mount_workbook().mount(&server).await;
4333 let verb = StructureVerb::InsertColumns {
4334 sheet: "Q2".to_string(),
4335 at: 0,
4336 count: 1,
4337 };
4338 let outcome = structure(
4339 &drive,
4340 &sheets,
4341 &opts(verb, false),
4342 &[allow_rule("parent-1")],
4343 )
4344 .await;
4345 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4346 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4347 };
4348 assert!(detail.contains("--at"), "{detail}");
4349 }
4350
4351 #[tokio::test]
4356 async fn insert_rows_refuses_an_at_past_the_sheets_end() {
4357 let server = wiremock::MockServer::start().await;
4358 let (drive, sheets) = clients(&server).await;
4359 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4360 .mount(&server)
4361 .await;
4362 mount_folder("parent-1").mount(&server).await;
4363 mount_workbook().mount(&server).await;
4364 let verb = StructureVerb::InsertRows {
4365 sheet: "Q2".to_string(),
4366 at: 502,
4367 count: 1,
4368 };
4369 let outcome = structure(
4370 &drive,
4371 &sheets,
4372 &opts(verb.clone(), false),
4373 &[allow_rule("parent-1")],
4374 )
4375 .await;
4376 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4377 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4378 };
4379 assert!(detail.contains("500 row(s)"), "{detail}");
4380 assert!(detail.contains("501"), "{detail}");
4381
4382 let dry = structure(
4383 &drive,
4384 &sheets,
4385 &opts(verb, true),
4386 &[allow_rule("parent-1")],
4387 )
4388 .await;
4389 assert_eq!(dry.result, outcome.result);
4390 }
4391
4392 #[test]
4393 fn an_at_and_count_that_would_overflow_the_index_space_are_refused() {
4394 let sheet = SheetSnapshot {
4395 sheet_id: Some(1),
4396 title: "Q2".to_string(),
4397 row_count: Some(500),
4398 column_count: Some(26),
4399 ..Default::default()
4400 };
4401 for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
4406 let Err(StructureResult::RefusedInvalidRange { detail }) =
4407 validate_insert_bounds(Dimension::Rows, at, count, Some(&sheet))
4408 else {
4409 panic!("expected RefusedInvalidRange for --at {at} --count {count}");
4410 };
4411 assert!(detail.contains("overflows the row index space"), "{detail}");
4412 }
4413 }
4414
4415 #[tokio::test]
4416 async fn a_refused_out_of_range_insert_still_records_without_panicking() {
4417 let server = wiremock::MockServer::start().await;
4423 let (drive, sheets) = clients(&server).await;
4424 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4425 .mount(&server)
4426 .await;
4427 mount_folder("parent-1").mount(&server).await;
4428 mount_workbook().mount(&server).await;
4429
4430 let outcome = structure(
4431 &drive,
4432 &sheets,
4433 &opts(
4434 StructureVerb::InsertRows {
4435 sheet: "Q2".to_string(),
4436 at: i64::MAX,
4437 count: 5,
4438 },
4439 false,
4440 ),
4441 &[allow_rule("parent-1")],
4442 )
4443 .await;
4444 assert!(
4445 matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
4446 "{:?}",
4447 outcome.result
4448 );
4449 }
4450
4451 #[tokio::test]
4456 async fn insert_rows_allows_at_equal_to_row_count_plus_one_as_an_append() {
4457 let server = wiremock::MockServer::start().await;
4458 let (drive, sheets) = clients(&server).await;
4459 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4460 .mount(&server)
4461 .await;
4462 mount_folder("parent-1").mount(&server).await;
4463 mount_workbook().mount(&server).await;
4464 let verb = StructureVerb::InsertRows {
4465 sheet: "Q2".to_string(),
4466 at: 501,
4467 count: 2,
4468 };
4469 let outcome = structure(
4470 &drive,
4471 &sheets,
4472 &opts(verb.clone(), true),
4473 &[allow_rule("parent-1")],
4474 )
4475 .await;
4476 assert!(matches!(
4477 outcome.result,
4478 StructureResult::WouldChange { .. }
4479 ));
4480 let text = describe(&outcome);
4481 assert!(text.contains("500 rows -> 502"), "{text}");
4482 assert!(text.contains("appended at the end"), "{text}");
4483 assert!(!text.contains("501-500"), "{text}");
4484 }
4485
4486 #[tokio::test]
4487 async fn add_sheet_refuses_non_positive_rows_and_columns() {
4488 let server = wiremock::MockServer::start().await;
4489 let (drive, sheets) = clients(&server).await;
4490 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4491 .mount(&server)
4492 .await;
4493 mount_folder("parent-1").mount(&server).await;
4494 mount_workbook().mount(&server).await;
4495 let verb = StructureVerb::AddSheet {
4496 title: "Q3".to_string(),
4497 index: None,
4498 rows: Some(0),
4499 columns: None,
4500 };
4501 let outcome = structure(
4502 &drive,
4503 &sheets,
4504 &opts(verb, false),
4505 &[allow_rule("parent-1")],
4506 )
4507 .await;
4508 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4509 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4510 };
4511 assert!(detail.contains("--rows"), "{detail}");
4512 }
4513
4514 #[tokio::test]
4517 async fn add_sheet_refuses_an_out_of_range_index() {
4518 let server = wiremock::MockServer::start().await;
4519 let (drive, sheets) = clients(&server).await;
4520 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4521 .mount(&server)
4522 .await;
4523 mount_folder("parent-1").mount(&server).await;
4524 mount_workbook().mount(&server).await;
4525 let verb = StructureVerb::AddSheet {
4526 title: "Q3".to_string(),
4527 index: Some(3),
4528 rows: None,
4529 columns: None,
4530 };
4531 let outcome = structure(
4532 &drive,
4533 &sheets,
4534 &opts(verb, false),
4535 &[allow_rule("parent-1")],
4536 )
4537 .await;
4538 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4539 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4540 };
4541 assert!(detail.contains("--index"), "{detail}");
4542 }
4543
4544 #[tokio::test]
4545 async fn add_sheet_allows_an_index_equal_to_the_sheet_count() {
4546 let server = wiremock::MockServer::start().await;
4547 let (drive, sheets) = clients(&server).await;
4548 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4549 .mount(&server)
4550 .await;
4551 mount_folder("parent-1").mount(&server).await;
4552 mount_workbook().mount(&server).await;
4553 let verb = StructureVerb::AddSheet {
4554 title: "Q3".to_string(),
4555 index: Some(2),
4556 rows: None,
4557 columns: None,
4558 };
4559 let outcome = structure(
4560 &drive,
4561 &sheets,
4562 &opts(verb, true),
4563 &[allow_rule("parent-1")],
4564 )
4565 .await;
4566 assert!(matches!(
4567 outcome.result,
4568 StructureResult::WouldChange { .. }
4569 ));
4570 }
4571
4572 #[tokio::test]
4575 async fn rename_sends_a_title_masked_update_for_the_resolved_sheet_id() {
4576 let server = wiremock::MockServer::start().await;
4577 let (drive, sheets) = clients(&server).await;
4578 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4579 .mount(&server)
4580 .await;
4581 mount_folder("parent-1").mount(&server).await;
4582 mount_workbook().mount(&server).await;
4583 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4584 .expect(1)
4585 .mount(&server)
4586 .await;
4587 let outcome = structure(
4588 &drive,
4589 &sheets,
4590 &opts(rename(), false),
4591 &[allow_rule("parent-1")],
4592 )
4593 .await;
4594 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4595
4596 let requests = server.received_requests().await.unwrap();
4597 let body: serde_json::Value = requests
4598 .iter()
4599 .find(|r| r.url.path().ends_with(":batchUpdate"))
4600 .map(|r| serde_json::from_slice(&r.body).unwrap())
4601 .expect("a batchUpdate request");
4602 let update = &body["requests"][0]["updateSheetProperties"];
4603 assert_eq!(update["properties"]["sheetId"], 118_293);
4605 assert_eq!(update["properties"]["title"], "Q3");
4606 assert_eq!(update["fields"], "title");
4609 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4610 }
4611
4612 #[tokio::test]
4613 async fn insert_rows_sends_a_zero_based_half_open_range() {
4614 let server = wiremock::MockServer::start().await;
4615 let (drive, sheets) = clients(&server).await;
4616 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4617 .mount(&server)
4618 .await;
4619 mount_folder("parent-1").mount(&server).await;
4620 mount_workbook().mount(&server).await;
4621 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4622 .mount(&server)
4623 .await;
4624 let outcome = structure(
4625 &drive,
4626 &sheets,
4627 &opts(insert_rows(), false),
4628 &[allow_rule("parent-1")],
4629 )
4630 .await;
4631 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4632
4633 let requests = server.received_requests().await.unwrap();
4634 let body: serde_json::Value = requests
4635 .iter()
4636 .find(|r| r.url.path().ends_with(":batchUpdate"))
4637 .map(|r| serde_json::from_slice(&r.body).unwrap())
4638 .expect("a batchUpdate request");
4639 let insert = &body["requests"][0]["insertDimension"];
4640 assert_eq!(insert["range"]["dimension"], "ROWS");
4641 assert_eq!(insert["range"]["sheetId"], 118_293);
4642 assert_eq!(insert["range"]["startIndex"], 4);
4645 assert_eq!(insert["range"]["endIndex"], 7);
4646 assert_eq!(insert["inheritFromBefore"], false);
4647 }
4648
4649 #[tokio::test]
4650 async fn delete_sheet_sends_the_resolved_sheet_id() {
4651 let server = wiremock::MockServer::start().await;
4652 let (drive, sheets) = clients(&server).await;
4653 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4654 .mount(&server)
4655 .await;
4656 mount_folder("parent-1").mount(&server).await;
4657 mount_workbook().mount(&server).await;
4658 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4659 .expect(1)
4660 .mount(&server)
4661 .await;
4662 let outcome = structure(
4663 &drive,
4664 &sheets,
4665 &opts(delete_sheet(), false),
4666 &[delete_allow_rule("parent-1")],
4667 )
4668 .await;
4669 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4670 assert!(describe(&outcome).contains("Deleted sheet 'Q2'"));
4671 assert!(describe(&outcome).contains("backed the file up when it was acquired"));
4675 assert!(describe(&outcome).contains("/tmp/test-backup"));
4676 assert!(describe(&outcome).contains("Google Drive's own version history"));
4677
4678 let requests = server.received_requests().await.unwrap();
4679 let body: serde_json::Value = requests
4680 .iter()
4681 .find(|r| r.url.path().ends_with(":batchUpdate"))
4682 .map(|r| serde_json::from_slice(&r.body).unwrap())
4683 .expect("a batchUpdate request");
4684 assert_eq!(body["requests"][0]["deleteSheet"]["sheetId"], 118_293);
4685 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4686 }
4687
4688 #[tokio::test]
4689 async fn delete_rows_sends_a_zero_based_half_open_range() {
4690 let server = wiremock::MockServer::start().await;
4691 let (drive, sheets) = clients(&server).await;
4692 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4693 .mount(&server)
4694 .await;
4695 mount_folder("parent-1").mount(&server).await;
4696 mount_workbook().mount(&server).await;
4697 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4698 .mount(&server)
4699 .await;
4700 let outcome = structure(
4701 &drive,
4702 &sheets,
4703 &opts(delete_rows(), false),
4704 &[delete_allow_rule("parent-1")],
4705 )
4706 .await;
4707 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4708
4709 let requests = server.received_requests().await.unwrap();
4710 let body: serde_json::Value = requests
4711 .iter()
4712 .find(|r| r.url.path().ends_with(":batchUpdate"))
4713 .map(|r| serde_json::from_slice(&r.body).unwrap())
4714 .expect("a batchUpdate request");
4715 let delete = &body["requests"][0]["deleteDimension"];
4716 assert_eq!(delete["range"]["dimension"], "ROWS");
4717 assert_eq!(delete["range"]["sheetId"], 118_293);
4718 assert_eq!(delete["range"]["startIndex"], 4);
4721 assert_eq!(delete["range"]["endIndex"], 7);
4722 }
4723
4724 #[tokio::test]
4725 async fn delete_columns_sends_a_zero_based_half_open_range() {
4726 let server = wiremock::MockServer::start().await;
4727 let (drive, sheets) = clients(&server).await;
4728 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4729 .mount(&server)
4730 .await;
4731 mount_folder("parent-1").mount(&server).await;
4732 mount_workbook().mount(&server).await;
4733 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4734 .mount(&server)
4735 .await;
4736 let outcome = structure(
4737 &drive,
4738 &sheets,
4739 &opts(delete_columns(), false),
4740 &[delete_allow_rule("parent-1")],
4741 )
4742 .await;
4743 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4744 assert!(describe(&outcome).contains("Deleted 2 column(s) 3-4"));
4745
4746 let requests = server.received_requests().await.unwrap();
4747 let body: serde_json::Value = requests
4748 .iter()
4749 .find(|r| r.url.path().ends_with(":batchUpdate"))
4750 .map(|r| serde_json::from_slice(&r.body).unwrap())
4751 .expect("a batchUpdate request");
4752 let delete = &body["requests"][0]["deleteDimension"];
4753 assert_eq!(delete["range"]["dimension"], "COLUMNS");
4754 assert_eq!(delete["range"]["sheetId"], 118_293);
4755 assert_eq!(delete["range"]["startIndex"], 2);
4758 assert_eq!(delete["range"]["endIndex"], 4);
4759 }
4760
4761 #[tokio::test]
4762 async fn insert_range_sends_a_zero_based_half_open_grid_range() {
4763 let server = wiremock::MockServer::start().await;
4764 let (drive, sheets) = clients(&server).await;
4765 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4766 .mount(&server)
4767 .await;
4768 mount_folder("parent-1").mount(&server).await;
4769 mount_workbook().mount(&server).await;
4770 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4771 .mount(&server)
4772 .await;
4773 let outcome = structure(
4774 &drive,
4775 &sheets,
4776 &opts(insert_range(), false),
4777 &[allow_rule("parent-1")],
4778 )
4779 .await;
4780 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4781 assert!(describe(&outcome).contains("shifting existing cells down"));
4782 assert!(describe(&outcome).contains("may have been dropped by Sheets"));
4783
4784 let requests = server.received_requests().await.unwrap();
4785 let body: serde_json::Value = requests
4786 .iter()
4787 .find(|r| r.url.path().ends_with(":batchUpdate"))
4788 .map(|r| serde_json::from_slice(&r.body).unwrap())
4789 .expect("a batchUpdate request");
4790 assert_eq!(
4791 body["requests"][0]["insertRange"],
4792 serde_json::json!({
4793 "range": {
4794 "sheetId": 118_293,
4795 "startRowIndex": 1,
4796 "endRowIndex": 4,
4797 "startColumnIndex": 1,
4798 "endColumnIndex": 3,
4799 },
4800 "shiftDimension": "ROWS",
4801 })
4802 );
4803 }
4804
4805 #[tokio::test]
4806 async fn insert_range_with_columns_shift_sends_the_column_direction() {
4807 let server = wiremock::MockServer::start().await;
4808 let (drive, sheets) = clients(&server).await;
4809 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4810 .mount(&server)
4811 .await;
4812 mount_folder("parent-1").mount(&server).await;
4813 mount_workbook().mount(&server).await;
4814 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4815 .mount(&server)
4816 .await;
4817 let outcome = structure(
4818 &drive,
4819 &sheets,
4820 &opts(insert_range_columns_shift(), false),
4821 &[allow_rule("parent-1")],
4822 )
4823 .await;
4824 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4825 assert!(describe(&outcome).contains("shifting existing cells right"));
4826
4827 let requests = server.received_requests().await.unwrap();
4828 let body: serde_json::Value = requests
4829 .iter()
4830 .find(|r| r.url.path().ends_with(":batchUpdate"))
4831 .map(|r| serde_json::from_slice(&r.body).unwrap())
4832 .expect("a batchUpdate request");
4833 assert_eq!(
4834 body["requests"][0]["insertRange"]["shiftDimension"],
4835 "COLUMNS"
4836 );
4837 }
4838
4839 #[tokio::test]
4840 async fn insert_range_dry_run_at_the_grid_edge_warns_without_mutating() {
4841 let server = wiremock::MockServer::start().await;
4842 let (drive, sheets) = clients(&server).await;
4843 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4844 .mount(&server)
4845 .await;
4846 mount_folder("parent-1").mount(&server).await;
4847 mount_workbook().mount(&server).await;
4848 let outcome = structure(
4849 &drive,
4850 &sheets,
4851 &opts(
4852 StructureVerb::InsertRange {
4853 sheet: "Q2".to_string(),
4854 start_row: 500,
4855 end_row: 500,
4856 start_column: 10,
4857 end_column: 10,
4858 shift: ShiftDimension::Columns,
4859 },
4860 true,
4861 ),
4862 &[allow_rule("parent-1")],
4863 )
4864 .await;
4865 assert!(matches!(
4866 outcome.result,
4867 StructureResult::WouldChange { .. }
4868 ));
4869 let text = describe(&outcome);
4870 assert!(text.contains("shifting existing cells right"), "{text}");
4871 assert!(text.contains("may be dropped by Sheets"), "{text}");
4872 assert!(server
4873 .received_requests()
4874 .await
4875 .unwrap()
4876 .iter()
4877 .all(|request| !request.url.path().ends_with(":batchUpdate")));
4878 }
4879
4880 #[tokio::test]
4881 async fn insert_range_dry_run_with_rows_shift_warns_without_mutating() {
4882 let server = wiremock::MockServer::start().await;
4883 let (drive, sheets) = clients(&server).await;
4884 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4885 .mount(&server)
4886 .await;
4887 mount_folder("parent-1").mount(&server).await;
4888 mount_workbook().mount(&server).await;
4889 let outcome = structure(
4890 &drive,
4891 &sheets,
4892 &opts(
4893 StructureVerb::InsertRange {
4894 sheet: "Q2".to_string(),
4895 start_row: 500,
4896 end_row: 500,
4897 start_column: 10,
4898 end_column: 10,
4899 shift: ShiftDimension::Rows,
4900 },
4901 true,
4902 ),
4903 &[allow_rule("parent-1")],
4904 )
4905 .await;
4906 assert!(matches!(
4907 outcome.result,
4908 StructureResult::WouldChange { .. }
4909 ));
4910 let text = describe(&outcome);
4911 assert!(text.contains("shifting existing cells down"), "{text}");
4912 assert!(text.contains("may be dropped by Sheets"), "{text}");
4913 assert!(server
4914 .received_requests()
4915 .await
4916 .unwrap()
4917 .iter()
4918 .all(|request| !request.url.path().ends_with(":batchUpdate")));
4919 }
4920
4921 #[tokio::test]
4922 async fn delete_range_sends_a_zero_based_half_open_grid_range() {
4923 let server = wiremock::MockServer::start().await;
4924 let (drive, sheets) = clients(&server).await;
4925 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4926 .mount(&server)
4927 .await;
4928 mount_folder("parent-1").mount(&server).await;
4929 mount_workbook().mount(&server).await;
4930 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4931 .mount(&server)
4932 .await;
4933 let outcome = structure(
4934 &drive,
4935 &sheets,
4936 &opts(delete_range(), false),
4937 &[delete_allow_rule("parent-1")],
4938 )
4939 .await;
4940 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4941
4942 let requests = server.received_requests().await.unwrap();
4943 let body: serde_json::Value = requests
4944 .iter()
4945 .find(|r| r.url.path().ends_with(":batchUpdate"))
4946 .map(|r| serde_json::from_slice(&r.body).unwrap())
4947 .expect("a batchUpdate request");
4948 let delete = &body["requests"][0]["deleteRange"];
4949 assert_eq!(delete["range"]["sheetId"], 118_293);
4950 assert_eq!(delete["range"]["startRowIndex"], 1);
4953 assert_eq!(delete["range"]["endRowIndex"], 4);
4954 assert_eq!(delete["range"]["startColumnIndex"], 1);
4955 assert_eq!(delete["range"]["endColumnIndex"], 3);
4956 assert_eq!(delete["shiftDimension"], "ROWS");
4957 }
4958
4959 #[tokio::test]
4960 async fn delete_range_with_columns_shift_sends_the_column_direction() {
4961 let server = wiremock::MockServer::start().await;
4962 let (drive, sheets) = clients(&server).await;
4963 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4964 .mount(&server)
4965 .await;
4966 mount_folder("parent-1").mount(&server).await;
4967 mount_workbook().mount(&server).await;
4968 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4969 .mount(&server)
4970 .await;
4971 let outcome = structure(
4972 &drive,
4973 &sheets,
4974 &opts(delete_range_columns_shift(), false),
4975 &[delete_allow_rule("parent-1")],
4976 )
4977 .await;
4978 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4979 assert!(describe(&outcome).contains("shifted remaining cells left"));
4980
4981 let requests = server.received_requests().await.unwrap();
4982 let body: serde_json::Value = requests
4983 .iter()
4984 .find(|r| r.url.path().ends_with(":batchUpdate"))
4985 .map(|r| serde_json::from_slice(&r.body).unwrap())
4986 .expect("a batchUpdate request");
4987 assert_eq!(
4988 body["requests"][0]["deleteRange"]["shiftDimension"],
4989 "COLUMNS"
4990 );
4991 }
4992
4993 #[tokio::test]
4994 async fn delete_dimension_past_the_end_of_the_sheet_is_refused() {
4995 let server = wiremock::MockServer::start().await;
4996 let (drive, sheets) = clients(&server).await;
4997 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4998 .mount(&server)
4999 .await;
5000 mount_folder("parent-1").mount(&server).await;
5001 mount_workbook().mount(&server).await;
5002 let outcome = structure(
5004 &drive,
5005 &sheets,
5006 &opts(
5007 StructureVerb::DeleteRows {
5008 sheet: "Q2".to_string(),
5009 at: 499,
5010 count: 5,
5011 },
5012 true,
5013 ),
5014 &[delete_allow_rule("parent-1")],
5015 )
5016 .await;
5017 assert!(matches!(
5018 outcome.result,
5019 StructureResult::RefusedInvalidRange { .. }
5020 ));
5021 }
5022
5023 #[tokio::test]
5024 async fn delete_range_with_end_before_start_is_refused() {
5025 let server = wiremock::MockServer::start().await;
5026 let (drive, sheets) = clients(&server).await;
5027 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5028 .mount(&server)
5029 .await;
5030 mount_folder("parent-1").mount(&server).await;
5031 mount_workbook().mount(&server).await;
5032 let outcome = structure(
5033 &drive,
5034 &sheets,
5035 &opts(
5036 StructureVerb::DeleteRange {
5037 sheet: "Q2".to_string(),
5038 start_row: 5,
5039 end_row: 2,
5040 start_column: 1,
5041 end_column: 2,
5042 shift: ShiftDimension::Rows,
5043 },
5044 true,
5045 ),
5046 &[delete_allow_rule("parent-1")],
5047 )
5048 .await;
5049 assert!(matches!(
5050 outcome.result,
5051 StructureResult::RefusedInvalidRange { .. }
5052 ));
5053 }
5054
5055 #[tokio::test]
5056 async fn delete_dimension_refuses_a_non_positive_count() {
5057 let server = wiremock::MockServer::start().await;
5058 let (drive, sheets) = clients(&server).await;
5059 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5060 .mount(&server)
5061 .await;
5062 mount_folder("parent-1").mount(&server).await;
5063 mount_workbook().mount(&server).await;
5064 let outcome = structure(
5065 &drive,
5066 &sheets,
5067 &opts(
5068 StructureVerb::DeleteRows {
5069 sheet: "Q2".to_string(),
5070 at: 1,
5071 count: 0,
5072 },
5073 true,
5074 ),
5075 &[delete_allow_rule("parent-1")],
5076 )
5077 .await;
5078 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5079 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5080 };
5081 assert!(detail.contains("--count must be at least 1"), "{detail}");
5082 }
5083
5084 #[tokio::test]
5085 async fn delete_dimension_refuses_an_at_below_one() {
5086 let server = wiremock::MockServer::start().await;
5087 let (drive, sheets) = clients(&server).await;
5088 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5089 .mount(&server)
5090 .await;
5091 mount_folder("parent-1").mount(&server).await;
5092 mount_workbook().mount(&server).await;
5093 let outcome = structure(
5094 &drive,
5095 &sheets,
5096 &opts(
5097 StructureVerb::DeleteColumns {
5098 sheet: "Q2".to_string(),
5099 at: 0,
5100 count: 1,
5101 },
5102 true,
5103 ),
5104 &[delete_allow_rule("parent-1")],
5105 )
5106 .await;
5107 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5108 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5109 };
5110 assert!(detail.contains("--at must be at least 1"), "{detail}");
5111 }
5112
5113 #[tokio::test]
5114 async fn delete_dimension_refuses_an_at_and_count_that_overflow_the_index_space() {
5115 let server = wiremock::MockServer::start().await;
5116 let (drive, sheets) = clients(&server).await;
5117 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5118 .mount(&server)
5119 .await;
5120 mount_folder("parent-1").mount(&server).await;
5121 mount_workbook().mount(&server).await;
5122 let outcome = structure(
5123 &drive,
5124 &sheets,
5125 &opts(
5126 StructureVerb::DeleteRows {
5127 sheet: "Q2".to_string(),
5128 at: i64::MAX,
5129 count: 2,
5130 },
5131 true,
5132 ),
5133 &[delete_allow_rule("parent-1")],
5134 )
5135 .await;
5136 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5137 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5138 };
5139 assert!(detail.contains("overflows the row index space"), "{detail}");
5140 }
5141
5142 #[tokio::test]
5143 async fn delete_range_refuses_a_start_row_below_one() {
5144 let server = wiremock::MockServer::start().await;
5145 let (drive, sheets) = clients(&server).await;
5146 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5147 .mount(&server)
5148 .await;
5149 mount_folder("parent-1").mount(&server).await;
5150 mount_workbook().mount(&server).await;
5151 let outcome = structure(
5152 &drive,
5153 &sheets,
5154 &opts(
5155 StructureVerb::DeleteRange {
5156 sheet: "Q2".to_string(),
5157 start_row: 0,
5158 end_row: 2,
5159 start_column: 1,
5160 end_column: 2,
5161 shift: ShiftDimension::Rows,
5162 },
5163 true,
5164 ),
5165 &[delete_allow_rule("parent-1")],
5166 )
5167 .await;
5168 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5169 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5170 };
5171 assert!(
5172 detail.contains("--start-row must be at least 1"),
5173 "{detail}"
5174 );
5175 }
5176
5177 #[tokio::test]
5178 async fn delete_range_refuses_a_start_column_below_one() {
5179 let server = wiremock::MockServer::start().await;
5180 let (drive, sheets) = clients(&server).await;
5181 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5182 .mount(&server)
5183 .await;
5184 mount_folder("parent-1").mount(&server).await;
5185 mount_workbook().mount(&server).await;
5186 let outcome = structure(
5187 &drive,
5188 &sheets,
5189 &opts(
5190 StructureVerb::DeleteRange {
5191 sheet: "Q2".to_string(),
5192 start_row: 1,
5193 end_row: 2,
5194 start_column: 0,
5195 end_column: 2,
5196 shift: ShiftDimension::Rows,
5197 },
5198 true,
5199 ),
5200 &[delete_allow_rule("parent-1")],
5201 )
5202 .await;
5203 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5204 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5205 };
5206 assert!(
5207 detail.contains("--start-column must be at least 1"),
5208 "{detail}"
5209 );
5210 }
5211
5212 #[tokio::test]
5213 async fn delete_range_refuses_an_end_column_before_start_column() {
5214 let server = wiremock::MockServer::start().await;
5215 let (drive, sheets) = clients(&server).await;
5216 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5217 .mount(&server)
5218 .await;
5219 mount_folder("parent-1").mount(&server).await;
5220 mount_workbook().mount(&server).await;
5221 let outcome = structure(
5225 &drive,
5226 &sheets,
5227 &opts(
5228 StructureVerb::DeleteRange {
5229 sheet: "Q2".to_string(),
5230 start_row: 1,
5231 end_row: 2,
5232 start_column: 5,
5233 end_column: 2,
5234 shift: ShiftDimension::Rows,
5235 },
5236 true,
5237 ),
5238 &[delete_allow_rule("parent-1")],
5239 )
5240 .await;
5241 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5242 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5243 };
5244 assert!(
5245 detail.contains("--end-column (2) must be at or after --start-column (5)"),
5246 "{detail}"
5247 );
5248 }
5249
5250 #[tokio::test]
5251 async fn delete_range_refuses_an_end_row_past_the_end_of_the_sheet() {
5252 let server = wiremock::MockServer::start().await;
5253 let (drive, sheets) = clients(&server).await;
5254 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5255 .mount(&server)
5256 .await;
5257 mount_folder("parent-1").mount(&server).await;
5258 mount_workbook().mount(&server).await;
5259 let outcome = structure(
5261 &drive,
5262 &sheets,
5263 &opts(
5264 StructureVerb::DeleteRange {
5265 sheet: "Q2".to_string(),
5266 start_row: 1,
5267 end_row: 501,
5268 start_column: 1,
5269 end_column: 2,
5270 shift: ShiftDimension::Rows,
5271 },
5272 true,
5273 ),
5274 &[delete_allow_rule("parent-1")],
5275 )
5276 .await;
5277 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5278 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5279 };
5280 assert!(
5281 detail.contains("--end-row 501 is past the end of the sheet"),
5282 "{detail}"
5283 );
5284 }
5285
5286 #[tokio::test]
5287 async fn delete_range_refuses_an_end_column_past_the_end_of_the_sheet() {
5288 let server = wiremock::MockServer::start().await;
5289 let (drive, sheets) = clients(&server).await;
5290 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5291 .mount(&server)
5292 .await;
5293 mount_folder("parent-1").mount(&server).await;
5294 mount_workbook().mount(&server).await;
5295 let outcome = structure(
5297 &drive,
5298 &sheets,
5299 &opts(
5300 StructureVerb::DeleteRange {
5301 sheet: "Q2".to_string(),
5302 start_row: 1,
5303 end_row: 2,
5304 start_column: 1,
5305 end_column: 11,
5306 shift: ShiftDimension::Rows,
5307 },
5308 true,
5309 ),
5310 &[delete_allow_rule("parent-1")],
5311 )
5312 .await;
5313 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5314 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5315 };
5316 assert!(
5317 detail.contains("--end-column 11 is past the end of the sheet"),
5318 "{detail}"
5319 );
5320 }
5321
5322 #[tokio::test]
5323 async fn add_sheet_reports_the_server_assigned_sheet_id() {
5324 let server = wiremock::MockServer::start().await;
5325 let (drive, sheets) = clients(&server).await;
5326 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5327 .mount(&server)
5328 .await;
5329 mount_folder("parent-1").mount(&server).await;
5330 mount_workbook().mount(&server).await;
5331 mount_batch_update(serde_json::json!({
5332 "spreadsheetId": "sheet-1",
5333 "replies": [{"addSheet": {"properties": {"sheetId": 999, "title": "Q3"}}}],
5334 }))
5335 .mount(&server)
5336 .await;
5337 let outcome = structure(
5338 &drive,
5339 &sheets,
5340 &opts(add_sheet(), false),
5341 &[allow_rule("parent-1")],
5342 )
5343 .await;
5344 assert!(matches!(
5346 outcome.result,
5347 StructureResult::Changed {
5348 sheet_id: Some(999),
5349 ..
5350 }
5351 ));
5352 assert!(describe(&outcome).contains("sheetId 999"));
5353 }
5354
5355 #[tokio::test]
5356 async fn add_sheet_omits_grid_properties_when_no_size_was_asked_for() {
5357 let server = wiremock::MockServer::start().await;
5358 let (drive, sheets) = clients(&server).await;
5359 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5360 .mount(&server)
5361 .await;
5362 mount_folder("parent-1").mount(&server).await;
5363 mount_workbook().mount(&server).await;
5364 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5365 .mount(&server)
5366 .await;
5367 let _ = structure(
5368 &drive,
5369 &sheets,
5370 &opts(add_sheet(), false),
5371 &[allow_rule("parent-1")],
5372 )
5373 .await;
5374 let requests = server.received_requests().await.unwrap();
5375 let body: serde_json::Value = requests
5376 .iter()
5377 .find(|r| r.url.path().ends_with(":batchUpdate"))
5378 .map(|r| serde_json::from_slice(&r.body).unwrap())
5379 .expect("a batchUpdate request");
5380 let props = &body["requests"][0]["addSheet"]["properties"];
5381 assert!(props.get("gridProperties").is_none(), "{props}");
5384 assert!(props.get("index").is_none(), "{props}");
5385 assert_eq!(props["title"], "Q3");
5386 }
5387
5388 #[tokio::test]
5389 async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
5390 let server = wiremock::MockServer::start().await;
5391 let (drive, sheets) = clients(&server).await;
5392 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5393 .mount(&server)
5394 .await;
5395 mount_folder("parent-1").mount(&server).await;
5396 mount_workbook().mount(&server).await;
5397 wiremock::Mock::given(wiremock::matchers::method("POST"))
5398 .and(wiremock::matchers::path(
5399 "/v4/spreadsheets/sheet-1:batchUpdate",
5400 ))
5401 .respond_with(
5402 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
5403 "error": {
5404 "code": 403,
5405 "message": "The caller does not have permission",
5406 "status": "PERMISSION_DENIED",
5407 }
5408 })),
5409 )
5410 .mount(&server)
5411 .await;
5412 let outcome = structure(
5413 &drive,
5414 &sheets,
5415 &opts(rename(), false),
5416 &[allow_rule("parent-1")],
5417 )
5418 .await;
5419 let StructureResult::Failed { detail } = &outcome.result else {
5420 panic!("expected Failed, got {:?}", outcome.result);
5421 };
5422 assert!(detail.contains("drive auth login"), "{detail}");
5425 }
5426
5427 #[tokio::test]
5430 async fn duplicate_sheet_apply_sends_source_title_and_index() {
5431 let server = wiremock::MockServer::start().await;
5432 let (drive, sheets) = clients(&server).await;
5433 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5434 .mount(&server)
5435 .await;
5436 mount_folder("parent-1").mount(&server).await;
5437 mount_workbook().mount(&server).await;
5438 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5439 .mount(&server)
5440 .await;
5441 let outcome = structure(
5442 &drive,
5443 &sheets,
5444 &opts(duplicate_sheet(), false),
5445 &[allow_rule("parent-1")],
5446 )
5447 .await;
5448 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5449
5450 let requests = server.received_requests().await.unwrap();
5451 let body: serde_json::Value = requests
5452 .iter()
5453 .find(|r| r.url.path().ends_with(":batchUpdate"))
5454 .map(|r| serde_json::from_slice(&r.body).unwrap())
5455 .expect("a batchUpdate request");
5456 let dup = &body["requests"][0]["duplicateSheet"];
5457 assert_eq!(dup["sourceSheetId"], 118_293);
5458 assert_eq!(dup["insertSheetIndex"], 1);
5459 assert_eq!(dup["newSheetName"], "Q2 Copy");
5460 }
5461
5462 #[tokio::test]
5463 async fn duplicate_sheet_apply_with_default_title_and_index() {
5464 let server = wiremock::MockServer::start().await;
5465 let (drive, sheets) = clients(&server).await;
5466 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5467 .mount(&server)
5468 .await;
5469 mount_folder("parent-1").mount(&server).await;
5470 mount_workbook().mount(&server).await;
5471 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5472 .mount(&server)
5473 .await;
5474 let outcome = structure(
5475 &drive,
5476 &sheets,
5477 &opts(duplicate_sheet_default(), false),
5478 &[allow_rule("parent-1")],
5479 )
5480 .await;
5481 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5482
5483 let requests = server.received_requests().await.unwrap();
5484 let body: serde_json::Value = requests
5485 .iter()
5486 .find(|r| r.url.path().ends_with(":batchUpdate"))
5487 .map(|r| serde_json::from_slice(&r.body).unwrap())
5488 .expect("a batchUpdate request");
5489 let dup = &body["requests"][0]["duplicateSheet"];
5490 assert!(dup.get("insertSheetIndex").is_none());
5491 assert!(dup.get("newSheetName").is_none());
5492 }
5493
5494 #[tokio::test]
5495 async fn duplicate_sheet_dry_run_names_title_and_index() {
5496 let server = wiremock::MockServer::start().await;
5497 let (drive, sheets) = clients(&server).await;
5498 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5499 .mount(&server)
5500 .await;
5501 mount_folder("parent-1").mount(&server).await;
5502 mount_workbook().mount(&server).await;
5503 let outcome = structure(
5504 &drive,
5505 &sheets,
5506 &opts(duplicate_sheet(), true),
5507 &[allow_rule("parent-1")],
5508 )
5509 .await;
5510 let text = describe(&outcome);
5511 assert!(text.contains("Would duplicate sheet 'Q2'"), "{text}");
5512 assert!(text.contains("as 'Q2 Copy'"), "{text}");
5513 assert!(text.contains("at index 1"), "{text}");
5514 let requests = server.received_requests().await.unwrap();
5515 assert!(!requests
5516 .iter()
5517 .any(|r| r.url.path().ends_with(":batchUpdate")));
5518 }
5519
5520 #[tokio::test]
5521 async fn duplicate_sheet_dry_run_uses_defaults_when_omitted() {
5522 let server = wiremock::MockServer::start().await;
5523 let (drive, sheets) = clients(&server).await;
5524 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5525 .mount(&server)
5526 .await;
5527 mount_folder("parent-1").mount(&server).await;
5528 mount_workbook().mount(&server).await;
5529 let outcome = structure(
5530 &drive,
5531 &sheets,
5532 &opts(duplicate_sheet_default(), true),
5533 &[allow_rule("parent-1")],
5534 )
5535 .await;
5536 let text = describe(&outcome);
5537 assert!(
5538 text.contains("as a copy Sheets names automatically"),
5539 "{text}"
5540 );
5541 assert!(text.contains("at the front"), "{text}");
5542 }
5543
5544 #[tokio::test]
5545 async fn duplicate_sheet_refuses_a_missing_source_sheet() {
5546 let server = wiremock::MockServer::start().await;
5547 let (drive, sheets) = clients(&server).await;
5548 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5549 .mount(&server)
5550 .await;
5551 mount_folder("parent-1").mount(&server).await;
5552 mount_workbook().mount(&server).await;
5553 let outcome = structure(
5554 &drive,
5555 &sheets,
5556 &opts(
5557 StructureVerb::DuplicateSheet {
5558 sheet: "Nope".to_string(),
5559 title: None,
5560 index: None,
5561 },
5562 false,
5563 ),
5564 &[allow_rule("parent-1")],
5565 )
5566 .await;
5567 assert!(matches!(
5568 outcome.result,
5569 StructureResult::RefusedSheetNotFound { .. }
5570 ));
5571 }
5572
5573 #[tokio::test]
5574 async fn duplicate_sheet_refuses_a_colliding_new_title() {
5575 let server = wiremock::MockServer::start().await;
5576 let (drive, sheets) = clients(&server).await;
5577 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5578 .mount(&server)
5579 .await;
5580 mount_folder("parent-1").mount(&server).await;
5581 mount_workbook().mount(&server).await;
5582 let outcome = structure(
5583 &drive,
5584 &sheets,
5585 &opts(
5586 StructureVerb::DuplicateSheet {
5587 sheet: "Q2".to_string(),
5588 title: Some("Q1".to_string()),
5589 index: None,
5590 },
5591 false,
5592 ),
5593 &[allow_rule("parent-1")],
5594 )
5595 .await;
5596 assert!(matches!(
5597 outcome.result,
5598 StructureResult::RefusedSheetExists { ref title } if title == "Q1"
5599 ));
5600 }
5601
5602 #[tokio::test]
5603 async fn duplicate_sheet_refuses_an_out_of_range_index() {
5604 let server = wiremock::MockServer::start().await;
5605 let (drive, sheets) = clients(&server).await;
5606 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5607 .mount(&server)
5608 .await;
5609 mount_folder("parent-1").mount(&server).await;
5610 mount_workbook().mount(&server).await;
5611 let outcome = structure(
5612 &drive,
5613 &sheets,
5614 &opts(
5615 StructureVerb::DuplicateSheet {
5616 sheet: "Q2".to_string(),
5617 title: None,
5618 index: Some(99),
5619 },
5620 false,
5621 ),
5622 &[allow_rule("parent-1")],
5623 )
5624 .await;
5625 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5626 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5627 };
5628 assert!(
5629 detail.contains("--index must be between 0 and 2"),
5630 "{detail}"
5631 );
5632 }
5633
5634 #[tokio::test]
5635 async fn reorder_sheet_apply_sends_index_masked_update() {
5636 let server = wiremock::MockServer::start().await;
5637 let (drive, sheets) = clients(&server).await;
5638 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5639 .mount(&server)
5640 .await;
5641 mount_folder("parent-1").mount(&server).await;
5642 mount_workbook().mount(&server).await;
5643 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5644 .mount(&server)
5645 .await;
5646 let outcome = structure(
5647 &drive,
5648 &sheets,
5649 &opts(reorder_sheet(), false),
5650 &[allow_rule("parent-1")],
5651 )
5652 .await;
5653 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5654
5655 let requests = server.received_requests().await.unwrap();
5656 let body: serde_json::Value = requests
5657 .iter()
5658 .find(|r| r.url.path().ends_with(":batchUpdate"))
5659 .map(|r| serde_json::from_slice(&r.body).unwrap())
5660 .expect("a batchUpdate request");
5661 let update = &body["requests"][0]["updateSheetProperties"];
5662 assert_eq!(update["properties"]["sheetId"], 118_293);
5663 assert_eq!(update["properties"]["index"], 0);
5664 assert_eq!(update["fields"], "index");
5665 }
5666
5667 #[tokio::test]
5668 async fn reorder_sheet_dry_run_names_the_target_index() {
5669 let server = wiremock::MockServer::start().await;
5670 let (drive, sheets) = clients(&server).await;
5671 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5672 .mount(&server)
5673 .await;
5674 mount_folder("parent-1").mount(&server).await;
5675 mount_workbook().mount(&server).await;
5676 let outcome = structure(
5677 &drive,
5678 &sheets,
5679 &opts(reorder_sheet(), true),
5680 &[allow_rule("parent-1")],
5681 )
5682 .await;
5683 let text = describe(&outcome);
5684 assert!(text.contains("Would move sheet 'Q2'"), "{text}");
5685 assert!(text.contains("to index 0"), "{text}");
5686 }
5687
5688 #[tokio::test]
5689 async fn reorder_sheet_refuses_an_out_of_range_index() {
5690 let server = wiremock::MockServer::start().await;
5691 let (drive, sheets) = clients(&server).await;
5692 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5693 .mount(&server)
5694 .await;
5695 mount_folder("parent-1").mount(&server).await;
5696 mount_workbook().mount(&server).await;
5697 let outcome = structure(
5698 &drive,
5699 &sheets,
5700 &opts(
5701 StructureVerb::ReorderSheet {
5702 sheet: "Q2".to_string(),
5703 index: 5,
5704 },
5705 false,
5706 ),
5707 &[allow_rule("parent-1")],
5708 )
5709 .await;
5710 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5711 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5712 };
5713 assert!(
5714 detail.contains("--index must be between 0 and 1"),
5715 "{detail}"
5716 );
5717 }
5718
5719 #[tokio::test]
5720 async fn hide_sheet_apply_sends_hidden_true() {
5721 let server = wiremock::MockServer::start().await;
5722 let (drive, sheets) = clients(&server).await;
5723 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5724 .mount(&server)
5725 .await;
5726 mount_folder("parent-1").mount(&server).await;
5727 mount_workbook().mount(&server).await;
5728 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5729 .mount(&server)
5730 .await;
5731 let outcome = structure(
5732 &drive,
5733 &sheets,
5734 &opts(hide_sheet(), false),
5735 &[allow_rule("parent-1")],
5736 )
5737 .await;
5738 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5739
5740 let requests = server.received_requests().await.unwrap();
5741 let body: serde_json::Value = requests
5742 .iter()
5743 .find(|r| r.url.path().ends_with(":batchUpdate"))
5744 .map(|r| serde_json::from_slice(&r.body).unwrap())
5745 .expect("a batchUpdate request");
5746 let update = &body["requests"][0]["updateSheetProperties"];
5747 assert_eq!(update["properties"]["sheetId"], 118_293);
5748 assert_eq!(update["properties"]["hidden"], true);
5749 assert_eq!(update["fields"], "hidden");
5750 }
5751
5752 #[tokio::test]
5753 async fn show_sheet_apply_sends_hidden_false() {
5754 let server = wiremock::MockServer::start().await;
5755 let (drive, sheets) = clients(&server).await;
5756 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5757 .mount(&server)
5758 .await;
5759 mount_folder("parent-1").mount(&server).await;
5760 mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5761 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5762 .mount(&server)
5763 .await;
5764 let outcome = structure(
5765 &drive,
5766 &sheets,
5767 &opts(show_sheet(), false),
5768 &[allow_rule("parent-1")],
5769 )
5770 .await;
5771 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5772
5773 let requests = server.received_requests().await.unwrap();
5774 let body: serde_json::Value = requests
5775 .iter()
5776 .find(|r| r.url.path().ends_with(":batchUpdate"))
5777 .map(|r| serde_json::from_slice(&r.body).unwrap())
5778 .expect("a batchUpdate request");
5779 let update = &body["requests"][0]["updateSheetProperties"];
5780 assert_eq!(update["properties"]["hidden"], false);
5781 }
5782
5783 #[tokio::test]
5784 async fn hide_sheet_dry_run_names_the_hide() {
5785 let server = wiremock::MockServer::start().await;
5786 let (drive, sheets) = clients(&server).await;
5787 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5788 .mount(&server)
5789 .await;
5790 mount_folder("parent-1").mount(&server).await;
5791 mount_workbook().mount(&server).await;
5792 let outcome = structure(
5793 &drive,
5794 &sheets,
5795 &opts(hide_sheet(), true),
5796 &[allow_rule("parent-1")],
5797 )
5798 .await;
5799 let text = describe(&outcome);
5800 assert!(text.contains("Would hide sheet 'Q2'"), "{text}");
5801 }
5802
5803 #[tokio::test]
5804 async fn show_sheet_dry_run_names_the_show() {
5805 let server = wiremock::MockServer::start().await;
5806 let (drive, sheets) = clients(&server).await;
5807 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5808 .mount(&server)
5809 .await;
5810 mount_folder("parent-1").mount(&server).await;
5811 mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5812 let outcome = structure(
5813 &drive,
5814 &sheets,
5815 &opts(show_sheet(), true),
5816 &[allow_rule("parent-1")],
5817 )
5818 .await;
5819 let text = describe(&outcome);
5820 assert!(text.contains("Would show sheet 'Q2'"), "{text}");
5821 }
5822
5823 #[tokio::test]
5827 async fn a_visibility_no_op_is_unchanged_in_dry_run_and_real_run() {
5828 for (verb, q2, state) in [
5829 (hide_sheet(), hidden_q2(), "hidden"),
5830 (show_sheet(), default_q2(), "shown"),
5831 ] {
5832 for dry_run in [true, false] {
5833 let server = wiremock::MockServer::start().await;
5834 let (drive, sheets) = clients(&server).await;
5835 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5836 .mount(&server)
5837 .await;
5838 mount_folder("parent-1").mount(&server).await;
5839 mount_workbook_with_q2(q2.clone()).mount(&server).await;
5840 let outcome = structure(
5841 &drive,
5842 &sheets,
5843 &opts(verb.clone(), dry_run),
5844 &[allow_rule("parent-1")],
5845 )
5846 .await;
5847 assert_eq!(
5848 outcome.result,
5849 StructureResult::Unchanged {
5850 detail: format!("sheet 'Q2' is already {state}")
5851 },
5852 "dry_run={dry_run}"
5853 );
5854 let text = describe(&outcome);
5855 assert!(
5856 text.starts_with(&format!("Unchanged: sheet 'Q2' is already {state} in ")),
5857 "{text}"
5858 );
5859 let requests = server.received_requests().await.unwrap();
5860 assert!(
5861 !requests
5862 .iter()
5863 .any(|r| r.url.path().ends_with(":batchUpdate")),
5864 "a no-op must not call batchUpdate"
5865 );
5866 }
5867 }
5868 }
5869
5870 #[tokio::test]
5871 async fn hide_sheet_refuses_leaving_no_visible_sheets() {
5872 let server = wiremock::MockServer::start().await;
5873 let (drive, sheets) = clients(&server).await;
5874 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5875 .mount(&server)
5876 .await;
5877 mount_folder("parent-1").mount(&server).await;
5878 wiremock::Mock::given(wiremock::matchers::method("GET"))
5879 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
5880 .respond_with(
5881 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5882 "spreadsheetId": "sheet-1",
5883 "properties": {"title": "Budget"},
5884 "sheets": [
5885 {"properties": {"sheetId": 0, "title": "Q1", "index": 0, "hidden": true}},
5886 {"properties": {"sheetId": 118_293, "title": "Q2", "index": 1}},
5887 ],
5888 })),
5889 )
5890 .mount(&server)
5891 .await;
5892 let outcome = structure(
5893 &drive,
5894 &sheets,
5895 &opts(hide_sheet(), false),
5896 &[allow_rule("parent-1")],
5897 )
5898 .await;
5899 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5900 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5901 };
5902 assert!(
5903 detail.contains("would leave the workbook with no visible sheets"),
5904 "{detail}"
5905 );
5906 }
5907
5908 fn update_sheet_properties(
5911 freeze_rows: Option<i64>,
5912 freeze_columns: Option<i64>,
5913 tab_color: Option<&str>,
5914 clear_tab_color: bool,
5915 right_to_left: Option<bool>,
5916 hide_gridlines: Option<bool>,
5917 ) -> StructureVerb {
5918 StructureVerb::UpdateSheetProperties {
5919 sheet: "Q2".to_string(),
5920 freeze_rows,
5921 freeze_columns,
5922 tab_color: tab_color.map(ToString::to_string),
5923 clear_tab_color,
5924 right_to_left,
5925 hide_gridlines,
5926 }
5927 }
5928
5929 fn mount_workbook_with_frozen_rows(
5934 sheet_id: i64,
5935 row_count: i64,
5936 frozen_row_count: i64,
5937 ) -> wiremock::Mock {
5938 mount_workbook_with_q2(serde_json::json!({
5939 "properties": {
5940 "sheetId": sheet_id, "title": "Q2", "index": 1,
5941 "gridProperties": {
5942 "rowCount": row_count, "columnCount": 10,
5943 "frozenRowCount": frozen_row_count,
5944 }}}))
5945 }
5946
5947 fn mount_workbook_with_view_state() -> wiremock::Mock {
5957 mount_workbook_with_q2(serde_json::json!({
5958 "properties": {
5959 "sheetId": 118_293, "title": "Q2", "index": 1,
5960 "rightToLeft": true,
5961 "gridProperties": {
5962 "rowCount": 500, "columnCount": 10,
5963 "frozenColumnCount": 2,
5964 "hideGridlines": true,
5965 }}}))
5966 }
5967
5968 #[tokio::test]
5969 async fn update_sheet_properties_apply_sends_only_the_set_fields_in_the_mask() {
5970 let server = wiremock::MockServer::start().await;
5971 let (drive, sheets) = clients(&server).await;
5972 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5973 .mount(&server)
5974 .await;
5975 mount_folder("parent-1").mount(&server).await;
5976 mount_workbook().mount(&server).await;
5977 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5978 .mount(&server)
5979 .await;
5980 let verb = update_sheet_properties(Some(2), None, Some("#FF8800"), false, None, None);
5981 let outcome = structure(
5982 &drive,
5983 &sheets,
5984 &opts(verb, false),
5985 &[allow_rule("parent-1")],
5986 )
5987 .await;
5988 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5989
5990 let requests = server.received_requests().await.unwrap();
5991 let body: serde_json::Value = requests
5992 .iter()
5993 .find(|r| r.url.path().ends_with(":batchUpdate"))
5994 .map(|r| serde_json::from_slice(&r.body).unwrap())
5995 .expect("a batchUpdate request");
5996 let update = &body["requests"][0]["updateSheetProperties"];
5997 assert_eq!(
5998 update["fields"],
5999 "gridProperties.frozenRowCount,tabColorStyle"
6000 );
6001 assert_eq!(update["properties"]["sheetId"], 118_293);
6002 assert_eq!(update["properties"]["gridProperties"]["frozenRowCount"], 2);
6003 let rgb = &update["properties"]["tabColorStyle"]["rgbColor"];
6004 assert!((rgb["red"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{rgb}");
6005 assert!(
6006 (rgb["green"].as_f64().unwrap() - (136.0 / 255.0)).abs() < 1e-6,
6007 "{rgb}"
6008 );
6009 assert!((rgb["blue"].as_f64().unwrap() - 0.0).abs() < 1e-6, "{rgb}");
6010 assert!(
6011 update["properties"]["gridProperties"]
6012 .get("frozenColumnCount")
6013 .is_none(),
6014 "{update}"
6015 );
6016 assert!(
6017 update["properties"].get("rightToLeft").is_none(),
6018 "{update}"
6019 );
6020 assert!(update["properties"].get("hidden").is_none(), "{update}");
6021 assert!(update["properties"].get("title").is_none(), "{update}");
6022 assert!(update["properties"].get("index").is_none(), "{update}");
6023 }
6024
6025 #[tokio::test]
6026 async fn update_sheet_properties_apply_clear_tab_color_names_tab_color_style_with_no_value() {
6027 let server = wiremock::MockServer::start().await;
6028 let (drive, sheets) = clients(&server).await;
6029 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6030 .mount(&server)
6031 .await;
6032 mount_folder("parent-1").mount(&server).await;
6033 mount_workbook().mount(&server).await;
6034 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6035 .mount(&server)
6036 .await;
6037 let verb = update_sheet_properties(None, None, None, true, None, None);
6038 let outcome = structure(
6039 &drive,
6040 &sheets,
6041 &opts(verb, false),
6042 &[allow_rule("parent-1")],
6043 )
6044 .await;
6045 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
6046
6047 let requests = server.received_requests().await.unwrap();
6048 let body: serde_json::Value = requests
6049 .iter()
6050 .find(|r| r.url.path().ends_with(":batchUpdate"))
6051 .map(|r| serde_json::from_slice(&r.body).unwrap())
6052 .expect("a batchUpdate request");
6053 let update = &body["requests"][0]["updateSheetProperties"];
6054 assert_eq!(update["fields"], "tabColorStyle");
6055 assert!(
6056 update["properties"].get("tabColorStyle").is_none(),
6057 "{update}"
6058 );
6059 }
6060
6061 #[tokio::test]
6062 async fn update_sheet_properties_dry_run_reports_before_and_after() {
6063 let server = wiremock::MockServer::start().await;
6064 let (drive, sheets) = clients(&server).await;
6065 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6066 .mount(&server)
6067 .await;
6068 mount_folder("parent-1").mount(&server).await;
6069 mount_workbook_with_frozen_rows(118_293, 500, 1)
6070 .mount(&server)
6071 .await;
6072 let verb = update_sheet_properties(Some(3), None, None, false, None, None);
6073 let outcome = structure(
6074 &drive,
6075 &sheets,
6076 &opts(verb, true),
6077 &[allow_rule("parent-1")],
6078 )
6079 .await;
6080 let text = describe(&outcome);
6081 assert!(text.contains("frozen rows 1 -> 3"), "{text}");
6082 }
6083
6084 #[tokio::test]
6085 async fn update_sheet_properties_apply_sends_every_property_and_reports_each_one() {
6086 let server = wiremock::MockServer::start().await;
6095 let (drive, sheets) = clients(&server).await;
6096 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6097 .mount(&server)
6098 .await;
6099 mount_folder("parent-1").mount(&server).await;
6100 mount_workbook().mount(&server).await;
6101 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6102 .mount(&server)
6103 .await;
6104 let verb = update_sheet_properties(
6107 Some(2),
6108 Some(3),
6109 Some("0000FF"),
6110 false,
6111 Some(true),
6112 Some(true),
6113 );
6114 let outcome = structure(
6115 &drive,
6116 &sheets,
6117 &opts(verb, false),
6118 &[allow_rule("parent-1")],
6119 )
6120 .await;
6121 assert!(
6122 matches!(outcome.result, StructureResult::Changed { .. }),
6123 "{:?}",
6124 outcome.result
6125 );
6126
6127 let requests = server.received_requests().await.unwrap();
6128 let body: serde_json::Value = requests
6129 .iter()
6130 .find(|r| r.url.path().ends_with(":batchUpdate"))
6131 .map(|r| serde_json::from_slice(&r.body).unwrap())
6132 .expect("a batchUpdate request");
6133 let update = &body["requests"][0]["updateSheetProperties"];
6134 assert_eq!(
6135 update["fields"],
6136 "gridProperties.frozenRowCount,gridProperties.frozenColumnCount,\
6137 gridProperties.hideGridlines,tabColorStyle,rightToLeft"
6138 );
6139 let grid = &update["properties"]["gridProperties"];
6140 assert_eq!(grid["frozenRowCount"], 2);
6141 assert_eq!(grid["frozenColumnCount"], 3);
6142 assert_eq!(grid["hideGridlines"], true);
6143 assert_eq!(update["properties"]["rightToLeft"], true);
6144
6145 let text = describe(&outcome);
6146 assert!(text.starts_with("Updated sheet 'Q2'"), "{text}");
6147 for fragment in [
6148 "frozen rows -> 2",
6149 "frozen columns -> 3",
6150 "tab color -> #0000FF",
6151 "right-to-left -> true",
6152 "gridlines -> hidden",
6153 ] {
6154 assert!(text.contains(fragment), "{fragment} missing from {text}");
6155 }
6156 }
6157
6158 #[tokio::test]
6159 async fn update_sheet_properties_dry_run_reports_clears_and_the_values_being_replaced() {
6160 let server = wiremock::MockServer::start().await;
6170 let (drive, sheets) = clients(&server).await;
6171 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6172 .mount(&server)
6173 .await;
6174 mount_folder("parent-1").mount(&server).await;
6175 mount_workbook_with_view_state().mount(&server).await;
6176 let verb = update_sheet_properties(None, Some(4), None, true, Some(false), Some(false));
6177 let outcome = structure(
6178 &drive,
6179 &sheets,
6180 &opts(verb, true),
6181 &[allow_rule("parent-1")],
6182 )
6183 .await;
6184 let text = describe(&outcome);
6185 assert!(text.starts_with("Would update sheet 'Q2'"), "{text}");
6186 assert!(!text.contains("frozen rows"), "{text}");
6187 for fragment in [
6188 "frozen columns 2 -> 4",
6189 "tab color cleared",
6190 "right-to-left true -> false",
6191 "gridlines hidden -> shown",
6192 ] {
6193 assert!(text.contains(fragment), "{fragment} missing from {text}");
6194 }
6195 }
6196
6197 #[test]
6198 fn fields_changed_label_names_exactly_the_properties_the_verb_set() {
6199 let every = update_sheet_properties(
6203 Some(2),
6204 Some(3),
6205 Some("#FF8800"),
6206 false,
6207 Some(true),
6208 Some(false),
6209 );
6210 assert_eq!(
6211 fields_changed_label(&every).as_deref(),
6212 Some(
6213 "frozenRowCount=2, frozenColumnCount=3, hideGridlines=false, \
6214 tabColorStyle=#FF8800, rightToLeft=true"
6215 )
6216 );
6217 let cleared = update_sheet_properties(None, None, None, true, None, None);
6220 assert_eq!(
6221 fields_changed_label(&cleared).as_deref(),
6222 Some("tabColorStyle=cleared")
6223 );
6224 let empty = update_sheet_properties(None, None, None, false, None, None);
6228 assert_eq!(fields_changed_label(&empty), None);
6229 assert_eq!(fields_changed_label(&rename()), None);
6231 }
6232
6233 #[tokio::test]
6234 async fn update_sheet_properties_refuses_a_tab_color_and_a_clear_together() {
6235 let server = wiremock::MockServer::start().await;
6240 let (drive, sheets) = clients(&server).await;
6241 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6242 .mount(&server)
6243 .await;
6244 mount_folder("parent-1").mount(&server).await;
6245 mount_workbook().mount(&server).await;
6246 let verb = update_sheet_properties(None, None, Some("#FF8800"), true, None, None);
6247 let outcome = structure(
6248 &drive,
6249 &sheets,
6250 &opts(verb, true),
6251 &[allow_rule("parent-1")],
6252 )
6253 .await;
6254 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6255 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6257 assert!(detail.contains("mutually exclusive"), "{detail}");
6258 }
6259
6260 #[tokio::test]
6261 async fn update_sheet_properties_names_its_own_subcommand_in_a_refusal() {
6262 let server = wiremock::MockServer::start().await;
6266 let (drive, sheets) = clients(&server).await;
6267 mount_file("sheet-1", "application/pdf", &["parent-1"])
6268 .mount(&server)
6269 .await;
6270 let verb = update_sheet_properties(Some(2), None, None, false, None, None);
6271 let outcome = structure(
6272 &drive,
6273 &sheets,
6274 &opts(verb, false),
6275 &[allow_rule("parent-1")],
6276 )
6277 .await;
6278 assert!(matches!(
6279 outcome.result,
6280 StructureResult::RefusedNotASpreadsheet { .. }
6281 ));
6282 let text = describe(&outcome);
6283 assert!(
6284 text.contains("drive sheets update-sheet-properties"),
6285 "{text}"
6286 );
6287 }
6288
6289 #[tokio::test]
6290 async fn update_sheet_properties_refuses_a_negative_freeze_count() {
6291 let server = wiremock::MockServer::start().await;
6292 let (drive, sheets) = clients(&server).await;
6293 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6294 .mount(&server)
6295 .await;
6296 mount_folder("parent-1").mount(&server).await;
6297 mount_workbook().mount(&server).await;
6298 let verb = update_sheet_properties(Some(-1), None, None, false, None, None);
6299 let outcome = structure(
6300 &drive,
6301 &sheets,
6302 &opts(verb, true),
6303 &[allow_rule("parent-1")],
6304 )
6305 .await;
6306 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6307 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6309 assert!(
6310 detail.contains("--freeze-rows must be at least 0"),
6311 "{detail}"
6312 );
6313 }
6314
6315 #[tokio::test]
6316 async fn update_sheet_properties_refuses_freezing_every_row() {
6317 let server = wiremock::MockServer::start().await;
6318 let (drive, sheets) = clients(&server).await;
6319 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6320 .mount(&server)
6321 .await;
6322 mount_folder("parent-1").mount(&server).await;
6323 mount_workbook_with_frozen_rows(118_293, 5, 0)
6324 .mount(&server)
6325 .await;
6326 let verb = update_sheet_properties(Some(5), None, None, false, None, None);
6327 let outcome = structure(
6328 &drive,
6329 &sheets,
6330 &opts(verb, true),
6331 &[allow_rule("parent-1")],
6332 )
6333 .await;
6334 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6335 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6337 assert!(detail.contains("would freeze every row"), "{detail}");
6338 }
6339
6340 #[tokio::test]
6341 async fn update_sheet_properties_allows_freezing_up_to_one_less_than_the_row_count() {
6342 let server = wiremock::MockServer::start().await;
6348 let (drive, sheets) = clients(&server).await;
6349 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6350 .mount(&server)
6351 .await;
6352 mount_folder("parent-1").mount(&server).await;
6353 mount_workbook_with_frozen_rows(118_293, 5, 0)
6354 .mount(&server)
6355 .await;
6356 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6357 .mount(&server)
6358 .await;
6359 let verb = update_sheet_properties(Some(4), None, None, false, None, None);
6360 let outcome = structure(
6361 &drive,
6362 &sheets,
6363 &opts(verb, false),
6364 &[allow_rule("parent-1")],
6365 )
6366 .await;
6367 assert!(
6368 matches!(outcome.result, StructureResult::Changed { .. }),
6369 "{:?}",
6370 outcome.result
6371 );
6372 }
6373
6374 #[tokio::test]
6375 async fn update_sheet_properties_invents_no_freeze_ceiling_when_the_count_is_unknown() {
6376 let server = wiremock::MockServer::start().await;
6384 let (drive, sheets) = clients(&server).await;
6385 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6386 .mount(&server)
6387 .await;
6388 mount_folder("parent-1").mount(&server).await;
6389 wiremock::Mock::given(wiremock::matchers::method("GET"))
6390 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6391 .respond_with(
6392 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6393 "spreadsheetId": "sheet-1",
6394 "properties": {"title": "Budget"},
6395 "sheets": [
6396 {"properties": {"sheetId": 118_293, "title": "Q2", "index": 0}}
6397 ],
6398 })),
6399 )
6400 .mount(&server)
6401 .await;
6402 let verb = update_sheet_properties(Some(9_000), Some(9_000), None, false, None, None);
6403 let outcome = structure(
6404 &drive,
6405 &sheets,
6406 &opts(verb, true),
6407 &[allow_rule("parent-1")],
6408 )
6409 .await;
6410 assert!(
6411 matches!(outcome.result, StructureResult::WouldChange { .. }),
6412 "{:?}",
6413 outcome.result
6414 );
6415 }
6416
6417 #[tokio::test]
6418 async fn update_sheet_properties_refuses_a_negative_freeze_column_count() {
6419 let server = wiremock::MockServer::start().await;
6420 let (drive, sheets) = clients(&server).await;
6421 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6422 .mount(&server)
6423 .await;
6424 mount_folder("parent-1").mount(&server).await;
6425 mount_workbook().mount(&server).await;
6426 let verb = update_sheet_properties(None, Some(-1), None, false, None, None);
6427 let outcome = structure(
6428 &drive,
6429 &sheets,
6430 &opts(verb, true),
6431 &[allow_rule("parent-1")],
6432 )
6433 .await;
6434 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6435 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6437 assert!(
6438 detail.contains("--freeze-columns must be at least 0"),
6439 "{detail}"
6440 );
6441 }
6442
6443 #[tokio::test]
6444 async fn update_sheet_properties_refuses_freezing_every_column() {
6445 let server = wiremock::MockServer::start().await;
6446 let (drive, sheets) = clients(&server).await;
6447 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6448 .mount(&server)
6449 .await;
6450 mount_folder("parent-1").mount(&server).await;
6451 mount_workbook().mount(&server).await;
6453 let verb = update_sheet_properties(None, Some(10), None, false, None, None);
6454 let outcome = structure(
6455 &drive,
6456 &sheets,
6457 &opts(verb, true),
6458 &[allow_rule("parent-1")],
6459 )
6460 .await;
6461 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6462 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6464 assert!(detail.contains("would freeze every column"), "{detail}");
6465 }
6466
6467 #[tokio::test]
6468 async fn update_sheet_properties_refuses_an_invalid_hex_color() {
6469 let server = wiremock::MockServer::start().await;
6470 let (drive, sheets) = clients(&server).await;
6471 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6472 .mount(&server)
6473 .await;
6474 mount_folder("parent-1").mount(&server).await;
6475 mount_workbook().mount(&server).await;
6476 let verb = update_sheet_properties(None, None, Some("nope"), false, None, None);
6477 let outcome = structure(
6478 &drive,
6479 &sheets,
6480 &opts(verb, true),
6481 &[allow_rule("parent-1")],
6482 )
6483 .await;
6484 assert!(matches!(
6485 outcome.result,
6486 StructureResult::RefusedInvalidRange { .. }
6487 ));
6488 }
6489
6490 #[tokio::test]
6491 async fn update_sheet_properties_refuses_when_nothing_is_set() {
6492 let server = wiremock::MockServer::start().await;
6493 let (drive, sheets) = clients(&server).await;
6494 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6495 .mount(&server)
6496 .await;
6497 mount_folder("parent-1").mount(&server).await;
6498 mount_workbook().mount(&server).await;
6499 let verb = update_sheet_properties(None, None, None, false, None, None);
6500 let outcome = structure(
6501 &drive,
6502 &sheets,
6503 &opts(verb, true),
6504 &[allow_rule("parent-1")],
6505 )
6506 .await;
6507 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6508 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6510 assert!(detail.contains("at least one"), "{detail}");
6511 }
6512
6513 fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
6516 FolderPermissionRule {
6517 require_lease: false,
6518 ..allow_rule(folder)
6519 }
6520 }
6521
6522 #[tokio::test]
6523 async fn refuses_without_a_lease_when_the_rule_requires_one() {
6524 let server = wiremock::MockServer::start().await;
6525 let (drive, sheets) = clients(&server).await;
6526 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6527 .mount(&server)
6528 .await;
6529 mount_folder("parent-1").mount(&server).await;
6530 mount_workbook().mount(&server).await;
6531 let mut o = opts(rename(), false);
6535 o.lease_token = None;
6536 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6537 assert_eq!(outcome.result, StructureResult::RefusedNoLease);
6538 }
6539
6540 #[tokio::test]
6541 async fn refuses_an_unknown_lease_token() {
6542 let server = wiremock::MockServer::start().await;
6543 let (drive, sheets) = clients(&server).await;
6544 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6545 .mount(&server)
6546 .await;
6547 mount_folder("parent-1").mount(&server).await;
6548 mount_workbook().mount(&server).await;
6549
6550 let mut o = opts(rename(), false);
6551 o.lease_token = Some("bogus-token".to_string());
6552 o.ledger_path = tempfile::tempdir()
6554 .unwrap()
6555 .keep()
6556 .join("lease-ledger.jsonl");
6557 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6558 assert_eq!(outcome.result, StructureResult::RefusedLeaseExpired);
6559 }
6560
6561 #[tokio::test]
6562 async fn refuses_a_lease_bound_to_a_different_file() {
6563 let server = wiremock::MockServer::start().await;
6564 let (drive, sheets) = clients(&server).await;
6565 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6566 .mount(&server)
6567 .await;
6568 mount_folder("parent-1").mount(&server).await;
6569 mount_workbook().mount(&server).await;
6570
6571 let ledger_path = tempfile::tempdir()
6572 .unwrap()
6573 .keep()
6574 .join("lease-ledger.jsonl");
6575 let token = seed_lease(&ledger_path, "other-sheet", "1");
6576 let mut o = opts(rename(), false);
6577 o.lease_token = Some(token);
6578 o.ledger_path = ledger_path;
6579 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6580 assert_eq!(outcome.result, StructureResult::RefusedLeaseWrongFile);
6581 }
6582
6583 #[tokio::test]
6584 async fn refuses_a_stale_lease() {
6585 let server = wiremock::MockServer::start().await;
6586 let (drive, sheets) = clients(&server).await;
6587 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6590 .mount(&server)
6591 .await;
6592 mount_folder("parent-1").mount(&server).await;
6593 mount_workbook().mount(&server).await;
6594
6595 let ledger_path = tempfile::tempdir()
6596 .unwrap()
6597 .keep()
6598 .join("lease-ledger.jsonl");
6599 let token = seed_lease(&ledger_path, "sheet-1", "0");
6600 let mut o = opts(rename(), false);
6601 o.lease_token = Some(token);
6602 o.ledger_path = ledger_path;
6603 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6604 assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6605 }
6606
6607 #[tokio::test]
6608 async fn reports_a_lock_acquisition_failure_as_failed() {
6609 let server = wiremock::MockServer::start().await;
6613 let (drive, sheets) = clients(&server).await;
6614 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6615 .mount(&server)
6616 .await;
6617 mount_folder("parent-1").mount(&server).await;
6618 mount_workbook().mount(&server).await;
6619
6620 let o = opts(rename(), false);
6621 let mut lock_path = o.ledger_path.clone().into_os_string();
6627 lock_path.push(".lock");
6628 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
6629
6630 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6631 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6632 }
6633
6634 #[tokio::test]
6635 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
6636 let server = wiremock::MockServer::start().await;
6641 let (drive, sheets) = clients(&server).await;
6642 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6643 .up_to_n_times(1)
6644 .with_priority(1)
6645 .mount(&server)
6646 .await;
6647 wiremock::Mock::given(wiremock::matchers::method("GET"))
6648 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
6649 .respond_with(wiremock::ResponseTemplate::new(500))
6650 .with_priority(2)
6651 .mount(&server)
6652 .await;
6653 mount_folder("parent-1").mount(&server).await;
6654 mount_workbook().mount(&server).await;
6655 wiremock::Mock::given(wiremock::matchers::method("POST"))
6656 .and(wiremock::matchers::path(
6657 "/v4/spreadsheets/sheet-1:batchUpdate",
6658 ))
6659 .respond_with(wiremock::ResponseTemplate::new(200))
6660 .expect(0)
6661 .mount(&server)
6662 .await;
6663
6664 let outcome = structure(
6665 &drive,
6666 &sheets,
6667 &opts(rename(), false),
6668 &[allow_rule("parent-1")],
6669 )
6670 .await;
6671 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6672 }
6673
6674 #[tokio::test]
6675 async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
6676 let server = wiremock::MockServer::start().await;
6680 let (drive, sheets) = clients(&server).await;
6681 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6682 .mount(&server)
6683 .await;
6684 mount_folder("parent-1").mount(&server).await;
6685 mount_workbook().mount(&server).await;
6686 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6687 .expect(1)
6688 .mount(&server)
6689 .await;
6690
6691 let mut o = opts(rename(), false);
6692 o.lease_token = None;
6693 let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6694 assert!(matches!(
6695 outcome.result,
6696 StructureResult::Changed { backup: None, .. }
6697 ));
6698 }
6699
6700 #[tokio::test]
6701 async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
6702 let server = wiremock::MockServer::start().await;
6706 let (drive, sheets) = clients(&server).await;
6707 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6710 .mount(&server)
6711 .await;
6712 mount_folder("parent-1").mount(&server).await;
6713 mount_workbook().mount(&server).await;
6714 let ledger_path = tempfile::tempdir()
6718 .unwrap()
6719 .keep()
6720 .join("lease-ledger.jsonl");
6721 let token = seed_lease(&ledger_path, "sheet-1", "0");
6722 let mut o = opts(rename(), false);
6723 o.lease_token = Some(token);
6724 o.ledger_path = ledger_path;
6725 let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6726 assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6727 }
6728
6729 #[tokio::test]
6735 async fn a_leased_delete_names_the_drive_copy_to_restore_from() {
6736 let server = wiremock::MockServer::start().await;
6737 let (drive, sheets) = clients(&server).await;
6738 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6739 .mount(&server)
6740 .await;
6741 mount_folder("parent-1").mount(&server).await;
6742 mount_workbook().mount(&server).await;
6743 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6744 .expect(1)
6745 .mount(&server)
6746 .await;
6747
6748 let ledger_path = tempfile::tempdir()
6749 .unwrap()
6750 .keep()
6751 .join("lease-ledger.jsonl");
6752 let token = seed_lease_with_backup(
6753 &ledger_path,
6754 "sheet-1",
6755 "1",
6756 LeaseBackup::DriveCopy {
6757 file_id: "backup-copy-1".to_string(),
6758 },
6759 );
6760 let mut o = opts(delete_sheet(), false);
6761 o.lease_token = Some(token);
6762 o.ledger_path = ledger_path;
6763 let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
6764
6765 assert!(matches!(
6766 &outcome.result,
6767 StructureResult::Changed {
6768 backup: Some(backup),
6769 ..
6770 } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6771 ));
6772 let text = describe(&outcome);
6773 assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6774 assert!(
6775 text.contains(
6776 "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6777 ),
6778 "{text}"
6779 );
6780 assert!(
6781 text.contains("restores a single deleted sheet automatically"),
6782 "{text}"
6783 );
6784 assert!(
6785 text.contains("Google Drive's own version history"),
6786 "{text}"
6787 );
6788 assert!(!text.contains("only recovery path"), "{text}");
6789
6790 let json = serde_json::to_value(&outcome).unwrap();
6793 assert_eq!(json["result"]["backup"]["kind"], "drive_copy");
6794 assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
6795 }
6796
6797 #[tokio::test]
6804 async fn a_presented_lease_under_a_non_requiring_rule_still_names_the_backup() {
6805 let server = wiremock::MockServer::start().await;
6806 let (drive, sheets) = clients(&server).await;
6807 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6808 .mount(&server)
6809 .await;
6810 mount_folder("parent-1").mount(&server).await;
6811 mount_workbook().mount(&server).await;
6812 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6813 .expect(1)
6814 .mount(&server)
6815 .await;
6816
6817 let ledger_path = tempfile::tempdir()
6818 .unwrap()
6819 .keep()
6820 .join("lease-ledger.jsonl");
6821 let token = seed_lease_with_backup(
6822 &ledger_path,
6823 "sheet-1",
6824 "1",
6825 LeaseBackup::DriveCopy {
6826 file_id: "backup-copy-1".to_string(),
6827 },
6828 );
6829 let mut o = opts(delete_sheet(), false);
6830 o.lease_token = Some(token);
6831 o.ledger_path = ledger_path;
6832 let rule = FolderPermissionRule {
6833 require_lease: false,
6834 ..delete_allow_rule("parent-1")
6835 };
6836 let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6837
6838 assert!(matches!(
6839 &outcome.result,
6840 StructureResult::Changed {
6841 backup: Some(backup),
6842 ..
6843 } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6844 ));
6845 let text = describe(&outcome);
6846 assert!(
6847 text.contains(
6848 "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6849 ),
6850 "{text}"
6851 );
6852 assert!(!text.contains("only recovery path"), "{text}");
6853 }
6854
6855 #[tokio::test]
6860 async fn an_unleased_delete_does_not_claim_a_backup_exists() {
6861 let server = wiremock::MockServer::start().await;
6862 let (drive, sheets) = clients(&server).await;
6863 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6864 .mount(&server)
6865 .await;
6866 mount_folder("parent-1").mount(&server).await;
6867 mount_workbook().mount(&server).await;
6868 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6869 .expect(1)
6870 .mount(&server)
6871 .await;
6872
6873 let mut o = opts(delete_sheet(), false);
6874 o.lease_token = None;
6875 let rule = FolderPermissionRule {
6876 require_lease: false,
6877 ..delete_allow_rule("parent-1")
6878 };
6879 let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6880
6881 assert!(matches!(
6882 outcome.result,
6883 StructureResult::Changed { backup: None, .. }
6884 ));
6885 let text = describe(&outcome);
6886 assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6887 assert!(text.contains("no lease backup was taken"), "{text}");
6888 assert!(text.contains("`require_lease: false`"), "{text}");
6889 assert!(
6890 text.contains("version history is the only recovery path"),
6891 "{text}"
6892 );
6893 assert!(!text.contains("restore from that copy"), "{text}");
6894 assert!(serde_json::to_value(&outcome).unwrap()["result"]
6895 .get("backup")
6896 .is_none());
6897 }
6898
6899 fn mount_unreadable_batch_update() -> wiremock::Mock {
6904 wiremock::Mock::given(wiremock::matchers::method("POST"))
6905 .and(wiremock::matchers::path(
6906 "/v4/spreadsheets/sheet-1:batchUpdate",
6907 ))
6908 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
6909 }
6910
6911 #[tokio::test]
6912 async fn add_sheet_with_an_unreadable_reply_reports_applied_reply_unreadable() {
6913 let server = wiremock::MockServer::start().await;
6914 let (drive, sheets) = clients(&server).await;
6915 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6916 .mount(&server)
6917 .await;
6918 mount_folder("parent-1").mount(&server).await;
6919 mount_workbook().mount(&server).await;
6920 mount_unreadable_batch_update()
6921 .expect(1)
6922 .mount(&server)
6923 .await;
6924 let o = opts(add_sheet(), false);
6925 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6926
6927 assert!(
6929 matches!(
6930 &outcome.result,
6931 StructureResult::AppliedReplyUnreadable {
6932 summary,
6933 sheet: None,
6934 sheet_id: None,
6935 backup: Some(_),
6936 ..
6937 } if summary == "add-sheet 'Q3'"
6938 ),
6939 "{:?}",
6940 outcome.result
6941 );
6942 let record = mutation_record(&outcome, &o, Duration::ZERO);
6943 assert_eq!(record.status, "applied-reply-unreadable");
6944 assert_eq!(record.sheet_id, None);
6945 assert_eq!(record.fields_changed.as_deref(), Some("add-sheet 'Q3'"));
6946 assert!(record.error.is_some());
6947 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
6948 let lines = describe_lines(&outcome);
6949 assert!(
6950 lines[0].starts_with("Applied, but the reply could not be read: add-sheet 'Q3'"),
6951 "{lines:?}"
6952 );
6953 assert!(lines[0].contains("do not retry"), "{lines:?}");
6954 assert!(
6955 !lines[0].contains("cannot be undone"),
6956 "an additive verb owes no recovery note: {lines:?}"
6957 );
6958 assert_eq!(lines.len(), 2, "{lines:?}");
6959 assert!(
6960 lines[1].starts_with(" the new sheet's id was in the unreadable reply"),
6961 "{lines:?}"
6962 );
6963 let json = serde_json::to_value(&outcome).unwrap();
6964 assert_eq!(json["result"]["status"], "applied-reply-unreadable");
6965 assert!(json["result"].get("sheet_id").is_none(), "{json}");
6966 assert!(json["result"].get("sheet").is_none(), "{json}");
6967 }
6968
6969 #[tokio::test]
6973 async fn a_delete_with_an_unreadable_reply_still_names_the_drive_copy_to_restore_from() {
6974 let server = wiremock::MockServer::start().await;
6975 let (drive, sheets) = clients(&server).await;
6976 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6977 .mount(&server)
6978 .await;
6979 mount_folder("parent-1").mount(&server).await;
6980 mount_workbook().mount(&server).await;
6981 mount_unreadable_batch_update()
6982 .expect(1)
6983 .mount(&server)
6984 .await;
6985
6986 let ledger_path = tempfile::tempdir()
6987 .unwrap()
6988 .keep()
6989 .join("lease-ledger.jsonl");
6990 let token = seed_lease_with_backup(
6991 &ledger_path,
6992 "sheet-1",
6993 "1",
6994 LeaseBackup::DriveCopy {
6995 file_id: "backup-copy-1".to_string(),
6996 },
6997 );
6998 let mut o = opts(delete_sheet(), false);
6999 o.lease_token = Some(token);
7000 o.ledger_path = ledger_path;
7001 let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
7002
7003 assert!(
7004 matches!(
7005 &outcome.result,
7006 StructureResult::AppliedReplyUnreadable {
7007 backup: Some(backup),
7008 ..
7009 } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
7010 ),
7011 "{:?}",
7012 outcome.result
7013 );
7014 let text = describe(&outcome);
7015 assert!(
7016 text.starts_with("Applied, but the reply could not be read: delete-sheet 'Q2'"),
7017 "{text}"
7018 );
7019 assert!(
7020 text.contains(&format!(
7021 "; {}",
7022 recovery_note(
7023 Some(&LeaseBackup::DriveCopy {
7024 file_id: "backup-copy-1".to_string(),
7025 }),
7026 true,
7027 )
7028 )),
7029 "{text}"
7030 );
7031 assert!(
7034 text.contains("delete-sheet 'Q2' (sheetId 118293) in 'sheet-1'"),
7035 "{text}"
7036 );
7037 let json = serde_json::to_value(&outcome).unwrap();
7038 assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
7039 assert_eq!(json["result"]["sheet_id"], 118_293);
7040 assert_eq!(json["result"]["sheet"]["title"], "Q2");
7041 let record = mutation_record(&outcome, &o, Duration::ZERO);
7042 assert_eq!(record.sheet_id, Some(118_293));
7043 assert_eq!(record.fields_changed.as_deref(), Some("delete-sheet 'Q2'"));
7044 assert!(record.error.is_some());
7045 }
7046
7047 #[test]
7050 fn an_unreadable_reply_logs_the_verbs_own_field_list_when_it_has_one() {
7051 let verb = StructureVerb::UpdateSheetProperties {
7052 sheet: "Q2".to_string(),
7053 freeze_rows: Some(2),
7054 freeze_columns: None,
7055 tab_color: None,
7056 clear_tab_color: false,
7057 right_to_left: None,
7058 hide_gridlines: None,
7059 };
7060 let outcome =
7061 unreadable_outcome_with_id(verb.clone(), "update-sheet-properties 'Q2'", 118_293);
7062 let record = mutation_record(&outcome, &opts(verb, false), Duration::ZERO);
7063 assert_eq!(record.fields_changed.as_deref(), Some("frozenRowCount=2"));
7064 assert_eq!(record.sheet_id, Some(118_293));
7065 }
7066
7067 #[test]
7068 fn describe_lines_names_the_known_sheet_id_of_an_unreadable_reply() {
7069 assert_eq!(
7070 describe_lines(&unreadable_outcome_with_id(
7071 rename(),
7072 "rename-sheet 'Q2' to 'Q3'",
7073 7
7074 )),
7075 vec![
7076 "Applied, but the reply could not be read: rename-sheet 'Q2' to 'Q3' \
7077 (sheetId 7) in 'Budget' — do not retry; check the spreadsheet first \
7078 (bad reply)"
7079 .to_string()
7080 ]
7081 );
7082 }
7083
7084 fn unreadable_outcome(verb: StructureVerb, summary: &str) -> StructureOutcome {
7085 StructureOutcome {
7086 spreadsheet_id: "sheet-1".to_string(),
7087 file_name: Some("Budget".to_string()),
7088 resolved_folder_id: None,
7089 verb,
7090 result: StructureResult::AppliedReplyUnreadable {
7091 summary: summary.to_string(),
7092 detail: "bad reply".to_string(),
7093 sheet: None,
7094 sheet_id: None,
7095 backup: None,
7096 },
7097 }
7098 }
7099
7100 fn unreadable_outcome_with_id(
7103 verb: StructureVerb,
7104 summary: &str,
7105 sheet_id: i64,
7106 ) -> StructureOutcome {
7107 let mut outcome = unreadable_outcome(verb, summary);
7108 if let StructureResult::AppliedReplyUnreadable {
7109 sheet: s,
7110 sheet_id: id,
7111 ..
7112 } = &mut outcome.result
7113 {
7114 *s = Some(Box::new(SheetSnapshot {
7115 sheet_id: Some(sheet_id),
7116 title: "Q2".to_string(),
7117 ..Default::default()
7118 }));
7119 *id = Some(sheet_id);
7120 }
7121 outcome
7122 }
7123
7124 #[test]
7125 fn describe_lines_renders_applied_reply_unreadable_for_an_additive_verb() {
7126 let lines = describe_lines(&unreadable_outcome(
7127 duplicate_sheet(),
7128 "duplicate-sheet 'Q2'",
7129 ));
7130 assert_eq!(
7131 lines,
7132 vec![
7133 "Applied, but the reply could not be read: duplicate-sheet 'Q2' in 'Budget' — \
7134 do not retry; check the spreadsheet first (bad reply)"
7135 .to_string(),
7136 " the new sheet's id was in the unreadable reply; check the spreadsheet's tabs \
7137 to find it"
7138 .to_string(),
7139 ]
7140 );
7141 assert_eq!(
7142 describe_lines(&unreadable_outcome(rename(), "rename-sheet 'Q2' to 'Q3'")),
7143 vec![
7144 "Applied, but the reply could not be read: rename-sheet 'Q2' to 'Q3' in \
7145 'Budget' — do not retry; check the spreadsheet first (bad reply)"
7146 .to_string()
7147 ]
7148 );
7149 }
7150
7151 #[test]
7152 fn describe_lines_renders_applied_reply_unreadable_for_a_destructive_verb() {
7153 let lines = describe_lines(&unreadable_outcome(
7154 delete_rows(),
7155 "delete-rows 'Q2' (ROWS 5:7)",
7156 ));
7157 assert_eq!(
7158 lines,
7159 vec![format!(
7160 "Applied, but the reply could not be read: delete-rows 'Q2' (ROWS 5:7) in \
7161 'Budget' — do not retry; check the spreadsheet first (bad reply); {}",
7162 recovery_note(None, false)
7163 )]
7164 );
7165 }
7166
7167 #[test]
7168 fn applied_summary_names_the_verb_sheet_and_span() {
7169 assert_eq!(applied_summary(&add_sheet()), "add-sheet 'Q3'");
7170 assert_eq!(applied_summary(&rename()), "rename-sheet 'Q2' to 'Q3'");
7171 assert_eq!(
7172 applied_summary(&delete_rows()),
7173 "delete-rows 'Q2' (ROWS 5:7)"
7174 );
7175 assert_eq!(
7176 applied_summary(&update_workbook_properties()),
7177 "update-workbook-properties"
7178 );
7179 }
7180
7181 fn sent_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
7185 requests
7186 .iter()
7187 .find(|r| r.url.path().ends_with(":batchUpdate"))
7188 .map(|r| serde_json::from_slice(&r.body).unwrap())
7189 .expect("a batchUpdate request")
7190 }
7191
7192 fn mount_workbook_with_q2(q2: serde_json::Value) -> wiremock::Mock {
7196 wiremock::Mock::given(wiremock::matchers::method("GET"))
7197 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
7198 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(workbook_body(q2)))
7199 }
7200
7201 async fn mount_move_prereqs(server: &wiremock::MockServer) {
7203 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7204 .mount(server)
7205 .await;
7206 mount_folder("parent-1").mount(server).await;
7207 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
7208 .mount(server)
7209 .await;
7210 }
7211
7212 #[tokio::test]
7217 async fn move_rows_apply_sends_move_dimension_with_pre_move_destination() {
7218 let server = wiremock::MockServer::start().await;
7219 let (drive, sheets) = clients(&server).await;
7220 mount_move_prereqs(&server).await;
7221 mount_workbook().mount(&server).await;
7222
7223 let outcome = structure(
7224 &drive,
7225 &sheets,
7226 &opts(move_rows(), false),
7227 &[allow_rule("parent-1")],
7228 )
7229 .await;
7230 assert!(
7231 matches!(outcome.result, StructureResult::Changed { .. }),
7232 "{:?}",
7233 outcome.result
7234 );
7235
7236 let requests = server.received_requests().await.unwrap();
7237 let body = sent_batch_update(&requests);
7238 assert_eq!(
7239 body["requests"][0]["moveDimension"],
7240 serde_json::json!({
7241 "source": {
7242 "sheetId": 118_293, "dimension": "ROWS",
7243 "startIndex": 1, "endIndex": 3,
7244 },
7245 "destinationIndex": 5,
7246 })
7247 );
7248 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
7249
7250 let rendered = describe(&outcome);
7253 assert!(rendered.contains("Moved 2 row(s) 2-3"), "{rendered}");
7254 assert!(rendered.contains("to before row 6"), "{rendered}");
7255 assert!(rendered.contains("(now rows 4-5)"), "{rendered}");
7256 }
7257
7258 #[tokio::test]
7259 async fn move_columns_apply_sends_move_dimension() {
7260 let server = wiremock::MockServer::start().await;
7261 let (drive, sheets) = clients(&server).await;
7262 mount_move_prereqs(&server).await;
7263 mount_workbook().mount(&server).await;
7264
7265 let outcome = structure(
7266 &drive,
7267 &sheets,
7268 &opts(
7269 StructureVerb::MoveColumns {
7270 sheet: "Q2".to_string(),
7271 at: 2,
7272 count: 2,
7273 before: 6,
7274 },
7275 false,
7276 ),
7277 &[allow_rule("parent-1")],
7278 )
7279 .await;
7280 assert!(
7281 matches!(outcome.result, StructureResult::Changed { .. }),
7282 "{:?}",
7283 outcome.result
7284 );
7285
7286 let requests = server.received_requests().await.unwrap();
7287 let body = sent_batch_update(&requests);
7288 assert_eq!(
7289 body["requests"][0]["moveDimension"],
7290 serde_json::json!({
7291 "source": {
7292 "sheetId": 118_293, "dimension": "COLUMNS",
7293 "startIndex": 1, "endIndex": 3,
7294 },
7295 "destinationIndex": 5,
7296 })
7297 );
7298 }
7299
7300 #[tokio::test]
7304 async fn move_rows_to_the_end_uses_row_count_plus_one() {
7305 let server = wiremock::MockServer::start().await;
7306 let (drive, sheets) = clients(&server).await;
7307 mount_move_prereqs(&server).await;
7308 mount_workbook().mount(&server).await;
7309
7310 let outcome = structure(
7311 &drive,
7312 &sheets,
7313 &opts(
7314 StructureVerb::MoveRows {
7315 sheet: "Q2".to_string(),
7316 at: 1,
7317 count: 1,
7318 before: 501,
7319 },
7320 false,
7321 ),
7322 &[allow_rule("parent-1")],
7323 )
7324 .await;
7325 assert!(
7326 matches!(outcome.result, StructureResult::Changed { .. }),
7327 "{:?}",
7328 outcome.result
7329 );
7330
7331 let requests = server.received_requests().await.unwrap();
7332 let body = sent_batch_update(&requests);
7333 assert_eq!(
7334 body["requests"][0]["moveDimension"]["destinationIndex"],
7335 500
7336 );
7337 }
7338
7339 #[tokio::test]
7342 async fn move_rows_refuses_a_source_past_the_sheets_end() {
7343 let server = wiremock::MockServer::start().await;
7344 let (drive, sheets) = clients(&server).await;
7345 mount_move_prereqs(&server).await;
7346 mount_workbook().mount(&server).await;
7347
7348 let outcome = structure(
7349 &drive,
7350 &sheets,
7351 &opts(
7352 StructureVerb::MoveRows {
7353 sheet: "Q2".to_string(),
7354 at: 499,
7355 count: 3,
7356 before: 1,
7357 },
7358 false,
7359 ),
7360 &[allow_rule("parent-1")],
7361 )
7362 .await;
7363 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
7364 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
7365 };
7366 assert!(detail.contains("500 row(s)"), "{detail}");
7367 assert!(detail.contains("501"), "{detail}");
7368 }
7369
7370 #[tokio::test]
7371 async fn move_rows_refuses_a_destination_past_the_end() {
7372 let server = wiremock::MockServer::start().await;
7373 let (drive, sheets) = clients(&server).await;
7374 mount_move_prereqs(&server).await;
7375 mount_workbook().mount(&server).await;
7376
7377 let outcome = structure(
7378 &drive,
7379 &sheets,
7380 &opts(
7381 StructureVerb::MoveRows {
7382 sheet: "Q2".to_string(),
7383 at: 5,
7384 count: 1,
7385 before: 502,
7386 },
7387 false,
7388 ),
7389 &[allow_rule("parent-1")],
7390 )
7391 .await;
7392 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
7393 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
7394 };
7395 assert!(detail.contains("500 row(s)"), "{detail}");
7396 assert!(detail.contains("501"), "{detail}");
7397 }
7398
7399 #[tokio::test]
7406 async fn move_rows_refuses_a_destination_inside_or_adjacent_to_the_block() {
7407 let server = wiremock::MockServer::start().await;
7408 let (drive, sheets) = clients(&server).await;
7409 mount_move_prereqs(&server).await;
7410 mount_workbook().mount(&server).await;
7411
7412 for before in 2..=4 {
7413 let outcome = structure(
7414 &drive,
7415 &sheets,
7416 &opts(
7417 StructureVerb::MoveRows {
7418 sheet: "Q2".to_string(),
7419 at: 2,
7420 count: 2,
7421 before,
7422 },
7423 false,
7424 ),
7425 &[allow_rule("parent-1")],
7426 )
7427 .await;
7428 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
7429 panic!(
7430 "expected RefusedInvalidRange for --before {before}, got {:?}",
7431 outcome.result
7432 );
7433 };
7434 assert!(detail.contains("nothing would move"), "{detail}");
7435 assert!(detail.contains("rows 2-3"), "{detail}");
7436 }
7437 }
7438
7439 #[tokio::test]
7445 async fn move_rows_allows_a_destination_immediately_adjacent_but_outside_the_block() {
7446 let server = wiremock::MockServer::start().await;
7447 let (drive, sheets) = clients(&server).await;
7448 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7449 .mount(&server)
7450 .await;
7451 mount_folder("parent-1").mount(&server).await;
7452 mount_workbook().mount(&server).await;
7453 for before in [1, 5] {
7456 let outcome = structure(
7457 &drive,
7458 &sheets,
7459 &opts(
7460 StructureVerb::MoveRows {
7461 sheet: "Q2".to_string(),
7462 at: 2,
7463 count: 2,
7464 before,
7465 },
7466 true,
7467 ),
7468 &[allow_rule("parent-1")],
7469 )
7470 .await;
7471 assert!(
7472 matches!(outcome.result, StructureResult::WouldChange { .. }),
7473 "--before {before} must be a real move, got {:?}",
7474 outcome.result
7475 );
7476 }
7477 }
7478
7479 #[test]
7480 fn move_bounds_refuse_every_argument_below_one() {
7481 let sheet = SheetSnapshot {
7482 sheet_id: Some(1),
7483 title: "Q2".to_string(),
7484 row_count: Some(500),
7485 column_count: Some(26),
7486 ..Default::default()
7487 };
7488 for (at, count, before, expected) in [
7489 (2, 0, 9, "--count must be at least 1, got 0"),
7490 (0, 1, 9, "--at must be at least 1, got 0"),
7491 (2, 1, 0, "--before must be at least 1, got 0"),
7492 ] {
7493 let Err(StructureResult::RefusedInvalidRange { detail }) =
7494 validate_move_bounds(Dimension::Rows, at, count, before, Some(&sheet))
7495 else {
7496 panic!(
7497 "expected RefusedInvalidRange for --at {at} --count {count} --before {before}"
7498 );
7499 };
7500 assert_eq!(detail, expected);
7501 }
7502 }
7503
7504 #[test]
7505 fn a_move_at_and_count_that_would_overflow_the_index_space_are_refused() {
7506 let sheet = SheetSnapshot {
7507 sheet_id: Some(1),
7508 title: "Q2".to_string(),
7509 row_count: Some(500),
7510 column_count: Some(26),
7511 ..Default::default()
7512 };
7513 for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
7516 let Err(StructureResult::RefusedInvalidRange { detail }) =
7517 validate_move_bounds(Dimension::Rows, at, count, 1, Some(&sheet))
7518 else {
7519 panic!("expected RefusedInvalidRange for --at {at} --count {count}");
7520 };
7521 assert!(detail.contains("overflows the row index space"), "{detail}");
7522 }
7523 }
7524
7525 #[tokio::test]
7532 async fn a_refused_move_still_records_without_panicking() {
7533 let server = wiremock::MockServer::start().await;
7534 let (drive, sheets) = clients(&server).await;
7535 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7536 .mount(&server)
7537 .await;
7538 mount_folder("parent-1").mount(&server).await;
7539 mount_workbook().mount(&server).await;
7540
7541 let outcome = structure(
7542 &drive,
7543 &sheets,
7544 &opts(
7545 StructureVerb::MoveRows {
7546 sheet: "Q2".to_string(),
7547 at: i64::MAX,
7548 count: 5,
7549 before: 1,
7550 },
7551 false,
7552 ),
7553 &[allow_rule("parent-1")],
7554 )
7555 .await;
7556 assert!(
7557 matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
7558 "{:?}",
7559 outcome.result
7560 );
7561 }
7562
7563 #[tokio::test]
7567 async fn move_rows_dry_run_describes_a_downward_move_and_its_landing_rows() {
7568 let server = wiremock::MockServer::start().await;
7569 let (drive, sheets) = clients(&server).await;
7570 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7571 .mount(&server)
7572 .await;
7573 mount_folder("parent-1").mount(&server).await;
7574 mount_workbook().mount(&server).await;
7575
7576 let outcome = structure(
7577 &drive,
7578 &sheets,
7579 &opts(move_rows(), true),
7580 &[allow_rule("parent-1")],
7581 )
7582 .await;
7583 let lines = describe_lines(&outcome);
7584 assert_eq!(lines.len(), 2, "{lines:?}");
7585 assert!(lines[0].contains("Would move 2 row(s) 2-3"), "{lines:?}");
7586 assert!(lines[0].contains("to before row 6"), "{lines:?}");
7587 assert_eq!(
7588 lines[1],
7589 " (500 rows unchanged; rows 4-5 shift up to 2-3; moved rows land at 4-5)"
7590 );
7591 }
7592
7593 #[tokio::test]
7597 async fn move_rows_dry_run_describes_an_upward_move_and_its_landing_rows() {
7598 let server = wiremock::MockServer::start().await;
7599 let (drive, sheets) = clients(&server).await;
7600 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7601 .mount(&server)
7602 .await;
7603 mount_folder("parent-1").mount(&server).await;
7604 mount_workbook().mount(&server).await;
7605
7606 let outcome = structure(
7607 &drive,
7608 &sheets,
7609 &opts(
7610 StructureVerb::MoveRows {
7611 sheet: "Q2".to_string(),
7612 at: 6,
7613 count: 2,
7614 before: 2,
7615 },
7616 true,
7617 ),
7618 &[allow_rule("parent-1")],
7619 )
7620 .await;
7621 let lines = describe_lines(&outcome);
7622 assert_eq!(lines.len(), 2, "{lines:?}");
7623 assert!(lines[0].contains("Would move 2 row(s) 6-7"), "{lines:?}");
7624 assert!(lines[0].contains("to before row 2"), "{lines:?}");
7625 assert_eq!(
7626 lines[1],
7627 " (500 rows unchanged; rows 2-5 shift down to 4-7; moved rows land at 2-3)"
7628 );
7629 }
7630
7631 #[tokio::test]
7637 async fn move_columns_dry_run_describes_a_downward_move_and_its_landing_columns() {
7638 let server = wiremock::MockServer::start().await;
7639 let (drive, sheets) = clients(&server).await;
7640 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7641 .mount(&server)
7642 .await;
7643 mount_folder("parent-1").mount(&server).await;
7644 mount_workbook().mount(&server).await;
7645
7646 let outcome = structure(
7647 &drive,
7648 &sheets,
7649 &opts(
7650 StructureVerb::MoveColumns {
7651 sheet: "Q2".to_string(),
7652 at: 2,
7653 count: 2,
7654 before: 6,
7655 },
7656 true,
7657 ),
7658 &[allow_rule("parent-1")],
7659 )
7660 .await;
7661 let lines = describe_lines(&outcome);
7662 assert_eq!(lines.len(), 2, "{lines:?}");
7663 assert!(lines[0].contains("Would move 2 column(s) 2-3"), "{lines:?}");
7664 assert!(lines[0].contains("to before column 6"), "{lines:?}");
7665 assert_eq!(
7666 lines[1],
7667 " (10 columns unchanged; columns 4-5 shift left to 2-3; moved columns land at 4-5)"
7668 );
7669 }
7670
7671 #[tokio::test]
7675 async fn move_columns_dry_run_describes_an_upward_move_and_its_landing_columns() {
7676 let server = wiremock::MockServer::start().await;
7677 let (drive, sheets) = clients(&server).await;
7678 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7679 .mount(&server)
7680 .await;
7681 mount_folder("parent-1").mount(&server).await;
7682 mount_workbook().mount(&server).await;
7683
7684 let outcome = structure(
7685 &drive,
7686 &sheets,
7687 &opts(
7688 StructureVerb::MoveColumns {
7689 sheet: "Q2".to_string(),
7690 at: 6,
7691 count: 2,
7692 before: 2,
7693 },
7694 true,
7695 ),
7696 &[allow_rule("parent-1")],
7697 )
7698 .await;
7699 let lines = describe_lines(&outcome);
7700 assert_eq!(lines.len(), 2, "{lines:?}");
7701 assert!(lines[0].contains("Would move 2 column(s) 6-7"), "{lines:?}");
7702 assert!(lines[0].contains("to before column 2"), "{lines:?}");
7703 assert_eq!(
7704 lines[1],
7705 " (10 columns unchanged; columns 2-5 shift right to 4-7; moved columns land at 2-3)"
7706 );
7707 }
7708
7709 #[tokio::test]
7714 async fn move_rows_dry_run_matches_the_real_run_refusal() {
7715 let server = wiremock::MockServer::start().await;
7716 let (drive, sheets) = clients(&server).await;
7717 mount_move_prereqs(&server).await;
7718 mount_workbook().mount(&server).await;
7719
7720 let verb = StructureVerb::MoveRows {
7721 sheet: "Q2".to_string(),
7722 at: 5,
7723 count: 1,
7724 before: 502,
7725 };
7726 let real = structure(
7727 &drive,
7728 &sheets,
7729 &opts(verb.clone(), false),
7730 &[allow_rule("parent-1")],
7731 )
7732 .await;
7733 let dry = structure(
7734 &drive,
7735 &sheets,
7736 &opts(verb, true),
7737 &[allow_rule("parent-1")],
7738 )
7739 .await;
7740 assert!(
7741 matches!(real.result, StructureResult::RefusedInvalidRange { .. }),
7742 "{:?}",
7743 real.result
7744 );
7745 assert_eq!(dry.result, real.result);
7746 }
7747
7748 #[tokio::test]
7755 async fn move_rows_does_not_consult_frozen_rows() {
7756 let server = wiremock::MockServer::start().await;
7757 let (drive, sheets) = clients(&server).await;
7758 mount_move_prereqs(&server).await;
7759 mount_workbook_with_q2(serde_json::json!({
7760 "properties": {
7761 "sheetId": 118_293, "title": "Q2", "index": 1,
7762 "gridProperties": {
7763 "rowCount": 500, "columnCount": 10, "frozenRowCount": 2,
7764 },
7765 },
7766 }))
7767 .mount(&server)
7768 .await;
7769
7770 let outcome = structure(
7771 &drive,
7772 &sheets,
7773 &opts(
7774 StructureVerb::MoveRows {
7775 sheet: "Q2".to_string(),
7776 at: 1,
7777 count: 1,
7778 before: 3,
7779 },
7780 false,
7781 ),
7782 &[allow_rule("parent-1")],
7783 )
7784 .await;
7785 assert!(
7786 matches!(outcome.result, StructureResult::Changed { .. }),
7787 "{:?}",
7788 outcome.result
7789 );
7790
7791 let requests = server.received_requests().await.unwrap();
7792 let body = sent_batch_update(&requests);
7793 assert_eq!(
7794 body["requests"][0]["moveDimension"],
7795 serde_json::json!({
7796 "source": {
7797 "sheetId": 118_293, "dimension": "ROWS",
7798 "startIndex": 0, "endIndex": 1,
7799 },
7800 "destinationIndex": 2,
7801 })
7802 );
7803 }
7804
7805 #[tokio::test]
7810 async fn move_rows_does_not_consult_dimension_groups() {
7811 let server = wiremock::MockServer::start().await;
7812 let (drive, sheets) = clients(&server).await;
7813 mount_move_prereqs(&server).await;
7814 mount_workbook_with_q2(serde_json::json!({
7815 "properties": {
7816 "sheetId": 118_293, "title": "Q2", "index": 1,
7817 "gridProperties": {"rowCount": 500, "columnCount": 10},
7818 },
7819 "rowGroups": [{
7820 "range": {
7821 "sheetId": 118_293, "dimension": "ROWS",
7822 "startIndex": 1, "endIndex": 4,
7823 },
7824 }],
7825 }))
7826 .mount(&server)
7827 .await;
7828
7829 let outcome = structure(
7830 &drive,
7831 &sheets,
7832 &opts(
7833 StructureVerb::MoveRows {
7834 sheet: "Q2".to_string(),
7835 at: 2,
7836 count: 1,
7837 before: 1,
7838 },
7839 false,
7840 ),
7841 &[allow_rule("parent-1")],
7842 )
7843 .await;
7844 assert!(
7845 matches!(outcome.result, StructureResult::Changed { .. }),
7846 "{:?}",
7847 outcome.result
7848 );
7849
7850 let requests = server.received_requests().await.unwrap();
7851 let body = sent_batch_update(&requests);
7852 assert_eq!(
7853 body["requests"][0]["moveDimension"],
7854 serde_json::json!({
7855 "source": {
7856 "sheetId": 118_293, "dimension": "ROWS",
7857 "startIndex": 1, "endIndex": 2,
7858 },
7859 "destinationIndex": 0,
7860 })
7861 );
7862 }
7863
7864 #[test]
7867 fn sheet_title_is_none_for_update_workbook_properties() {
7868 assert_eq!(update_workbook_properties().sheet_title(), None);
7869 assert_eq!(update_workbook_properties().new_sheet_title(), None);
7870 assert_eq!(update_workbook_properties().dimension(), None);
7871 }
7872
7873 #[test]
7874 fn resolve_sheet_returns_none_for_update_workbook_properties_without_consulting_sheets() {
7875 let workbook = Spreadsheet {
7879 spreadsheet_id: Some("sheet-1".to_string()),
7880 properties: None,
7881 sheets: Vec::new(),
7882 named_ranges: Vec::new(),
7883 };
7884 assert_eq!(
7885 resolve_sheet(&workbook, &update_workbook_properties()),
7886 Ok(None)
7887 );
7888 }
7889
7890 #[test]
7891 fn validate_verb_args_refuses_update_workbook_properties_with_no_fields() {
7892 let verb = StructureVerb::UpdateWorkbookProperties {
7893 locale: None,
7894 time_zone: None,
7895 auto_recalc: None,
7896 iterative_calculation: None,
7897 iterative_calculation_max_iterations: None,
7898 iterative_calculation_convergence_threshold: None,
7899 };
7900 let workbook = Spreadsheet::default();
7901 let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7902 assert!(
7903 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7904 if detail.contains("at least one property")),
7905 "{err:?}"
7906 );
7907 }
7908
7909 fn update_time_zone(time_zone: &str) -> StructureVerb {
7910 StructureVerb::UpdateWorkbookProperties {
7911 locale: None,
7912 time_zone: Some(time_zone.to_string()),
7913 auto_recalc: None,
7914 iterative_calculation: None,
7915 iterative_calculation_max_iterations: None,
7916 iterative_calculation_convergence_threshold: None,
7917 }
7918 }
7919
7920 #[test]
7921 fn validate_verb_args_refuses_an_unrecognised_time_zone() {
7922 let workbook = Spreadsheet::default();
7925 let err =
7926 validate_verb_args(&workbook, &update_time_zone("Bogus/Zone9"), None).unwrap_err();
7927 assert!(
7928 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7929 if detail.contains("'Bogus/Zone9'")
7930 && detail.contains("IANA")
7931 && detail.contains("America/New_York")),
7932 "{err:?}"
7933 );
7934 }
7935
7936 #[test]
7937 fn validate_verb_args_accepts_recognised_iana_time_zones() {
7938 let workbook = Spreadsheet::default();
7939 for tz in ["Europe/London", "America/New_York", "Etc/GMT", "UTC"] {
7940 assert_eq!(
7941 validate_verb_args(&workbook, &update_time_zone(tz), None),
7942 Ok(()),
7943 "{tz}"
7944 );
7945 }
7946 }
7947
7948 #[test]
7949 fn validate_verb_args_refuses_iterative_calculation_settings_without_the_toggle() {
7950 let verb = StructureVerb::UpdateWorkbookProperties {
7951 locale: None,
7952 time_zone: None,
7953 auto_recalc: None,
7954 iterative_calculation: None,
7955 iterative_calculation_max_iterations: Some(50),
7956 iterative_calculation_convergence_threshold: None,
7957 };
7958 let workbook = Spreadsheet::default();
7959 let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7960 assert!(
7961 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7962 if detail.contains("--iterative-calculation on")),
7963 "{err:?}"
7964 );
7965
7966 let verb_off = StructureVerb::UpdateWorkbookProperties {
7968 locale: None,
7969 time_zone: None,
7970 auto_recalc: None,
7971 iterative_calculation: Some(IterativeCalculationToggle::Off),
7972 iterative_calculation_max_iterations: Some(50),
7973 iterative_calculation_convergence_threshold: None,
7974 };
7975 assert!(validate_verb_args(&workbook, &verb_off, None).is_err());
7976 }
7977
7978 #[test]
7979 fn validate_verb_args_refuses_non_positive_iterative_calculation_bounds() {
7980 let workbook = Spreadsheet::default();
7981 let bad_max = StructureVerb::UpdateWorkbookProperties {
7982 locale: None,
7983 time_zone: None,
7984 auto_recalc: None,
7985 iterative_calculation: Some(IterativeCalculationToggle::On),
7986 iterative_calculation_max_iterations: Some(0),
7987 iterative_calculation_convergence_threshold: None,
7988 };
7989 assert!(validate_verb_args(&workbook, &bad_max, None).is_err());
7990
7991 let bad_threshold = StructureVerb::UpdateWorkbookProperties {
7992 locale: None,
7993 time_zone: None,
7994 auto_recalc: None,
7995 iterative_calculation: Some(IterativeCalculationToggle::On),
7996 iterative_calculation_max_iterations: None,
7997 iterative_calculation_convergence_threshold: Some(0.0),
7998 };
7999 assert!(validate_verb_args(&workbook, &bad_threshold, None).is_err());
8000
8001 let nan_threshold = StructureVerb::UpdateWorkbookProperties {
8002 locale: None,
8003 time_zone: None,
8004 auto_recalc: None,
8005 iterative_calculation: Some(IterativeCalculationToggle::On),
8006 iterative_calculation_max_iterations: None,
8007 iterative_calculation_convergence_threshold: Some(f64::NAN),
8008 };
8009 assert!(validate_verb_args(&workbook, &nan_threshold, None).is_err());
8010 }
8011
8012 #[test]
8013 fn validate_verb_args_accepts_positive_iterative_calculation_bounds() {
8014 let workbook = Spreadsheet::default();
8015 let verb = StructureVerb::UpdateWorkbookProperties {
8016 locale: None,
8017 time_zone: None,
8018 auto_recalc: None,
8019 iterative_calculation: Some(IterativeCalculationToggle::On),
8020 iterative_calculation_max_iterations: Some(50),
8021 iterative_calculation_convergence_threshold: Some(0.01),
8022 };
8023 assert!(validate_verb_args(&workbook, &verb, None).is_ok());
8024 }
8025
8026 #[test]
8027 fn validate_verb_args_accepts_a_single_field() {
8028 let workbook = Spreadsheet::default();
8029 let verb = StructureVerb::UpdateWorkbookProperties {
8030 locale: Some("fr_FR".to_string()),
8031 time_zone: None,
8032 auto_recalc: None,
8033 iterative_calculation: None,
8034 iterative_calculation_max_iterations: None,
8035 iterative_calculation_convergence_threshold: None,
8036 };
8037 assert!(validate_verb_args(&workbook, &verb, None).is_ok());
8038 }
8039
8040 #[test]
8041 fn build_request_masks_exactly_the_fields_given() {
8042 let verb = StructureVerb::UpdateWorkbookProperties {
8043 locale: Some("en_US".to_string()),
8044 time_zone: None,
8045 auto_recalc: Some(RecalculationInterval::Hour),
8046 iterative_calculation: None,
8047 iterative_calculation_max_iterations: None,
8048 iterative_calculation_convergence_threshold: None,
8049 };
8050 let request = build_request(&verb, None).unwrap();
8051 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
8052 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
8054 assert_eq!(req.fields, "locale,autoRecalc");
8055 assert_eq!(req.properties.locale.as_deref(), Some("en_US"));
8056 assert_eq!(req.properties.time_zone, None);
8057 assert_eq!(
8058 req.properties.auto_recalc,
8059 Some(RecalculationInterval::Hour)
8060 );
8061 assert_eq!(req.properties.iterative_calculation_settings, None);
8062 }
8063
8064 #[test]
8065 fn build_request_turns_iterative_calculation_on_with_explicit_settings() {
8066 let verb = StructureVerb::UpdateWorkbookProperties {
8067 locale: None,
8068 time_zone: None,
8069 auto_recalc: None,
8070 iterative_calculation: Some(IterativeCalculationToggle::On),
8071 iterative_calculation_max_iterations: Some(50),
8072 iterative_calculation_convergence_threshold: Some(0.01),
8073 };
8074 let request = build_request(&verb, None).unwrap();
8075 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
8076 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
8078 assert_eq!(req.fields, "iterativeCalculationSettings");
8079 assert_eq!(
8080 req.properties.iterative_calculation_settings,
8081 Some(IterativeCalculationSettings {
8082 max_iterations: Some(50),
8083 convergence_threshold: Some(0.01),
8084 })
8085 );
8086 }
8087
8088 #[test]
8089 fn build_request_turns_iterative_calculation_off_by_naming_the_field_without_a_value() {
8090 let verb = StructureVerb::UpdateWorkbookProperties {
8095 locale: None,
8096 time_zone: None,
8097 auto_recalc: None,
8098 iterative_calculation: Some(IterativeCalculationToggle::Off),
8099 iterative_calculation_max_iterations: None,
8100 iterative_calculation_convergence_threshold: None,
8101 };
8102 let request = build_request(&verb, None).unwrap();
8103 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
8104 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
8106 assert_eq!(req.fields, "iterativeCalculationSettings");
8107 assert_eq!(req.properties.iterative_calculation_settings, None);
8108 let json = serde_json::to_value(&req.properties).unwrap();
8109 assert!(
8110 json.as_object().unwrap().is_empty(),
8111 "expected an empty properties object, got {json}"
8112 );
8113 }
8114
8115 #[test]
8116 fn workbook_properties_summary_formats_every_field() {
8117 let verb = StructureVerb::UpdateWorkbookProperties {
8118 locale: Some("en_US".to_string()),
8119 time_zone: Some("America/New_York".to_string()),
8120 auto_recalc: Some(RecalculationInterval::OnChange),
8121 iterative_calculation: Some(IterativeCalculationToggle::On),
8122 iterative_calculation_max_iterations: Some(50),
8123 iterative_calculation_convergence_threshold: Some(0.01),
8124 };
8125 let summary = workbook_properties_summary(&verb);
8126 assert_eq!(
8127 summary,
8128 "locale -> 'en_US', time zone -> 'America/New_York', \
8129 auto-recalc -> ON_CHANGE, iterative calculation -> on \
8130 (max 50 iterations, threshold 0.01)"
8131 );
8132 }
8133
8134 #[tokio::test]
8135 async fn update_workbook_properties_dry_run_reports_would_change_with_no_sheet() {
8136 let server = wiremock::MockServer::start().await;
8137 let (drive, sheets) = clients(&server).await;
8138 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8139 .mount(&server)
8140 .await;
8141 mount_folder("parent-1").mount(&server).await;
8142 mount_workbook().mount(&server).await;
8143
8144 let outcome = structure(
8145 &drive,
8146 &sheets,
8147 &opts(update_workbook_properties(), true),
8148 &[allow_rule("parent-1")],
8149 )
8150 .await;
8151 match &outcome.result {
8152 StructureResult::WouldChange { sheet, sheet_count } => {
8153 assert_eq!(*sheet, None);
8154 assert_eq!(*sheet_count, 2);
8155 }
8156 other => panic!("expected WouldChange, got {other:?}"), }
8158 let lines = describe_lines(&outcome);
8159 assert_eq!(lines.len(), 1);
8160 assert!(
8161 lines[0].starts_with("Would update workbook properties of 'sheet-1'"),
8162 "{lines:?}"
8163 );
8164
8165 let requests = server.received_requests().await.unwrap();
8168 assert!(!requests
8169 .iter()
8170 .any(|r| r.url.path().ends_with(":batchUpdate")));
8171 }
8172
8173 #[tokio::test]
8174 async fn update_workbook_properties_refuses_an_unrecognised_time_zone_on_dry_run_and_apply() {
8175 for dry_run in [true, false] {
8178 let server = wiremock::MockServer::start().await;
8179 let (drive, sheets) = clients(&server).await;
8180 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8181 .mount(&server)
8182 .await;
8183 mount_folder("parent-1").mount(&server).await;
8184 mount_workbook().mount(&server).await;
8185
8186 let outcome = structure(
8187 &drive,
8188 &sheets,
8189 &opts(update_time_zone("Bogus/Zone9"), dry_run),
8190 &[allow_rule("parent-1")],
8191 )
8192 .await;
8193 assert!(
8194 matches!(&outcome.result, StructureResult::RefusedInvalidRange { detail }
8195 if detail.contains("'Bogus/Zone9'")),
8196 "dry_run={dry_run}: {:?}",
8197 outcome.result
8198 );
8199 let requests = server.received_requests().await.unwrap();
8200 assert!(
8201 !requests
8202 .iter()
8203 .any(|r| r.url.path().ends_with(":batchUpdate")),
8204 "dry_run={dry_run}"
8205 );
8206 }
8207 }
8208
8209 #[tokio::test]
8210 async fn update_workbook_properties_apply_sends_the_masked_request_and_reports_changed() {
8211 let server = wiremock::MockServer::start().await;
8212 let (drive, sheets) = clients(&server).await;
8213 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8214 .mount(&server)
8215 .await;
8216 mount_folder("parent-1").mount(&server).await;
8217 mount_workbook().mount(&server).await;
8218 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8219 .mount(&server)
8220 .await;
8221
8222 let outcome = structure(
8223 &drive,
8224 &sheets,
8225 &opts(update_workbook_properties(), false),
8226 &[allow_rule("parent-1")],
8227 )
8228 .await;
8229 match &outcome.result {
8230 StructureResult::Changed {
8231 sheet, sheet_id, ..
8232 } => {
8233 assert_eq!(*sheet, None);
8234 assert_eq!(*sheet_id, None);
8235 }
8236 other => panic!("expected Changed, got {other:?}"), }
8238
8239 let requests = server.received_requests().await.unwrap();
8240 let body = sent_batch_update(&requests);
8241 let update = &body["requests"][0]["updateSpreadsheetProperties"];
8242 assert_eq!(update["properties"]["locale"], "en_US");
8243 assert_eq!(update["properties"]["timeZone"], "America/New_York");
8244 assert_eq!(update["properties"]["autoRecalc"], "ON_CHANGE");
8245 assert_eq!(update["fields"], "locale,timeZone,autoRecalc");
8246 }
8247
8248 #[test]
8249 fn record_attempt_carries_the_fields_changed_summary_and_no_sheet_title() {
8250 let opts = opts(update_workbook_properties(), false);
8251 assert_eq!(opts.verb.sheet_title(), None);
8252 assert_eq!(
8253 workbook_properties_summary(&opts.verb),
8254 "locale -> 'en_US', time zone -> 'America/New_York', auto-recalc -> ON_CHANGE"
8255 );
8256 }
8257
8258 #[test]
8261 fn log_operation_is_distinct_per_verb() {
8262 let verbs = [
8263 add_sheet(),
8264 rename(),
8265 insert_rows(),
8266 StructureVerb::InsertColumns {
8267 sheet: "Q2".to_string(),
8268 at: 1,
8269 count: 1,
8270 },
8271 insert_range(),
8272 move_rows(),
8273 StructureVerb::MoveColumns {
8274 sheet: "Q2".to_string(),
8275 at: 1,
8276 count: 1,
8277 before: 3,
8278 },
8279 delete_sheet(),
8280 delete_rows(),
8281 StructureVerb::DeleteColumns {
8282 sheet: "Q2".to_string(),
8283 at: 1,
8284 count: 1,
8285 },
8286 delete_range(),
8287 duplicate_sheet(),
8288 reorder_sheet(),
8289 hide_sheet(),
8290 show_sheet(),
8291 update_workbook_properties(),
8292 ];
8293 let names: Vec<&str> = verbs.iter().map(StructureVerb::log_operation).collect();
8294 assert_eq!(
8295 names,
8296 vec![
8297 "sheets-add-sheet",
8298 "sheets-rename-sheet",
8299 "sheets-insert-rows",
8300 "sheets-insert-columns",
8301 "sheets-insert-range",
8302 "sheets-move-rows",
8303 "sheets-move-columns",
8304 "sheets-delete-sheet",
8305 "sheets-delete-rows",
8306 "sheets-delete-columns",
8307 "sheets-delete-range",
8308 "sheets-duplicate-sheet",
8309 "sheets-reorder-sheet",
8310 "sheets-hide-sheet",
8311 "sheets-show-sheet",
8312 "sheets-update-workbook-properties",
8313 ]
8314 );
8315 let unique: HashSet<&&str> = names.iter().collect();
8318 assert_eq!(unique.len(), names.len());
8319 assert!(!names.contains(&"sheets-write"));
8320 }
8321
8322 #[test]
8323 fn insert_range_records_its_one_based_inclusive_grid_context() {
8324 assert_eq!(
8325 grid_range_label(&insert_range()).as_deref(),
8326 Some("rows 2-4, columns 2-3")
8327 );
8328 }
8329
8330 fn every_structure_result() -> Vec<StructureResult> {
8336 let all = vec![
8337 StructureResult::WouldChange {
8338 sheet: Some(Box::new(SheetSnapshot {
8339 sheet_id: Some(7),
8340 title: "Q2".to_string(),
8341 row_count: Some(500),
8342 column_count: Some(26),
8343 ..Default::default()
8344 })),
8345 sheet_count: 2,
8346 },
8347 StructureResult::RefusedNotASpreadsheet {
8348 mime_type: "application/pdf".to_string(),
8349 },
8350 StructureResult::RefusedShortcut,
8351 StructureResult::RefusedNoVisibleParents,
8352 StructureResult::RefusedSheetNotFound {
8353 title: "Nope".to_string(),
8354 available: vec!["Q1".to_string(), "Q2".to_string()],
8355 },
8356 StructureResult::RefusedSheetExists {
8357 title: "Q1".to_string(),
8358 },
8359 StructureResult::RefusedInvalidRange {
8360 detail: "--count must be at least 1, got 0".to_string(),
8361 },
8362 StructureResult::Blocked { decided_by: None },
8363 StructureResult::RefusedNoLease,
8364 StructureResult::RefusedLeaseExpired,
8365 StructureResult::RefusedLeaseWrongFile,
8366 StructureResult::RefusedLeaseStale,
8367 StructureResult::Unchanged {
8368 detail: "sheet 'Q2' is already hidden".to_string(),
8369 },
8370 StructureResult::Changed {
8371 sheet: Some(Box::new(SheetSnapshot {
8372 sheet_id: Some(7),
8373 title: "Q2".to_string(),
8374 row_count: Some(500),
8375 column_count: Some(26),
8376 ..Default::default()
8377 })),
8378 sheet_id: Some(7),
8379 backup: Some(Box::new(LeaseBackup::DriveCopy {
8380 file_id: "backup-copy-1".to_string(),
8381 })),
8382 },
8383 StructureResult::AppliedReplyUnreadable {
8384 summary: "delete-sheet 'Q2'".to_string(),
8385 detail: "bad reply".to_string(),
8386 sheet: Some(Box::new(SheetSnapshot {
8387 sheet_id: Some(7),
8388 title: "Q2".to_string(),
8389 row_count: Some(500),
8390 column_count: Some(26),
8391 ..Default::default()
8392 })),
8393 sheet_id: Some(7),
8394 backup: Some(Box::new(LeaseBackup::DriveCopy {
8395 file_id: "backup-copy-1".to_string(),
8396 })),
8397 },
8398 StructureResult::Failed {
8399 detail: "boom".to_string(),
8400 },
8401 ];
8402 for result in &all {
8404 match result {
8405 StructureResult::WouldChange { .. }
8406 | StructureResult::RefusedNotASpreadsheet { .. }
8407 | StructureResult::RefusedShortcut
8408 | StructureResult::RefusedNoVisibleParents
8409 | StructureResult::RefusedSheetNotFound { .. }
8410 | StructureResult::RefusedSheetExists { .. }
8411 | StructureResult::RefusedInvalidRange { .. }
8412 | StructureResult::Blocked { .. }
8413 | StructureResult::RefusedNoLease
8414 | StructureResult::RefusedLeaseExpired
8415 | StructureResult::RefusedLeaseWrongFile
8416 | StructureResult::RefusedLeaseStale
8417 | StructureResult::Unchanged { .. }
8418 | StructureResult::Changed { .. }
8419 | StructureResult::AppliedReplyUnreadable { .. }
8420 | StructureResult::Failed { .. } => {}
8421 }
8422 }
8423 all
8424 }
8425
8426 #[test]
8449 fn no_describe_line_contains_a_newline() {
8450 for verb in [
8451 add_sheet(),
8452 rename(),
8453 insert_rows(),
8454 StructureVerb::InsertColumns {
8455 sheet: "Q2".to_string(),
8456 at: 2,
8457 count: 1,
8458 },
8459 move_rows(),
8460 StructureVerb::MoveColumns {
8461 sheet: "Q2".to_string(),
8462 at: 2,
8463 count: 1,
8464 before: 5,
8465 },
8466 delete_sheet(),
8467 delete_rows(),
8468 delete_columns(),
8469 insert_range(),
8470 insert_range_columns_shift(),
8471 delete_range(),
8472 delete_range_columns_shift(),
8473 duplicate_sheet(),
8474 duplicate_sheet_default(),
8475 reorder_sheet(),
8476 hide_sheet(),
8477 show_sheet(),
8478 update_workbook_properties(),
8479 ] {
8480 let has_dimension_shift = verb.dimension().is_some();
8485 let adds_a_sheet = matches!(
8488 verb,
8489 StructureVerb::AddSheet { .. } | StructureVerb::DuplicateSheet { .. }
8490 );
8491 for result in every_structure_result() {
8492 let has_second_line = (has_dimension_shift
8493 && matches!(result, StructureResult::WouldChange { .. }))
8494 || (adds_a_sheet
8495 && matches!(result, StructureResult::AppliedReplyUnreadable { .. }));
8496 let outcome = StructureOutcome {
8497 spreadsheet_id: "sheet-1".to_string(),
8498 file_name: Some("Budget".to_string()),
8499 resolved_folder_id: None,
8500 verb: verb.clone(),
8501 result,
8502 };
8503 let lines = describe_lines(&outcome);
8504 assert_eq!(
8505 lines.len(),
8506 usize::from(has_second_line) + 1,
8507 "unexpected line count for {:?}/{:?}: {lines:?}",
8508 outcome.verb,
8509 outcome.result
8510 );
8511 for rendered in &lines {
8512 assert!(
8513 !rendered.chars().any(char::is_control),
8514 "describe_lines emitted a control character for {:?}/{:?}: \
8515 {rendered:?}",
8516 outcome.verb,
8517 outcome.result
8518 );
8519 }
8520 assert_eq!(describe(&outcome), lines.join("\n"));
8523 }
8524 }
8525 }
8526
8527 #[test]
8536 fn describe_would_delete_dimension_handles_unvalidated_edge_cases() {
8537 let would_change = |verb: StructureVerb, sheet: Option<SheetSnapshot>| StructureOutcome {
8538 spreadsheet_id: "sheet-1".to_string(),
8539 file_name: Some("Budget".to_string()),
8540 resolved_folder_id: None,
8541 verb,
8542 result: StructureResult::WouldChange {
8543 sheet: sheet.map(Box::new),
8544 sheet_count: 2,
8545 },
8546 };
8547 let sheet_with_columns = |column_count| SheetSnapshot {
8550 sheet_id: None,
8551 title: "Q2".to_string(),
8552 row_count: None,
8553 column_count: Some(column_count),
8554 ..Default::default()
8555 };
8556
8557 let overflow = describe(&would_change(
8560 StructureVerb::DeleteColumns {
8561 sheet: "Q2".to_string(),
8562 at: i64::MAX,
8563 count: 2,
8564 },
8565 Some(sheet_with_columns(26)),
8566 ));
8567 assert!(
8568 overflow.contains("Would delete 2 column(s) from column"),
8569 "{overflow}"
8570 );
8571 assert!(!overflow.contains('-'), "{overflow}");
8572
8573 let no_sheet = describe(&would_change(delete_columns(), None));
8576 assert_eq!(no_sheet, "Would delete 2 column(s) 3-4 of 'Q2' in 'Budget'");
8577
8578 let after_underflows = describe(&would_change(
8581 StructureVerb::DeleteColumns {
8582 sheet: "Q2".to_string(),
8583 at: 0,
8584 count: i64::MAX,
8585 },
8586 Some(sheet_with_columns(-2)),
8587 ));
8588 assert_eq!(
8589 after_underflows,
8590 format!(
8591 "Would delete {} column(s) 0-{} of 'Q2' in 'Budget'",
8592 i64::MAX,
8593 i64::MAX - 1
8594 )
8595 );
8596
8597 let to_the_end = describe(&would_change(
8600 StructureVerb::DeleteColumns {
8601 sheet: "Q2".to_string(),
8602 at: 25,
8603 count: 2,
8604 },
8605 Some(sheet_with_columns(26)),
8606 ));
8607 assert!(
8608 to_the_end.contains("nothing remains after the deleted columns"),
8609 "{to_the_end}"
8610 );
8611 }
8612
8613 #[test]
8614 fn log_status_covers_every_variant() {
8615 let statuses = [
8616 StructureResult::WouldChange {
8617 sheet: None,
8618 sheet_count: 1,
8619 }
8620 .log_status(),
8621 StructureResult::RefusedNotASpreadsheet {
8622 mime_type: "application/pdf".to_string(),
8623 }
8624 .log_status(),
8625 StructureResult::RefusedShortcut.log_status(),
8626 StructureResult::RefusedNoVisibleParents.log_status(),
8627 StructureResult::RefusedSheetNotFound {
8628 title: "x".to_string(),
8629 available: Vec::new(),
8630 }
8631 .log_status(),
8632 StructureResult::RefusedSheetExists {
8633 title: "x".to_string(),
8634 }
8635 .log_status(),
8636 StructureResult::RefusedInvalidRange {
8637 detail: "x".to_string(),
8638 }
8639 .log_status(),
8640 StructureResult::Blocked { decided_by: None }.log_status(),
8641 StructureResult::RefusedNoLease.log_status(),
8642 StructureResult::RefusedLeaseExpired.log_status(),
8643 StructureResult::RefusedLeaseWrongFile.log_status(),
8644 StructureResult::RefusedLeaseStale.log_status(),
8645 StructureResult::Unchanged {
8646 detail: "x".to_string(),
8647 }
8648 .log_status(),
8649 StructureResult::Changed {
8650 sheet: None,
8651 sheet_id: None,
8652 backup: None,
8653 }
8654 .log_status(),
8655 StructureResult::AppliedReplyUnreadable {
8656 summary: "x".to_string(),
8657 detail: "x".to_string(),
8658 sheet: None,
8659 sheet_id: None,
8660 backup: None,
8661 }
8662 .log_status(),
8663 StructureResult::Failed {
8664 detail: "x".to_string(),
8665 }
8666 .log_status(),
8667 ];
8668 let unique: HashSet<&&str> = statuses.iter().collect();
8669 assert_eq!(unique.len(), statuses.len(), "statuses must be distinct");
8670 assert!(statuses.iter().all(|s| !s.is_empty()));
8671 }
8672
8673 #[test]
8674 fn a_verdict_of_allow_is_what_lets_a_run_proceed() {
8675 assert_ne!(Verdict::Allow, Verdict::Deny);
8678 }
8679
8680 #[test]
8681 fn build_request_fails_rather_than_guessing_a_missing_sheet_id() {
8682 let sheet = SheetSnapshot {
8685 sheet_id: None,
8686 title: "Q2".to_string(),
8687 row_count: None,
8688 column_count: None,
8689 ..Default::default()
8690 };
8691 let err = build_request(&rename(), Some(&sheet)).unwrap_err();
8692 assert!(err.contains("sheetId"), "{err}");
8693 }
8694
8695 #[test]
8696 fn added_sheet_id_reads_the_add_sheet_reply_only() {
8697 let empty: BatchUpdateResponse =
8698 serde_json::from_value(serde_json::json!({"spreadsheetId": "s", "replies": [{}]}))
8699 .unwrap();
8700 assert_eq!(added_sheet_id(&empty), None);
8701 let added: BatchUpdateResponse = serde_json::from_value(serde_json::json!({
8702 "replies": [{"addSheet": {"properties": {"sheetId": 42, "title": "New"}}}]
8703 }))
8704 .unwrap();
8705 assert_eq!(added_sheet_id(&added), Some(42));
8706 }
8707
8708 #[tokio::test]
8711 async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
8712 let server = wiremock::MockServer::start().await;
8715 let (drive, sheets) = clients(&server).await;
8716 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8717 .mount(&server)
8718 .await;
8719 mount_workbook().mount(&server).await;
8720 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8721 .expect(1)
8722 .mount(&server)
8723 .await;
8724
8725 let outcome = structure(
8726 &drive,
8727 &sheets,
8728 &opts(rename(), false),
8729 &[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsStructure])],
8730 )
8731 .await;
8732
8733 assert!(
8734 matches!(outcome.result, StructureResult::Changed { .. }),
8735 "{:?}",
8736 outcome.result
8737 );
8738 }
8739
8740 #[tokio::test]
8741 async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
8742 let server = wiremock::MockServer::start().await;
8744 let (drive, sheets) = clients(&server).await;
8745 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8746 .mount(&server)
8747 .await;
8748 let outcome = structure(
8752 &drive,
8753 &sheets,
8754 &opts(rename(), false),
8755 &[
8756 allow_rule("parent-1"),
8757 FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsStructure]),
8758 ],
8759 )
8760 .await;
8761
8762 match &outcome.result {
8763 StructureResult::Blocked { decided_by } => {
8764 let rule = decided_by.as_ref().expect("a file rule decided this");
8765 assert_eq!(rule.kind_label(), "file");
8766 assert_eq!(rule.id(), "sheet-1");
8767 }
8768 other => panic!("expected Blocked, got {other:?}"),
8769 }
8770 let text = describe(&outcome);
8771 assert!(text.contains("refused by rule on file sheet-1"), "{text}");
8772 assert!(!text.contains("depth"), "a file rule has no depth: {text}");
8773 }
8774
8775 #[tokio::test]
8776 async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
8777 let server = wiremock::MockServer::start().await;
8778 let (drive, sheets) = clients(&server).await;
8779 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8780 .mount(&server)
8781 .await;
8782 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
8783 let text = describe(&outcome);
8784 assert!(text.contains("file_id"), "{text}");
8785 assert!(text.contains("sheets-structure"), "{text}");
8786 }
8787
8788 #[tokio::test]
8791 async fn a_leased_structure_change_concludes_its_audit_pair_with_allowed() {
8792 let server = wiremock::MockServer::start().await;
8793 let (drive, sheets) = clients(&server).await;
8794 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8795 .mount(&server)
8796 .await;
8797 mount_folder("parent-1").mount(&server).await;
8798 mount_workbook().mount(&server).await;
8799 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8800 .mount(&server)
8801 .await;
8802 let dir = tempfile::tempdir().unwrap();
8803 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8804 let verb = StructureVerb::RenameSheet {
8805 sheet: "Q2".to_string(),
8806 new_title: "Q2 (final)".to_string(),
8807 };
8808
8809 let outcome = structure(
8810 &drive,
8811 &sheets,
8812 &opts(verb, false),
8813 &[allow_rule("parent-1")],
8814 )
8815 .await;
8816 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
8817
8818 let records = audit.records();
8819 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
8820 assert_eq!(records[0].command, ["drive", "sheets-rename-sheet"]);
8824 }
8825
8826 #[tokio::test]
8835 async fn a_missing_sheet_id_fails_before_opening_the_audit_pair() {
8836 let server = wiremock::MockServer::start().await;
8837 let (drive, sheets) = clients(&server).await;
8838 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8839 .mount(&server)
8840 .await;
8841 mount_folder("parent-1").mount(&server).await;
8842 wiremock::Mock::given(wiremock::matchers::method("GET"))
8843 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
8844 .respond_with(
8845 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8846 "spreadsheetId": "sheet-1",
8847 "properties": {"title": "Budget"},
8848 "sheets": [
8849 {"properties": {"title": "Q2", "index": 0}}
8850 ],
8851 })),
8852 )
8853 .mount(&server)
8854 .await;
8855 let dir = tempfile::tempdir().unwrap();
8858 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8859
8860 let outcome = structure(
8861 &drive,
8862 &sheets,
8863 &opts(rename(), false),
8864 &[allow_rule("parent-1")],
8865 )
8866 .await;
8867 match outcome.result {
8868 StructureResult::Failed { detail } => {
8869 assert!(detail.contains("sheetId"), "{detail}");
8870 }
8871 other => panic!("expected Failed, got {other:?}"),
8872 }
8873 assert!(audit.records().is_empty(), "{:?}", audit.records());
8874 }
8875}