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::SheetsApi;
86use crate::drive::sheets::client::SheetsClient;
87use crate::drive::sheets::format::{normalize_hex_for_display, parse_hex_color};
88use crate::drive::sheets::grid_range;
89use crate::drive::sheets::target_gate;
90use crate::drive::sheets::types::{
91 AddSheetRequest, BatchUpdateRequestItem, BatchUpdateResponse, ColorStyle,
92 DeleteDimensionRequest, DeleteRangeRequest, DeleteSheetRequest, Dimension, DimensionRange,
93 DuplicateSheetRequest, GridProperties, GridPropertiesUpdate, GridRange, InsertDimensionRequest,
94 InsertRangeRequest, IterativeCalculationSettings, MoveDimensionRequest, NewSheetProperties,
95 RecalculationInterval, SheetProperties, SheetPropertiesUpdate, ShiftDimension, Spreadsheet,
96 SpreadsheetPropertiesUpdate, UpdateSheetPropertiesRequest, UpdateSpreadsheetPropertiesRequest,
97};
98use crate::drive::types::SheetTargetRefusal;
99use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
100use crate::request_log::{self, DriveMutationOutcome};
101
102#[derive(Debug, Clone, PartialEq)]
114pub enum StructureVerb {
115 AddSheet {
117 title: String,
119 index: Option<i64>,
121 rows: Option<i64>,
123 columns: Option<i64>,
125 },
126 RenameSheet {
128 sheet: String,
130 new_title: String,
132 },
133 InsertRows {
135 sheet: String,
137 at: i64,
139 count: i64,
141 },
142 InsertColumns {
144 sheet: String,
146 at: i64,
148 count: i64,
150 },
151 InsertRange {
158 sheet: String,
160 start_row: i64,
162 end_row: i64,
164 start_column: i64,
166 end_column: i64,
168 shift: ShiftDimension,
170 },
171 MoveRows {
174 sheet: String,
176 at: i64,
178 count: i64,
180 before: i64,
184 },
185 MoveColumns {
189 sheet: String,
191 at: i64,
193 count: i64,
195 before: i64,
198 },
199 DeleteSheet {
201 sheet: String,
203 },
204 DeleteRows {
206 sheet: String,
208 at: i64,
210 count: i64,
212 },
213 DeleteColumns {
215 sheet: String,
217 at: i64,
219 count: i64,
221 },
222 DeleteRange {
230 sheet: String,
232 start_row: i64,
234 end_row: i64,
236 start_column: i64,
238 end_column: i64,
240 shift: ShiftDimension,
242 },
243 DuplicateSheet {
245 sheet: String,
247 title: Option<String>,
249 index: Option<i64>,
251 },
252 ReorderSheet {
254 sheet: String,
256 index: i64,
258 },
259 SetSheetVisibility {
261 sheet: String,
263 hidden: bool,
265 },
266 UpdateSheetProperties {
271 sheet: String,
273 freeze_rows: Option<i64>,
275 freeze_columns: Option<i64>,
277 tab_color: Option<String>,
279 clear_tab_color: bool,
283 right_to_left: Option<bool>,
285 hide_gridlines: Option<bool>,
287 },
288 UpdateWorkbookProperties {
292 locale: Option<String>,
294 time_zone: Option<String>,
297 auto_recalc: Option<RecalculationInterval>,
299 iterative_calculation: Option<IterativeCalculationToggle>,
304 iterative_calculation_max_iterations: Option<i64>,
307 iterative_calculation_convergence_threshold: Option<f64>,
310 },
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum IterativeCalculationToggle {
325 On,
328 Off,
330}
331
332impl StructureVerb {
333 const fn log_operation(&self) -> &'static str {
339 match self {
340 Self::AddSheet { .. } => "sheets-add-sheet",
341 Self::RenameSheet { .. } => "sheets-rename-sheet",
342 Self::InsertRows { .. } => "sheets-insert-rows",
343 Self::InsertColumns { .. } => "sheets-insert-columns",
344 Self::InsertRange { .. } => "sheets-insert-range",
345 Self::MoveRows { .. } => "sheets-move-rows",
346 Self::MoveColumns { .. } => "sheets-move-columns",
347 Self::DeleteSheet { .. } => "sheets-delete-sheet",
348 Self::DeleteRows { .. } => "sheets-delete-rows",
349 Self::DeleteColumns { .. } => "sheets-delete-columns",
350 Self::DeleteRange { .. } => "sheets-delete-range",
351 Self::DuplicateSheet { .. } => "sheets-duplicate-sheet",
352 Self::ReorderSheet { .. } => "sheets-reorder-sheet",
353 Self::SetSheetVisibility { hidden: true, .. } => "sheets-hide-sheet",
354 Self::SetSheetVisibility { hidden: false, .. } => "sheets-show-sheet",
355 Self::UpdateSheetProperties { .. } => "sheets-update-sheet-properties",
356 Self::UpdateWorkbookProperties { .. } => "sheets-update-workbook-properties",
357 }
358 }
359
360 const fn gate_operation(&self) -> DriveOperation {
366 match self {
367 Self::AddSheet { .. }
368 | Self::RenameSheet { .. }
369 | Self::InsertRows { .. }
370 | Self::InsertColumns { .. }
371 | Self::InsertRange { .. }
372 | Self::MoveRows { .. }
373 | Self::MoveColumns { .. }
374 | Self::DuplicateSheet { .. }
375 | Self::ReorderSheet { .. }
376 | Self::SetSheetVisibility { .. }
377 | Self::UpdateSheetProperties { .. }
378 | Self::UpdateWorkbookProperties { .. } => DriveOperation::SheetsStructure,
379 Self::DeleteSheet { .. }
380 | Self::DeleteRows { .. }
381 | Self::DeleteColumns { .. }
382 | Self::DeleteRange { .. } => DriveOperation::SheetsDelete,
383 }
384 }
385
386 const fn label(&self) -> &'static str {
390 match self {
391 Self::AddSheet { .. } => "add-sheet",
392 Self::RenameSheet { .. } => "rename-sheet",
393 Self::InsertRows { .. } => "insert-rows",
394 Self::InsertColumns { .. } => "insert-columns",
395 Self::InsertRange { .. } => "insert-range",
396 Self::MoveRows { .. } => "move-rows",
397 Self::MoveColumns { .. } => "move-columns",
398 Self::DeleteSheet { .. } => "delete-sheet",
399 Self::DeleteRows { .. } => "delete-rows",
400 Self::DeleteColumns { .. } => "delete-columns",
401 Self::DeleteRange { .. } => "delete-range",
402 Self::DuplicateSheet { .. } => "duplicate-sheet",
403 Self::ReorderSheet { .. } => "reorder-sheet",
404 Self::SetSheetVisibility { hidden: true, .. } => "hide-sheet",
405 Self::SetSheetVisibility { hidden: false, .. } => "show-sheet",
406 Self::UpdateSheetProperties { .. } => "update-sheet-properties",
407 Self::UpdateWorkbookProperties { .. } => "update-workbook-properties",
408 }
409 }
410
411 fn sheet_title(&self) -> Option<&str> {
416 match self {
417 Self::AddSheet { title, .. } => Some(title),
418 Self::RenameSheet { sheet, .. }
419 | Self::InsertRows { sheet, .. }
420 | Self::InsertColumns { sheet, .. }
421 | Self::InsertRange { sheet, .. }
422 | Self::MoveRows { sheet, .. }
423 | Self::MoveColumns { sheet, .. }
424 | Self::DeleteSheet { sheet }
425 | Self::DeleteRows { sheet, .. }
426 | Self::DeleteColumns { sheet, .. }
427 | Self::DeleteRange { sheet, .. }
428 | Self::DuplicateSheet { sheet, .. }
429 | Self::ReorderSheet { sheet, .. }
430 | Self::SetSheetVisibility { sheet, .. }
431 | Self::UpdateSheetProperties { sheet, .. } => Some(sheet),
432 Self::UpdateWorkbookProperties { .. } => None,
433 }
434 }
435
436 fn new_sheet_title(&self) -> Option<&String> {
447 match self {
448 Self::RenameSheet { new_title, .. } => Some(new_title),
449 Self::DuplicateSheet { title, .. } => title.as_ref(),
450 Self::AddSheet { .. }
451 | Self::InsertRows { .. }
452 | Self::InsertColumns { .. }
453 | Self::InsertRange { .. }
454 | Self::MoveRows { .. }
455 | Self::MoveColumns { .. }
456 | Self::DeleteSheet { .. }
457 | Self::DeleteRows { .. }
458 | Self::DeleteColumns { .. }
459 | Self::DeleteRange { .. }
460 | Self::ReorderSheet { .. }
461 | Self::SetSheetVisibility { .. }
462 | Self::UpdateSheetProperties { .. }
463 | Self::UpdateWorkbookProperties { .. } => None,
464 }
465 }
466
467 const fn dimension(&self) -> Option<Dimension> {
471 match self {
472 Self::InsertRows { .. } | Self::DeleteRows { .. } | Self::MoveRows { .. } => {
473 Some(Dimension::Rows)
474 }
475 Self::InsertColumns { .. } | Self::DeleteColumns { .. } | Self::MoveColumns { .. } => {
476 Some(Dimension::Columns)
477 }
478 Self::AddSheet { .. }
479 | Self::RenameSheet { .. }
480 | Self::DeleteSheet { .. }
481 | Self::InsertRange { .. }
482 | Self::DeleteRange { .. }
483 | Self::DuplicateSheet { .. }
484 | Self::ReorderSheet { .. }
485 | Self::SetSheetVisibility { .. }
486 | Self::UpdateSheetProperties { .. }
487 | Self::UpdateWorkbookProperties { .. } => None,
488 }
489 }
490}
491
492#[derive(Debug, Clone)]
494pub struct StructureOptions {
495 pub spreadsheet_id: String,
497 pub verb: StructureVerb,
499 pub dry_run: bool,
501 pub lease_token: Option<String>,
506 pub ledger_path: PathBuf,
510}
511
512#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
518pub struct SheetSnapshot {
519 #[serde(skip_serializing_if = "Option::is_none")]
521 pub sheet_id: Option<i64>,
522 pub title: String,
524 #[serde(skip_serializing_if = "Option::is_none")]
526 pub row_count: Option<i64>,
527 #[serde(skip_serializing_if = "Option::is_none")]
529 pub column_count: Option<i64>,
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub frozen_row_count: Option<i64>,
533 #[serde(skip_serializing_if = "Option::is_none")]
535 pub frozen_column_count: Option<i64>,
536 #[serde(skip_serializing_if = "Option::is_none")]
539 pub right_to_left: Option<bool>,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub hide_gridlines: Option<bool>,
543}
544
545impl SheetSnapshot {
546 fn from_properties(props: &SheetProperties) -> Self {
547 let grid = props.grid_properties.as_ref();
548 Self {
549 sheet_id: props.sheet_id,
550 title: props.title.clone(),
551 row_count: grid.and_then(|g| g.row_count),
552 column_count: grid.and_then(|g| g.column_count),
553 frozen_row_count: grid.and_then(|g| g.frozen_row_count),
554 frozen_column_count: grid.and_then(|g| g.frozen_column_count),
555 right_to_left: props.right_to_left,
556 hide_gridlines: grid.and_then(|g| g.hide_gridlines),
557 }
558 }
559}
560
561#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
563#[serde(tag = "status", rename_all = "kebab-case")]
564pub enum StructureResult {
565 WouldChange {
567 #[serde(skip_serializing_if = "Option::is_none")]
576 sheet: Option<Box<SheetSnapshot>>,
577 sheet_count: usize,
579 },
580 RefusedNotASpreadsheet {
583 mime_type: String,
585 },
586 RefusedShortcut,
588 RefusedNoVisibleParents,
592 RefusedSheetNotFound {
599 title: String,
601 available: Vec<String>,
603 },
604 RefusedSheetExists {
609 title: String,
611 },
612 RefusedInvalidRange {
625 detail: String,
627 },
628 Blocked {
630 decided_by: Option<DecidingRule>,
633 },
634 RefusedNoLease,
637 RefusedLeaseExpired,
640 RefusedLeaseWrongFile,
642 RefusedLeaseStale,
645 Unchanged {
650 detail: String,
652 },
653 Changed {
655 #[serde(skip_serializing_if = "Option::is_none")]
660 sheet: Option<Box<SheetSnapshot>>,
661 #[serde(skip_serializing_if = "Option::is_none")]
664 sheet_id: Option<i64>,
665 #[serde(skip_serializing_if = "Option::is_none")]
679 backup: Option<Box<LeaseBackup>>,
680 },
681 Failed {
683 detail: String,
685 },
686}
687
688impl FromLeaseRefusal for StructureResult {
689 fn from_no_lease() -> Self {
690 Self::RefusedNoLease
691 }
692 fn from_lease_expired() -> Self {
693 Self::RefusedLeaseExpired
694 }
695 fn from_lease_wrong_file() -> Self {
696 Self::RefusedLeaseWrongFile
697 }
698 fn from_lease_stale() -> Self {
699 Self::RefusedLeaseStale
700 }
701 fn from_lease_failed(detail: String) -> Self {
702 Self::Failed { detail }
703 }
704}
705
706impl StructureResult {
707 fn log_status(&self) -> &'static str {
713 match self {
714 Self::WouldChange { .. } => "would-change",
715 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
716 Self::RefusedShortcut => "refused-shortcut",
717 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
718 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
719 Self::RefusedSheetExists { .. } => "refused-sheet-exists",
720 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
721 Self::Blocked { .. } => "blocked",
722 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
723 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
724 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
725 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
726 Self::Unchanged { .. } => "unchanged",
727 Self::Changed { .. } => "changed",
728 Self::Failed { .. } => "failed",
729 }
730 }
731}
732
733#[derive(Debug, Clone, Serialize, PartialEq)]
738pub struct StructureOutcome {
739 pub spreadsheet_id: String,
741 #[serde(skip_serializing_if = "Option::is_none")]
743 pub file_name: Option<String>,
744 #[serde(skip_serializing_if = "Option::is_none")]
747 pub resolved_folder_id: Option<String>,
748 #[serde(skip)]
755 pub verb: StructureVerb,
756 pub result: StructureResult,
758}
759
760impl JsonlSerialize for StructureOutcome {
761 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
762 write_scalar_jsonl(self, out)
763 }
764}
765
766fn dimension_range(sheet_id: i64, dimension: Dimension, at: i64, count: i64) -> DimensionRange {
774 let start = at - 1;
775 DimensionRange {
776 sheet_id,
777 dimension,
778 start_index: start,
779 end_index: start + count,
780 }
781}
782
783const fn move_destination_index(before: i64) -> i64 {
792 before - 1
793}
794
795const fn move_landing_span(before: i64, count: i64, last: i64) -> (i64, i64) {
805 if before > last {
806 (before - count, before - 1)
811 } else {
812 (before, before + count - 1)
815 }
816}
817
818pub async fn structure(
824 drive: &DriveClient,
825 sheets: &SheetsClient,
826 opts: &StructureOptions,
827 rules: &[FolderPermissionRule],
828) -> StructureOutcome {
829 let started = Instant::now();
830 let outcome = structure_inner(drive, sheets, opts, rules).await;
831 if !opts.dry_run {
834 record_attempt(&outcome, opts, started.elapsed());
835 }
836 outcome
837}
838
839async fn structure_inner(
840 drive: &DriveClient,
841 sheets: &SheetsClient,
842 opts: &StructureOptions,
843 rules: &[FolderPermissionRule],
844) -> StructureOutcome {
845 let bare = |result| StructureOutcome {
846 spreadsheet_id: opts.spreadsheet_id.clone(),
847 file_name: None,
848 resolved_folder_id: None,
849 verb: opts.verb.clone(),
850 result,
851 };
852
853 let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
859 drive,
860 &opts.spreadsheet_id,
861 opts.verb.gate_operation(),
862 rules,
863 )
864 .await
865 {
866 target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
867 return bare(StructureResult::Failed { detail })
868 }
869 target_gate::TargetGateOutcome::Refused { target, refusal } => {
870 let result = match refusal {
871 SheetTargetRefusal::Shortcut => StructureResult::RefusedShortcut,
872 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
873 StructureResult::RefusedNotASpreadsheet { mime_type }
874 }
875 SheetTargetRefusal::NoVisibleParents => StructureResult::RefusedNoVisibleParents,
876 };
877 return StructureOutcome {
878 spreadsheet_id: opts.spreadsheet_id.clone(),
879 file_name: Some(target.name),
880 resolved_folder_id: None,
881 verb: opts.verb.clone(),
882 result,
883 };
884 }
885 target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
888 return StructureOutcome {
889 spreadsheet_id: opts.spreadsheet_id.clone(),
890 file_name: Some(target.name),
891 resolved_folder_id: None,
892 verb: opts.verb.clone(),
893 result: StructureResult::Failed { detail },
894 };
895 }
896 target_gate::TargetGateOutcome::Gated {
897 target,
898 decision,
899 resolved_folder_id,
900 requires_lease,
901 } => (target, decision, resolved_folder_id, requires_lease),
902 };
903
904 let gated = |result| StructureOutcome {
905 spreadsheet_id: opts.spreadsheet_id.clone(),
906 file_name: Some(target.name.clone()),
907 resolved_folder_id: resolved_folder_id.clone(),
908 verb: opts.verb.clone(),
909 result,
910 };
911
912 if decision.verdict == write_gate::Verdict::Deny {
913 return gated(StructureResult::Blocked {
914 decided_by: decision.decided_by,
915 });
916 }
917
918 let api = SheetsApi::new(sheets);
924 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
925 Ok(workbook) => workbook,
926 Err(err) => {
927 return gated(StructureResult::Failed {
928 detail: format!("{err:#}"),
929 })
930 }
931 };
932
933 let sheet = match resolve_sheet(&workbook, &opts.verb) {
934 Ok(sheet) => sheet,
935 Err(result) => return gated(result),
936 };
937 let sheet_count = workbook.sheets.len();
938
939 if let Err(result) = validate_verb_args(&workbook, &opts.verb, sheet.as_ref()) {
940 return gated(result);
941 }
942
943 if let Some(detail) = already_applied(&workbook, &opts.verb, sheet.as_ref()) {
946 return gated(StructureResult::Unchanged { detail });
947 }
948
949 if opts.dry_run {
950 return gated(StructureResult::WouldChange {
954 sheet: sheet.map(Box::new),
955 sheet_count,
956 });
957 }
958
959 let request = match build_request(&opts.verb, sheet.as_ref()) {
968 Ok(request) => request,
969 Err(detail) => return gated(StructureResult::Failed { detail }),
970 };
971
972 let files_api = FilesApi::new(drive);
981 let leased = LeasedWrite {
982 log_prefix: "drive sheets structure",
983 operation: opts.verb.log_operation(),
984 ledger_path: &opts.ledger_path,
985 file_id: &opts.spreadsheet_id,
986 };
987 let lease_grant = match gate_optional_leased_write(
988 leased,
989 &files_api,
990 requires_lease,
991 opts.lease_token.as_deref(),
992 )
993 .await
994 {
995 Ok(grant) => grant,
996 Err(err) => return gated(err.into_result()),
997 };
998
999 let result = match conclude_native_leased_write(
1000 leased,
1001 &lease_grant,
1002 &files_api,
1003 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
1004 |err| format!("{err:#}"),
1005 )
1006 .await
1007 {
1008 Ok(response) => StructureResult::Changed {
1009 sheet_id: added_sheet_id(&response).or_else(|| sheet.as_ref().and_then(|s| s.sheet_id)),
1010 sheet: sheet.map(Box::new),
1011 backup: lease_grant
1012 .as_ref()
1013 .map(|grant| Box::new(grant.backup.clone())),
1014 },
1015 Err(err) => StructureResult::Failed {
1016 detail: format!("{err:#}"),
1017 },
1018 };
1019 drop(lease_grant);
1020 gated(result)
1021}
1022
1023fn resolve_sheet(
1038 workbook: &Spreadsheet,
1039 verb: &StructureVerb,
1040) -> Result<Option<SheetSnapshot>, StructureResult> {
1041 let Some(wanted) = verb.sheet_title() else {
1042 return Ok(None);
1043 };
1044 let found = workbook
1045 .sheets
1046 .iter()
1047 .filter_map(|sheet| sheet.properties.as_ref())
1048 .find(|props| props.title == wanted);
1049
1050 match verb {
1051 StructureVerb::AddSheet { .. } => match found {
1052 Some(_) => Err(StructureResult::RefusedSheetExists {
1053 title: wanted.to_string(),
1054 }),
1055 None => Ok(None),
1056 },
1057 StructureVerb::RenameSheet { new_title, .. } => match found {
1058 Some(props) => {
1059 let collides = new_title != wanted && workbook.has_sheet_titled(new_title);
1060 if collides {
1061 return Err(StructureResult::RefusedSheetExists {
1062 title: new_title.clone(),
1063 });
1064 }
1065 Ok(Some(SheetSnapshot::from_properties(props)))
1066 }
1067 None => Err(StructureResult::RefusedSheetNotFound {
1068 title: wanted.to_string(),
1069 available: workbook.sheet_titles(),
1070 }),
1071 },
1072 StructureVerb::DuplicateSheet { title, .. } => match found {
1078 Some(props) => {
1079 if let Some(new_title) = title {
1080 let collides = workbook.has_sheet_titled(new_title);
1081 if collides {
1082 return Err(StructureResult::RefusedSheetExists {
1083 title: new_title.clone(),
1084 });
1085 }
1086 }
1087 Ok(Some(SheetSnapshot::from_properties(props)))
1088 }
1089 None => Err(StructureResult::RefusedSheetNotFound {
1090 title: wanted.to_string(),
1091 available: workbook.sheet_titles(),
1092 }),
1093 },
1094 _ => match found {
1095 Some(props) => Ok(Some(SheetSnapshot::from_properties(props))),
1096 None => Err(StructureResult::RefusedSheetNotFound {
1097 title: wanted.to_string(),
1098 available: workbook.sheet_titles(),
1099 }),
1100 },
1101 }
1102}
1103
1104fn already_applied(
1108 workbook: &Spreadsheet,
1109 verb: &StructureVerb,
1110 sheet: Option<&SheetSnapshot>,
1111) -> Option<String> {
1112 let StructureVerb::SetSheetVisibility {
1113 sheet: title,
1114 hidden,
1115 } = verb
1116 else {
1117 return None;
1118 };
1119 let target_id = sheet.and_then(|s| s.sheet_id)?;
1120 let currently_hidden = workbook
1121 .sheets
1122 .iter()
1123 .filter_map(|s| s.properties.as_ref())
1124 .find(|props| props.sheet_id == Some(target_id))?
1125 .hidden
1126 .unwrap_or(false);
1127 (currently_hidden == *hidden).then(|| {
1128 let state = if *hidden { "hidden" } else { "shown" };
1129 format!("sheet '{title}' is already {state}")
1130 })
1131}
1132
1133fn validate_verb_args(
1139 workbook: &Spreadsheet,
1140 verb: &StructureVerb,
1141 sheet: Option<&SheetSnapshot>,
1142) -> Result<(), StructureResult> {
1143 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1144
1145 match verb {
1146 StructureVerb::AddSheet {
1147 index,
1148 rows,
1149 columns,
1150 ..
1151 } => {
1152 if let Some(rows) = rows {
1153 if *rows < 1 {
1154 return invalid(format!("--rows must be at least 1, got {rows}"));
1155 }
1156 }
1157 if let Some(columns) = columns {
1158 if *columns < 1 {
1159 return invalid(format!("--columns must be at least 1, got {columns}"));
1160 }
1161 }
1162 if let Some(index) = index {
1163 let max = workbook.sheets.len() as i64;
1164 if *index < 0 || *index > max {
1165 return invalid(format!(
1166 "--index must be between 0 and {max} inclusive (the workbook has \
1167 {max} sheet(s)), got {index}"
1168 ));
1169 }
1170 }
1171 Ok(())
1172 }
1173 StructureVerb::RenameSheet { .. }
1174 | StructureVerb::DeleteSheet { .. }
1175 | StructureVerb::DuplicateSheet { index: None, .. }
1176 | StructureVerb::SetSheetVisibility { hidden: false, .. } => Ok(()),
1177 StructureVerb::InsertRows { at, count, .. } => {
1178 validate_insert_bounds(Dimension::Rows, *at, *count, sheet)
1179 }
1180 StructureVerb::InsertColumns { at, count, .. } => {
1181 validate_insert_bounds(Dimension::Columns, *at, *count, sheet)
1182 }
1183 StructureVerb::MoveRows {
1184 at, count, before, ..
1185 } => validate_move_bounds(Dimension::Rows, *at, *count, *before, sheet),
1186 StructureVerb::MoveColumns {
1187 at, count, before, ..
1188 } => validate_move_bounds(Dimension::Columns, *at, *count, *before, sheet),
1189 StructureVerb::DeleteRows { at, count, .. } => {
1190 validate_delete_dimension_bounds(Dimension::Rows, *at, *count, sheet)
1191 }
1192 StructureVerb::DeleteColumns { at, count, .. } => {
1193 validate_delete_dimension_bounds(Dimension::Columns, *at, *count, sheet)
1194 }
1195 StructureVerb::InsertRange {
1196 start_row,
1197 end_row,
1198 start_column,
1199 end_column,
1200 ..
1201 }
1202 | StructureVerb::DeleteRange {
1203 start_row,
1204 end_row,
1205 start_column,
1206 end_column,
1207 ..
1208 } => validate_range_bounds(*start_row, *end_row, *start_column, *end_column, sheet),
1209 StructureVerb::DuplicateSheet {
1210 index: Some(index), ..
1211 } => {
1212 let max = workbook.sheets.len() as i64;
1215 if *index < 0 || *index > max {
1216 return invalid(format!(
1217 "--index must be between 0 and {max} inclusive (the workbook has \
1218 {max} sheet(s)), got {index}"
1219 ));
1220 }
1221 Ok(())
1222 }
1223 StructureVerb::ReorderSheet { index, .. } => {
1224 let len = workbook.sheets.len() as i64;
1228 let max = len - 1;
1229 if *index < 0 || *index > max {
1230 return invalid(format!(
1231 "--index must be between 0 and {max} inclusive (the workbook has \
1232 {len} sheet(s)), got {index}"
1233 ));
1234 }
1235 Ok(())
1236 }
1237 StructureVerb::SetSheetVisibility {
1238 sheet: title,
1239 hidden: true,
1240 } => {
1241 let target_id = sheet.and_then(|s| s.sheet_id);
1242 let another_stays_visible = workbook
1243 .sheets
1244 .iter()
1245 .filter_map(|s| s.properties.as_ref())
1246 .filter(|props| props.sheet_id != target_id)
1247 .any(|props| !props.hidden.unwrap_or(false));
1248 if !another_stays_visible {
1249 return invalid(format!(
1250 "hiding '{title}' would leave the workbook with no visible sheets; \
1251 Sheets requires at least one"
1252 ));
1253 }
1254 Ok(())
1255 }
1256 StructureVerb::UpdateSheetProperties {
1257 sheet: _title,
1258 freeze_rows,
1259 freeze_columns,
1260 tab_color,
1261 clear_tab_color,
1262 right_to_left,
1263 hide_gridlines,
1264 } => {
1265 if freeze_rows.is_none()
1266 && freeze_columns.is_none()
1267 && tab_color.is_none()
1268 && !clear_tab_color
1269 && right_to_left.is_none()
1270 && hide_gridlines.is_none()
1271 {
1272 return invalid(
1273 "update-sheet-properties requires at least one property to change".to_string(),
1274 );
1275 }
1276 if tab_color.is_some() && *clear_tab_color {
1277 return invalid(
1278 "--tab-color and --clear-tab-color are mutually exclusive".to_string(),
1279 );
1280 }
1281 if let Some(hex) = tab_color {
1282 if let Err(detail) = parse_hex_color(hex) {
1283 return invalid(detail);
1284 }
1285 }
1286 if let Some(rows) = freeze_rows {
1287 check_freeze_bound(
1288 "--freeze-rows",
1289 "row",
1290 *rows,
1291 sheet.and_then(|s| s.row_count),
1292 )?;
1293 }
1294 if let Some(columns) = freeze_columns {
1295 check_freeze_bound(
1296 "--freeze-columns",
1297 "column",
1298 *columns,
1299 sheet.and_then(|s| s.column_count),
1300 )?;
1301 }
1302 Ok(())
1303 }
1304 StructureVerb::UpdateWorkbookProperties {
1305 locale,
1306 time_zone,
1307 auto_recalc,
1308 iterative_calculation,
1309 iterative_calculation_max_iterations,
1310 iterative_calculation_convergence_threshold,
1311 } => {
1312 let touches_iterative_settings = iterative_calculation_max_iterations.is_some()
1313 || iterative_calculation_convergence_threshold.is_some();
1314 if touches_iterative_settings
1315 && *iterative_calculation != Some(IterativeCalculationToggle::On)
1316 {
1317 return invalid(
1318 "--iterative-calculation-max-iterations/\
1319 --iterative-calculation-convergence-threshold require \
1320 --iterative-calculation on"
1321 .to_string(),
1322 );
1323 }
1324 if let Some(max_iterations) = iterative_calculation_max_iterations {
1325 if *max_iterations < 1 {
1326 return invalid(format!(
1327 "--iterative-calculation-max-iterations must be at least 1, got \
1328 {max_iterations}"
1329 ));
1330 }
1331 }
1332 if let Some(threshold) = iterative_calculation_convergence_threshold {
1333 if !threshold.is_finite() || *threshold <= 0.0 {
1334 return invalid(format!(
1335 "--iterative-calculation-convergence-threshold must be a positive, \
1336 finite number, got {threshold}"
1337 ));
1338 }
1339 }
1340 if let Some(tz) = time_zone {
1345 if tz.parse::<chrono_tz::Tz>().is_err() {
1346 return invalid(format!(
1347 "--time-zone '{tz}' is not a recognised IANA time zone name \
1348 (for example 'America/New_York' or 'Europe/London'); the \
1349 Sheets API would silently reset the workbook to 'Etc/GMT' \
1350 instead of rejecting it"
1351 ));
1352 }
1353 }
1354 if locale.is_none()
1355 && time_zone.is_none()
1356 && auto_recalc.is_none()
1357 && iterative_calculation.is_none()
1358 {
1359 return invalid(
1360 "update-workbook-properties needs at least one property to set \
1361 (--locale, --time-zone, --auto-recalc, --iterative-calculation)"
1362 .to_string(),
1363 );
1364 }
1365 Ok(())
1366 }
1367 }
1368}
1369
1370fn validate_span_start(dimension: Dimension, at: i64, count: i64) -> Result<i64, StructureResult> {
1385 let invalid = |detail: String| StructureResult::RefusedInvalidRange { detail };
1386
1387 if count < 1 {
1388 return Err(invalid(format!("--count must be at least 1, got {count}")));
1389 }
1390 if at < 1 {
1391 return Err(invalid(format!("--at must be at least 1, got {at}")));
1392 }
1393 at.checked_sub(1)
1394 .and_then(|start| start.checked_add(count))
1395 .ok_or_else(|| {
1396 invalid(format!(
1397 "--at {at} with --count {count} overflows the {noun} index space",
1398 noun = dimension.noun(),
1399 ))
1400 })
1401}
1402
1403fn check_freeze_bound(
1410 flag: &str,
1411 noun: &str,
1412 value: i64,
1413 current: Option<i64>,
1414) -> Result<(), StructureResult> {
1415 if value < 0 {
1416 return Err(StructureResult::RefusedInvalidRange {
1417 detail: format!("{flag} must be at least 0, got {value}"),
1418 });
1419 }
1420 if let Some(current) = current {
1421 if value >= current {
1422 return Err(StructureResult::RefusedInvalidRange {
1423 detail: format!(
1424 "{flag} {value} would freeze every {noun}; the sheet has {current} \
1425 {noun}(s), so the most that can be frozen is {}",
1426 current - 1
1427 ),
1428 });
1429 }
1430 }
1431 Ok(())
1432}
1433
1434fn validate_insert_bounds(
1438 dimension: Dimension,
1439 at: i64,
1440 count: i64,
1441 sheet: Option<&SheetSnapshot>,
1442) -> Result<(), StructureResult> {
1443 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1444
1445 validate_span_start(dimension, at, count)?;
1446 let current = match dimension {
1447 Dimension::Rows => sheet.and_then(|s| s.row_count),
1448 Dimension::Columns => sheet.and_then(|s| s.column_count),
1449 };
1450 if let Some(current) = current {
1451 let max_at = current + 1;
1455 if at > max_at {
1456 return invalid(format!(
1457 "--at {at} is past the end of the sheet, which has {current} {noun}(s); \
1458 the furthest valid position is {max_at}",
1459 noun = dimension.noun(),
1460 ));
1461 }
1462 }
1463 Ok(())
1464}
1465
1466fn validate_move_bounds(
1479 dimension: Dimension,
1480 at: i64,
1481 count: i64,
1482 before: i64,
1483 sheet: Option<&SheetSnapshot>,
1484) -> Result<(), StructureResult> {
1485 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1486
1487 let last = validate_span_start(dimension, at, count)?;
1488 if before < 1 {
1489 return invalid(format!("--before must be at least 1, got {before}"));
1490 }
1491 if before >= at && before - 1 <= last {
1499 return invalid(format!(
1500 "--before {before} is inside or immediately after the block being moved \
1501 ({noun}s {at}-{last}), so nothing would move",
1502 noun = dimension.noun(),
1503 ));
1504 }
1505 let current = match dimension {
1506 Dimension::Rows => sheet.and_then(|s| s.row_count),
1507 Dimension::Columns => sheet.and_then(|s| s.column_count),
1508 };
1509 if let Some(current) = current {
1510 if last > current {
1511 return invalid(format!(
1512 "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1513 sheet, which has {current} {noun}(s)",
1514 noun = dimension.noun(),
1515 ));
1516 }
1517 let max_before = current + 1;
1520 if before > max_before {
1521 return invalid(format!(
1522 "--before {before} is past the end of the sheet, which has {current} \
1523 {noun}(s); the furthest valid position is {max_before}",
1524 noun = dimension.noun(),
1525 ));
1526 }
1527 }
1528 Ok(())
1529}
1530
1531fn validate_delete_dimension_bounds(
1537 dimension: Dimension,
1538 at: i64,
1539 count: i64,
1540 sheet: Option<&SheetSnapshot>,
1541) -> Result<(), StructureResult> {
1542 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1543
1544 let last = validate_span_start(dimension, at, count)?;
1545 let current = match dimension {
1546 Dimension::Rows => sheet.and_then(|s| s.row_count),
1547 Dimension::Columns => sheet.and_then(|s| s.column_count),
1548 };
1549 if let Some(current) = current {
1550 if last > current {
1551 return invalid(format!(
1552 "--at {at} with --count {count} reaches {noun} {last}, past the end of the \
1553 sheet, which has {current} {noun}(s)",
1554 noun = dimension.noun(),
1555 ));
1556 }
1557 }
1558 Ok(())
1559}
1560
1561fn validate_range_bounds(
1567 start_row: i64,
1568 end_row: i64,
1569 start_column: i64,
1570 end_column: i64,
1571 sheet: Option<&SheetSnapshot>,
1572) -> Result<(), StructureResult> {
1573 let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
1574
1575 if start_row < 1 {
1576 return invalid(format!("--start-row must be at least 1, got {start_row}"));
1577 }
1578 if start_column < 1 {
1579 return invalid(format!(
1580 "--start-column must be at least 1, got {start_column}"
1581 ));
1582 }
1583 if end_row < start_row {
1584 return invalid(format!(
1585 "--end-row ({end_row}) must be at or after --start-row ({start_row})"
1586 ));
1587 }
1588 if end_column < start_column {
1589 return invalid(format!(
1590 "--end-column ({end_column}) must be at or after --start-column ({start_column})"
1591 ));
1592 }
1593 if let Some(current) = sheet.and_then(|s| s.row_count) {
1594 if end_row > current {
1595 return invalid(format!(
1596 "--end-row {end_row} is past the end of the sheet, which has {current} row(s)"
1597 ));
1598 }
1599 }
1600 if let Some(current) = sheet.and_then(|s| s.column_count) {
1601 if end_column > current {
1602 return invalid(format!(
1603 "--end-column {end_column} is past the end of the sheet, which has {current} \
1604 column(s)"
1605 ));
1606 }
1607 }
1608 Ok(())
1609}
1610
1611fn build_request(
1617 verb: &StructureVerb,
1618 sheet: Option<&SheetSnapshot>,
1619) -> Result<BatchUpdateRequestItem, String> {
1620 let sheet_id = |verb_name: &str| -> Result<i64, String> {
1626 sheet.and_then(|s| s.sheet_id).ok_or_else(|| {
1627 format!("Sheets did not report a sheetId for the target sheet, so {verb_name} cannot address it")
1628 })
1629 };
1630
1631 match verb {
1632 StructureVerb::AddSheet {
1633 title,
1634 index,
1635 rows,
1636 columns,
1637 } => {
1638 let grid_properties = (rows.is_some() || columns.is_some()).then_some(GridProperties {
1639 row_count: *rows,
1640 column_count: *columns,
1641 ..Default::default()
1642 });
1643 Ok(BatchUpdateRequestItem::AddSheet(AddSheetRequest {
1644 properties: NewSheetProperties {
1645 title: title.clone(),
1646 index: *index,
1647 grid_properties,
1648 },
1649 }))
1650 }
1651 StructureVerb::RenameSheet { new_title, .. } => Ok(
1652 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1653 properties: SheetPropertiesUpdate {
1654 sheet_id: sheet_id("rename-sheet")?,
1655 title: Some(new_title.clone()),
1656 ..Default::default()
1657 },
1658 fields: "title".to_string(),
1661 }),
1662 ),
1663 StructureVerb::InsertRows { at, count, .. } => Ok(BatchUpdateRequestItem::InsertDimension(
1664 InsertDimensionRequest {
1665 range: dimension_range(sheet_id("insert-rows")?, Dimension::Rows, *at, *count),
1666 inherit_from_before: false,
1667 },
1668 )),
1669 StructureVerb::InsertColumns { at, count, .. } => Ok(
1670 BatchUpdateRequestItem::InsertDimension(InsertDimensionRequest {
1671 range: dimension_range(
1672 sheet_id("insert-columns")?,
1673 Dimension::Columns,
1674 *at,
1675 *count,
1676 ),
1677 inherit_from_before: false,
1678 }),
1679 ),
1680 StructureVerb::InsertRange {
1681 start_row,
1682 end_row,
1683 start_column,
1684 end_column,
1685 shift,
1686 ..
1687 } => Ok(BatchUpdateRequestItem::InsertRange(InsertRangeRequest {
1688 range: grid_range(
1689 sheet_id("insert-range")?,
1690 *start_row,
1691 *end_row,
1692 *start_column,
1693 *end_column,
1694 ),
1695 shift_dimension: *shift,
1696 })),
1697 StructureVerb::MoveRows {
1698 at, count, before, ..
1699 } => Ok(BatchUpdateRequestItem::MoveDimension(
1700 MoveDimensionRequest {
1701 source: dimension_range(sheet_id("move-rows")?, Dimension::Rows, *at, *count),
1702 destination_index: move_destination_index(*before),
1703 },
1704 )),
1705 StructureVerb::MoveColumns {
1706 at, count, before, ..
1707 } => Ok(BatchUpdateRequestItem::MoveDimension(
1708 MoveDimensionRequest {
1709 source: dimension_range(sheet_id("move-columns")?, Dimension::Columns, *at, *count),
1710 destination_index: move_destination_index(*before),
1711 },
1712 )),
1713 StructureVerb::DeleteSheet { .. } => {
1714 Ok(BatchUpdateRequestItem::DeleteSheet(DeleteSheetRequest {
1715 sheet_id: sheet_id("delete-sheet")?,
1716 }))
1717 }
1718 StructureVerb::DeleteRows { at, count, .. } => Ok(BatchUpdateRequestItem::DeleteDimension(
1719 DeleteDimensionRequest {
1720 range: dimension_range(sheet_id("delete-rows")?, Dimension::Rows, *at, *count),
1721 },
1722 )),
1723 StructureVerb::DeleteColumns { at, count, .. } => Ok(
1724 BatchUpdateRequestItem::DeleteDimension(DeleteDimensionRequest {
1725 range: dimension_range(
1726 sheet_id("delete-columns")?,
1727 Dimension::Columns,
1728 *at,
1729 *count,
1730 ),
1731 }),
1732 ),
1733 StructureVerb::DeleteRange {
1734 start_row,
1735 end_row,
1736 start_column,
1737 end_column,
1738 shift,
1739 ..
1740 } => Ok(BatchUpdateRequestItem::DeleteRange(DeleteRangeRequest {
1741 range: grid_range(
1742 sheet_id("delete-range")?,
1743 *start_row,
1744 *end_row,
1745 *start_column,
1746 *end_column,
1747 ),
1748 shift_dimension: *shift,
1749 })),
1750 StructureVerb::DuplicateSheet { title, index, .. } => Ok(
1751 BatchUpdateRequestItem::DuplicateSheet(DuplicateSheetRequest {
1752 source_sheet_id: sheet_id("duplicate-sheet")?,
1753 insert_sheet_index: *index,
1754 new_sheet_name: title.clone(),
1755 }),
1756 ),
1757 StructureVerb::ReorderSheet { index, .. } => Ok(
1758 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1759 properties: SheetPropertiesUpdate {
1760 sheet_id: sheet_id("reorder-sheet")?,
1761 index: Some(*index),
1762 ..Default::default()
1763 },
1764 fields: "index".to_string(),
1765 }),
1766 ),
1767 StructureVerb::SetSheetVisibility { hidden, .. } => Ok(
1768 BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
1769 properties: SheetPropertiesUpdate {
1770 sheet_id: sheet_id(if *hidden { "hide-sheet" } else { "show-sheet" })?,
1771 hidden: Some(*hidden),
1772 ..Default::default()
1773 },
1774 fields: "hidden".to_string(),
1775 }),
1776 ),
1777 StructureVerb::UpdateSheetProperties {
1778 freeze_rows,
1779 freeze_columns,
1780 tab_color,
1781 clear_tab_color,
1782 right_to_left,
1783 hide_gridlines,
1784 ..
1785 } => {
1786 let mut fields = Vec::new();
1787 let mut grid = GridPropertiesUpdate::default();
1788 if let Some(rows) = freeze_rows {
1789 grid.frozen_row_count = Some(*rows);
1790 fields.push("gridProperties.frozenRowCount");
1791 }
1792 if let Some(columns) = freeze_columns {
1793 grid.frozen_column_count = Some(*columns);
1794 fields.push("gridProperties.frozenColumnCount");
1795 }
1796 if let Some(hide) = hide_gridlines {
1797 grid.hide_gridlines = Some(*hide);
1798 fields.push("gridProperties.hideGridlines");
1799 }
1800 let mut tab_color_style = None;
1801 if let Some(hex) = tab_color {
1802 let color = parse_hex_color(hex)
1807 .map_err(|detail| format!("update-sheet-properties: {detail}"))?;
1808 tab_color_style = Some(ColorStyle::rgb(color));
1809 fields.push("tabColorStyle");
1810 } else if *clear_tab_color {
1811 fields.push("tabColorStyle");
1812 }
1813 if right_to_left.is_some() {
1814 fields.push("rightToLeft");
1815 }
1816 if fields.is_empty() {
1817 return Err("update-sheet-properties: no property was set".to_string());
1822 }
1824 Ok(BatchUpdateRequestItem::UpdateSheetProperties(
1825 UpdateSheetPropertiesRequest {
1826 properties: SheetPropertiesUpdate {
1827 sheet_id: sheet_id("update-sheet-properties")?,
1828 grid_properties: (grid != GridPropertiesUpdate::default()).then_some(grid),
1829 tab_color_style,
1830 right_to_left: *right_to_left,
1831 ..Default::default()
1832 },
1833 fields: fields.join(","),
1834 },
1835 ))
1836 }
1837 StructureVerb::UpdateWorkbookProperties {
1838 locale,
1839 time_zone,
1840 auto_recalc,
1841 iterative_calculation,
1842 iterative_calculation_max_iterations,
1843 iterative_calculation_convergence_threshold,
1844 } => {
1845 let mut properties = SpreadsheetPropertiesUpdate::default();
1850 let mut fields = Vec::new();
1851 if let Some(locale) = locale {
1852 properties.locale = Some(locale.clone());
1853 fields.push("locale");
1854 }
1855 if let Some(time_zone) = time_zone {
1856 properties.time_zone = Some(time_zone.clone());
1857 fields.push("timeZone");
1858 }
1859 if let Some(auto_recalc) = auto_recalc {
1860 properties.auto_recalc = Some(*auto_recalc);
1861 fields.push("autoRecalc");
1862 }
1863 match iterative_calculation {
1864 Some(IterativeCalculationToggle::On) => {
1865 properties.iterative_calculation_settings =
1866 Some(IterativeCalculationSettings {
1867 max_iterations: *iterative_calculation_max_iterations,
1868 convergence_threshold: *iterative_calculation_convergence_threshold,
1869 });
1870 fields.push("iterativeCalculationSettings");
1871 }
1872 Some(IterativeCalculationToggle::Off) => {
1878 fields.push("iterativeCalculationSettings");
1879 }
1880 None => {}
1881 }
1882 Ok(BatchUpdateRequestItem::UpdateSpreadsheetProperties(
1883 UpdateSpreadsheetPropertiesRequest {
1884 properties,
1885 fields: fields.join(","),
1886 },
1887 ))
1888 }
1889 }
1890}
1891
1892fn workbook_properties_summary(verb: &StructureVerb) -> String {
1906 let StructureVerb::UpdateWorkbookProperties {
1907 locale,
1908 time_zone,
1909 auto_recalc,
1910 iterative_calculation,
1911 iterative_calculation_max_iterations,
1912 iterative_calculation_convergence_threshold,
1913 } = verb
1914 else {
1916 unreachable!("workbook_properties_summary called on a non-UpdateWorkbookProperties verb")
1917 };
1918 let mut parts = Vec::new();
1921 if let Some(locale) = locale {
1922 parts.push(format!("locale -> '{locale}'"));
1923 }
1924 if let Some(time_zone) = time_zone {
1925 parts.push(format!("time zone -> '{time_zone}'"));
1926 }
1927 if let Some(auto_recalc) = auto_recalc {
1928 parts.push(format!("auto-recalc -> {}", auto_recalc.as_str()));
1929 }
1930 match iterative_calculation {
1931 Some(IterativeCalculationToggle::On) => {
1932 let bounds = IterativeCalculationSettings {
1933 max_iterations: *iterative_calculation_max_iterations,
1934 convergence_threshold: *iterative_calculation_convergence_threshold,
1935 }
1936 .describe_bounds();
1937 parts.push(format!("iterative calculation -> on{bounds}"));
1938 }
1939 Some(IterativeCalculationToggle::Off) => {
1940 parts.push("iterative calculation -> off".to_string());
1941 }
1942 None => {}
1943 }
1944 parts.join(", ")
1945}
1946
1947fn grid_range(
1951 sheet_id: i64,
1952 start_row: i64,
1953 end_row: i64,
1954 start_column: i64,
1955 end_column: i64,
1956) -> GridRange {
1957 GridRange {
1958 sheet_id,
1959 start_row_index: Some(start_row - 1),
1960 end_row_index: Some(end_row),
1961 start_column_index: Some(start_column - 1),
1962 end_column_index: Some(end_column),
1963 }
1964}
1965
1966fn added_sheet_id(response: &BatchUpdateResponse) -> Option<i64> {
1971 response
1972 .replies
1973 .iter()
1974 .find_map(|reply| reply.add_sheet.as_ref().or(reply.duplicate_sheet.as_ref()))
1975 .and_then(|added| added.properties.as_ref())
1976 .and_then(|props| props.sheet_id)
1977}
1978
1979fn dimension_range_label(verb: &StructureVerb) -> Option<String> {
1992 let dimension = verb.dimension()?;
1993 let (at, count) = match verb {
1994 StructureVerb::InsertRows { at, count, .. }
1995 | StructureVerb::InsertColumns { at, count, .. }
1996 | StructureVerb::DeleteRows { at, count, .. }
1997 | StructureVerb::DeleteColumns { at, count, .. }
1998 | StructureVerb::MoveRows { at, count, .. }
1999 | StructureVerb::MoveColumns { at, count, .. } => (*at, *count),
2000 _ => return None,
2001 };
2002 if count < 1 {
2008 return None;
2009 }
2010 let end = at.checked_add(count).and_then(|end| end.checked_sub(1))?;
2011 Some(dimension.span_label(at, end))
2012}
2013
2014fn grid_range_label(verb: &StructureVerb) -> Option<String> {
2019 match verb {
2020 StructureVerb::InsertRange {
2021 start_row,
2022 end_row,
2023 start_column,
2024 end_column,
2025 ..
2026 }
2027 | StructureVerb::DeleteRange {
2028 start_row,
2029 end_row,
2030 start_column,
2031 end_column,
2032 ..
2033 } => Some(format!(
2034 "rows {start_row}-{end_row}, columns {start_column}-{end_column}"
2035 )),
2036 _ => None,
2037 }
2038}
2039
2040fn record_attempt(outcome: &StructureOutcome, opts: &StructureOptions, duration: Duration) {
2046 let error = match &outcome.result {
2047 StructureResult::Failed { detail } => Some(detail.clone()),
2048 _ => None,
2049 };
2050 let decided_by = match &outcome.result {
2051 StructureResult::Blocked { decided_by } => decided_by.as_ref(),
2052 _ => None,
2053 };
2054 let decided_by = write_gate::decided_by_log_fields(decided_by);
2055 let sheet_id = match &outcome.result {
2056 StructureResult::Changed { sheet_id, .. } => *sheet_id,
2057 StructureResult::WouldChange { sheet, .. } => sheet.as_ref().and_then(|s| s.sheet_id),
2058 _ => None,
2059 };
2060 let move_to = match &opts.verb {
2063 StructureVerb::MoveRows { before, .. } | StructureVerb::MoveColumns { before, .. } => {
2064 Some(*before)
2065 }
2066 _ => None,
2067 };
2068 let fields_changed = match &opts.verb {
2073 StructureVerb::UpdateWorkbookProperties { .. } => {
2074 Some(workbook_properties_summary(&opts.verb))
2075 }
2076 _ => fields_changed_label(&opts.verb),
2077 };
2078
2079 request_log::record_drive_mutation(DriveMutationOutcome {
2080 operation: opts.verb.log_operation(),
2081 file_id: outcome.spreadsheet_id.clone(),
2082 file_name: outcome.file_name.clone().unwrap_or_default(),
2083 status: outcome.result.log_status().to_string(),
2084 resolved_folder_id: outcome.resolved_folder_id.clone(),
2085 decided_by_folder_id: decided_by.folder_id,
2086 decided_by_depth: decided_by.depth,
2087 decided_by_file_id: decided_by.file_id,
2088 sheet_id,
2089 sheet_title: opts.verb.sheet_title().map(ToString::to_string),
2090 sheet_new_title: opts.verb.new_sheet_title().map(ToString::to_string),
2091 dimension_range: dimension_range_label(&opts.verb),
2092 move_to,
2093 grid_range: grid_range_label(&opts.verb),
2094 fields_changed,
2095 error,
2096 duration,
2097 ..Default::default()
2098 });
2099}
2100
2101fn fields_changed_label(verb: &StructureVerb) -> Option<String> {
2113 let StructureVerb::UpdateSheetProperties {
2114 freeze_rows,
2115 freeze_columns,
2116 tab_color,
2117 clear_tab_color,
2118 right_to_left,
2119 hide_gridlines,
2120 ..
2121 } = verb
2122 else {
2123 return None;
2124 };
2125 let mut parts = Vec::new();
2126 if let Some(rows) = freeze_rows {
2127 parts.push(format!("frozenRowCount={rows}"));
2128 }
2129 if let Some(columns) = freeze_columns {
2130 parts.push(format!("frozenColumnCount={columns}"));
2131 }
2132 if let Some(hide) = hide_gridlines {
2133 parts.push(format!("hideGridlines={hide}"));
2134 }
2135 if let Some(hex) = tab_color {
2136 parts.push(format!("tabColorStyle={hex}"));
2137 } else if *clear_tab_color {
2138 parts.push("tabColorStyle=cleared".to_string());
2139 }
2140 if let Some(rtl) = right_to_left {
2141 parts.push(format!("rightToLeft={rtl}"));
2142 }
2143 if parts.is_empty() {
2144 return None;
2145 }
2146 Some(parts.join(", "))
2147}
2148
2149#[must_use]
2154pub fn describe(outcome: &StructureOutcome) -> String {
2155 describe_lines(outcome).join("\n")
2156}
2157
2158#[must_use]
2179pub fn describe_lines(outcome: &StructureOutcome) -> Vec<String> {
2180 let verb = &outcome.verb;
2181 let book = outcome.file_name.as_deref().map_or_else(
2182 || format!("'{}'", outcome.spreadsheet_id),
2183 |n| format!("'{n}'"),
2184 );
2185
2186 match &outcome.result {
2187 StructureResult::WouldChange { sheet, sheet_count } => {
2188 describe_would_change(verb, sheet.as_deref(), *sheet_count, &book)
2189 }
2190 StructureResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
2191 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
2192 `drive sheets {}` only works on spreadsheets",
2193 verb.label()
2194 )],
2195 StructureResult::RefusedShortcut => vec![format!(
2196 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
2197 resolve the target spreadsheet's id and use that instead",
2198 verb.label()
2199 )],
2200 StructureResult::RefusedNoVisibleParents => {
2201 let op = verb.gate_operation();
2202 vec![format!(
2203 "Refused: {book} has no parent folder visible to this account, so no folder \
2204 rule can apply to it. This is normal for a Sheet shared by link or email. \
2205 Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
2206 [\"{op}\"]}} to write_permissions.rules. (Adding it to a folder in your own \
2207 Drive and granting that folder `{op}` also works.)"
2208 )]
2209 }
2210 StructureResult::RefusedSheetNotFound { title, available } => {
2211 vec![grid_range::no_sheet_refusal(&book, title, available)]
2212 }
2213 StructureResult::RefusedSheetExists { title } => {
2214 vec![format!(
2215 "Refused: {book} already has a sheet titled '{title}'"
2216 )]
2217 }
2218 StructureResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
2219 StructureResult::Blocked { decided_by } => {
2220 let op = verb.gate_operation();
2221 let action = if matches!(op, DriveOperation::SheetsDelete) {
2222 "destructive edits"
2223 } else {
2224 "structural edits"
2225 };
2226 vec![match decided_by {
2227 Some(rule) => format!(
2228 "Blocked: {action} to {book} refused by rule on {} {}{}",
2229 rule.kind_label(),
2230 rule.id(),
2231 rule.depth_suffix()
2232 ),
2233 None => format!(
2234 "Blocked: {action} to {book} refused by default policy (no matching rule \
2235 for {op})"
2236 ),
2237 }]
2238 }
2239 StructureResult::RefusedNoLease => LeaseGateRefusal::NoLease
2240 .describe_line(&outcome.spreadsheet_id, &book)
2241 .into_iter()
2242 .collect(),
2243 StructureResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
2244 .describe_line(&outcome.spreadsheet_id, &book)
2245 .into_iter()
2246 .collect(),
2247 StructureResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
2248 .describe_line(&outcome.spreadsheet_id, &book)
2249 .into_iter()
2250 .collect(),
2251 StructureResult::RefusedLeaseStale => LeaseGateRefusal::Stale
2252 .describe_line(&outcome.spreadsheet_id, &book)
2253 .into_iter()
2254 .collect(),
2255 StructureResult::Changed {
2256 sheet,
2257 sheet_id,
2258 backup,
2259 } => {
2260 vec![describe_changed(
2261 verb,
2262 sheet.as_deref(),
2263 *sheet_id,
2264 backup.as_deref(),
2265 &book,
2266 )]
2267 }
2268 StructureResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
2269 StructureResult::Failed { detail } => vec![format!("Failed: {detail}")],
2270 }
2271}
2272
2273fn describe_would_change(
2274 verb: &StructureVerb,
2275 sheet: Option<&SheetSnapshot>,
2276 sheet_count: usize,
2277 book: &str,
2278) -> Vec<String> {
2279 let id = sheet
2280 .and_then(|s| s.sheet_id)
2281 .map_or_else(String::new, |id| format!(" (sheetId {id})"));
2282
2283 match verb {
2284 StructureVerb::AddSheet {
2285 title,
2286 index,
2287 rows,
2288 columns,
2289 } => {
2290 let size = match (rows, columns) {
2291 (Some(r), Some(c)) => format!(" ({r} x {c})"),
2292 (Some(r), None) => format!(" ({r} rows)"),
2293 (None, Some(c)) => format!(" ({c} columns)"),
2294 (None, None) => String::new(),
2295 };
2296 let position = index.map_or_else(
2297 || " at the end".to_string(),
2298 |index| format!(" at index {index}"),
2299 );
2300 vec![format!(
2301 "Would add sheet '{title}'{size}{position} of {book} \
2302 ({sheet_count} sheet(s) -> {})",
2303 sheet_count + 1
2304 )]
2305 }
2306 StructureVerb::RenameSheet {
2307 sheet: from,
2308 new_title,
2309 } => {
2310 vec![format!(
2311 "Would rename sheet '{from}'{id} to '{new_title}' in {book}"
2312 )]
2313 }
2314 StructureVerb::InsertRows {
2315 sheet: from,
2316 at,
2317 count,
2318 } => describe_would_insert(Dimension::Rows, from, &id, *at, *count, sheet, book),
2319 StructureVerb::InsertColumns {
2320 sheet: from,
2321 at,
2322 count,
2323 } => describe_would_insert(Dimension::Columns, from, &id, *at, *count, sheet, book),
2324 StructureVerb::InsertRange {
2325 sheet: from,
2326 start_row,
2327 end_row,
2328 start_column,
2329 end_column,
2330 shift,
2331 } => vec![describe_would_insert_range(
2332 from,
2333 &id,
2334 *start_row,
2335 *end_row,
2336 *start_column,
2337 *end_column,
2338 *shift,
2339 book,
2340 )],
2341 StructureVerb::MoveRows {
2342 sheet: from,
2343 at,
2344 count,
2345 before,
2346 } => describe_would_move(
2347 Dimension::Rows,
2348 from,
2349 &id,
2350 *at,
2351 *count,
2352 *before,
2353 sheet,
2354 book,
2355 ),
2356 StructureVerb::MoveColumns {
2357 sheet: from,
2358 at,
2359 count,
2360 before,
2361 } => describe_would_move(
2362 Dimension::Columns,
2363 from,
2364 &id,
2365 *at,
2366 *count,
2367 *before,
2368 sheet,
2369 book,
2370 ),
2371 StructureVerb::DeleteSheet { sheet: from } => {
2372 vec![format!(
2373 "Would delete sheet '{from}'{id} from {book} ({sheet_count} sheet(s) -> {}); \
2374 {FORMULA_CAVEAT}",
2375 sheet_count.saturating_sub(1)
2376 )]
2377 }
2378 StructureVerb::DeleteRows {
2379 sheet: from,
2380 at,
2381 count,
2382 } => describe_would_delete_dimension(Dimension::Rows, from, &id, *at, *count, sheet, book),
2383 StructureVerb::DeleteColumns {
2384 sheet: from,
2385 at,
2386 count,
2387 } => {
2388 describe_would_delete_dimension(Dimension::Columns, from, &id, *at, *count, sheet, book)
2389 }
2390 StructureVerb::DeleteRange {
2391 sheet: from,
2392 start_row,
2393 end_row,
2394 start_column,
2395 end_column,
2396 shift,
2397 } => vec![describe_would_delete_range(
2398 from,
2399 &id,
2400 *start_row,
2401 *end_row,
2402 *start_column,
2403 *end_column,
2404 *shift,
2405 book,
2406 )],
2407 StructureVerb::DuplicateSheet {
2408 sheet: from,
2409 title,
2410 index,
2411 } => {
2412 let to = title.as_deref().map_or_else(
2413 || " as a copy Sheets names automatically".to_string(),
2414 |title| format!(" as '{title}'"),
2415 );
2416 let position = index.map_or_else(
2417 || " at the front (Sheets' default; not the end)".to_string(),
2421 |index| format!(" at index {index}"),
2422 );
2423 vec![format!(
2424 "Would duplicate sheet '{from}'{id}{to}{position} of {book} \
2425 ({sheet_count} sheet(s) -> {})",
2426 sheet_count + 1
2427 )]
2428 }
2429 StructureVerb::ReorderSheet { sheet: from, index } => {
2430 vec![format!(
2431 "Would move sheet '{from}'{id} to index {index} in {book}"
2432 )]
2433 }
2434 StructureVerb::SetSheetVisibility {
2435 sheet: from,
2436 hidden,
2437 } => {
2438 let verb_word = if *hidden { "hide" } else { "show" };
2439 vec![format!("Would {verb_word} sheet '{from}'{id} in {book}")]
2440 }
2441 StructureVerb::UpdateSheetProperties {
2442 sheet: from,
2443 freeze_rows,
2444 freeze_columns,
2445 tab_color,
2446 clear_tab_color,
2447 right_to_left,
2448 hide_gridlines,
2449 } => {
2450 let fragments = update_sheet_properties_fragments(
2451 freeze_rows,
2452 freeze_columns,
2453 tab_color,
2454 *clear_tab_color,
2455 *right_to_left,
2456 *hide_gridlines,
2457 sheet,
2458 );
2459 vec![format!(
2460 "Would update sheet '{from}'{id} in {book}: {}",
2461 fragments.join(", ")
2462 )]
2463 }
2464 StructureVerb::UpdateWorkbookProperties { .. } => vec![format!(
2465 "Would update workbook properties of {book}: {}",
2466 workbook_properties_summary(verb)
2467 )],
2468 }
2469}
2470
2471fn before_after_fragment(
2477 label: &str,
2478 before: Option<impl std::fmt::Display>,
2479 after: impl std::fmt::Display,
2480) -> String {
2481 match before {
2482 Some(before) => format!("{label} {before} -> {after}"),
2483 None => format!("{label} -> {after}"),
2484 }
2485}
2486
2487fn gridlines_label(hidden: bool) -> &'static str {
2490 if hidden {
2491 "hidden"
2492 } else {
2493 "shown"
2494 }
2495}
2496
2497fn update_sheet_properties_fragments(
2504 freeze_rows: &Option<i64>,
2505 freeze_columns: &Option<i64>,
2506 tab_color: &Option<String>,
2507 clear_tab_color: bool,
2508 right_to_left: Option<bool>,
2509 hide_gridlines: Option<bool>,
2510 sheet: Option<&SheetSnapshot>,
2511) -> Vec<String> {
2512 let mut fragments = Vec::new();
2513 if let Some(rows) = freeze_rows {
2514 fragments.push(before_after_fragment(
2515 "frozen rows",
2516 sheet.and_then(|s| s.frozen_row_count),
2517 rows,
2518 ));
2519 }
2520 if let Some(columns) = freeze_columns {
2521 fragments.push(before_after_fragment(
2522 "frozen columns",
2523 sheet.and_then(|s| s.frozen_column_count),
2524 columns,
2525 ));
2526 }
2527 if let Some(hex) = tab_color {
2528 fragments.push(format!("tab color -> {}", normalize_hex_for_display(hex)));
2529 } else if clear_tab_color {
2530 fragments.push("tab color cleared".to_string());
2531 }
2532 if let Some(rtl) = right_to_left {
2533 fragments.push(before_after_fragment(
2534 "right-to-left",
2535 sheet.and_then(|s| s.right_to_left),
2536 rtl,
2537 ));
2538 }
2539 if let Some(hide) = hide_gridlines {
2540 fragments.push(before_after_fragment(
2541 "gridlines",
2542 sheet.and_then(|s| s.hide_gridlines).map(gridlines_label),
2543 gridlines_label(hide),
2544 ));
2545 }
2546 fragments
2547}
2548
2549#[allow(clippy::too_many_arguments)]
2560fn describe_would_insert(
2561 dimension: Dimension,
2562 from: &str,
2563 id: &str,
2564 at: i64,
2565 count: i64,
2566 sheet: Option<&SheetSnapshot>,
2567 book: &str,
2568) -> Vec<String> {
2569 let summary = format!(
2570 "Would insert {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}",
2571 noun = dimension.noun(),
2572 );
2573 let before = match dimension {
2574 Dimension::Rows => sheet.and_then(|s| s.row_count),
2575 Dimension::Columns => sheet.and_then(|s| s.column_count),
2576 };
2577 let Some(before) = before else {
2582 return vec![summary];
2583 };
2584 let Some(after) = before.checked_add(count) else {
2585 return vec![summary];
2586 };
2587 let existing = if at <= before {
2592 format!(
2593 "; existing {plural} {at}-{before} shift {direction}",
2594 plural = plural(dimension),
2595 direction = shift_word(dimension, true),
2597 )
2598 } else {
2599 format!(
2600 "; appended at the end, no existing {plural} shift",
2601 plural = plural(dimension)
2602 )
2603 };
2604 vec![
2605 summary,
2606 format!(
2607 " ({before} {plural} -> {after}{existing})",
2608 plural = plural(dimension),
2609 ),
2610 ]
2611}
2612
2613#[allow(clippy::too_many_arguments)]
2625fn describe_would_move(
2626 dimension: Dimension,
2627 from: &str,
2628 id: &str,
2629 at: i64,
2630 count: i64,
2631 before: i64,
2632 sheet: Option<&SheetSnapshot>,
2633 book: &str,
2634) -> Vec<String> {
2635 let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2636 return vec![format!(
2637 "Would move {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2638 noun = dimension.noun(),
2639 )];
2640 };
2641 let summary = format!(
2642 "Would move {count} {noun}(s) {at}-{last} of '{from}'{id} in {book} to before \
2643 {noun} {before}",
2644 noun = dimension.noun(),
2645 );
2646 let current = match dimension {
2647 Dimension::Rows => sheet.and_then(|s| s.row_count),
2648 Dimension::Columns => sheet.and_then(|s| s.column_count),
2649 };
2650 let Some(current) = current else {
2654 return vec![summary];
2655 };
2656 let (land_first, land_last) = move_landing_span(before, count, last);
2661 let (shift_first, shift_last, shifted_first, shifted_last, forward) = if before > last {
2662 (last + 1, before - 1, at, before - 1 - count, false)
2667 } else {
2668 (before, at - 1, before + count, at - 1 + count, true)
2672 };
2673 let detail = format!(
2674 " ({current} {plural} unchanged; {plural} {shift_first}-{shift_last} shift \
2675 {direction} to {shifted_first}-{shifted_last}; moved {plural} land at {land_first}-\
2676 {land_last})",
2677 plural = plural(dimension),
2678 direction = shift_word(dimension, forward),
2679 );
2680 vec![summary, detail]
2681}
2682
2683const FORMULA_CAVEAT: &str =
2690 "formulas elsewhere in the workbook that reference this may break, which cannot be \
2691 checked automatically";
2692
2693const INSERT_RANGE_EDGE_CAVEAT_DRY_RUN: &str =
2698 "cells pushed past the sheet's grid extent may be dropped by Sheets";
2699const INSERT_RANGE_EDGE_CAVEAT: &str =
2700 "cells pushed past the sheet's grid extent may have been dropped by Sheets";
2701
2702#[allow(clippy::too_many_arguments)]
2710fn describe_would_delete_dimension(
2711 dimension: Dimension,
2712 from: &str,
2713 id: &str,
2714 at: i64,
2715 count: i64,
2716 sheet: Option<&SheetSnapshot>,
2717 book: &str,
2718) -> Vec<String> {
2719 let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
2720 return vec![format!(
2721 "Would delete {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
2722 noun = dimension.noun(),
2723 )];
2724 };
2725 let summary = format!(
2726 "Would delete {count} {noun}(s) {at}-{last} of '{from}'{id} in {book}",
2727 noun = dimension.noun(),
2728 );
2729 let before = match dimension {
2730 Dimension::Rows => sheet.and_then(|s| s.row_count),
2731 Dimension::Columns => sheet.and_then(|s| s.column_count),
2732 };
2733 let Some(before) = before else {
2734 return vec![summary];
2735 };
2736 let Some(after) = before.checked_sub(count) else {
2737 return vec![summary];
2738 };
2739 let existing = if last < before {
2740 format!(
2741 "; existing {plural} {next}-{before} shift {direction}",
2742 plural = plural(dimension),
2743 next = last + 1,
2744 direction = shift_word(dimension, false),
2745 )
2746 } else {
2747 format!(
2748 "; nothing remains after the deleted {plural}",
2749 plural = plural(dimension)
2750 )
2751 };
2752 vec![
2753 summary,
2754 format!(
2755 " ({before} {plural} -> {after}{existing}; {FORMULA_CAVEAT})",
2756 plural = plural(dimension),
2757 ),
2758 ]
2759}
2760
2761#[allow(clippy::too_many_arguments)]
2768fn describe_would_delete_range(
2769 from: &str,
2770 id: &str,
2771 start_row: i64,
2772 end_row: i64,
2773 start_column: i64,
2774 end_column: i64,
2775 shift: ShiftDimension,
2776 book: &str,
2777) -> String {
2778 let direction = shift_word(shift.into(), false);
2779 format!(
2780 "Would delete rows {start_row}-{end_row}, columns {start_column}-{end_column} of \
2781 '{from}'{id} in {book}, shifting remaining cells {direction} to close the gap; \
2782 {FORMULA_CAVEAT}"
2783 )
2784}
2785
2786#[allow(clippy::too_many_arguments)]
2788fn describe_would_insert_range(
2789 from: &str,
2790 id: &str,
2791 start_row: i64,
2792 end_row: i64,
2793 start_column: i64,
2794 end_column: i64,
2795 shift: ShiftDimension,
2796 book: &str,
2797) -> String {
2798 let direction = shift_word(shift.into(), true);
2799 format!(
2800 "Would insert empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
2801 of '{from}'{id} in {book}, shifting existing cells {direction}; \
2802 {INSERT_RANGE_EDGE_CAVEAT_DRY_RUN}"
2803 )
2804}
2805
2806fn describe_changed(
2807 verb: &StructureVerb,
2808 sheet: Option<&SheetSnapshot>,
2809 sheet_id: Option<i64>,
2810 backup: Option<&LeaseBackup>,
2811 book: &str,
2812) -> String {
2813 let id = sheet_id.map_or_else(String::new, |id| format!(" (sheetId {id})"));
2814 let recovery = recovery_note(backup, matches!(verb, StructureVerb::DeleteSheet { .. }));
2815 match verb {
2816 StructureVerb::AddSheet { title, .. } => {
2817 format!("Added sheet '{title}'{id} to {book}")
2818 }
2819 StructureVerb::RenameSheet {
2820 sheet: from,
2821 new_title,
2822 } => {
2823 format!("Renamed sheet '{from}' to '{new_title}'{id} in {book}")
2824 }
2825 StructureVerb::InsertRows {
2826 sheet: from,
2827 at,
2828 count,
2829 } => describe_inserted(Dimension::Rows, from, &id, *at, *count, sheet, book),
2830 StructureVerb::InsertColumns {
2831 sheet: from,
2832 at,
2833 count,
2834 } => describe_inserted(Dimension::Columns, from, &id, *at, *count, sheet, book),
2835 StructureVerb::InsertRange {
2836 sheet: from,
2837 start_row,
2838 end_row,
2839 start_column,
2840 end_column,
2841 shift,
2842 } => describe_inserted_range(
2843 from,
2844 &id,
2845 *start_row,
2846 *end_row,
2847 *start_column,
2848 *end_column,
2849 *shift,
2850 book,
2851 ),
2852 StructureVerb::MoveRows {
2853 sheet: from,
2854 at,
2855 count,
2856 before,
2857 } => describe_moved(Dimension::Rows, from, &id, *at, *count, *before, book),
2858 StructureVerb::MoveColumns {
2859 sheet: from,
2860 at,
2861 count,
2862 before,
2863 } => describe_moved(Dimension::Columns, from, &id, *at, *count, *before, book),
2864 StructureVerb::DeleteSheet { sheet: from } => {
2865 format!("Deleted sheet '{from}'{id} from {book}; {recovery}")
2866 }
2867 StructureVerb::DeleteRows {
2868 sheet: from,
2869 at,
2870 count,
2871 } => describe_deleted_dimension(
2872 Dimension::Rows,
2873 from,
2874 &id,
2875 *at,
2876 *count,
2877 sheet,
2878 book,
2879 &recovery,
2880 ),
2881 StructureVerb::DeleteColumns {
2882 sheet: from,
2883 at,
2884 count,
2885 } => describe_deleted_dimension(
2886 Dimension::Columns,
2887 from,
2888 &id,
2889 *at,
2890 *count,
2891 sheet,
2892 book,
2893 &recovery,
2894 ),
2895 StructureVerb::DeleteRange {
2896 sheet: from,
2897 start_row,
2898 end_row,
2899 start_column,
2900 end_column,
2901 shift,
2902 } => describe_deleted_range(
2903 from,
2904 &id,
2905 *start_row,
2906 *end_row,
2907 *start_column,
2908 *end_column,
2909 *shift,
2910 book,
2911 &recovery,
2912 ),
2913 StructureVerb::DuplicateSheet {
2914 sheet: from, title, ..
2915 } => {
2916 let to = title
2917 .as_deref()
2918 .map_or_else(String::new, |title| format!(" as '{title}'"));
2919 format!("Duplicated sheet '{from}'{to}{id} in {book}")
2920 }
2921 StructureVerb::ReorderSheet { sheet: from, index } => {
2922 format!("Moved sheet '{from}'{id} to index {index} in {book}")
2923 }
2924 StructureVerb::SetSheetVisibility {
2925 sheet: from,
2926 hidden,
2927 } => {
2928 let verb_word = if *hidden { "Hid" } else { "Showed" };
2929 format!("{verb_word} sheet '{from}'{id} in {book}")
2930 }
2931 StructureVerb::UpdateSheetProperties {
2932 sheet: from,
2933 freeze_rows,
2934 freeze_columns,
2935 tab_color,
2936 clear_tab_color,
2937 right_to_left,
2938 hide_gridlines,
2939 } => {
2940 let fragments = update_sheet_properties_fragments(
2941 freeze_rows,
2942 freeze_columns,
2943 tab_color,
2944 *clear_tab_color,
2945 *right_to_left,
2946 *hide_gridlines,
2947 sheet,
2948 );
2949 format!(
2950 "Updated sheet '{from}'{id} in {book}: {}",
2951 fragments.join(", ")
2952 )
2953 }
2954 StructureVerb::UpdateWorkbookProperties { .. } => format!(
2955 "Updated workbook properties of {book}: {}",
2956 workbook_properties_summary(verb)
2957 ),
2958 }
2959}
2960
2961#[allow(clippy::too_many_arguments)]
2965fn describe_inserted(
2966 dimension: Dimension,
2967 from: &str,
2968 id: &str,
2969 at: i64,
2970 count: i64,
2971 sheet: Option<&SheetSnapshot>,
2972 book: &str,
2973) -> String {
2974 let now = match dimension {
2975 Dimension::Rows => sheet.and_then(|s| s.row_count),
2976 Dimension::Columns => sheet.and_then(|s| s.column_count),
2977 }
2978 .and_then(|before| before.checked_add(count))
2979 .map_or_else(String::new, |after| {
2980 format!(" ({after} {} now)", plural(dimension))
2981 });
2982 format!(
2983 "Inserted {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}{now}",
2984 noun = dimension.noun(),
2985 )
2986}
2987
2988fn describe_moved(
2999 dimension: Dimension,
3000 from: &str,
3001 id: &str,
3002 at: i64,
3003 count: i64,
3004 before: i64,
3005 book: &str,
3006) -> String {
3007 let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3008 let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3009 let now = last.map_or_else(String::new, |last| {
3010 let (land_first, land_last) = move_landing_span(before, count, last);
3011 format!(" (now {} {land_first}-{land_last})", plural(dimension))
3012 });
3013 format!(
3014 "Moved {count} {noun}(s) {range} of '{from}'{id} in {book} to before {noun} \
3015 {before}{now}",
3016 noun = dimension.noun(),
3017 )
3018}
3019
3020#[allow(clippy::too_many_arguments)]
3022fn describe_deleted_dimension(
3023 dimension: Dimension,
3024 from: &str,
3025 id: &str,
3026 at: i64,
3027 count: i64,
3028 sheet: Option<&SheetSnapshot>,
3029 book: &str,
3030 recovery: &str,
3031) -> String {
3032 let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
3033 let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
3034 let now = match dimension {
3035 Dimension::Rows => sheet.and_then(|s| s.row_count),
3036 Dimension::Columns => sheet.and_then(|s| s.column_count),
3037 }
3038 .and_then(|before| before.checked_sub(count))
3039 .map_or_else(String::new, |after| {
3040 format!(" ({after} {} now)", plural(dimension))
3041 });
3042 format!(
3043 "Deleted {count} {noun}(s) {range} of '{from}'{id} in {book}{now}; {recovery}",
3044 noun = dimension.noun(),
3045 )
3046}
3047
3048#[allow(clippy::too_many_arguments)]
3050fn describe_deleted_range(
3051 from: &str,
3052 id: &str,
3053 start_row: i64,
3054 end_row: i64,
3055 start_column: i64,
3056 end_column: i64,
3057 shift: ShiftDimension,
3058 book: &str,
3059 recovery: &str,
3060) -> String {
3061 let direction = shift_word(shift.into(), false);
3062 format!(
3063 "Deleted rows {start_row}-{end_row}, columns {start_column}-{end_column} of '{from}'{id} \
3064 in {book}, shifted remaining cells {direction}; {recovery}"
3065 )
3066}
3067
3068#[allow(clippy::too_many_arguments)]
3070fn describe_inserted_range(
3071 from: &str,
3072 id: &str,
3073 start_row: i64,
3074 end_row: i64,
3075 start_column: i64,
3076 end_column: i64,
3077 shift: ShiftDimension,
3078 book: &str,
3079) -> String {
3080 let direction = shift_word(shift.into(), true);
3081 format!(
3082 "Inserted empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
3083 of '{from}'{id} in {book}, shifting existing cells {direction}; \
3084 {INSERT_RANGE_EDGE_CAVEAT}"
3085 )
3086}
3087
3088const fn plural(dimension: Dimension) -> &'static str {
3089 match dimension {
3090 Dimension::Rows => "rows",
3091 Dimension::Columns => "columns",
3092 }
3093}
3094
3095const fn shift_word(dimension: Dimension, forward: bool) -> &'static str {
3104 match (dimension, forward) {
3105 (Dimension::Rows, true) => "down",
3106 (Dimension::Rows, false) => "up",
3107 (Dimension::Columns, true) => "right",
3108 (Dimension::Columns, false) => "left",
3109 }
3110}
3111
3112#[cfg(test)]
3113#[allow(clippy::unwrap_used, clippy::expect_used)]
3114mod tests {
3115 use super::*;
3116 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
3117 use crate::drive::sheets::client::SHEETS_API_URL;
3118 use crate::drive::test_support::{seed_lease, seed_lease_with_backup};
3119 use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
3120 use crate::drive::write_gate::Verdict;
3121 use crate::test_support::env::MapEnv;
3122 use crate::utils::secret::Secret;
3123 use std::collections::HashSet;
3124
3125 fn test_credentials() -> DriveCredentials {
3126 DriveCredentials {
3127 client_id: "client-1".to_string(),
3128 client_secret: Secret::new("secret-1"),
3129 refresh_token: Secret::new("refresh-1"),
3130 scope: DriveGrantedScopes::READONLY,
3131 }
3132 }
3133
3134 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
3140 wiremock::Mock::given(wiremock::matchers::method("POST"))
3141 .and(wiremock::matchers::path("/token"))
3142 .respond_with(
3143 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3144 "access_token": "test-token", "expires_in": 3600,
3145 })),
3146 )
3147 .mount(server)
3148 .await;
3149 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
3150 crate::drive::client::test_support::replace_session(
3151 &mut drive,
3152 &test_credentials(),
3153 &format!("{}/token", server.uri()),
3154 );
3155 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
3156 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
3157 (drive, sheets)
3158 }
3159
3160 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
3164 let parents: Vec<&str> = parents.to_vec();
3165 wiremock::Mock::given(wiremock::matchers::method("GET"))
3166 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
3167 .respond_with(
3168 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3169 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
3170 "version": "1",
3171 })),
3172 )
3173 }
3174
3175 fn mount_folder(id: &str) -> wiremock::Mock {
3176 mount_file(id, "application/vnd.google-apps.folder", &[])
3177 }
3178
3179 fn allow_rule(folder: &str) -> FolderPermissionRule {
3180 FolderPermissionRule {
3181 folder_id: Some(folder.to_string()),
3182 file_id: None,
3183 recursive: true,
3184 allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
3185 deny: HashSet::default(),
3186 require_lease: true,
3187 }
3188 }
3189
3190 fn workbook_body(q2: serde_json::Value) -> serde_json::Value {
3194 serde_json::json!({
3195 "spreadsheetId": "sheet-1",
3196 "properties": {"title": "Budget"},
3197 "sheets": [
3198 {"properties": {
3199 "sheetId": 0, "title": "Q1", "index": 0,
3200 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
3201 q2,
3202 ],
3203 })
3204 }
3205
3206 fn default_q2() -> serde_json::Value {
3209 serde_json::json!({"properties": {
3210 "sheetId": 118_293, "title": "Q2", "index": 1,
3211 "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3212 }
3213
3214 fn hidden_q2() -> serde_json::Value {
3217 serde_json::json!({"properties": {
3218 "sheetId": 118_293, "title": "Q2", "index": 1, "hidden": true,
3219 "gridProperties": {"rowCount": 500, "columnCount": 10}}})
3220 }
3221
3222 fn mount_workbook() -> wiremock::Mock {
3225 mount_workbook_with_q2(default_q2())
3226 }
3227
3228 fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
3229 wiremock::Mock::given(wiremock::matchers::method("POST"))
3230 .and(wiremock::matchers::path(
3231 "/v4/spreadsheets/sheet-1:batchUpdate",
3232 ))
3233 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
3234 }
3235
3236 fn opts(verb: StructureVerb, dry_run: bool) -> StructureOptions {
3245 opts_for("sheet-1", verb, dry_run)
3246 }
3247
3248 fn opts_for(spreadsheet_id: &str, verb: StructureVerb, dry_run: bool) -> StructureOptions {
3258 let ledger_path = tempfile::tempdir()
3259 .unwrap()
3260 .keep()
3261 .join("lease-ledger.jsonl");
3262 let token = seed_lease(&ledger_path, spreadsheet_id, "1");
3263 StructureOptions {
3264 spreadsheet_id: spreadsheet_id.to_string(),
3265 verb,
3266 dry_run,
3267 lease_token: Some(token),
3268 ledger_path,
3269 }
3270 }
3271
3272 fn rename() -> StructureVerb {
3273 StructureVerb::RenameSheet {
3274 sheet: "Q2".to_string(),
3275 new_title: "Q3".to_string(),
3276 }
3277 }
3278
3279 fn insert_rows() -> StructureVerb {
3280 StructureVerb::InsertRows {
3281 sheet: "Q2".to_string(),
3282 at: 5,
3283 count: 3,
3284 }
3285 }
3286
3287 fn move_rows() -> StructureVerb {
3291 StructureVerb::MoveRows {
3292 sheet: "Q2".to_string(),
3293 at: 2,
3294 count: 2,
3295 before: 6,
3296 }
3297 }
3298
3299 fn add_sheet() -> StructureVerb {
3300 StructureVerb::AddSheet {
3301 title: "Q3".to_string(),
3302 index: None,
3303 rows: None,
3304 columns: None,
3305 }
3306 }
3307
3308 fn delete_sheet() -> StructureVerb {
3309 StructureVerb::DeleteSheet {
3310 sheet: "Q2".to_string(),
3311 }
3312 }
3313
3314 fn delete_rows() -> StructureVerb {
3315 StructureVerb::DeleteRows {
3316 sheet: "Q2".to_string(),
3317 at: 5,
3318 count: 3,
3319 }
3320 }
3321
3322 fn delete_columns() -> StructureVerb {
3323 StructureVerb::DeleteColumns {
3324 sheet: "Q2".to_string(),
3325 at: 3,
3326 count: 2,
3327 }
3328 }
3329
3330 fn insert_range() -> StructureVerb {
3331 StructureVerb::InsertRange {
3332 sheet: "Q2".to_string(),
3333 start_row: 2,
3334 end_row: 4,
3335 start_column: 2,
3336 end_column: 3,
3337 shift: ShiftDimension::Rows,
3338 }
3339 }
3340
3341 fn insert_range_columns_shift() -> StructureVerb {
3346 StructureVerb::InsertRange {
3347 sheet: "Q2".to_string(),
3348 start_row: 2,
3349 end_row: 4,
3350 start_column: 2,
3351 end_column: 3,
3352 shift: ShiftDimension::Columns,
3353 }
3354 }
3355
3356 fn delete_range() -> StructureVerb {
3357 StructureVerb::DeleteRange {
3358 sheet: "Q2".to_string(),
3359 start_row: 2,
3360 end_row: 4,
3361 start_column: 2,
3362 end_column: 3,
3363 shift: ShiftDimension::Rows,
3364 }
3365 }
3366
3367 fn delete_range_columns_shift() -> StructureVerb {
3371 StructureVerb::DeleteRange {
3372 sheet: "Q2".to_string(),
3373 start_row: 2,
3374 end_row: 4,
3375 start_column: 2,
3376 end_column: 3,
3377 shift: ShiftDimension::Columns,
3378 }
3379 }
3380
3381 fn duplicate_sheet() -> StructureVerb {
3382 StructureVerb::DuplicateSheet {
3383 sheet: "Q2".to_string(),
3384 title: Some("Q2 Copy".to_string()),
3385 index: Some(1),
3386 }
3387 }
3388
3389 fn duplicate_sheet_default() -> StructureVerb {
3390 StructureVerb::DuplicateSheet {
3391 sheet: "Q2".to_string(),
3392 title: None,
3393 index: None,
3394 }
3395 }
3396
3397 fn reorder_sheet() -> StructureVerb {
3398 StructureVerb::ReorderSheet {
3399 sheet: "Q2".to_string(),
3400 index: 0,
3401 }
3402 }
3403
3404 fn hide_sheet() -> StructureVerb {
3405 StructureVerb::SetSheetVisibility {
3406 sheet: "Q2".to_string(),
3407 hidden: true,
3408 }
3409 }
3410
3411 fn show_sheet() -> StructureVerb {
3412 StructureVerb::SetSheetVisibility {
3413 sheet: "Q2".to_string(),
3414 hidden: false,
3415 }
3416 }
3417
3418 fn update_workbook_properties() -> StructureVerb {
3419 StructureVerb::UpdateWorkbookProperties {
3420 locale: Some("en_US".to_string()),
3421 time_zone: Some("America/New_York".to_string()),
3422 auto_recalc: Some(RecalculationInterval::OnChange),
3423 iterative_calculation: None,
3424 iterative_calculation_max_iterations: None,
3425 iterative_calculation_convergence_threshold: None,
3426 }
3427 }
3428
3429 fn delete_allow_rule(folder: &str) -> FolderPermissionRule {
3430 FolderPermissionRule {
3431 folder_id: Some(folder.to_string()),
3432 file_id: None,
3433 recursive: true,
3434 allow: std::iter::once(DriveOperation::SheetsDelete).collect(),
3435 deny: HashSet::default(),
3436 require_lease: true,
3437 }
3438 }
3439
3440 #[test]
3453 fn every_delete_verb_gates_on_sheets_delete_not_sheets_structure() {
3454 for verb in [
3455 StructureVerb::DeleteSheet {
3456 sheet: "Q1".to_string(),
3457 },
3458 StructureVerb::DeleteRows {
3459 sheet: "Q1".to_string(),
3460 at: 1,
3461 count: 1,
3462 },
3463 StructureVerb::DeleteColumns {
3464 sheet: "Q1".to_string(),
3465 at: 1,
3466 count: 1,
3467 },
3468 StructureVerb::DeleteRange {
3469 sheet: "Q1".to_string(),
3470 start_row: 1,
3471 end_row: 2,
3472 start_column: 1,
3473 end_column: 2,
3474 shift: ShiftDimension::Rows,
3475 },
3476 ] {
3477 assert_eq!(verb.gate_operation(), DriveOperation::SheetsDelete);
3478 }
3479 }
3480
3481 #[test]
3482 fn every_additive_verb_still_gates_on_sheets_structure() {
3483 for verb in [
3484 add_sheet(),
3485 StructureVerb::RenameSheet {
3486 sheet: "Q1".to_string(),
3487 new_title: "Q2".to_string(),
3488 },
3489 StructureVerb::InsertRows {
3490 sheet: "Q1".to_string(),
3491 at: 1,
3492 count: 1,
3493 },
3494 StructureVerb::InsertColumns {
3495 sheet: "Q1".to_string(),
3496 at: 1,
3497 count: 1,
3498 },
3499 StructureVerb::InsertRange {
3500 sheet: "Q1".to_string(),
3501 start_row: 1,
3502 end_row: 2,
3503 start_column: 1,
3504 end_column: 2,
3505 shift: ShiftDimension::Rows,
3506 },
3507 StructureVerb::MoveRows {
3508 sheet: "Q1".to_string(),
3509 at: 1,
3510 count: 1,
3511 before: 3,
3512 },
3513 StructureVerb::MoveColumns {
3514 sheet: "Q1".to_string(),
3515 at: 1,
3516 count: 1,
3517 before: 3,
3518 },
3519 StructureVerb::DuplicateSheet {
3520 sheet: "Q1".to_string(),
3521 title: None,
3522 index: None,
3523 },
3524 StructureVerb::ReorderSheet {
3525 sheet: "Q1".to_string(),
3526 index: 0,
3527 },
3528 StructureVerb::SetSheetVisibility {
3529 sheet: "Q1".to_string(),
3530 hidden: true,
3531 },
3532 StructureVerb::UpdateSheetProperties {
3533 sheet: "Q1".to_string(),
3534 freeze_rows: None,
3535 freeze_columns: None,
3536 tab_color: None,
3537 clear_tab_color: false,
3538 right_to_left: Some(true),
3539 hide_gridlines: None,
3540 },
3541 StructureVerb::UpdateWorkbookProperties {
3542 locale: Some("en_US".to_string()),
3543 time_zone: None,
3544 auto_recalc: None,
3545 iterative_calculation: None,
3546 iterative_calculation_max_iterations: None,
3547 iterative_calculation_convergence_threshold: None,
3548 },
3549 ] {
3550 assert_eq!(verb.gate_operation(), DriveOperation::SheetsStructure);
3551 }
3552 }
3553
3554 #[test]
3557 fn at_is_one_based_inclusive_and_becomes_zero_based_half_open() {
3558 let range = dimension_range(7, Dimension::Rows, 5, 3);
3560 assert_eq!(range.start_index, 4);
3561 assert_eq!(range.end_index, 7);
3562 assert_eq!(range.sheet_id, 7);
3563 assert_eq!(range.dimension, Dimension::Rows);
3564 }
3565
3566 #[test]
3567 fn inserting_at_row_one_starts_at_index_zero() {
3568 let range = dimension_range(0, Dimension::Rows, 1, 1);
3571 assert_eq!(range.start_index, 0);
3572 assert_eq!(range.end_index, 1);
3573 }
3574
3575 #[test]
3576 fn a_single_insert_spans_exactly_one_index() {
3577 let range = dimension_range(0, Dimension::Columns, 3, 1);
3578 assert_eq!((range.start_index, range.end_index), (2, 3));
3579 }
3580
3581 #[test]
3582 fn dimension_range_label_is_one_based_inclusive_like_the_flag() {
3583 assert_eq!(
3585 dimension_range_label(&insert_rows()).as_deref(),
3586 Some("ROWS 5:7")
3587 );
3588 assert_eq!(
3589 dimension_range_label(&StructureVerb::InsertColumns {
3590 sheet: "Q2".to_string(),
3591 at: 2,
3592 count: 1,
3593 })
3594 .as_deref(),
3595 Some("COLUMNS 2:2")
3596 );
3597 assert_eq!(dimension_range_label(&rename()), None);
3599 assert_eq!(dimension_range_label(&add_sheet()), None);
3600 }
3601
3602 #[test]
3603 fn dimension_range_label_omits_a_span_it_cannot_compute() {
3604 for (at, count) in [(i64::MAX, 5), (1, i64::MIN), (-1, i64::MIN)] {
3610 assert_eq!(
3611 dimension_range_label(&StructureVerb::InsertRows {
3612 sheet: "Q2".to_string(),
3613 at,
3614 count,
3615 }),
3616 None,
3617 "--at {at} --count {count} must not produce a span"
3618 );
3619 }
3620 }
3621
3622 #[tokio::test]
3625 async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
3626 let server = wiremock::MockServer::start().await;
3627 let (drive, sheets) = clients(&server).await;
3628 mount_file("sheet-1", "application/pdf", &["parent-1"])
3629 .mount(&server)
3630 .await;
3631 let outcome = structure(
3634 &drive,
3635 &sheets,
3636 &opts(rename(), false),
3637 &[allow_rule("parent-1")],
3638 )
3639 .await;
3640 assert!(matches!(
3641 outcome.result,
3642 StructureResult::RefusedNotASpreadsheet { .. }
3643 ));
3644 let text = describe(&outcome);
3645 assert!(text.contains("is not a Google Sheet"), "{text}");
3646 assert!(text.contains("drive sheets rename-sheet"), "{text}");
3647 }
3648
3649 #[tokio::test]
3650 async fn shortcut_is_refused_with_its_own_message() {
3651 let server = wiremock::MockServer::start().await;
3652 let (drive, sheets) = clients(&server).await;
3653 mount_file(
3654 "sheet-1",
3655 "application/vnd.google-apps.shortcut",
3656 &["parent-1"],
3657 )
3658 .mount(&server)
3659 .await;
3660 let outcome = structure(
3661 &drive,
3662 &sheets,
3663 &opts(rename(), false),
3664 &[allow_rule("parent-1")],
3665 )
3666 .await;
3667 assert!(matches!(outcome.result, StructureResult::RefusedShortcut));
3668 let text = describe(&outcome);
3669 assert!(text.contains("is a shortcut"), "{text}");
3670 }
3671
3672 #[tokio::test]
3673 async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_blocked() {
3674 let server = wiremock::MockServer::start().await;
3675 let (drive, sheets) = clients(&server).await;
3676 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3677 .mount(&server)
3678 .await;
3679 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3680 assert!(matches!(
3681 outcome.result,
3682 StructureResult::RefusedNoVisibleParents
3683 ));
3684 let text = describe(&outcome);
3685 assert!(text.contains("no parent folder visible"), "{text}");
3686 assert!(text.contains("sheets-structure"), "{text}");
3689 }
3690
3691 #[tokio::test]
3696 async fn a_delete_verbs_no_visible_parents_message_names_sheets_delete() {
3697 let server = wiremock::MockServer::start().await;
3698 let (drive, sheets) = clients(&server).await;
3699 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
3700 .mount(&server)
3701 .await;
3702 let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3703 let text = describe(&outcome);
3704 assert!(text.contains("sheets-delete"), "{text}");
3705 assert!(!text.contains("sheets-structure"), "{text}");
3706 }
3707
3708 #[tokio::test]
3711 async fn denied_target_makes_zero_sheets_calls() {
3712 let server = wiremock::MockServer::start().await;
3713 let (drive, sheets) = clients(&server).await;
3714 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3715 .mount(&server)
3716 .await;
3717 mount_folder("parent-1").mount(&server).await;
3718 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
3722 assert!(matches!(
3723 outcome.result,
3724 StructureResult::Blocked { decided_by: None }
3725 ));
3726 let text = describe(&outcome);
3727 assert!(text.contains("default policy"), "{text}");
3728 }
3729
3730 #[tokio::test]
3731 async fn a_blocked_delete_names_destructive_edits_and_sheets_delete() {
3732 let server = wiremock::MockServer::start().await;
3733 let (drive, sheets) = clients(&server).await;
3734 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3735 .mount(&server)
3736 .await;
3737 mount_folder("parent-1").mount(&server).await;
3738 let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
3739 assert!(matches!(
3740 outcome.result,
3741 StructureResult::Blocked { decided_by: None }
3742 ));
3743 let text = describe(&outcome);
3744 assert!(text.contains("destructive edits"), "{text}");
3745 assert!(text.contains("default policy"), "{text}");
3746 assert!(text.contains("sheets-delete"), "{text}");
3747 assert!(!text.contains("structural edits"), "{text}");
3748 }
3749
3750 #[tokio::test]
3751 async fn a_sheets_write_rule_alone_does_not_permit_a_structural_edit() {
3752 let server = wiremock::MockServer::start().await;
3753 let (drive, sheets) = clients(&server).await;
3754 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3755 .mount(&server)
3756 .await;
3757 mount_folder("parent-1").mount(&server).await;
3758 let rules = [FolderPermissionRule {
3762 folder_id: Some("parent-1".to_string()),
3763 file_id: None,
3764 recursive: true,
3765 allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
3766 deny: HashSet::default(),
3767 require_lease: true,
3768 }];
3769 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3770 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3771 }
3772
3773 #[tokio::test]
3777 async fn a_sheets_structure_rule_alone_does_not_permit_deletion() {
3778 let server = wiremock::MockServer::start().await;
3779 let (drive, sheets) = clients(&server).await;
3780 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3781 .mount(&server)
3782 .await;
3783 mount_folder("parent-1").mount(&server).await;
3784 let rules = [allow_rule("parent-1")]; for verb in [delete_sheet(), delete_rows(), delete_range()] {
3786 let outcome = structure(&drive, &sheets, &opts(verb, false), &rules).await;
3787 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3788 }
3789 }
3790
3791 #[tokio::test]
3794 async fn a_sheets_delete_rule_alone_does_not_permit_a_structural_edit() {
3795 let server = wiremock::MockServer::start().await;
3796 let (drive, sheets) = clients(&server).await;
3797 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3798 .mount(&server)
3799 .await;
3800 mount_folder("parent-1").mount(&server).await;
3801 let rules = [delete_allow_rule("parent-1")];
3802 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3803 assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
3804 }
3805
3806 #[tokio::test]
3807 async fn an_explicit_deny_is_reported_with_the_deciding_rule() {
3808 let server = wiremock::MockServer::start().await;
3809 let (drive, sheets) = clients(&server).await;
3810 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3811 .mount(&server)
3812 .await;
3813 mount_folder("parent-1").mount(&server).await;
3814 let rules = [FolderPermissionRule {
3815 folder_id: Some("parent-1".to_string()),
3816 file_id: None,
3817 recursive: true,
3818 allow: HashSet::default(),
3819 deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
3820 require_lease: true,
3821 }];
3822 let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
3823 let StructureResult::Blocked { decided_by } = &outcome.result else {
3824 panic!("expected Blocked, got {:?}", outcome.result);
3825 };
3826 let rule = decided_by.as_ref().expect("an explicit rule decided this");
3827 assert_eq!(rule.kind_label(), "folder");
3828 assert_eq!(rule.id(), "parent-1");
3829 assert_eq!(rule.depth_suffix(), " (depth 0)");
3830 }
3831
3832 #[tokio::test]
3833 async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
3834 let server = wiremock::MockServer::start().await;
3835 let (drive, sheets) = clients(&server).await;
3836 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3837 .mount(&server)
3838 .await;
3839 wiremock::Mock::given(wiremock::matchers::method("GET"))
3840 .and(wiremock::matchers::path("/drive/v3/files/parent-1"))
3841 .respond_with(wiremock::ResponseTemplate::new(500))
3842 .mount(&server)
3843 .await;
3844 let outcome = structure(
3845 &drive,
3846 &sheets,
3847 &opts(rename(), false),
3848 &[allow_rule("parent-1")],
3849 )
3850 .await;
3851 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
3852 }
3853
3854 #[tokio::test]
3864 async fn dry_run_reads_the_workbook_but_never_calls_batch_update() {
3865 let server = wiremock::MockServer::start().await;
3866 let (drive, sheets) = clients(&server).await;
3867 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3868 .mount(&server)
3869 .await;
3870 mount_folder("parent-1").mount(&server).await;
3871 mount_workbook().expect(1).mount(&server).await;
3872 let outcome = structure(
3873 &drive,
3874 &sheets,
3875 &opts(insert_rows(), true),
3876 &[allow_rule("parent-1")],
3877 )
3878 .await;
3879 assert!(matches!(
3880 outcome.result,
3881 StructureResult::WouldChange { .. }
3882 ));
3883 }
3884
3885 #[tokio::test]
3886 async fn dry_run_names_the_resulting_dimensions_and_the_shift() {
3887 let server = wiremock::MockServer::start().await;
3888 let (drive, sheets) = clients(&server).await;
3889 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3890 .mount(&server)
3891 .await;
3892 mount_folder("parent-1").mount(&server).await;
3893 mount_workbook().mount(&server).await;
3894 let outcome = structure(
3895 &drive,
3896 &sheets,
3897 &opts(insert_rows(), true),
3898 &[allow_rule("parent-1")],
3899 )
3900 .await;
3901 let text = describe(&outcome);
3902 assert!(
3905 text.contains("Would insert 3 row(s) before row 5"),
3906 "{text}"
3907 );
3908 assert!(text.contains("500 rows -> 503"), "{text}");
3909 assert!(text.contains("shift down"), "{text}");
3910 assert!(text.contains("sheetId 118293"), "{text}");
3911 }
3912
3913 #[tokio::test]
3917 async fn dry_run_never_calls_batch_update_for_a_delete_verb() {
3918 let server = wiremock::MockServer::start().await;
3919 let (drive, sheets) = clients(&server).await;
3920 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3921 .mount(&server)
3922 .await;
3923 mount_folder("parent-1").mount(&server).await;
3924 mount_workbook().mount(&server).await;
3925 for verb in [delete_sheet(), delete_rows(), delete_range()] {
3926 let outcome = structure(
3927 &drive,
3928 &sheets,
3929 &opts(verb, true),
3930 &[delete_allow_rule("parent-1")],
3931 )
3932 .await;
3933 assert!(matches!(
3934 outcome.result,
3935 StructureResult::WouldChange { .. }
3936 ));
3937 }
3938 }
3939
3940 #[tokio::test]
3941 async fn dry_run_names_the_deleted_dimensions_and_the_shift_plus_the_caveat() {
3942 let server = wiremock::MockServer::start().await;
3943 let (drive, sheets) = clients(&server).await;
3944 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3945 .mount(&server)
3946 .await;
3947 mount_folder("parent-1").mount(&server).await;
3948 mount_workbook().mount(&server).await;
3949 let outcome = structure(
3950 &drive,
3951 &sheets,
3952 &opts(delete_rows(), true),
3953 &[delete_allow_rule("parent-1")],
3954 )
3955 .await;
3956 let text = describe(&outcome);
3957 assert!(text.contains("Would delete 3 row(s) 5-7"), "{text}");
3958 assert!(text.contains("500 rows -> 497"), "{text}");
3959 assert!(text.contains("shift up"), "{text}");
3960 assert!(
3961 text.contains("formulas elsewhere in the workbook"),
3962 "{text}"
3963 );
3964 }
3965
3966 #[tokio::test]
3967 async fn dry_run_names_the_deleted_sheet_and_the_caveat() {
3968 let server = wiremock::MockServer::start().await;
3969 let (drive, sheets) = clients(&server).await;
3970 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3971 .mount(&server)
3972 .await;
3973 mount_folder("parent-1").mount(&server).await;
3974 mount_workbook().mount(&server).await;
3975 let outcome = structure(
3976 &drive,
3977 &sheets,
3978 &opts(delete_sheet(), true),
3979 &[delete_allow_rule("parent-1")],
3980 )
3981 .await;
3982 let text = describe(&outcome);
3983 assert!(text.contains("Would delete sheet 'Q2'"), "{text}");
3984 assert!(text.contains("2 sheet(s) -> 1"), "{text}");
3985 assert!(
3986 text.contains("formulas elsewhere in the workbook"),
3987 "{text}"
3988 );
3989 }
3990
3991 #[tokio::test]
3992 async fn dry_run_names_the_deleted_range_and_the_shift() {
3993 let server = wiremock::MockServer::start().await;
3994 let (drive, sheets) = clients(&server).await;
3995 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
3996 .mount(&server)
3997 .await;
3998 mount_folder("parent-1").mount(&server).await;
3999 mount_workbook().mount(&server).await;
4000 let outcome = structure(
4001 &drive,
4002 &sheets,
4003 &opts(delete_range(), true),
4004 &[delete_allow_rule("parent-1")],
4005 )
4006 .await;
4007 let text = describe(&outcome);
4008 assert!(
4009 text.contains("Would delete rows 2-4, columns 2-3"),
4010 "{text}"
4011 );
4012 assert!(text.contains("shifting remaining cells up"), "{text}");
4013 }
4014
4015 #[tokio::test]
4016 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
4017 let server = wiremock::MockServer::start().await;
4018 let (drive, sheets) = clients(&server).await;
4019 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4020 .mount(&server)
4021 .await;
4022 mount_folder("parent-1").mount(&server).await;
4023 let dry = structure(&drive, &sheets, &opts(rename(), true), &[]).await;
4026 let wet = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
4027 assert_eq!(dry.result, wet.result);
4028 assert!(matches!(dry.result, StructureResult::Blocked { .. }));
4029 }
4030
4031 #[tokio::test]
4034 async fn a_missing_sheet_is_refused_and_lists_what_does_exist() {
4035 let server = wiremock::MockServer::start().await;
4036 let (drive, sheets) = clients(&server).await;
4037 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4038 .mount(&server)
4039 .await;
4040 mount_folder("parent-1").mount(&server).await;
4041 mount_workbook().mount(&server).await;
4042 let verb = StructureVerb::RenameSheet {
4043 sheet: "Nope".to_string(),
4044 new_title: "Q3".to_string(),
4045 };
4046 let outcome = structure(
4047 &drive,
4048 &sheets,
4049 &opts(verb.clone(), false),
4050 &[allow_rule("parent-1")],
4051 )
4052 .await;
4053 assert!(matches!(
4054 outcome.result,
4055 StructureResult::RefusedSheetNotFound { .. }
4056 ));
4057 let text = describe(&outcome);
4058 assert!(text.contains("no sheet titled 'Nope'"), "{text}");
4059 assert!(text.contains("'Q1'"), "{text}");
4060 assert!(text.contains("'Q2'"), "{text}");
4061 }
4062
4063 #[tokio::test]
4066 async fn add_sheet_refuses_a_title_the_workbook_already_uses() {
4067 let server = wiremock::MockServer::start().await;
4068 let (drive, sheets) = clients(&server).await;
4069 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4070 .mount(&server)
4071 .await;
4072 mount_folder("parent-1").mount(&server).await;
4073 mount_workbook().mount(&server).await;
4074 let verb = StructureVerb::AddSheet {
4075 title: "Q1".to_string(),
4076 index: None,
4077 rows: None,
4078 columns: None,
4079 };
4080 let outcome = structure(
4082 &drive,
4083 &sheets,
4084 &opts(verb.clone(), false),
4085 &[allow_rule("parent-1")],
4086 )
4087 .await;
4088 assert!(matches!(
4089 outcome.result,
4090 StructureResult::RefusedSheetExists { .. }
4091 ));
4092 assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4093 }
4094
4095 #[tokio::test]
4099 async fn rename_sheet_refuses_a_title_another_sheet_already_uses() {
4100 let server = wiremock::MockServer::start().await;
4101 let (drive, sheets) = clients(&server).await;
4102 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4103 .mount(&server)
4104 .await;
4105 mount_folder("parent-1").mount(&server).await;
4106 mount_workbook().mount(&server).await;
4107 let verb = StructureVerb::RenameSheet {
4108 sheet: "Q2".to_string(),
4109 new_title: "Q1".to_string(),
4110 };
4111 let outcome = structure(
4114 &drive,
4115 &sheets,
4116 &opts(verb.clone(), false),
4117 &[allow_rule("parent-1")],
4118 )
4119 .await;
4120 assert!(matches!(
4121 outcome.result,
4122 StructureResult::RefusedSheetExists { .. }
4123 ));
4124 assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
4125
4126 let dry = structure(
4127 &drive,
4128 &sheets,
4129 &opts(verb.clone(), true),
4130 &[allow_rule("parent-1")],
4131 )
4132 .await;
4133 assert_eq!(dry.result, outcome.result);
4134 }
4135
4136 #[tokio::test]
4139 async fn rename_sheet_to_its_own_current_title_is_not_a_duplicate() {
4140 let server = wiremock::MockServer::start().await;
4141 let (drive, sheets) = clients(&server).await;
4142 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4143 .mount(&server)
4144 .await;
4145 mount_folder("parent-1").mount(&server).await;
4146 mount_workbook().mount(&server).await;
4147 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4148 .mount(&server)
4149 .await;
4150 let verb = StructureVerb::RenameSheet {
4151 sheet: "Q2".to_string(),
4152 new_title: "Q2".to_string(),
4153 };
4154 let outcome = structure(
4155 &drive,
4156 &sheets,
4157 &opts(verb, false),
4158 &[allow_rule("parent-1")],
4159 )
4160 .await;
4161 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4162 }
4163
4164 #[tokio::test]
4167 async fn insert_rows_refuses_a_count_below_one() {
4168 let server = wiremock::MockServer::start().await;
4169 let (drive, sheets) = clients(&server).await;
4170 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4171 .mount(&server)
4172 .await;
4173 mount_folder("parent-1").mount(&server).await;
4174 mount_workbook().mount(&server).await;
4175 let verb = StructureVerb::InsertRows {
4176 sheet: "Q2".to_string(),
4177 at: 5,
4178 count: 0,
4179 };
4180 let outcome = structure(
4182 &drive,
4183 &sheets,
4184 &opts(verb.clone(), false),
4185 &[allow_rule("parent-1")],
4186 )
4187 .await;
4188 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4189 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4190 };
4191 assert!(detail.contains("--count"), "{detail}");
4192 assert!(describe(&outcome).starts_with("Refused: --count"));
4193 }
4194
4195 #[tokio::test]
4196 async fn insert_columns_refuses_an_at_below_one() {
4197 let server = wiremock::MockServer::start().await;
4198 let (drive, sheets) = clients(&server).await;
4199 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4200 .mount(&server)
4201 .await;
4202 mount_folder("parent-1").mount(&server).await;
4203 mount_workbook().mount(&server).await;
4204 let verb = StructureVerb::InsertColumns {
4205 sheet: "Q2".to_string(),
4206 at: 0,
4207 count: 1,
4208 };
4209 let outcome = structure(
4210 &drive,
4211 &sheets,
4212 &opts(verb, false),
4213 &[allow_rule("parent-1")],
4214 )
4215 .await;
4216 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4217 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4218 };
4219 assert!(detail.contains("--at"), "{detail}");
4220 }
4221
4222 #[tokio::test]
4227 async fn insert_rows_refuses_an_at_past_the_sheets_end() {
4228 let server = wiremock::MockServer::start().await;
4229 let (drive, sheets) = clients(&server).await;
4230 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4231 .mount(&server)
4232 .await;
4233 mount_folder("parent-1").mount(&server).await;
4234 mount_workbook().mount(&server).await;
4235 let verb = StructureVerb::InsertRows {
4236 sheet: "Q2".to_string(),
4237 at: 502,
4238 count: 1,
4239 };
4240 let outcome = structure(
4241 &drive,
4242 &sheets,
4243 &opts(verb.clone(), false),
4244 &[allow_rule("parent-1")],
4245 )
4246 .await;
4247 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4248 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4249 };
4250 assert!(detail.contains("500 row(s)"), "{detail}");
4251 assert!(detail.contains("501"), "{detail}");
4252
4253 let dry = structure(
4254 &drive,
4255 &sheets,
4256 &opts(verb, true),
4257 &[allow_rule("parent-1")],
4258 )
4259 .await;
4260 assert_eq!(dry.result, outcome.result);
4261 }
4262
4263 #[test]
4264 fn an_at_and_count_that_would_overflow_the_index_space_are_refused() {
4265 let sheet = SheetSnapshot {
4266 sheet_id: Some(1),
4267 title: "Q2".to_string(),
4268 row_count: Some(500),
4269 column_count: Some(26),
4270 ..Default::default()
4271 };
4272 for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
4277 let Err(StructureResult::RefusedInvalidRange { detail }) =
4278 validate_insert_bounds(Dimension::Rows, at, count, Some(&sheet))
4279 else {
4280 panic!("expected RefusedInvalidRange for --at {at} --count {count}");
4281 };
4282 assert!(detail.contains("overflows the row index space"), "{detail}");
4283 }
4284 }
4285
4286 #[tokio::test]
4287 async fn a_refused_out_of_range_insert_still_records_without_panicking() {
4288 let server = wiremock::MockServer::start().await;
4294 let (drive, sheets) = clients(&server).await;
4295 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4296 .mount(&server)
4297 .await;
4298 mount_folder("parent-1").mount(&server).await;
4299 mount_workbook().mount(&server).await;
4300
4301 let outcome = structure(
4302 &drive,
4303 &sheets,
4304 &opts(
4305 StructureVerb::InsertRows {
4306 sheet: "Q2".to_string(),
4307 at: i64::MAX,
4308 count: 5,
4309 },
4310 false,
4311 ),
4312 &[allow_rule("parent-1")],
4313 )
4314 .await;
4315 assert!(
4316 matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
4317 "{:?}",
4318 outcome.result
4319 );
4320 }
4321
4322 #[tokio::test]
4327 async fn insert_rows_allows_at_equal_to_row_count_plus_one_as_an_append() {
4328 let server = wiremock::MockServer::start().await;
4329 let (drive, sheets) = clients(&server).await;
4330 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4331 .mount(&server)
4332 .await;
4333 mount_folder("parent-1").mount(&server).await;
4334 mount_workbook().mount(&server).await;
4335 let verb = StructureVerb::InsertRows {
4336 sheet: "Q2".to_string(),
4337 at: 501,
4338 count: 2,
4339 };
4340 let outcome = structure(
4341 &drive,
4342 &sheets,
4343 &opts(verb.clone(), true),
4344 &[allow_rule("parent-1")],
4345 )
4346 .await;
4347 assert!(matches!(
4348 outcome.result,
4349 StructureResult::WouldChange { .. }
4350 ));
4351 let text = describe(&outcome);
4352 assert!(text.contains("500 rows -> 502"), "{text}");
4353 assert!(text.contains("appended at the end"), "{text}");
4354 assert!(!text.contains("501-500"), "{text}");
4355 }
4356
4357 #[tokio::test]
4358 async fn add_sheet_refuses_non_positive_rows_and_columns() {
4359 let server = wiremock::MockServer::start().await;
4360 let (drive, sheets) = clients(&server).await;
4361 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4362 .mount(&server)
4363 .await;
4364 mount_folder("parent-1").mount(&server).await;
4365 mount_workbook().mount(&server).await;
4366 let verb = StructureVerb::AddSheet {
4367 title: "Q3".to_string(),
4368 index: None,
4369 rows: Some(0),
4370 columns: None,
4371 };
4372 let outcome = structure(
4373 &drive,
4374 &sheets,
4375 &opts(verb, false),
4376 &[allow_rule("parent-1")],
4377 )
4378 .await;
4379 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4380 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4381 };
4382 assert!(detail.contains("--rows"), "{detail}");
4383 }
4384
4385 #[tokio::test]
4388 async fn add_sheet_refuses_an_out_of_range_index() {
4389 let server = wiremock::MockServer::start().await;
4390 let (drive, sheets) = clients(&server).await;
4391 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4392 .mount(&server)
4393 .await;
4394 mount_folder("parent-1").mount(&server).await;
4395 mount_workbook().mount(&server).await;
4396 let verb = StructureVerb::AddSheet {
4397 title: "Q3".to_string(),
4398 index: Some(3),
4399 rows: None,
4400 columns: None,
4401 };
4402 let outcome = structure(
4403 &drive,
4404 &sheets,
4405 &opts(verb, false),
4406 &[allow_rule("parent-1")],
4407 )
4408 .await;
4409 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4410 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4411 };
4412 assert!(detail.contains("--index"), "{detail}");
4413 }
4414
4415 #[tokio::test]
4416 async fn add_sheet_allows_an_index_equal_to_the_sheet_count() {
4417 let server = wiremock::MockServer::start().await;
4418 let (drive, sheets) = clients(&server).await;
4419 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4420 .mount(&server)
4421 .await;
4422 mount_folder("parent-1").mount(&server).await;
4423 mount_workbook().mount(&server).await;
4424 let verb = StructureVerb::AddSheet {
4425 title: "Q3".to_string(),
4426 index: Some(2),
4427 rows: None,
4428 columns: None,
4429 };
4430 let outcome = structure(
4431 &drive,
4432 &sheets,
4433 &opts(verb, true),
4434 &[allow_rule("parent-1")],
4435 )
4436 .await;
4437 assert!(matches!(
4438 outcome.result,
4439 StructureResult::WouldChange { .. }
4440 ));
4441 }
4442
4443 #[tokio::test]
4446 async fn rename_sends_a_title_masked_update_for_the_resolved_sheet_id() {
4447 let server = wiremock::MockServer::start().await;
4448 let (drive, sheets) = clients(&server).await;
4449 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4450 .mount(&server)
4451 .await;
4452 mount_folder("parent-1").mount(&server).await;
4453 mount_workbook().mount(&server).await;
4454 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4455 .expect(1)
4456 .mount(&server)
4457 .await;
4458 let outcome = structure(
4459 &drive,
4460 &sheets,
4461 &opts(rename(), false),
4462 &[allow_rule("parent-1")],
4463 )
4464 .await;
4465 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4466
4467 let requests = server.received_requests().await.unwrap();
4468 let body: serde_json::Value = requests
4469 .iter()
4470 .find(|r| r.url.path().ends_with(":batchUpdate"))
4471 .map(|r| serde_json::from_slice(&r.body).unwrap())
4472 .expect("a batchUpdate request");
4473 let update = &body["requests"][0]["updateSheetProperties"];
4474 assert_eq!(update["properties"]["sheetId"], 118_293);
4476 assert_eq!(update["properties"]["title"], "Q3");
4477 assert_eq!(update["fields"], "title");
4480 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4481 }
4482
4483 #[tokio::test]
4484 async fn insert_rows_sends_a_zero_based_half_open_range() {
4485 let server = wiremock::MockServer::start().await;
4486 let (drive, sheets) = clients(&server).await;
4487 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4488 .mount(&server)
4489 .await;
4490 mount_folder("parent-1").mount(&server).await;
4491 mount_workbook().mount(&server).await;
4492 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4493 .mount(&server)
4494 .await;
4495 let outcome = structure(
4496 &drive,
4497 &sheets,
4498 &opts(insert_rows(), false),
4499 &[allow_rule("parent-1")],
4500 )
4501 .await;
4502 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4503
4504 let requests = server.received_requests().await.unwrap();
4505 let body: serde_json::Value = requests
4506 .iter()
4507 .find(|r| r.url.path().ends_with(":batchUpdate"))
4508 .map(|r| serde_json::from_slice(&r.body).unwrap())
4509 .expect("a batchUpdate request");
4510 let insert = &body["requests"][0]["insertDimension"];
4511 assert_eq!(insert["range"]["dimension"], "ROWS");
4512 assert_eq!(insert["range"]["sheetId"], 118_293);
4513 assert_eq!(insert["range"]["startIndex"], 4);
4516 assert_eq!(insert["range"]["endIndex"], 7);
4517 assert_eq!(insert["inheritFromBefore"], false);
4518 }
4519
4520 #[tokio::test]
4521 async fn delete_sheet_sends_the_resolved_sheet_id() {
4522 let server = wiremock::MockServer::start().await;
4523 let (drive, sheets) = clients(&server).await;
4524 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4525 .mount(&server)
4526 .await;
4527 mount_folder("parent-1").mount(&server).await;
4528 mount_workbook().mount(&server).await;
4529 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4530 .expect(1)
4531 .mount(&server)
4532 .await;
4533 let outcome = structure(
4534 &drive,
4535 &sheets,
4536 &opts(delete_sheet(), false),
4537 &[delete_allow_rule("parent-1")],
4538 )
4539 .await;
4540 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4541 assert!(describe(&outcome).contains("Deleted sheet 'Q2'"));
4542 assert!(describe(&outcome).contains("backed the file up when it was acquired"));
4546 assert!(describe(&outcome).contains("/tmp/test-backup"));
4547 assert!(describe(&outcome).contains("Google Drive's own version history"));
4548
4549 let requests = server.received_requests().await.unwrap();
4550 let body: serde_json::Value = requests
4551 .iter()
4552 .find(|r| r.url.path().ends_with(":batchUpdate"))
4553 .map(|r| serde_json::from_slice(&r.body).unwrap())
4554 .expect("a batchUpdate request");
4555 assert_eq!(body["requests"][0]["deleteSheet"]["sheetId"], 118_293);
4556 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
4557 }
4558
4559 #[tokio::test]
4560 async fn delete_rows_sends_a_zero_based_half_open_range() {
4561 let server = wiremock::MockServer::start().await;
4562 let (drive, sheets) = clients(&server).await;
4563 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4564 .mount(&server)
4565 .await;
4566 mount_folder("parent-1").mount(&server).await;
4567 mount_workbook().mount(&server).await;
4568 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4569 .mount(&server)
4570 .await;
4571 let outcome = structure(
4572 &drive,
4573 &sheets,
4574 &opts(delete_rows(), false),
4575 &[delete_allow_rule("parent-1")],
4576 )
4577 .await;
4578 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4579
4580 let requests = server.received_requests().await.unwrap();
4581 let body: serde_json::Value = requests
4582 .iter()
4583 .find(|r| r.url.path().ends_with(":batchUpdate"))
4584 .map(|r| serde_json::from_slice(&r.body).unwrap())
4585 .expect("a batchUpdate request");
4586 let delete = &body["requests"][0]["deleteDimension"];
4587 assert_eq!(delete["range"]["dimension"], "ROWS");
4588 assert_eq!(delete["range"]["sheetId"], 118_293);
4589 assert_eq!(delete["range"]["startIndex"], 4);
4592 assert_eq!(delete["range"]["endIndex"], 7);
4593 }
4594
4595 #[tokio::test]
4596 async fn delete_columns_sends_a_zero_based_half_open_range() {
4597 let server = wiremock::MockServer::start().await;
4598 let (drive, sheets) = clients(&server).await;
4599 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4600 .mount(&server)
4601 .await;
4602 mount_folder("parent-1").mount(&server).await;
4603 mount_workbook().mount(&server).await;
4604 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4605 .mount(&server)
4606 .await;
4607 let outcome = structure(
4608 &drive,
4609 &sheets,
4610 &opts(delete_columns(), false),
4611 &[delete_allow_rule("parent-1")],
4612 )
4613 .await;
4614 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4615 assert!(describe(&outcome).contains("Deleted 2 column(s) 3-4"));
4616
4617 let requests = server.received_requests().await.unwrap();
4618 let body: serde_json::Value = requests
4619 .iter()
4620 .find(|r| r.url.path().ends_with(":batchUpdate"))
4621 .map(|r| serde_json::from_slice(&r.body).unwrap())
4622 .expect("a batchUpdate request");
4623 let delete = &body["requests"][0]["deleteDimension"];
4624 assert_eq!(delete["range"]["dimension"], "COLUMNS");
4625 assert_eq!(delete["range"]["sheetId"], 118_293);
4626 assert_eq!(delete["range"]["startIndex"], 2);
4629 assert_eq!(delete["range"]["endIndex"], 4);
4630 }
4631
4632 #[tokio::test]
4633 async fn insert_range_sends_a_zero_based_half_open_grid_range() {
4634 let server = wiremock::MockServer::start().await;
4635 let (drive, sheets) = clients(&server).await;
4636 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4637 .mount(&server)
4638 .await;
4639 mount_folder("parent-1").mount(&server).await;
4640 mount_workbook().mount(&server).await;
4641 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4642 .mount(&server)
4643 .await;
4644 let outcome = structure(
4645 &drive,
4646 &sheets,
4647 &opts(insert_range(), false),
4648 &[allow_rule("parent-1")],
4649 )
4650 .await;
4651 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4652 assert!(describe(&outcome).contains("shifting existing cells down"));
4653 assert!(describe(&outcome).contains("may have been dropped by Sheets"));
4654
4655 let requests = server.received_requests().await.unwrap();
4656 let body: serde_json::Value = requests
4657 .iter()
4658 .find(|r| r.url.path().ends_with(":batchUpdate"))
4659 .map(|r| serde_json::from_slice(&r.body).unwrap())
4660 .expect("a batchUpdate request");
4661 assert_eq!(
4662 body["requests"][0]["insertRange"],
4663 serde_json::json!({
4664 "range": {
4665 "sheetId": 118_293,
4666 "startRowIndex": 1,
4667 "endRowIndex": 4,
4668 "startColumnIndex": 1,
4669 "endColumnIndex": 3,
4670 },
4671 "shiftDimension": "ROWS",
4672 })
4673 );
4674 }
4675
4676 #[tokio::test]
4677 async fn insert_range_with_columns_shift_sends_the_column_direction() {
4678 let server = wiremock::MockServer::start().await;
4679 let (drive, sheets) = clients(&server).await;
4680 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4681 .mount(&server)
4682 .await;
4683 mount_folder("parent-1").mount(&server).await;
4684 mount_workbook().mount(&server).await;
4685 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4686 .mount(&server)
4687 .await;
4688 let outcome = structure(
4689 &drive,
4690 &sheets,
4691 &opts(insert_range_columns_shift(), false),
4692 &[allow_rule("parent-1")],
4693 )
4694 .await;
4695 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4696 assert!(describe(&outcome).contains("shifting existing cells right"));
4697
4698 let requests = server.received_requests().await.unwrap();
4699 let body: serde_json::Value = requests
4700 .iter()
4701 .find(|r| r.url.path().ends_with(":batchUpdate"))
4702 .map(|r| serde_json::from_slice(&r.body).unwrap())
4703 .expect("a batchUpdate request");
4704 assert_eq!(
4705 body["requests"][0]["insertRange"]["shiftDimension"],
4706 "COLUMNS"
4707 );
4708 }
4709
4710 #[tokio::test]
4711 async fn insert_range_dry_run_at_the_grid_edge_warns_without_mutating() {
4712 let server = wiremock::MockServer::start().await;
4713 let (drive, sheets) = clients(&server).await;
4714 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4715 .mount(&server)
4716 .await;
4717 mount_folder("parent-1").mount(&server).await;
4718 mount_workbook().mount(&server).await;
4719 let outcome = structure(
4720 &drive,
4721 &sheets,
4722 &opts(
4723 StructureVerb::InsertRange {
4724 sheet: "Q2".to_string(),
4725 start_row: 500,
4726 end_row: 500,
4727 start_column: 10,
4728 end_column: 10,
4729 shift: ShiftDimension::Columns,
4730 },
4731 true,
4732 ),
4733 &[allow_rule("parent-1")],
4734 )
4735 .await;
4736 assert!(matches!(
4737 outcome.result,
4738 StructureResult::WouldChange { .. }
4739 ));
4740 let text = describe(&outcome);
4741 assert!(text.contains("shifting existing cells right"), "{text}");
4742 assert!(text.contains("may be dropped by Sheets"), "{text}");
4743 assert!(server
4744 .received_requests()
4745 .await
4746 .unwrap()
4747 .iter()
4748 .all(|request| !request.url.path().ends_with(":batchUpdate")));
4749 }
4750
4751 #[tokio::test]
4752 async fn insert_range_dry_run_with_rows_shift_warns_without_mutating() {
4753 let server = wiremock::MockServer::start().await;
4754 let (drive, sheets) = clients(&server).await;
4755 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4756 .mount(&server)
4757 .await;
4758 mount_folder("parent-1").mount(&server).await;
4759 mount_workbook().mount(&server).await;
4760 let outcome = structure(
4761 &drive,
4762 &sheets,
4763 &opts(
4764 StructureVerb::InsertRange {
4765 sheet: "Q2".to_string(),
4766 start_row: 500,
4767 end_row: 500,
4768 start_column: 10,
4769 end_column: 10,
4770 shift: ShiftDimension::Rows,
4771 },
4772 true,
4773 ),
4774 &[allow_rule("parent-1")],
4775 )
4776 .await;
4777 assert!(matches!(
4778 outcome.result,
4779 StructureResult::WouldChange { .. }
4780 ));
4781 let text = describe(&outcome);
4782 assert!(text.contains("shifting existing cells down"), "{text}");
4783 assert!(text.contains("may be dropped by Sheets"), "{text}");
4784 assert!(server
4785 .received_requests()
4786 .await
4787 .unwrap()
4788 .iter()
4789 .all(|request| !request.url.path().ends_with(":batchUpdate")));
4790 }
4791
4792 #[tokio::test]
4793 async fn delete_range_sends_a_zero_based_half_open_grid_range() {
4794 let server = wiremock::MockServer::start().await;
4795 let (drive, sheets) = clients(&server).await;
4796 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4797 .mount(&server)
4798 .await;
4799 mount_folder("parent-1").mount(&server).await;
4800 mount_workbook().mount(&server).await;
4801 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4802 .mount(&server)
4803 .await;
4804 let outcome = structure(
4805 &drive,
4806 &sheets,
4807 &opts(delete_range(), false),
4808 &[delete_allow_rule("parent-1")],
4809 )
4810 .await;
4811 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4812
4813 let requests = server.received_requests().await.unwrap();
4814 let body: serde_json::Value = requests
4815 .iter()
4816 .find(|r| r.url.path().ends_with(":batchUpdate"))
4817 .map(|r| serde_json::from_slice(&r.body).unwrap())
4818 .expect("a batchUpdate request");
4819 let delete = &body["requests"][0]["deleteRange"];
4820 assert_eq!(delete["range"]["sheetId"], 118_293);
4821 assert_eq!(delete["range"]["startRowIndex"], 1);
4824 assert_eq!(delete["range"]["endRowIndex"], 4);
4825 assert_eq!(delete["range"]["startColumnIndex"], 1);
4826 assert_eq!(delete["range"]["endColumnIndex"], 3);
4827 assert_eq!(delete["shiftDimension"], "ROWS");
4828 }
4829
4830 #[tokio::test]
4831 async fn delete_range_with_columns_shift_sends_the_column_direction() {
4832 let server = wiremock::MockServer::start().await;
4833 let (drive, sheets) = clients(&server).await;
4834 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4835 .mount(&server)
4836 .await;
4837 mount_folder("parent-1").mount(&server).await;
4838 mount_workbook().mount(&server).await;
4839 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
4840 .mount(&server)
4841 .await;
4842 let outcome = structure(
4843 &drive,
4844 &sheets,
4845 &opts(delete_range_columns_shift(), false),
4846 &[delete_allow_rule("parent-1")],
4847 )
4848 .await;
4849 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
4850 assert!(describe(&outcome).contains("shifted remaining cells left"));
4851
4852 let requests = server.received_requests().await.unwrap();
4853 let body: serde_json::Value = requests
4854 .iter()
4855 .find(|r| r.url.path().ends_with(":batchUpdate"))
4856 .map(|r| serde_json::from_slice(&r.body).unwrap())
4857 .expect("a batchUpdate request");
4858 assert_eq!(
4859 body["requests"][0]["deleteRange"]["shiftDimension"],
4860 "COLUMNS"
4861 );
4862 }
4863
4864 #[tokio::test]
4865 async fn delete_dimension_past_the_end_of_the_sheet_is_refused() {
4866 let server = wiremock::MockServer::start().await;
4867 let (drive, sheets) = clients(&server).await;
4868 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4869 .mount(&server)
4870 .await;
4871 mount_folder("parent-1").mount(&server).await;
4872 mount_workbook().mount(&server).await;
4873 let outcome = structure(
4875 &drive,
4876 &sheets,
4877 &opts(
4878 StructureVerb::DeleteRows {
4879 sheet: "Q2".to_string(),
4880 at: 499,
4881 count: 5,
4882 },
4883 true,
4884 ),
4885 &[delete_allow_rule("parent-1")],
4886 )
4887 .await;
4888 assert!(matches!(
4889 outcome.result,
4890 StructureResult::RefusedInvalidRange { .. }
4891 ));
4892 }
4893
4894 #[tokio::test]
4895 async fn delete_range_with_end_before_start_is_refused() {
4896 let server = wiremock::MockServer::start().await;
4897 let (drive, sheets) = clients(&server).await;
4898 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4899 .mount(&server)
4900 .await;
4901 mount_folder("parent-1").mount(&server).await;
4902 mount_workbook().mount(&server).await;
4903 let outcome = structure(
4904 &drive,
4905 &sheets,
4906 &opts(
4907 StructureVerb::DeleteRange {
4908 sheet: "Q2".to_string(),
4909 start_row: 5,
4910 end_row: 2,
4911 start_column: 1,
4912 end_column: 2,
4913 shift: ShiftDimension::Rows,
4914 },
4915 true,
4916 ),
4917 &[delete_allow_rule("parent-1")],
4918 )
4919 .await;
4920 assert!(matches!(
4921 outcome.result,
4922 StructureResult::RefusedInvalidRange { .. }
4923 ));
4924 }
4925
4926 #[tokio::test]
4927 async fn delete_dimension_refuses_a_non_positive_count() {
4928 let server = wiremock::MockServer::start().await;
4929 let (drive, sheets) = clients(&server).await;
4930 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4931 .mount(&server)
4932 .await;
4933 mount_folder("parent-1").mount(&server).await;
4934 mount_workbook().mount(&server).await;
4935 let outcome = structure(
4936 &drive,
4937 &sheets,
4938 &opts(
4939 StructureVerb::DeleteRows {
4940 sheet: "Q2".to_string(),
4941 at: 1,
4942 count: 0,
4943 },
4944 true,
4945 ),
4946 &[delete_allow_rule("parent-1")],
4947 )
4948 .await;
4949 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4950 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4951 };
4952 assert!(detail.contains("--count must be at least 1"), "{detail}");
4953 }
4954
4955 #[tokio::test]
4956 async fn delete_dimension_refuses_an_at_below_one() {
4957 let server = wiremock::MockServer::start().await;
4958 let (drive, sheets) = clients(&server).await;
4959 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4960 .mount(&server)
4961 .await;
4962 mount_folder("parent-1").mount(&server).await;
4963 mount_workbook().mount(&server).await;
4964 let outcome = structure(
4965 &drive,
4966 &sheets,
4967 &opts(
4968 StructureVerb::DeleteColumns {
4969 sheet: "Q2".to_string(),
4970 at: 0,
4971 count: 1,
4972 },
4973 true,
4974 ),
4975 &[delete_allow_rule("parent-1")],
4976 )
4977 .await;
4978 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
4979 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
4980 };
4981 assert!(detail.contains("--at must be at least 1"), "{detail}");
4982 }
4983
4984 #[tokio::test]
4985 async fn delete_dimension_refuses_an_at_and_count_that_overflow_the_index_space() {
4986 let server = wiremock::MockServer::start().await;
4987 let (drive, sheets) = clients(&server).await;
4988 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
4989 .mount(&server)
4990 .await;
4991 mount_folder("parent-1").mount(&server).await;
4992 mount_workbook().mount(&server).await;
4993 let outcome = structure(
4994 &drive,
4995 &sheets,
4996 &opts(
4997 StructureVerb::DeleteRows {
4998 sheet: "Q2".to_string(),
4999 at: i64::MAX,
5000 count: 2,
5001 },
5002 true,
5003 ),
5004 &[delete_allow_rule("parent-1")],
5005 )
5006 .await;
5007 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5008 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5009 };
5010 assert!(detail.contains("overflows the row index space"), "{detail}");
5011 }
5012
5013 #[tokio::test]
5014 async fn delete_range_refuses_a_start_row_below_one() {
5015 let server = wiremock::MockServer::start().await;
5016 let (drive, sheets) = clients(&server).await;
5017 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5018 .mount(&server)
5019 .await;
5020 mount_folder("parent-1").mount(&server).await;
5021 mount_workbook().mount(&server).await;
5022 let outcome = structure(
5023 &drive,
5024 &sheets,
5025 &opts(
5026 StructureVerb::DeleteRange {
5027 sheet: "Q2".to_string(),
5028 start_row: 0,
5029 end_row: 2,
5030 start_column: 1,
5031 end_column: 2,
5032 shift: ShiftDimension::Rows,
5033 },
5034 true,
5035 ),
5036 &[delete_allow_rule("parent-1")],
5037 )
5038 .await;
5039 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5040 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5041 };
5042 assert!(
5043 detail.contains("--start-row must be at least 1"),
5044 "{detail}"
5045 );
5046 }
5047
5048 #[tokio::test]
5049 async fn delete_range_refuses_a_start_column_below_one() {
5050 let server = wiremock::MockServer::start().await;
5051 let (drive, sheets) = clients(&server).await;
5052 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5053 .mount(&server)
5054 .await;
5055 mount_folder("parent-1").mount(&server).await;
5056 mount_workbook().mount(&server).await;
5057 let outcome = structure(
5058 &drive,
5059 &sheets,
5060 &opts(
5061 StructureVerb::DeleteRange {
5062 sheet: "Q2".to_string(),
5063 start_row: 1,
5064 end_row: 2,
5065 start_column: 0,
5066 end_column: 2,
5067 shift: ShiftDimension::Rows,
5068 },
5069 true,
5070 ),
5071 &[delete_allow_rule("parent-1")],
5072 )
5073 .await;
5074 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5075 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5076 };
5077 assert!(
5078 detail.contains("--start-column must be at least 1"),
5079 "{detail}"
5080 );
5081 }
5082
5083 #[tokio::test]
5084 async fn delete_range_refuses_an_end_column_before_start_column() {
5085 let server = wiremock::MockServer::start().await;
5086 let (drive, sheets) = clients(&server).await;
5087 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5088 .mount(&server)
5089 .await;
5090 mount_folder("parent-1").mount(&server).await;
5091 mount_workbook().mount(&server).await;
5092 let outcome = structure(
5096 &drive,
5097 &sheets,
5098 &opts(
5099 StructureVerb::DeleteRange {
5100 sheet: "Q2".to_string(),
5101 start_row: 1,
5102 end_row: 2,
5103 start_column: 5,
5104 end_column: 2,
5105 shift: ShiftDimension::Rows,
5106 },
5107 true,
5108 ),
5109 &[delete_allow_rule("parent-1")],
5110 )
5111 .await;
5112 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5113 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5114 };
5115 assert!(
5116 detail.contains("--end-column (2) must be at or after --start-column (5)"),
5117 "{detail}"
5118 );
5119 }
5120
5121 #[tokio::test]
5122 async fn delete_range_refuses_an_end_row_past_the_end_of_the_sheet() {
5123 let server = wiremock::MockServer::start().await;
5124 let (drive, sheets) = clients(&server).await;
5125 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5126 .mount(&server)
5127 .await;
5128 mount_folder("parent-1").mount(&server).await;
5129 mount_workbook().mount(&server).await;
5130 let outcome = structure(
5132 &drive,
5133 &sheets,
5134 &opts(
5135 StructureVerb::DeleteRange {
5136 sheet: "Q2".to_string(),
5137 start_row: 1,
5138 end_row: 501,
5139 start_column: 1,
5140 end_column: 2,
5141 shift: ShiftDimension::Rows,
5142 },
5143 true,
5144 ),
5145 &[delete_allow_rule("parent-1")],
5146 )
5147 .await;
5148 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5149 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5150 };
5151 assert!(
5152 detail.contains("--end-row 501 is past the end of the sheet"),
5153 "{detail}"
5154 );
5155 }
5156
5157 #[tokio::test]
5158 async fn delete_range_refuses_an_end_column_past_the_end_of_the_sheet() {
5159 let server = wiremock::MockServer::start().await;
5160 let (drive, sheets) = clients(&server).await;
5161 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5162 .mount(&server)
5163 .await;
5164 mount_folder("parent-1").mount(&server).await;
5165 mount_workbook().mount(&server).await;
5166 let outcome = structure(
5168 &drive,
5169 &sheets,
5170 &opts(
5171 StructureVerb::DeleteRange {
5172 sheet: "Q2".to_string(),
5173 start_row: 1,
5174 end_row: 2,
5175 start_column: 1,
5176 end_column: 11,
5177 shift: ShiftDimension::Rows,
5178 },
5179 true,
5180 ),
5181 &[delete_allow_rule("parent-1")],
5182 )
5183 .await;
5184 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5185 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5186 };
5187 assert!(
5188 detail.contains("--end-column 11 is past the end of the sheet"),
5189 "{detail}"
5190 );
5191 }
5192
5193 #[tokio::test]
5194 async fn add_sheet_reports_the_server_assigned_sheet_id() {
5195 let server = wiremock::MockServer::start().await;
5196 let (drive, sheets) = clients(&server).await;
5197 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5198 .mount(&server)
5199 .await;
5200 mount_folder("parent-1").mount(&server).await;
5201 mount_workbook().mount(&server).await;
5202 mount_batch_update(serde_json::json!({
5203 "spreadsheetId": "sheet-1",
5204 "replies": [{"addSheet": {"properties": {"sheetId": 999, "title": "Q3"}}}],
5205 }))
5206 .mount(&server)
5207 .await;
5208 let outcome = structure(
5209 &drive,
5210 &sheets,
5211 &opts(add_sheet(), false),
5212 &[allow_rule("parent-1")],
5213 )
5214 .await;
5215 assert!(matches!(
5217 outcome.result,
5218 StructureResult::Changed {
5219 sheet_id: Some(999),
5220 ..
5221 }
5222 ));
5223 assert!(describe(&outcome).contains("sheetId 999"));
5224 }
5225
5226 #[tokio::test]
5227 async fn add_sheet_omits_grid_properties_when_no_size_was_asked_for() {
5228 let server = wiremock::MockServer::start().await;
5229 let (drive, sheets) = clients(&server).await;
5230 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5231 .mount(&server)
5232 .await;
5233 mount_folder("parent-1").mount(&server).await;
5234 mount_workbook().mount(&server).await;
5235 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5236 .mount(&server)
5237 .await;
5238 let _ = structure(
5239 &drive,
5240 &sheets,
5241 &opts(add_sheet(), false),
5242 &[allow_rule("parent-1")],
5243 )
5244 .await;
5245 let requests = server.received_requests().await.unwrap();
5246 let body: serde_json::Value = requests
5247 .iter()
5248 .find(|r| r.url.path().ends_with(":batchUpdate"))
5249 .map(|r| serde_json::from_slice(&r.body).unwrap())
5250 .expect("a batchUpdate request");
5251 let props = &body["requests"][0]["addSheet"]["properties"];
5252 assert!(props.get("gridProperties").is_none(), "{props}");
5255 assert!(props.get("index").is_none(), "{props}");
5256 assert_eq!(props["title"], "Q3");
5257 }
5258
5259 #[tokio::test]
5260 async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
5261 let server = wiremock::MockServer::start().await;
5262 let (drive, sheets) = clients(&server).await;
5263 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5264 .mount(&server)
5265 .await;
5266 mount_folder("parent-1").mount(&server).await;
5267 mount_workbook().mount(&server).await;
5268 wiremock::Mock::given(wiremock::matchers::method("POST"))
5269 .and(wiremock::matchers::path(
5270 "/v4/spreadsheets/sheet-1:batchUpdate",
5271 ))
5272 .respond_with(
5273 wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
5274 "error": {
5275 "code": 403,
5276 "message": "The caller does not have permission",
5277 "status": "PERMISSION_DENIED",
5278 }
5279 })),
5280 )
5281 .mount(&server)
5282 .await;
5283 let outcome = structure(
5284 &drive,
5285 &sheets,
5286 &opts(rename(), false),
5287 &[allow_rule("parent-1")],
5288 )
5289 .await;
5290 let StructureResult::Failed { detail } = &outcome.result else {
5291 panic!("expected Failed, got {:?}", outcome.result);
5292 };
5293 assert!(detail.contains("drive auth login"), "{detail}");
5296 }
5297
5298 #[tokio::test]
5301 async fn duplicate_sheet_apply_sends_source_title_and_index() {
5302 let server = wiremock::MockServer::start().await;
5303 let (drive, sheets) = clients(&server).await;
5304 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5305 .mount(&server)
5306 .await;
5307 mount_folder("parent-1").mount(&server).await;
5308 mount_workbook().mount(&server).await;
5309 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5310 .mount(&server)
5311 .await;
5312 let outcome = structure(
5313 &drive,
5314 &sheets,
5315 &opts(duplicate_sheet(), false),
5316 &[allow_rule("parent-1")],
5317 )
5318 .await;
5319 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5320
5321 let requests = server.received_requests().await.unwrap();
5322 let body: serde_json::Value = requests
5323 .iter()
5324 .find(|r| r.url.path().ends_with(":batchUpdate"))
5325 .map(|r| serde_json::from_slice(&r.body).unwrap())
5326 .expect("a batchUpdate request");
5327 let dup = &body["requests"][0]["duplicateSheet"];
5328 assert_eq!(dup["sourceSheetId"], 118_293);
5329 assert_eq!(dup["insertSheetIndex"], 1);
5330 assert_eq!(dup["newSheetName"], "Q2 Copy");
5331 }
5332
5333 #[tokio::test]
5334 async fn duplicate_sheet_apply_with_default_title_and_index() {
5335 let server = wiremock::MockServer::start().await;
5336 let (drive, sheets) = clients(&server).await;
5337 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5338 .mount(&server)
5339 .await;
5340 mount_folder("parent-1").mount(&server).await;
5341 mount_workbook().mount(&server).await;
5342 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5343 .mount(&server)
5344 .await;
5345 let outcome = structure(
5346 &drive,
5347 &sheets,
5348 &opts(duplicate_sheet_default(), false),
5349 &[allow_rule("parent-1")],
5350 )
5351 .await;
5352 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5353
5354 let requests = server.received_requests().await.unwrap();
5355 let body: serde_json::Value = requests
5356 .iter()
5357 .find(|r| r.url.path().ends_with(":batchUpdate"))
5358 .map(|r| serde_json::from_slice(&r.body).unwrap())
5359 .expect("a batchUpdate request");
5360 let dup = &body["requests"][0]["duplicateSheet"];
5361 assert!(dup.get("insertSheetIndex").is_none());
5362 assert!(dup.get("newSheetName").is_none());
5363 }
5364
5365 #[tokio::test]
5366 async fn duplicate_sheet_dry_run_names_title_and_index() {
5367 let server = wiremock::MockServer::start().await;
5368 let (drive, sheets) = clients(&server).await;
5369 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5370 .mount(&server)
5371 .await;
5372 mount_folder("parent-1").mount(&server).await;
5373 mount_workbook().mount(&server).await;
5374 let outcome = structure(
5375 &drive,
5376 &sheets,
5377 &opts(duplicate_sheet(), true),
5378 &[allow_rule("parent-1")],
5379 )
5380 .await;
5381 let text = describe(&outcome);
5382 assert!(text.contains("Would duplicate sheet 'Q2'"), "{text}");
5383 assert!(text.contains("as 'Q2 Copy'"), "{text}");
5384 assert!(text.contains("at index 1"), "{text}");
5385 let requests = server.received_requests().await.unwrap();
5386 assert!(!requests
5387 .iter()
5388 .any(|r| r.url.path().ends_with(":batchUpdate")));
5389 }
5390
5391 #[tokio::test]
5392 async fn duplicate_sheet_dry_run_uses_defaults_when_omitted() {
5393 let server = wiremock::MockServer::start().await;
5394 let (drive, sheets) = clients(&server).await;
5395 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5396 .mount(&server)
5397 .await;
5398 mount_folder("parent-1").mount(&server).await;
5399 mount_workbook().mount(&server).await;
5400 let outcome = structure(
5401 &drive,
5402 &sheets,
5403 &opts(duplicate_sheet_default(), true),
5404 &[allow_rule("parent-1")],
5405 )
5406 .await;
5407 let text = describe(&outcome);
5408 assert!(
5409 text.contains("as a copy Sheets names automatically"),
5410 "{text}"
5411 );
5412 assert!(text.contains("at the front"), "{text}");
5413 }
5414
5415 #[tokio::test]
5416 async fn duplicate_sheet_refuses_a_missing_source_sheet() {
5417 let server = wiremock::MockServer::start().await;
5418 let (drive, sheets) = clients(&server).await;
5419 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5420 .mount(&server)
5421 .await;
5422 mount_folder("parent-1").mount(&server).await;
5423 mount_workbook().mount(&server).await;
5424 let outcome = structure(
5425 &drive,
5426 &sheets,
5427 &opts(
5428 StructureVerb::DuplicateSheet {
5429 sheet: "Nope".to_string(),
5430 title: None,
5431 index: None,
5432 },
5433 false,
5434 ),
5435 &[allow_rule("parent-1")],
5436 )
5437 .await;
5438 assert!(matches!(
5439 outcome.result,
5440 StructureResult::RefusedSheetNotFound { .. }
5441 ));
5442 }
5443
5444 #[tokio::test]
5445 async fn duplicate_sheet_refuses_a_colliding_new_title() {
5446 let server = wiremock::MockServer::start().await;
5447 let (drive, sheets) = clients(&server).await;
5448 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5449 .mount(&server)
5450 .await;
5451 mount_folder("parent-1").mount(&server).await;
5452 mount_workbook().mount(&server).await;
5453 let outcome = structure(
5454 &drive,
5455 &sheets,
5456 &opts(
5457 StructureVerb::DuplicateSheet {
5458 sheet: "Q2".to_string(),
5459 title: Some("Q1".to_string()),
5460 index: None,
5461 },
5462 false,
5463 ),
5464 &[allow_rule("parent-1")],
5465 )
5466 .await;
5467 assert!(matches!(
5468 outcome.result,
5469 StructureResult::RefusedSheetExists { ref title } if title == "Q1"
5470 ));
5471 }
5472
5473 #[tokio::test]
5474 async fn duplicate_sheet_refuses_an_out_of_range_index() {
5475 let server = wiremock::MockServer::start().await;
5476 let (drive, sheets) = clients(&server).await;
5477 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5478 .mount(&server)
5479 .await;
5480 mount_folder("parent-1").mount(&server).await;
5481 mount_workbook().mount(&server).await;
5482 let outcome = structure(
5483 &drive,
5484 &sheets,
5485 &opts(
5486 StructureVerb::DuplicateSheet {
5487 sheet: "Q2".to_string(),
5488 title: None,
5489 index: Some(99),
5490 },
5491 false,
5492 ),
5493 &[allow_rule("parent-1")],
5494 )
5495 .await;
5496 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5497 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5498 };
5499 assert!(
5500 detail.contains("--index must be between 0 and 2"),
5501 "{detail}"
5502 );
5503 }
5504
5505 #[tokio::test]
5506 async fn reorder_sheet_apply_sends_index_masked_update() {
5507 let server = wiremock::MockServer::start().await;
5508 let (drive, sheets) = clients(&server).await;
5509 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5510 .mount(&server)
5511 .await;
5512 mount_folder("parent-1").mount(&server).await;
5513 mount_workbook().mount(&server).await;
5514 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5515 .mount(&server)
5516 .await;
5517 let outcome = structure(
5518 &drive,
5519 &sheets,
5520 &opts(reorder_sheet(), false),
5521 &[allow_rule("parent-1")],
5522 )
5523 .await;
5524 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5525
5526 let requests = server.received_requests().await.unwrap();
5527 let body: serde_json::Value = requests
5528 .iter()
5529 .find(|r| r.url.path().ends_with(":batchUpdate"))
5530 .map(|r| serde_json::from_slice(&r.body).unwrap())
5531 .expect("a batchUpdate request");
5532 let update = &body["requests"][0]["updateSheetProperties"];
5533 assert_eq!(update["properties"]["sheetId"], 118_293);
5534 assert_eq!(update["properties"]["index"], 0);
5535 assert_eq!(update["fields"], "index");
5536 }
5537
5538 #[tokio::test]
5539 async fn reorder_sheet_dry_run_names_the_target_index() {
5540 let server = wiremock::MockServer::start().await;
5541 let (drive, sheets) = clients(&server).await;
5542 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5543 .mount(&server)
5544 .await;
5545 mount_folder("parent-1").mount(&server).await;
5546 mount_workbook().mount(&server).await;
5547 let outcome = structure(
5548 &drive,
5549 &sheets,
5550 &opts(reorder_sheet(), true),
5551 &[allow_rule("parent-1")],
5552 )
5553 .await;
5554 let text = describe(&outcome);
5555 assert!(text.contains("Would move sheet 'Q2'"), "{text}");
5556 assert!(text.contains("to index 0"), "{text}");
5557 }
5558
5559 #[tokio::test]
5560 async fn reorder_sheet_refuses_an_out_of_range_index() {
5561 let server = wiremock::MockServer::start().await;
5562 let (drive, sheets) = clients(&server).await;
5563 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5564 .mount(&server)
5565 .await;
5566 mount_folder("parent-1").mount(&server).await;
5567 mount_workbook().mount(&server).await;
5568 let outcome = structure(
5569 &drive,
5570 &sheets,
5571 &opts(
5572 StructureVerb::ReorderSheet {
5573 sheet: "Q2".to_string(),
5574 index: 5,
5575 },
5576 false,
5577 ),
5578 &[allow_rule("parent-1")],
5579 )
5580 .await;
5581 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5582 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5583 };
5584 assert!(
5585 detail.contains("--index must be between 0 and 1"),
5586 "{detail}"
5587 );
5588 }
5589
5590 #[tokio::test]
5591 async fn hide_sheet_apply_sends_hidden_true() {
5592 let server = wiremock::MockServer::start().await;
5593 let (drive, sheets) = clients(&server).await;
5594 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5595 .mount(&server)
5596 .await;
5597 mount_folder("parent-1").mount(&server).await;
5598 mount_workbook().mount(&server).await;
5599 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5600 .mount(&server)
5601 .await;
5602 let outcome = structure(
5603 &drive,
5604 &sheets,
5605 &opts(hide_sheet(), false),
5606 &[allow_rule("parent-1")],
5607 )
5608 .await;
5609 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5610
5611 let requests = server.received_requests().await.unwrap();
5612 let body: serde_json::Value = requests
5613 .iter()
5614 .find(|r| r.url.path().ends_with(":batchUpdate"))
5615 .map(|r| serde_json::from_slice(&r.body).unwrap())
5616 .expect("a batchUpdate request");
5617 let update = &body["requests"][0]["updateSheetProperties"];
5618 assert_eq!(update["properties"]["sheetId"], 118_293);
5619 assert_eq!(update["properties"]["hidden"], true);
5620 assert_eq!(update["fields"], "hidden");
5621 }
5622
5623 #[tokio::test]
5624 async fn show_sheet_apply_sends_hidden_false() {
5625 let server = wiremock::MockServer::start().await;
5626 let (drive, sheets) = clients(&server).await;
5627 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5628 .mount(&server)
5629 .await;
5630 mount_folder("parent-1").mount(&server).await;
5631 mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5632 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5633 .mount(&server)
5634 .await;
5635 let outcome = structure(
5636 &drive,
5637 &sheets,
5638 &opts(show_sheet(), false),
5639 &[allow_rule("parent-1")],
5640 )
5641 .await;
5642 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5643
5644 let requests = server.received_requests().await.unwrap();
5645 let body: serde_json::Value = requests
5646 .iter()
5647 .find(|r| r.url.path().ends_with(":batchUpdate"))
5648 .map(|r| serde_json::from_slice(&r.body).unwrap())
5649 .expect("a batchUpdate request");
5650 let update = &body["requests"][0]["updateSheetProperties"];
5651 assert_eq!(update["properties"]["hidden"], false);
5652 }
5653
5654 #[tokio::test]
5655 async fn hide_sheet_dry_run_names_the_hide() {
5656 let server = wiremock::MockServer::start().await;
5657 let (drive, sheets) = clients(&server).await;
5658 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5659 .mount(&server)
5660 .await;
5661 mount_folder("parent-1").mount(&server).await;
5662 mount_workbook().mount(&server).await;
5663 let outcome = structure(
5664 &drive,
5665 &sheets,
5666 &opts(hide_sheet(), true),
5667 &[allow_rule("parent-1")],
5668 )
5669 .await;
5670 let text = describe(&outcome);
5671 assert!(text.contains("Would hide sheet 'Q2'"), "{text}");
5672 }
5673
5674 #[tokio::test]
5675 async fn show_sheet_dry_run_names_the_show() {
5676 let server = wiremock::MockServer::start().await;
5677 let (drive, sheets) = clients(&server).await;
5678 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5679 .mount(&server)
5680 .await;
5681 mount_folder("parent-1").mount(&server).await;
5682 mount_workbook_with_q2(hidden_q2()).mount(&server).await;
5683 let outcome = structure(
5684 &drive,
5685 &sheets,
5686 &opts(show_sheet(), true),
5687 &[allow_rule("parent-1")],
5688 )
5689 .await;
5690 let text = describe(&outcome);
5691 assert!(text.contains("Would show sheet 'Q2'"), "{text}");
5692 }
5693
5694 #[tokio::test]
5698 async fn a_visibility_no_op_is_unchanged_in_dry_run_and_real_run() {
5699 for (verb, q2, state) in [
5700 (hide_sheet(), hidden_q2(), "hidden"),
5701 (show_sheet(), default_q2(), "shown"),
5702 ] {
5703 for dry_run in [true, false] {
5704 let server = wiremock::MockServer::start().await;
5705 let (drive, sheets) = clients(&server).await;
5706 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5707 .mount(&server)
5708 .await;
5709 mount_folder("parent-1").mount(&server).await;
5710 mount_workbook_with_q2(q2.clone()).mount(&server).await;
5711 let outcome = structure(
5712 &drive,
5713 &sheets,
5714 &opts(verb.clone(), dry_run),
5715 &[allow_rule("parent-1")],
5716 )
5717 .await;
5718 assert_eq!(
5719 outcome.result,
5720 StructureResult::Unchanged {
5721 detail: format!("sheet 'Q2' is already {state}")
5722 },
5723 "dry_run={dry_run}"
5724 );
5725 let text = describe(&outcome);
5726 assert!(
5727 text.starts_with(&format!("Unchanged: sheet 'Q2' is already {state} in ")),
5728 "{text}"
5729 );
5730 let requests = server.received_requests().await.unwrap();
5731 assert!(
5732 !requests
5733 .iter()
5734 .any(|r| r.url.path().ends_with(":batchUpdate")),
5735 "a no-op must not call batchUpdate"
5736 );
5737 }
5738 }
5739 }
5740
5741 #[tokio::test]
5742 async fn hide_sheet_refuses_leaving_no_visible_sheets() {
5743 let server = wiremock::MockServer::start().await;
5744 let (drive, sheets) = clients(&server).await;
5745 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5746 .mount(&server)
5747 .await;
5748 mount_folder("parent-1").mount(&server).await;
5749 wiremock::Mock::given(wiremock::matchers::method("GET"))
5750 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
5751 .respond_with(
5752 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
5753 "spreadsheetId": "sheet-1",
5754 "properties": {"title": "Budget"},
5755 "sheets": [
5756 {"properties": {"sheetId": 0, "title": "Q1", "index": 0, "hidden": true}},
5757 {"properties": {"sheetId": 118_293, "title": "Q2", "index": 1}},
5758 ],
5759 })),
5760 )
5761 .mount(&server)
5762 .await;
5763 let outcome = structure(
5764 &drive,
5765 &sheets,
5766 &opts(hide_sheet(), false),
5767 &[allow_rule("parent-1")],
5768 )
5769 .await;
5770 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
5771 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
5772 };
5773 assert!(
5774 detail.contains("would leave the workbook with no visible sheets"),
5775 "{detail}"
5776 );
5777 }
5778
5779 fn update_sheet_properties(
5782 freeze_rows: Option<i64>,
5783 freeze_columns: Option<i64>,
5784 tab_color: Option<&str>,
5785 clear_tab_color: bool,
5786 right_to_left: Option<bool>,
5787 hide_gridlines: Option<bool>,
5788 ) -> StructureVerb {
5789 StructureVerb::UpdateSheetProperties {
5790 sheet: "Q2".to_string(),
5791 freeze_rows,
5792 freeze_columns,
5793 tab_color: tab_color.map(ToString::to_string),
5794 clear_tab_color,
5795 right_to_left,
5796 hide_gridlines,
5797 }
5798 }
5799
5800 fn mount_workbook_with_frozen_rows(
5805 sheet_id: i64,
5806 row_count: i64,
5807 frozen_row_count: i64,
5808 ) -> wiremock::Mock {
5809 mount_workbook_with_q2(serde_json::json!({
5810 "properties": {
5811 "sheetId": sheet_id, "title": "Q2", "index": 1,
5812 "gridProperties": {
5813 "rowCount": row_count, "columnCount": 10,
5814 "frozenRowCount": frozen_row_count,
5815 }}}))
5816 }
5817
5818 fn mount_workbook_with_view_state() -> wiremock::Mock {
5828 mount_workbook_with_q2(serde_json::json!({
5829 "properties": {
5830 "sheetId": 118_293, "title": "Q2", "index": 1,
5831 "rightToLeft": true,
5832 "gridProperties": {
5833 "rowCount": 500, "columnCount": 10,
5834 "frozenColumnCount": 2,
5835 "hideGridlines": true,
5836 }}}))
5837 }
5838
5839 #[tokio::test]
5840 async fn update_sheet_properties_apply_sends_only_the_set_fields_in_the_mask() {
5841 let server = wiremock::MockServer::start().await;
5842 let (drive, sheets) = clients(&server).await;
5843 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5844 .mount(&server)
5845 .await;
5846 mount_folder("parent-1").mount(&server).await;
5847 mount_workbook().mount(&server).await;
5848 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5849 .mount(&server)
5850 .await;
5851 let verb = update_sheet_properties(Some(2), None, Some("#FF8800"), false, None, None);
5852 let outcome = structure(
5853 &drive,
5854 &sheets,
5855 &opts(verb, false),
5856 &[allow_rule("parent-1")],
5857 )
5858 .await;
5859 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5860
5861 let requests = server.received_requests().await.unwrap();
5862 let body: serde_json::Value = requests
5863 .iter()
5864 .find(|r| r.url.path().ends_with(":batchUpdate"))
5865 .map(|r| serde_json::from_slice(&r.body).unwrap())
5866 .expect("a batchUpdate request");
5867 let update = &body["requests"][0]["updateSheetProperties"];
5868 assert_eq!(
5869 update["fields"],
5870 "gridProperties.frozenRowCount,tabColorStyle"
5871 );
5872 assert_eq!(update["properties"]["sheetId"], 118_293);
5873 assert_eq!(update["properties"]["gridProperties"]["frozenRowCount"], 2);
5874 let rgb = &update["properties"]["tabColorStyle"]["rgbColor"];
5875 assert!((rgb["red"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{rgb}");
5876 assert!(
5877 (rgb["green"].as_f64().unwrap() - (136.0 / 255.0)).abs() < 1e-6,
5878 "{rgb}"
5879 );
5880 assert!((rgb["blue"].as_f64().unwrap() - 0.0).abs() < 1e-6, "{rgb}");
5881 assert!(
5882 update["properties"]["gridProperties"]
5883 .get("frozenColumnCount")
5884 .is_none(),
5885 "{update}"
5886 );
5887 assert!(
5888 update["properties"].get("rightToLeft").is_none(),
5889 "{update}"
5890 );
5891 assert!(update["properties"].get("hidden").is_none(), "{update}");
5892 assert!(update["properties"].get("title").is_none(), "{update}");
5893 assert!(update["properties"].get("index").is_none(), "{update}");
5894 }
5895
5896 #[tokio::test]
5897 async fn update_sheet_properties_apply_clear_tab_color_names_tab_color_style_with_no_value() {
5898 let server = wiremock::MockServer::start().await;
5899 let (drive, sheets) = clients(&server).await;
5900 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5901 .mount(&server)
5902 .await;
5903 mount_folder("parent-1").mount(&server).await;
5904 mount_workbook().mount(&server).await;
5905 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5906 .mount(&server)
5907 .await;
5908 let verb = update_sheet_properties(None, None, None, true, None, None);
5909 let outcome = structure(
5910 &drive,
5911 &sheets,
5912 &opts(verb, false),
5913 &[allow_rule("parent-1")],
5914 )
5915 .await;
5916 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
5917
5918 let requests = server.received_requests().await.unwrap();
5919 let body: serde_json::Value = requests
5920 .iter()
5921 .find(|r| r.url.path().ends_with(":batchUpdate"))
5922 .map(|r| serde_json::from_slice(&r.body).unwrap())
5923 .expect("a batchUpdate request");
5924 let update = &body["requests"][0]["updateSheetProperties"];
5925 assert_eq!(update["fields"], "tabColorStyle");
5926 assert!(
5927 update["properties"].get("tabColorStyle").is_none(),
5928 "{update}"
5929 );
5930 }
5931
5932 #[tokio::test]
5933 async fn update_sheet_properties_dry_run_reports_before_and_after() {
5934 let server = wiremock::MockServer::start().await;
5935 let (drive, sheets) = clients(&server).await;
5936 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5937 .mount(&server)
5938 .await;
5939 mount_folder("parent-1").mount(&server).await;
5940 mount_workbook_with_frozen_rows(118_293, 500, 1)
5941 .mount(&server)
5942 .await;
5943 let verb = update_sheet_properties(Some(3), None, None, false, None, None);
5944 let outcome = structure(
5945 &drive,
5946 &sheets,
5947 &opts(verb, true),
5948 &[allow_rule("parent-1")],
5949 )
5950 .await;
5951 let text = describe(&outcome);
5952 assert!(text.contains("frozen rows 1 -> 3"), "{text}");
5953 }
5954
5955 #[tokio::test]
5956 async fn update_sheet_properties_apply_sends_every_property_and_reports_each_one() {
5957 let server = wiremock::MockServer::start().await;
5966 let (drive, sheets) = clients(&server).await;
5967 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
5968 .mount(&server)
5969 .await;
5970 mount_folder("parent-1").mount(&server).await;
5971 mount_workbook().mount(&server).await;
5972 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
5973 .mount(&server)
5974 .await;
5975 let verb = update_sheet_properties(
5978 Some(2),
5979 Some(3),
5980 Some("0000FF"),
5981 false,
5982 Some(true),
5983 Some(true),
5984 );
5985 let outcome = structure(
5986 &drive,
5987 &sheets,
5988 &opts(verb, false),
5989 &[allow_rule("parent-1")],
5990 )
5991 .await;
5992 assert!(
5993 matches!(outcome.result, StructureResult::Changed { .. }),
5994 "{:?}",
5995 outcome.result
5996 );
5997
5998 let requests = server.received_requests().await.unwrap();
5999 let body: serde_json::Value = requests
6000 .iter()
6001 .find(|r| r.url.path().ends_with(":batchUpdate"))
6002 .map(|r| serde_json::from_slice(&r.body).unwrap())
6003 .expect("a batchUpdate request");
6004 let update = &body["requests"][0]["updateSheetProperties"];
6005 assert_eq!(
6006 update["fields"],
6007 "gridProperties.frozenRowCount,gridProperties.frozenColumnCount,\
6008 gridProperties.hideGridlines,tabColorStyle,rightToLeft"
6009 );
6010 let grid = &update["properties"]["gridProperties"];
6011 assert_eq!(grid["frozenRowCount"], 2);
6012 assert_eq!(grid["frozenColumnCount"], 3);
6013 assert_eq!(grid["hideGridlines"], true);
6014 assert_eq!(update["properties"]["rightToLeft"], true);
6015
6016 let text = describe(&outcome);
6017 assert!(text.starts_with("Updated sheet 'Q2'"), "{text}");
6018 for fragment in [
6019 "frozen rows -> 2",
6020 "frozen columns -> 3",
6021 "tab color -> #0000FF",
6022 "right-to-left -> true",
6023 "gridlines -> hidden",
6024 ] {
6025 assert!(text.contains(fragment), "{fragment} missing from {text}");
6026 }
6027 }
6028
6029 #[tokio::test]
6030 async fn update_sheet_properties_dry_run_reports_clears_and_the_values_being_replaced() {
6031 let server = wiremock::MockServer::start().await;
6041 let (drive, sheets) = clients(&server).await;
6042 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6043 .mount(&server)
6044 .await;
6045 mount_folder("parent-1").mount(&server).await;
6046 mount_workbook_with_view_state().mount(&server).await;
6047 let verb = update_sheet_properties(None, Some(4), None, true, Some(false), Some(false));
6048 let outcome = structure(
6049 &drive,
6050 &sheets,
6051 &opts(verb, true),
6052 &[allow_rule("parent-1")],
6053 )
6054 .await;
6055 let text = describe(&outcome);
6056 assert!(text.starts_with("Would update sheet 'Q2'"), "{text}");
6057 assert!(!text.contains("frozen rows"), "{text}");
6058 for fragment in [
6059 "frozen columns 2 -> 4",
6060 "tab color cleared",
6061 "right-to-left true -> false",
6062 "gridlines hidden -> shown",
6063 ] {
6064 assert!(text.contains(fragment), "{fragment} missing from {text}");
6065 }
6066 }
6067
6068 #[test]
6069 fn fields_changed_label_names_exactly_the_properties_the_verb_set() {
6070 let every = update_sheet_properties(
6074 Some(2),
6075 Some(3),
6076 Some("#FF8800"),
6077 false,
6078 Some(true),
6079 Some(false),
6080 );
6081 assert_eq!(
6082 fields_changed_label(&every).as_deref(),
6083 Some(
6084 "frozenRowCount=2, frozenColumnCount=3, hideGridlines=false, \
6085 tabColorStyle=#FF8800, rightToLeft=true"
6086 )
6087 );
6088 let cleared = update_sheet_properties(None, None, None, true, None, None);
6091 assert_eq!(
6092 fields_changed_label(&cleared).as_deref(),
6093 Some("tabColorStyle=cleared")
6094 );
6095 let empty = update_sheet_properties(None, None, None, false, None, None);
6099 assert_eq!(fields_changed_label(&empty), None);
6100 assert_eq!(fields_changed_label(&rename()), None);
6102 }
6103
6104 #[tokio::test]
6105 async fn update_sheet_properties_refuses_a_tab_color_and_a_clear_together() {
6106 let server = wiremock::MockServer::start().await;
6111 let (drive, sheets) = clients(&server).await;
6112 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6113 .mount(&server)
6114 .await;
6115 mount_folder("parent-1").mount(&server).await;
6116 mount_workbook().mount(&server).await;
6117 let verb = update_sheet_properties(None, None, Some("#FF8800"), true, None, None);
6118 let outcome = structure(
6119 &drive,
6120 &sheets,
6121 &opts(verb, true),
6122 &[allow_rule("parent-1")],
6123 )
6124 .await;
6125 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6126 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6128 assert!(detail.contains("mutually exclusive"), "{detail}");
6129 }
6130
6131 #[tokio::test]
6132 async fn update_sheet_properties_names_its_own_subcommand_in_a_refusal() {
6133 let server = wiremock::MockServer::start().await;
6137 let (drive, sheets) = clients(&server).await;
6138 mount_file("sheet-1", "application/pdf", &["parent-1"])
6139 .mount(&server)
6140 .await;
6141 let verb = update_sheet_properties(Some(2), None, None, false, None, None);
6142 let outcome = structure(
6143 &drive,
6144 &sheets,
6145 &opts(verb, false),
6146 &[allow_rule("parent-1")],
6147 )
6148 .await;
6149 assert!(matches!(
6150 outcome.result,
6151 StructureResult::RefusedNotASpreadsheet { .. }
6152 ));
6153 let text = describe(&outcome);
6154 assert!(
6155 text.contains("drive sheets update-sheet-properties"),
6156 "{text}"
6157 );
6158 }
6159
6160 #[tokio::test]
6161 async fn update_sheet_properties_refuses_a_negative_freeze_count() {
6162 let server = wiremock::MockServer::start().await;
6163 let (drive, sheets) = clients(&server).await;
6164 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6165 .mount(&server)
6166 .await;
6167 mount_folder("parent-1").mount(&server).await;
6168 mount_workbook().mount(&server).await;
6169 let verb = update_sheet_properties(Some(-1), None, None, false, None, None);
6170 let outcome = structure(
6171 &drive,
6172 &sheets,
6173 &opts(verb, true),
6174 &[allow_rule("parent-1")],
6175 )
6176 .await;
6177 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6178 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6180 assert!(
6181 detail.contains("--freeze-rows must be at least 0"),
6182 "{detail}"
6183 );
6184 }
6185
6186 #[tokio::test]
6187 async fn update_sheet_properties_refuses_freezing_every_row() {
6188 let server = wiremock::MockServer::start().await;
6189 let (drive, sheets) = clients(&server).await;
6190 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6191 .mount(&server)
6192 .await;
6193 mount_folder("parent-1").mount(&server).await;
6194 mount_workbook_with_frozen_rows(118_293, 5, 0)
6195 .mount(&server)
6196 .await;
6197 let verb = update_sheet_properties(Some(5), None, None, false, None, None);
6198 let outcome = structure(
6199 &drive,
6200 &sheets,
6201 &opts(verb, true),
6202 &[allow_rule("parent-1")],
6203 )
6204 .await;
6205 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6206 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6208 assert!(detail.contains("would freeze every row"), "{detail}");
6209 }
6210
6211 #[tokio::test]
6212 async fn update_sheet_properties_allows_freezing_up_to_one_less_than_the_row_count() {
6213 let server = wiremock::MockServer::start().await;
6219 let (drive, sheets) = clients(&server).await;
6220 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6221 .mount(&server)
6222 .await;
6223 mount_folder("parent-1").mount(&server).await;
6224 mount_workbook_with_frozen_rows(118_293, 5, 0)
6225 .mount(&server)
6226 .await;
6227 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6228 .mount(&server)
6229 .await;
6230 let verb = update_sheet_properties(Some(4), None, None, false, None, None);
6231 let outcome = structure(
6232 &drive,
6233 &sheets,
6234 &opts(verb, false),
6235 &[allow_rule("parent-1")],
6236 )
6237 .await;
6238 assert!(
6239 matches!(outcome.result, StructureResult::Changed { .. }),
6240 "{:?}",
6241 outcome.result
6242 );
6243 }
6244
6245 #[tokio::test]
6246 async fn update_sheet_properties_invents_no_freeze_ceiling_when_the_count_is_unknown() {
6247 let server = wiremock::MockServer::start().await;
6255 let (drive, sheets) = clients(&server).await;
6256 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6257 .mount(&server)
6258 .await;
6259 mount_folder("parent-1").mount(&server).await;
6260 wiremock::Mock::given(wiremock::matchers::method("GET"))
6261 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6262 .respond_with(
6263 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
6264 "spreadsheetId": "sheet-1",
6265 "properties": {"title": "Budget"},
6266 "sheets": [
6267 {"properties": {"sheetId": 118_293, "title": "Q2", "index": 0}}
6268 ],
6269 })),
6270 )
6271 .mount(&server)
6272 .await;
6273 let verb = update_sheet_properties(Some(9_000), Some(9_000), None, false, None, None);
6274 let outcome = structure(
6275 &drive,
6276 &sheets,
6277 &opts(verb, true),
6278 &[allow_rule("parent-1")],
6279 )
6280 .await;
6281 assert!(
6282 matches!(outcome.result, StructureResult::WouldChange { .. }),
6283 "{:?}",
6284 outcome.result
6285 );
6286 }
6287
6288 #[tokio::test]
6289 async fn update_sheet_properties_refuses_a_negative_freeze_column_count() {
6290 let server = wiremock::MockServer::start().await;
6291 let (drive, sheets) = clients(&server).await;
6292 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6293 .mount(&server)
6294 .await;
6295 mount_folder("parent-1").mount(&server).await;
6296 mount_workbook().mount(&server).await;
6297 let verb = update_sheet_properties(None, Some(-1), None, false, None, None);
6298 let outcome = structure(
6299 &drive,
6300 &sheets,
6301 &opts(verb, true),
6302 &[allow_rule("parent-1")],
6303 )
6304 .await;
6305 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6306 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6308 assert!(
6309 detail.contains("--freeze-columns must be at least 0"),
6310 "{detail}"
6311 );
6312 }
6313
6314 #[tokio::test]
6315 async fn update_sheet_properties_refuses_freezing_every_column() {
6316 let server = wiremock::MockServer::start().await;
6317 let (drive, sheets) = clients(&server).await;
6318 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6319 .mount(&server)
6320 .await;
6321 mount_folder("parent-1").mount(&server).await;
6322 mount_workbook().mount(&server).await;
6324 let verb = update_sheet_properties(None, Some(10), None, false, None, None);
6325 let outcome = structure(
6326 &drive,
6327 &sheets,
6328 &opts(verb, true),
6329 &[allow_rule("parent-1")],
6330 )
6331 .await;
6332 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6333 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6335 assert!(detail.contains("would freeze every column"), "{detail}");
6336 }
6337
6338 #[tokio::test]
6339 async fn update_sheet_properties_refuses_an_invalid_hex_color() {
6340 let server = wiremock::MockServer::start().await;
6341 let (drive, sheets) = clients(&server).await;
6342 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6343 .mount(&server)
6344 .await;
6345 mount_folder("parent-1").mount(&server).await;
6346 mount_workbook().mount(&server).await;
6347 let verb = update_sheet_properties(None, None, Some("nope"), false, None, None);
6348 let outcome = structure(
6349 &drive,
6350 &sheets,
6351 &opts(verb, true),
6352 &[allow_rule("parent-1")],
6353 )
6354 .await;
6355 assert!(matches!(
6356 outcome.result,
6357 StructureResult::RefusedInvalidRange { .. }
6358 ));
6359 }
6360
6361 #[tokio::test]
6362 async fn update_sheet_properties_refuses_when_nothing_is_set() {
6363 let server = wiremock::MockServer::start().await;
6364 let (drive, sheets) = clients(&server).await;
6365 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6366 .mount(&server)
6367 .await;
6368 mount_folder("parent-1").mount(&server).await;
6369 mount_workbook().mount(&server).await;
6370 let verb = update_sheet_properties(None, None, None, false, None, None);
6371 let outcome = structure(
6372 &drive,
6373 &sheets,
6374 &opts(verb, true),
6375 &[allow_rule("parent-1")],
6376 )
6377 .await;
6378 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6379 panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
6381 assert!(detail.contains("at least one"), "{detail}");
6382 }
6383
6384 fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
6387 FolderPermissionRule {
6388 require_lease: false,
6389 ..allow_rule(folder)
6390 }
6391 }
6392
6393 #[tokio::test]
6394 async fn refuses_without_a_lease_when_the_rule_requires_one() {
6395 let server = wiremock::MockServer::start().await;
6396 let (drive, sheets) = clients(&server).await;
6397 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6398 .mount(&server)
6399 .await;
6400 mount_folder("parent-1").mount(&server).await;
6401 mount_workbook().mount(&server).await;
6402 let mut o = opts(rename(), false);
6406 o.lease_token = None;
6407 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6408 assert_eq!(outcome.result, StructureResult::RefusedNoLease);
6409 }
6410
6411 #[tokio::test]
6412 async fn refuses_an_unknown_lease_token() {
6413 let server = wiremock::MockServer::start().await;
6414 let (drive, sheets) = clients(&server).await;
6415 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6416 .mount(&server)
6417 .await;
6418 mount_folder("parent-1").mount(&server).await;
6419 mount_workbook().mount(&server).await;
6420
6421 let mut o = opts(rename(), false);
6422 o.lease_token = Some("bogus-token".to_string());
6423 o.ledger_path = tempfile::tempdir()
6425 .unwrap()
6426 .keep()
6427 .join("lease-ledger.jsonl");
6428 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6429 assert_eq!(outcome.result, StructureResult::RefusedLeaseExpired);
6430 }
6431
6432 #[tokio::test]
6433 async fn refuses_a_lease_bound_to_a_different_file() {
6434 let server = wiremock::MockServer::start().await;
6435 let (drive, sheets) = clients(&server).await;
6436 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6437 .mount(&server)
6438 .await;
6439 mount_folder("parent-1").mount(&server).await;
6440 mount_workbook().mount(&server).await;
6441
6442 let ledger_path = tempfile::tempdir()
6443 .unwrap()
6444 .keep()
6445 .join("lease-ledger.jsonl");
6446 let token = seed_lease(&ledger_path, "other-sheet", "1");
6447 let mut o = opts(rename(), false);
6448 o.lease_token = Some(token);
6449 o.ledger_path = ledger_path;
6450 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6451 assert_eq!(outcome.result, StructureResult::RefusedLeaseWrongFile);
6452 }
6453
6454 #[tokio::test]
6455 async fn refuses_a_stale_lease() {
6456 let server = wiremock::MockServer::start().await;
6457 let (drive, sheets) = clients(&server).await;
6458 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6461 .mount(&server)
6462 .await;
6463 mount_folder("parent-1").mount(&server).await;
6464 mount_workbook().mount(&server).await;
6465
6466 let ledger_path = tempfile::tempdir()
6467 .unwrap()
6468 .keep()
6469 .join("lease-ledger.jsonl");
6470 let token = seed_lease(&ledger_path, "sheet-1", "0");
6471 let mut o = opts(rename(), false);
6472 o.lease_token = Some(token);
6473 o.ledger_path = ledger_path;
6474 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6475 assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6476 }
6477
6478 #[tokio::test]
6479 async fn reports_a_lock_acquisition_failure_as_failed() {
6480 let server = wiremock::MockServer::start().await;
6484 let (drive, sheets) = clients(&server).await;
6485 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6486 .mount(&server)
6487 .await;
6488 mount_folder("parent-1").mount(&server).await;
6489 mount_workbook().mount(&server).await;
6490
6491 let o = opts(rename(), false);
6492 let mut lock_path = o.ledger_path.clone().into_os_string();
6498 lock_path.push(".lock");
6499 std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
6500
6501 let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
6502 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6503 }
6504
6505 #[tokio::test]
6506 async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
6507 let server = wiremock::MockServer::start().await;
6512 let (drive, sheets) = clients(&server).await;
6513 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6514 .up_to_n_times(1)
6515 .with_priority(1)
6516 .mount(&server)
6517 .await;
6518 wiremock::Mock::given(wiremock::matchers::method("GET"))
6519 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
6520 .respond_with(wiremock::ResponseTemplate::new(500))
6521 .with_priority(2)
6522 .mount(&server)
6523 .await;
6524 mount_folder("parent-1").mount(&server).await;
6525 mount_workbook().mount(&server).await;
6526 wiremock::Mock::given(wiremock::matchers::method("POST"))
6527 .and(wiremock::matchers::path(
6528 "/v4/spreadsheets/sheet-1:batchUpdate",
6529 ))
6530 .respond_with(wiremock::ResponseTemplate::new(200))
6531 .expect(0)
6532 .mount(&server)
6533 .await;
6534
6535 let outcome = structure(
6536 &drive,
6537 &sheets,
6538 &opts(rename(), false),
6539 &[allow_rule("parent-1")],
6540 )
6541 .await;
6542 assert!(matches!(outcome.result, StructureResult::Failed { .. }));
6543 }
6544
6545 #[tokio::test]
6546 async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
6547 let server = wiremock::MockServer::start().await;
6551 let (drive, sheets) = clients(&server).await;
6552 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6553 .mount(&server)
6554 .await;
6555 mount_folder("parent-1").mount(&server).await;
6556 mount_workbook().mount(&server).await;
6557 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6558 .expect(1)
6559 .mount(&server)
6560 .await;
6561
6562 let mut o = opts(rename(), false);
6563 o.lease_token = None;
6564 let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6565 assert!(matches!(
6566 outcome.result,
6567 StructureResult::Changed { backup: None, .. }
6568 ));
6569 }
6570
6571 #[tokio::test]
6572 async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
6573 let server = wiremock::MockServer::start().await;
6577 let (drive, sheets) = clients(&server).await;
6578 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6581 .mount(&server)
6582 .await;
6583 mount_folder("parent-1").mount(&server).await;
6584 mount_workbook().mount(&server).await;
6585 let ledger_path = tempfile::tempdir()
6589 .unwrap()
6590 .keep()
6591 .join("lease-ledger.jsonl");
6592 let token = seed_lease(&ledger_path, "sheet-1", "0");
6593 let mut o = opts(rename(), false);
6594 o.lease_token = Some(token);
6595 o.ledger_path = ledger_path;
6596 let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
6597 assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
6598 }
6599
6600 #[tokio::test]
6606 async fn a_leased_delete_names_the_drive_copy_to_restore_from() {
6607 let server = wiremock::MockServer::start().await;
6608 let (drive, sheets) = clients(&server).await;
6609 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6610 .mount(&server)
6611 .await;
6612 mount_folder("parent-1").mount(&server).await;
6613 mount_workbook().mount(&server).await;
6614 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6615 .expect(1)
6616 .mount(&server)
6617 .await;
6618
6619 let ledger_path = tempfile::tempdir()
6620 .unwrap()
6621 .keep()
6622 .join("lease-ledger.jsonl");
6623 let token = seed_lease_with_backup(
6624 &ledger_path,
6625 "sheet-1",
6626 "1",
6627 LeaseBackup::DriveCopy {
6628 file_id: "backup-copy-1".to_string(),
6629 },
6630 );
6631 let mut o = opts(delete_sheet(), false);
6632 o.lease_token = Some(token);
6633 o.ledger_path = ledger_path;
6634 let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
6635
6636 assert!(matches!(
6637 &outcome.result,
6638 StructureResult::Changed {
6639 backup: Some(backup),
6640 ..
6641 } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6642 ));
6643 let text = describe(&outcome);
6644 assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6645 assert!(
6646 text.contains(
6647 "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6648 ),
6649 "{text}"
6650 );
6651 assert!(
6652 text.contains("restores a single deleted sheet automatically"),
6653 "{text}"
6654 );
6655 assert!(
6656 text.contains("Google Drive's own version history"),
6657 "{text}"
6658 );
6659 assert!(!text.contains("only recovery path"), "{text}");
6660
6661 let json = serde_json::to_value(&outcome).unwrap();
6664 assert_eq!(json["result"]["backup"]["kind"], "drive_copy");
6665 assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
6666 }
6667
6668 #[tokio::test]
6675 async fn a_presented_lease_under_a_non_requiring_rule_still_names_the_backup() {
6676 let server = wiremock::MockServer::start().await;
6677 let (drive, sheets) = clients(&server).await;
6678 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6679 .mount(&server)
6680 .await;
6681 mount_folder("parent-1").mount(&server).await;
6682 mount_workbook().mount(&server).await;
6683 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6684 .expect(1)
6685 .mount(&server)
6686 .await;
6687
6688 let ledger_path = tempfile::tempdir()
6689 .unwrap()
6690 .keep()
6691 .join("lease-ledger.jsonl");
6692 let token = seed_lease_with_backup(
6693 &ledger_path,
6694 "sheet-1",
6695 "1",
6696 LeaseBackup::DriveCopy {
6697 file_id: "backup-copy-1".to_string(),
6698 },
6699 );
6700 let mut o = opts(delete_sheet(), false);
6701 o.lease_token = Some(token);
6702 o.ledger_path = ledger_path;
6703 let rule = FolderPermissionRule {
6704 require_lease: false,
6705 ..delete_allow_rule("parent-1")
6706 };
6707 let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6708
6709 assert!(matches!(
6710 &outcome.result,
6711 StructureResult::Changed {
6712 backup: Some(backup),
6713 ..
6714 } if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
6715 ));
6716 let text = describe(&outcome);
6717 assert!(
6718 text.contains(
6719 "backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
6720 ),
6721 "{text}"
6722 );
6723 assert!(!text.contains("only recovery path"), "{text}");
6724 }
6725
6726 #[tokio::test]
6731 async fn an_unleased_delete_does_not_claim_a_backup_exists() {
6732 let server = wiremock::MockServer::start().await;
6733 let (drive, sheets) = clients(&server).await;
6734 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6735 .mount(&server)
6736 .await;
6737 mount_folder("parent-1").mount(&server).await;
6738 mount_workbook().mount(&server).await;
6739 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6740 .expect(1)
6741 .mount(&server)
6742 .await;
6743
6744 let mut o = opts(delete_sheet(), false);
6745 o.lease_token = None;
6746 let rule = FolderPermissionRule {
6747 require_lease: false,
6748 ..delete_allow_rule("parent-1")
6749 };
6750 let outcome = structure(&drive, &sheets, &o, &[rule]).await;
6751
6752 assert!(matches!(
6753 outcome.result,
6754 StructureResult::Changed { backup: None, .. }
6755 ));
6756 let text = describe(&outcome);
6757 assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
6758 assert!(text.contains("no lease backup was taken"), "{text}");
6759 assert!(text.contains("`require_lease: false`"), "{text}");
6760 assert!(
6761 text.contains("version history is the only recovery path"),
6762 "{text}"
6763 );
6764 assert!(!text.contains("restore from that copy"), "{text}");
6765 assert!(serde_json::to_value(&outcome).unwrap()["result"]
6766 .get("backup")
6767 .is_none());
6768 }
6769
6770 fn sent_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
6774 requests
6775 .iter()
6776 .find(|r| r.url.path().ends_with(":batchUpdate"))
6777 .map(|r| serde_json::from_slice(&r.body).unwrap())
6778 .expect("a batchUpdate request")
6779 }
6780
6781 fn mount_workbook_with_q2(q2: serde_json::Value) -> wiremock::Mock {
6785 wiremock::Mock::given(wiremock::matchers::method("GET"))
6786 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
6787 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(workbook_body(q2)))
6788 }
6789
6790 async fn mount_move_prereqs(server: &wiremock::MockServer) {
6792 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
6793 .mount(server)
6794 .await;
6795 mount_folder("parent-1").mount(server).await;
6796 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
6797 .mount(server)
6798 .await;
6799 }
6800
6801 #[tokio::test]
6806 async fn move_rows_apply_sends_move_dimension_with_pre_move_destination() {
6807 let server = wiremock::MockServer::start().await;
6808 let (drive, sheets) = clients(&server).await;
6809 mount_move_prereqs(&server).await;
6810 mount_workbook().mount(&server).await;
6811
6812 let outcome = structure(
6813 &drive,
6814 &sheets,
6815 &opts(move_rows(), false),
6816 &[allow_rule("parent-1")],
6817 )
6818 .await;
6819 assert!(
6820 matches!(outcome.result, StructureResult::Changed { .. }),
6821 "{:?}",
6822 outcome.result
6823 );
6824
6825 let requests = server.received_requests().await.unwrap();
6826 let body = sent_batch_update(&requests);
6827 assert_eq!(
6828 body["requests"][0]["moveDimension"],
6829 serde_json::json!({
6830 "source": {
6831 "sheetId": 118_293, "dimension": "ROWS",
6832 "startIndex": 1, "endIndex": 3,
6833 },
6834 "destinationIndex": 5,
6835 })
6836 );
6837 assert_eq!(body["requests"].as_array().unwrap().len(), 1);
6838
6839 let rendered = describe(&outcome);
6842 assert!(rendered.contains("Moved 2 row(s) 2-3"), "{rendered}");
6843 assert!(rendered.contains("to before row 6"), "{rendered}");
6844 assert!(rendered.contains("(now rows 4-5)"), "{rendered}");
6845 }
6846
6847 #[tokio::test]
6848 async fn move_columns_apply_sends_move_dimension() {
6849 let server = wiremock::MockServer::start().await;
6850 let (drive, sheets) = clients(&server).await;
6851 mount_move_prereqs(&server).await;
6852 mount_workbook().mount(&server).await;
6853
6854 let outcome = structure(
6855 &drive,
6856 &sheets,
6857 &opts(
6858 StructureVerb::MoveColumns {
6859 sheet: "Q2".to_string(),
6860 at: 2,
6861 count: 2,
6862 before: 6,
6863 },
6864 false,
6865 ),
6866 &[allow_rule("parent-1")],
6867 )
6868 .await;
6869 assert!(
6870 matches!(outcome.result, StructureResult::Changed { .. }),
6871 "{:?}",
6872 outcome.result
6873 );
6874
6875 let requests = server.received_requests().await.unwrap();
6876 let body = sent_batch_update(&requests);
6877 assert_eq!(
6878 body["requests"][0]["moveDimension"],
6879 serde_json::json!({
6880 "source": {
6881 "sheetId": 118_293, "dimension": "COLUMNS",
6882 "startIndex": 1, "endIndex": 3,
6883 },
6884 "destinationIndex": 5,
6885 })
6886 );
6887 }
6888
6889 #[tokio::test]
6893 async fn move_rows_to_the_end_uses_row_count_plus_one() {
6894 let server = wiremock::MockServer::start().await;
6895 let (drive, sheets) = clients(&server).await;
6896 mount_move_prereqs(&server).await;
6897 mount_workbook().mount(&server).await;
6898
6899 let outcome = structure(
6900 &drive,
6901 &sheets,
6902 &opts(
6903 StructureVerb::MoveRows {
6904 sheet: "Q2".to_string(),
6905 at: 1,
6906 count: 1,
6907 before: 501,
6908 },
6909 false,
6910 ),
6911 &[allow_rule("parent-1")],
6912 )
6913 .await;
6914 assert!(
6915 matches!(outcome.result, StructureResult::Changed { .. }),
6916 "{:?}",
6917 outcome.result
6918 );
6919
6920 let requests = server.received_requests().await.unwrap();
6921 let body = sent_batch_update(&requests);
6922 assert_eq!(
6923 body["requests"][0]["moveDimension"]["destinationIndex"],
6924 500
6925 );
6926 }
6927
6928 #[tokio::test]
6931 async fn move_rows_refuses_a_source_past_the_sheets_end() {
6932 let server = wiremock::MockServer::start().await;
6933 let (drive, sheets) = clients(&server).await;
6934 mount_move_prereqs(&server).await;
6935 mount_workbook().mount(&server).await;
6936
6937 let outcome = structure(
6938 &drive,
6939 &sheets,
6940 &opts(
6941 StructureVerb::MoveRows {
6942 sheet: "Q2".to_string(),
6943 at: 499,
6944 count: 3,
6945 before: 1,
6946 },
6947 false,
6948 ),
6949 &[allow_rule("parent-1")],
6950 )
6951 .await;
6952 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6953 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
6954 };
6955 assert!(detail.contains("500 row(s)"), "{detail}");
6956 assert!(detail.contains("501"), "{detail}");
6957 }
6958
6959 #[tokio::test]
6960 async fn move_rows_refuses_a_destination_past_the_end() {
6961 let server = wiremock::MockServer::start().await;
6962 let (drive, sheets) = clients(&server).await;
6963 mount_move_prereqs(&server).await;
6964 mount_workbook().mount(&server).await;
6965
6966 let outcome = structure(
6967 &drive,
6968 &sheets,
6969 &opts(
6970 StructureVerb::MoveRows {
6971 sheet: "Q2".to_string(),
6972 at: 5,
6973 count: 1,
6974 before: 502,
6975 },
6976 false,
6977 ),
6978 &[allow_rule("parent-1")],
6979 )
6980 .await;
6981 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
6982 panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
6983 };
6984 assert!(detail.contains("500 row(s)"), "{detail}");
6985 assert!(detail.contains("501"), "{detail}");
6986 }
6987
6988 #[tokio::test]
6995 async fn move_rows_refuses_a_destination_inside_or_adjacent_to_the_block() {
6996 let server = wiremock::MockServer::start().await;
6997 let (drive, sheets) = clients(&server).await;
6998 mount_move_prereqs(&server).await;
6999 mount_workbook().mount(&server).await;
7000
7001 for before in 2..=4 {
7002 let outcome = structure(
7003 &drive,
7004 &sheets,
7005 &opts(
7006 StructureVerb::MoveRows {
7007 sheet: "Q2".to_string(),
7008 at: 2,
7009 count: 2,
7010 before,
7011 },
7012 false,
7013 ),
7014 &[allow_rule("parent-1")],
7015 )
7016 .await;
7017 let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
7018 panic!(
7019 "expected RefusedInvalidRange for --before {before}, got {:?}",
7020 outcome.result
7021 );
7022 };
7023 assert!(detail.contains("nothing would move"), "{detail}");
7024 assert!(detail.contains("rows 2-3"), "{detail}");
7025 }
7026 }
7027
7028 #[tokio::test]
7034 async fn move_rows_allows_a_destination_immediately_adjacent_but_outside_the_block() {
7035 let server = wiremock::MockServer::start().await;
7036 let (drive, sheets) = clients(&server).await;
7037 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7038 .mount(&server)
7039 .await;
7040 mount_folder("parent-1").mount(&server).await;
7041 mount_workbook().mount(&server).await;
7042 for before in [1, 5] {
7045 let outcome = structure(
7046 &drive,
7047 &sheets,
7048 &opts(
7049 StructureVerb::MoveRows {
7050 sheet: "Q2".to_string(),
7051 at: 2,
7052 count: 2,
7053 before,
7054 },
7055 true,
7056 ),
7057 &[allow_rule("parent-1")],
7058 )
7059 .await;
7060 assert!(
7061 matches!(outcome.result, StructureResult::WouldChange { .. }),
7062 "--before {before} must be a real move, got {:?}",
7063 outcome.result
7064 );
7065 }
7066 }
7067
7068 #[test]
7069 fn move_bounds_refuse_every_argument_below_one() {
7070 let sheet = SheetSnapshot {
7071 sheet_id: Some(1),
7072 title: "Q2".to_string(),
7073 row_count: Some(500),
7074 column_count: Some(26),
7075 ..Default::default()
7076 };
7077 for (at, count, before, expected) in [
7078 (2, 0, 9, "--count must be at least 1, got 0"),
7079 (0, 1, 9, "--at must be at least 1, got 0"),
7080 (2, 1, 0, "--before must be at least 1, got 0"),
7081 ] {
7082 let Err(StructureResult::RefusedInvalidRange { detail }) =
7083 validate_move_bounds(Dimension::Rows, at, count, before, Some(&sheet))
7084 else {
7085 panic!(
7086 "expected RefusedInvalidRange for --at {at} --count {count} --before {before}"
7087 );
7088 };
7089 assert_eq!(detail, expected);
7090 }
7091 }
7092
7093 #[test]
7094 fn a_move_at_and_count_that_would_overflow_the_index_space_are_refused() {
7095 let sheet = SheetSnapshot {
7096 sheet_id: Some(1),
7097 title: "Q2".to_string(),
7098 row_count: Some(500),
7099 column_count: Some(26),
7100 ..Default::default()
7101 };
7102 for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
7105 let Err(StructureResult::RefusedInvalidRange { detail }) =
7106 validate_move_bounds(Dimension::Rows, at, count, 1, Some(&sheet))
7107 else {
7108 panic!("expected RefusedInvalidRange for --at {at} --count {count}");
7109 };
7110 assert!(detail.contains("overflows the row index space"), "{detail}");
7111 }
7112 }
7113
7114 #[tokio::test]
7121 async fn a_refused_move_still_records_without_panicking() {
7122 let server = wiremock::MockServer::start().await;
7123 let (drive, sheets) = clients(&server).await;
7124 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7125 .mount(&server)
7126 .await;
7127 mount_folder("parent-1").mount(&server).await;
7128 mount_workbook().mount(&server).await;
7129
7130 let outcome = structure(
7131 &drive,
7132 &sheets,
7133 &opts(
7134 StructureVerb::MoveRows {
7135 sheet: "Q2".to_string(),
7136 at: i64::MAX,
7137 count: 5,
7138 before: 1,
7139 },
7140 false,
7141 ),
7142 &[allow_rule("parent-1")],
7143 )
7144 .await;
7145 assert!(
7146 matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
7147 "{:?}",
7148 outcome.result
7149 );
7150 }
7151
7152 #[tokio::test]
7156 async fn move_rows_dry_run_describes_a_downward_move_and_its_landing_rows() {
7157 let server = wiremock::MockServer::start().await;
7158 let (drive, sheets) = clients(&server).await;
7159 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7160 .mount(&server)
7161 .await;
7162 mount_folder("parent-1").mount(&server).await;
7163 mount_workbook().mount(&server).await;
7164
7165 let outcome = structure(
7166 &drive,
7167 &sheets,
7168 &opts(move_rows(), true),
7169 &[allow_rule("parent-1")],
7170 )
7171 .await;
7172 let lines = describe_lines(&outcome);
7173 assert_eq!(lines.len(), 2, "{lines:?}");
7174 assert!(lines[0].contains("Would move 2 row(s) 2-3"), "{lines:?}");
7175 assert!(lines[0].contains("to before row 6"), "{lines:?}");
7176 assert_eq!(
7177 lines[1],
7178 " (500 rows unchanged; rows 4-5 shift up to 2-3; moved rows land at 4-5)"
7179 );
7180 }
7181
7182 #[tokio::test]
7186 async fn move_rows_dry_run_describes_an_upward_move_and_its_landing_rows() {
7187 let server = wiremock::MockServer::start().await;
7188 let (drive, sheets) = clients(&server).await;
7189 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7190 .mount(&server)
7191 .await;
7192 mount_folder("parent-1").mount(&server).await;
7193 mount_workbook().mount(&server).await;
7194
7195 let outcome = structure(
7196 &drive,
7197 &sheets,
7198 &opts(
7199 StructureVerb::MoveRows {
7200 sheet: "Q2".to_string(),
7201 at: 6,
7202 count: 2,
7203 before: 2,
7204 },
7205 true,
7206 ),
7207 &[allow_rule("parent-1")],
7208 )
7209 .await;
7210 let lines = describe_lines(&outcome);
7211 assert_eq!(lines.len(), 2, "{lines:?}");
7212 assert!(lines[0].contains("Would move 2 row(s) 6-7"), "{lines:?}");
7213 assert!(lines[0].contains("to before row 2"), "{lines:?}");
7214 assert_eq!(
7215 lines[1],
7216 " (500 rows unchanged; rows 2-5 shift down to 4-7; moved rows land at 2-3)"
7217 );
7218 }
7219
7220 #[tokio::test]
7226 async fn move_columns_dry_run_describes_a_downward_move_and_its_landing_columns() {
7227 let server = wiremock::MockServer::start().await;
7228 let (drive, sheets) = clients(&server).await;
7229 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7230 .mount(&server)
7231 .await;
7232 mount_folder("parent-1").mount(&server).await;
7233 mount_workbook().mount(&server).await;
7234
7235 let outcome = structure(
7236 &drive,
7237 &sheets,
7238 &opts(
7239 StructureVerb::MoveColumns {
7240 sheet: "Q2".to_string(),
7241 at: 2,
7242 count: 2,
7243 before: 6,
7244 },
7245 true,
7246 ),
7247 &[allow_rule("parent-1")],
7248 )
7249 .await;
7250 let lines = describe_lines(&outcome);
7251 assert_eq!(lines.len(), 2, "{lines:?}");
7252 assert!(lines[0].contains("Would move 2 column(s) 2-3"), "{lines:?}");
7253 assert!(lines[0].contains("to before column 6"), "{lines:?}");
7254 assert_eq!(
7255 lines[1],
7256 " (10 columns unchanged; columns 4-5 shift left to 2-3; moved columns land at 4-5)"
7257 );
7258 }
7259
7260 #[tokio::test]
7264 async fn move_columns_dry_run_describes_an_upward_move_and_its_landing_columns() {
7265 let server = wiremock::MockServer::start().await;
7266 let (drive, sheets) = clients(&server).await;
7267 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7268 .mount(&server)
7269 .await;
7270 mount_folder("parent-1").mount(&server).await;
7271 mount_workbook().mount(&server).await;
7272
7273 let outcome = structure(
7274 &drive,
7275 &sheets,
7276 &opts(
7277 StructureVerb::MoveColumns {
7278 sheet: "Q2".to_string(),
7279 at: 6,
7280 count: 2,
7281 before: 2,
7282 },
7283 true,
7284 ),
7285 &[allow_rule("parent-1")],
7286 )
7287 .await;
7288 let lines = describe_lines(&outcome);
7289 assert_eq!(lines.len(), 2, "{lines:?}");
7290 assert!(lines[0].contains("Would move 2 column(s) 6-7"), "{lines:?}");
7291 assert!(lines[0].contains("to before column 2"), "{lines:?}");
7292 assert_eq!(
7293 lines[1],
7294 " (10 columns unchanged; columns 2-5 shift right to 4-7; moved columns land at 2-3)"
7295 );
7296 }
7297
7298 #[tokio::test]
7303 async fn move_rows_dry_run_matches_the_real_run_refusal() {
7304 let server = wiremock::MockServer::start().await;
7305 let (drive, sheets) = clients(&server).await;
7306 mount_move_prereqs(&server).await;
7307 mount_workbook().mount(&server).await;
7308
7309 let verb = StructureVerb::MoveRows {
7310 sheet: "Q2".to_string(),
7311 at: 5,
7312 count: 1,
7313 before: 502,
7314 };
7315 let real = structure(
7316 &drive,
7317 &sheets,
7318 &opts(verb.clone(), false),
7319 &[allow_rule("parent-1")],
7320 )
7321 .await;
7322 let dry = structure(
7323 &drive,
7324 &sheets,
7325 &opts(verb, true),
7326 &[allow_rule("parent-1")],
7327 )
7328 .await;
7329 assert!(
7330 matches!(real.result, StructureResult::RefusedInvalidRange { .. }),
7331 "{:?}",
7332 real.result
7333 );
7334 assert_eq!(dry.result, real.result);
7335 }
7336
7337 #[tokio::test]
7344 async fn move_rows_does_not_consult_frozen_rows() {
7345 let server = wiremock::MockServer::start().await;
7346 let (drive, sheets) = clients(&server).await;
7347 mount_move_prereqs(&server).await;
7348 mount_workbook_with_q2(serde_json::json!({
7349 "properties": {
7350 "sheetId": 118_293, "title": "Q2", "index": 1,
7351 "gridProperties": {
7352 "rowCount": 500, "columnCount": 10, "frozenRowCount": 2,
7353 },
7354 },
7355 }))
7356 .mount(&server)
7357 .await;
7358
7359 let outcome = structure(
7360 &drive,
7361 &sheets,
7362 &opts(
7363 StructureVerb::MoveRows {
7364 sheet: "Q2".to_string(),
7365 at: 1,
7366 count: 1,
7367 before: 3,
7368 },
7369 false,
7370 ),
7371 &[allow_rule("parent-1")],
7372 )
7373 .await;
7374 assert!(
7375 matches!(outcome.result, StructureResult::Changed { .. }),
7376 "{:?}",
7377 outcome.result
7378 );
7379
7380 let requests = server.received_requests().await.unwrap();
7381 let body = sent_batch_update(&requests);
7382 assert_eq!(
7383 body["requests"][0]["moveDimension"],
7384 serde_json::json!({
7385 "source": {
7386 "sheetId": 118_293, "dimension": "ROWS",
7387 "startIndex": 0, "endIndex": 1,
7388 },
7389 "destinationIndex": 2,
7390 })
7391 );
7392 }
7393
7394 #[tokio::test]
7399 async fn move_rows_does_not_consult_dimension_groups() {
7400 let server = wiremock::MockServer::start().await;
7401 let (drive, sheets) = clients(&server).await;
7402 mount_move_prereqs(&server).await;
7403 mount_workbook_with_q2(serde_json::json!({
7404 "properties": {
7405 "sheetId": 118_293, "title": "Q2", "index": 1,
7406 "gridProperties": {"rowCount": 500, "columnCount": 10},
7407 },
7408 "rowGroups": [{
7409 "range": {
7410 "sheetId": 118_293, "dimension": "ROWS",
7411 "startIndex": 1, "endIndex": 4,
7412 },
7413 }],
7414 }))
7415 .mount(&server)
7416 .await;
7417
7418 let outcome = structure(
7419 &drive,
7420 &sheets,
7421 &opts(
7422 StructureVerb::MoveRows {
7423 sheet: "Q2".to_string(),
7424 at: 2,
7425 count: 1,
7426 before: 1,
7427 },
7428 false,
7429 ),
7430 &[allow_rule("parent-1")],
7431 )
7432 .await;
7433 assert!(
7434 matches!(outcome.result, StructureResult::Changed { .. }),
7435 "{:?}",
7436 outcome.result
7437 );
7438
7439 let requests = server.received_requests().await.unwrap();
7440 let body = sent_batch_update(&requests);
7441 assert_eq!(
7442 body["requests"][0]["moveDimension"],
7443 serde_json::json!({
7444 "source": {
7445 "sheetId": 118_293, "dimension": "ROWS",
7446 "startIndex": 1, "endIndex": 2,
7447 },
7448 "destinationIndex": 0,
7449 })
7450 );
7451 }
7452
7453 #[test]
7456 fn sheet_title_is_none_for_update_workbook_properties() {
7457 assert_eq!(update_workbook_properties().sheet_title(), None);
7458 assert_eq!(update_workbook_properties().new_sheet_title(), None);
7459 assert_eq!(update_workbook_properties().dimension(), None);
7460 }
7461
7462 #[test]
7463 fn resolve_sheet_returns_none_for_update_workbook_properties_without_consulting_sheets() {
7464 let workbook = Spreadsheet {
7468 spreadsheet_id: Some("sheet-1".to_string()),
7469 properties: None,
7470 sheets: Vec::new(),
7471 named_ranges: Vec::new(),
7472 };
7473 assert_eq!(
7474 resolve_sheet(&workbook, &update_workbook_properties()),
7475 Ok(None)
7476 );
7477 }
7478
7479 #[test]
7480 fn validate_verb_args_refuses_update_workbook_properties_with_no_fields() {
7481 let verb = StructureVerb::UpdateWorkbookProperties {
7482 locale: None,
7483 time_zone: None,
7484 auto_recalc: None,
7485 iterative_calculation: None,
7486 iterative_calculation_max_iterations: None,
7487 iterative_calculation_convergence_threshold: None,
7488 };
7489 let workbook = Spreadsheet::default();
7490 let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7491 assert!(
7492 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7493 if detail.contains("at least one property")),
7494 "{err:?}"
7495 );
7496 }
7497
7498 fn update_time_zone(time_zone: &str) -> StructureVerb {
7499 StructureVerb::UpdateWorkbookProperties {
7500 locale: None,
7501 time_zone: Some(time_zone.to_string()),
7502 auto_recalc: None,
7503 iterative_calculation: None,
7504 iterative_calculation_max_iterations: None,
7505 iterative_calculation_convergence_threshold: None,
7506 }
7507 }
7508
7509 #[test]
7510 fn validate_verb_args_refuses_an_unrecognised_time_zone() {
7511 let workbook = Spreadsheet::default();
7514 let err =
7515 validate_verb_args(&workbook, &update_time_zone("Bogus/Zone9"), None).unwrap_err();
7516 assert!(
7517 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7518 if detail.contains("'Bogus/Zone9'")
7519 && detail.contains("IANA")
7520 && detail.contains("America/New_York")),
7521 "{err:?}"
7522 );
7523 }
7524
7525 #[test]
7526 fn validate_verb_args_accepts_recognised_iana_time_zones() {
7527 let workbook = Spreadsheet::default();
7528 for tz in ["Europe/London", "America/New_York", "Etc/GMT", "UTC"] {
7529 assert_eq!(
7530 validate_verb_args(&workbook, &update_time_zone(tz), None),
7531 Ok(()),
7532 "{tz}"
7533 );
7534 }
7535 }
7536
7537 #[test]
7538 fn validate_verb_args_refuses_iterative_calculation_settings_without_the_toggle() {
7539 let verb = StructureVerb::UpdateWorkbookProperties {
7540 locale: None,
7541 time_zone: None,
7542 auto_recalc: None,
7543 iterative_calculation: None,
7544 iterative_calculation_max_iterations: Some(50),
7545 iterative_calculation_convergence_threshold: None,
7546 };
7547 let workbook = Spreadsheet::default();
7548 let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
7549 assert!(
7550 matches!(&err, StructureResult::RefusedInvalidRange { detail }
7551 if detail.contains("--iterative-calculation on")),
7552 "{err:?}"
7553 );
7554
7555 let verb_off = StructureVerb::UpdateWorkbookProperties {
7557 locale: None,
7558 time_zone: None,
7559 auto_recalc: None,
7560 iterative_calculation: Some(IterativeCalculationToggle::Off),
7561 iterative_calculation_max_iterations: Some(50),
7562 iterative_calculation_convergence_threshold: None,
7563 };
7564 assert!(validate_verb_args(&workbook, &verb_off, None).is_err());
7565 }
7566
7567 #[test]
7568 fn validate_verb_args_refuses_non_positive_iterative_calculation_bounds() {
7569 let workbook = Spreadsheet::default();
7570 let bad_max = StructureVerb::UpdateWorkbookProperties {
7571 locale: None,
7572 time_zone: None,
7573 auto_recalc: None,
7574 iterative_calculation: Some(IterativeCalculationToggle::On),
7575 iterative_calculation_max_iterations: Some(0),
7576 iterative_calculation_convergence_threshold: None,
7577 };
7578 assert!(validate_verb_args(&workbook, &bad_max, None).is_err());
7579
7580 let bad_threshold = StructureVerb::UpdateWorkbookProperties {
7581 locale: None,
7582 time_zone: None,
7583 auto_recalc: None,
7584 iterative_calculation: Some(IterativeCalculationToggle::On),
7585 iterative_calculation_max_iterations: None,
7586 iterative_calculation_convergence_threshold: Some(0.0),
7587 };
7588 assert!(validate_verb_args(&workbook, &bad_threshold, None).is_err());
7589
7590 let nan_threshold = StructureVerb::UpdateWorkbookProperties {
7591 locale: None,
7592 time_zone: None,
7593 auto_recalc: None,
7594 iterative_calculation: Some(IterativeCalculationToggle::On),
7595 iterative_calculation_max_iterations: None,
7596 iterative_calculation_convergence_threshold: Some(f64::NAN),
7597 };
7598 assert!(validate_verb_args(&workbook, &nan_threshold, None).is_err());
7599 }
7600
7601 #[test]
7602 fn validate_verb_args_accepts_positive_iterative_calculation_bounds() {
7603 let workbook = Spreadsheet::default();
7604 let verb = StructureVerb::UpdateWorkbookProperties {
7605 locale: None,
7606 time_zone: None,
7607 auto_recalc: None,
7608 iterative_calculation: Some(IterativeCalculationToggle::On),
7609 iterative_calculation_max_iterations: Some(50),
7610 iterative_calculation_convergence_threshold: Some(0.01),
7611 };
7612 assert!(validate_verb_args(&workbook, &verb, None).is_ok());
7613 }
7614
7615 #[test]
7616 fn validate_verb_args_accepts_a_single_field() {
7617 let workbook = Spreadsheet::default();
7618 let verb = StructureVerb::UpdateWorkbookProperties {
7619 locale: Some("fr_FR".to_string()),
7620 time_zone: None,
7621 auto_recalc: None,
7622 iterative_calculation: None,
7623 iterative_calculation_max_iterations: None,
7624 iterative_calculation_convergence_threshold: None,
7625 };
7626 assert!(validate_verb_args(&workbook, &verb, None).is_ok());
7627 }
7628
7629 #[test]
7630 fn build_request_masks_exactly_the_fields_given() {
7631 let verb = StructureVerb::UpdateWorkbookProperties {
7632 locale: Some("en_US".to_string()),
7633 time_zone: None,
7634 auto_recalc: Some(RecalculationInterval::Hour),
7635 iterative_calculation: None,
7636 iterative_calculation_max_iterations: None,
7637 iterative_calculation_convergence_threshold: None,
7638 };
7639 let request = build_request(&verb, None).unwrap();
7640 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7641 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
7643 assert_eq!(req.fields, "locale,autoRecalc");
7644 assert_eq!(req.properties.locale.as_deref(), Some("en_US"));
7645 assert_eq!(req.properties.time_zone, None);
7646 assert_eq!(
7647 req.properties.auto_recalc,
7648 Some(RecalculationInterval::Hour)
7649 );
7650 assert_eq!(req.properties.iterative_calculation_settings, None);
7651 }
7652
7653 #[test]
7654 fn build_request_turns_iterative_calculation_on_with_explicit_settings() {
7655 let verb = StructureVerb::UpdateWorkbookProperties {
7656 locale: None,
7657 time_zone: None,
7658 auto_recalc: None,
7659 iterative_calculation: Some(IterativeCalculationToggle::On),
7660 iterative_calculation_max_iterations: Some(50),
7661 iterative_calculation_convergence_threshold: Some(0.01),
7662 };
7663 let request = build_request(&verb, None).unwrap();
7664 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7665 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
7667 assert_eq!(req.fields, "iterativeCalculationSettings");
7668 assert_eq!(
7669 req.properties.iterative_calculation_settings,
7670 Some(IterativeCalculationSettings {
7671 max_iterations: Some(50),
7672 convergence_threshold: Some(0.01),
7673 })
7674 );
7675 }
7676
7677 #[test]
7678 fn build_request_turns_iterative_calculation_off_by_naming_the_field_without_a_value() {
7679 let verb = StructureVerb::UpdateWorkbookProperties {
7684 locale: None,
7685 time_zone: None,
7686 auto_recalc: None,
7687 iterative_calculation: Some(IterativeCalculationToggle::Off),
7688 iterative_calculation_max_iterations: None,
7689 iterative_calculation_convergence_threshold: None,
7690 };
7691 let request = build_request(&verb, None).unwrap();
7692 let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
7693 panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
7695 assert_eq!(req.fields, "iterativeCalculationSettings");
7696 assert_eq!(req.properties.iterative_calculation_settings, None);
7697 let json = serde_json::to_value(&req.properties).unwrap();
7698 assert!(
7699 json.as_object().unwrap().is_empty(),
7700 "expected an empty properties object, got {json}"
7701 );
7702 }
7703
7704 #[test]
7705 fn workbook_properties_summary_formats_every_field() {
7706 let verb = StructureVerb::UpdateWorkbookProperties {
7707 locale: Some("en_US".to_string()),
7708 time_zone: Some("America/New_York".to_string()),
7709 auto_recalc: Some(RecalculationInterval::OnChange),
7710 iterative_calculation: Some(IterativeCalculationToggle::On),
7711 iterative_calculation_max_iterations: Some(50),
7712 iterative_calculation_convergence_threshold: Some(0.01),
7713 };
7714 let summary = workbook_properties_summary(&verb);
7715 assert_eq!(
7716 summary,
7717 "locale -> 'en_US', time zone -> 'America/New_York', \
7718 auto-recalc -> ON_CHANGE, iterative calculation -> on \
7719 (max 50 iterations, threshold 0.01)"
7720 );
7721 }
7722
7723 #[tokio::test]
7724 async fn update_workbook_properties_dry_run_reports_would_change_with_no_sheet() {
7725 let server = wiremock::MockServer::start().await;
7726 let (drive, sheets) = clients(&server).await;
7727 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7728 .mount(&server)
7729 .await;
7730 mount_folder("parent-1").mount(&server).await;
7731 mount_workbook().mount(&server).await;
7732
7733 let outcome = structure(
7734 &drive,
7735 &sheets,
7736 &opts(update_workbook_properties(), true),
7737 &[allow_rule("parent-1")],
7738 )
7739 .await;
7740 match &outcome.result {
7741 StructureResult::WouldChange { sheet, sheet_count } => {
7742 assert_eq!(*sheet, None);
7743 assert_eq!(*sheet_count, 2);
7744 }
7745 other => panic!("expected WouldChange, got {other:?}"), }
7747 let lines = describe_lines(&outcome);
7748 assert_eq!(lines.len(), 1);
7749 assert!(
7750 lines[0].starts_with("Would update workbook properties of 'sheet-1'"),
7751 "{lines:?}"
7752 );
7753
7754 let requests = server.received_requests().await.unwrap();
7757 assert!(!requests
7758 .iter()
7759 .any(|r| r.url.path().ends_with(":batchUpdate")));
7760 }
7761
7762 #[tokio::test]
7763 async fn update_workbook_properties_refuses_an_unrecognised_time_zone_on_dry_run_and_apply() {
7764 for dry_run in [true, false] {
7767 let server = wiremock::MockServer::start().await;
7768 let (drive, sheets) = clients(&server).await;
7769 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7770 .mount(&server)
7771 .await;
7772 mount_folder("parent-1").mount(&server).await;
7773 mount_workbook().mount(&server).await;
7774
7775 let outcome = structure(
7776 &drive,
7777 &sheets,
7778 &opts(update_time_zone("Bogus/Zone9"), dry_run),
7779 &[allow_rule("parent-1")],
7780 )
7781 .await;
7782 assert!(
7783 matches!(&outcome.result, StructureResult::RefusedInvalidRange { detail }
7784 if detail.contains("'Bogus/Zone9'")),
7785 "dry_run={dry_run}: {:?}",
7786 outcome.result
7787 );
7788 let requests = server.received_requests().await.unwrap();
7789 assert!(
7790 !requests
7791 .iter()
7792 .any(|r| r.url.path().ends_with(":batchUpdate")),
7793 "dry_run={dry_run}"
7794 );
7795 }
7796 }
7797
7798 #[tokio::test]
7799 async fn update_workbook_properties_apply_sends_the_masked_request_and_reports_changed() {
7800 let server = wiremock::MockServer::start().await;
7801 let (drive, sheets) = clients(&server).await;
7802 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
7803 .mount(&server)
7804 .await;
7805 mount_folder("parent-1").mount(&server).await;
7806 mount_workbook().mount(&server).await;
7807 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
7808 .mount(&server)
7809 .await;
7810
7811 let outcome = structure(
7812 &drive,
7813 &sheets,
7814 &opts(update_workbook_properties(), false),
7815 &[allow_rule("parent-1")],
7816 )
7817 .await;
7818 match &outcome.result {
7819 StructureResult::Changed {
7820 sheet, sheet_id, ..
7821 } => {
7822 assert_eq!(*sheet, None);
7823 assert_eq!(*sheet_id, None);
7824 }
7825 other => panic!("expected Changed, got {other:?}"), }
7827
7828 let requests = server.received_requests().await.unwrap();
7829 let body = sent_batch_update(&requests);
7830 let update = &body["requests"][0]["updateSpreadsheetProperties"];
7831 assert_eq!(update["properties"]["locale"], "en_US");
7832 assert_eq!(update["properties"]["timeZone"], "America/New_York");
7833 assert_eq!(update["properties"]["autoRecalc"], "ON_CHANGE");
7834 assert_eq!(update["fields"], "locale,timeZone,autoRecalc");
7835 }
7836
7837 #[test]
7838 fn record_attempt_carries_the_fields_changed_summary_and_no_sheet_title() {
7839 let opts = opts(update_workbook_properties(), false);
7840 assert_eq!(opts.verb.sheet_title(), None);
7841 assert_eq!(
7842 workbook_properties_summary(&opts.verb),
7843 "locale -> 'en_US', time zone -> 'America/New_York', auto-recalc -> ON_CHANGE"
7844 );
7845 }
7846
7847 #[test]
7850 fn log_operation_is_distinct_per_verb() {
7851 let verbs = [
7852 add_sheet(),
7853 rename(),
7854 insert_rows(),
7855 StructureVerb::InsertColumns {
7856 sheet: "Q2".to_string(),
7857 at: 1,
7858 count: 1,
7859 },
7860 insert_range(),
7861 move_rows(),
7862 StructureVerb::MoveColumns {
7863 sheet: "Q2".to_string(),
7864 at: 1,
7865 count: 1,
7866 before: 3,
7867 },
7868 delete_sheet(),
7869 delete_rows(),
7870 StructureVerb::DeleteColumns {
7871 sheet: "Q2".to_string(),
7872 at: 1,
7873 count: 1,
7874 },
7875 delete_range(),
7876 duplicate_sheet(),
7877 reorder_sheet(),
7878 hide_sheet(),
7879 show_sheet(),
7880 update_workbook_properties(),
7881 ];
7882 let names: Vec<&str> = verbs.iter().map(StructureVerb::log_operation).collect();
7883 assert_eq!(
7884 names,
7885 vec![
7886 "sheets-add-sheet",
7887 "sheets-rename-sheet",
7888 "sheets-insert-rows",
7889 "sheets-insert-columns",
7890 "sheets-insert-range",
7891 "sheets-move-rows",
7892 "sheets-move-columns",
7893 "sheets-delete-sheet",
7894 "sheets-delete-rows",
7895 "sheets-delete-columns",
7896 "sheets-delete-range",
7897 "sheets-duplicate-sheet",
7898 "sheets-reorder-sheet",
7899 "sheets-hide-sheet",
7900 "sheets-show-sheet",
7901 "sheets-update-workbook-properties",
7902 ]
7903 );
7904 let unique: HashSet<&&str> = names.iter().collect();
7907 assert_eq!(unique.len(), names.len());
7908 assert!(!names.contains(&"sheets-write"));
7909 }
7910
7911 #[test]
7912 fn insert_range_records_its_one_based_inclusive_grid_context() {
7913 assert_eq!(
7914 grid_range_label(&insert_range()).as_deref(),
7915 Some("rows 2-4, columns 2-3")
7916 );
7917 }
7918
7919 fn every_structure_result() -> Vec<StructureResult> {
7925 let all = vec![
7926 StructureResult::WouldChange {
7927 sheet: Some(Box::new(SheetSnapshot {
7928 sheet_id: Some(7),
7929 title: "Q2".to_string(),
7930 row_count: Some(500),
7931 column_count: Some(26),
7932 ..Default::default()
7933 })),
7934 sheet_count: 2,
7935 },
7936 StructureResult::RefusedNotASpreadsheet {
7937 mime_type: "application/pdf".to_string(),
7938 },
7939 StructureResult::RefusedShortcut,
7940 StructureResult::RefusedNoVisibleParents,
7941 StructureResult::RefusedSheetNotFound {
7942 title: "Nope".to_string(),
7943 available: vec!["Q1".to_string(), "Q2".to_string()],
7944 },
7945 StructureResult::RefusedSheetExists {
7946 title: "Q1".to_string(),
7947 },
7948 StructureResult::RefusedInvalidRange {
7949 detail: "--count must be at least 1, got 0".to_string(),
7950 },
7951 StructureResult::Blocked { decided_by: None },
7952 StructureResult::RefusedNoLease,
7953 StructureResult::RefusedLeaseExpired,
7954 StructureResult::RefusedLeaseWrongFile,
7955 StructureResult::RefusedLeaseStale,
7956 StructureResult::Unchanged {
7957 detail: "sheet 'Q2' is already hidden".to_string(),
7958 },
7959 StructureResult::Changed {
7960 sheet: Some(Box::new(SheetSnapshot {
7961 sheet_id: Some(7),
7962 title: "Q2".to_string(),
7963 row_count: Some(500),
7964 column_count: Some(26),
7965 ..Default::default()
7966 })),
7967 sheet_id: Some(7),
7968 backup: Some(Box::new(LeaseBackup::DriveCopy {
7969 file_id: "backup-copy-1".to_string(),
7970 })),
7971 },
7972 StructureResult::Failed {
7973 detail: "boom".to_string(),
7974 },
7975 ];
7976 for result in &all {
7978 match result {
7979 StructureResult::WouldChange { .. }
7980 | StructureResult::RefusedNotASpreadsheet { .. }
7981 | StructureResult::RefusedShortcut
7982 | StructureResult::RefusedNoVisibleParents
7983 | StructureResult::RefusedSheetNotFound { .. }
7984 | StructureResult::RefusedSheetExists { .. }
7985 | StructureResult::RefusedInvalidRange { .. }
7986 | StructureResult::Blocked { .. }
7987 | StructureResult::RefusedNoLease
7988 | StructureResult::RefusedLeaseExpired
7989 | StructureResult::RefusedLeaseWrongFile
7990 | StructureResult::RefusedLeaseStale
7991 | StructureResult::Unchanged { .. }
7992 | StructureResult::Changed { .. }
7993 | StructureResult::Failed { .. } => {}
7994 }
7995 }
7996 all
7997 }
7998
7999 #[test]
8020 fn no_describe_line_contains_a_newline() {
8021 for verb in [
8022 add_sheet(),
8023 rename(),
8024 insert_rows(),
8025 StructureVerb::InsertColumns {
8026 sheet: "Q2".to_string(),
8027 at: 2,
8028 count: 1,
8029 },
8030 move_rows(),
8031 StructureVerb::MoveColumns {
8032 sheet: "Q2".to_string(),
8033 at: 2,
8034 count: 1,
8035 before: 5,
8036 },
8037 delete_sheet(),
8038 delete_rows(),
8039 delete_columns(),
8040 insert_range(),
8041 insert_range_columns_shift(),
8042 delete_range(),
8043 delete_range_columns_shift(),
8044 duplicate_sheet(),
8045 duplicate_sheet_default(),
8046 reorder_sheet(),
8047 hide_sheet(),
8048 show_sheet(),
8049 update_workbook_properties(),
8050 ] {
8051 let has_dimension_shift = verb.dimension().is_some();
8056 for result in every_structure_result() {
8057 let previews_an_insert =
8058 has_dimension_shift && matches!(result, StructureResult::WouldChange { .. });
8059 let outcome = StructureOutcome {
8060 spreadsheet_id: "sheet-1".to_string(),
8061 file_name: Some("Budget".to_string()),
8062 resolved_folder_id: None,
8063 verb: verb.clone(),
8064 result,
8065 };
8066 let lines = describe_lines(&outcome);
8067 assert_eq!(
8068 lines.len(),
8069 usize::from(previews_an_insert) + 1,
8070 "unexpected line count for {:?}/{:?}: {lines:?}",
8071 outcome.verb,
8072 outcome.result
8073 );
8074 for rendered in &lines {
8075 assert!(
8076 !rendered.chars().any(char::is_control),
8077 "describe_lines emitted a control character for {:?}/{:?}: \
8078 {rendered:?}",
8079 outcome.verb,
8080 outcome.result
8081 );
8082 }
8083 assert_eq!(describe(&outcome), lines.join("\n"));
8086 }
8087 }
8088 }
8089
8090 #[test]
8099 fn describe_would_delete_dimension_handles_unvalidated_edge_cases() {
8100 let would_change = |verb: StructureVerb, sheet: Option<SheetSnapshot>| StructureOutcome {
8101 spreadsheet_id: "sheet-1".to_string(),
8102 file_name: Some("Budget".to_string()),
8103 resolved_folder_id: None,
8104 verb,
8105 result: StructureResult::WouldChange {
8106 sheet: sheet.map(Box::new),
8107 sheet_count: 2,
8108 },
8109 };
8110 let sheet_with_columns = |column_count| SheetSnapshot {
8113 sheet_id: None,
8114 title: "Q2".to_string(),
8115 row_count: None,
8116 column_count: Some(column_count),
8117 ..Default::default()
8118 };
8119
8120 let overflow = describe(&would_change(
8123 StructureVerb::DeleteColumns {
8124 sheet: "Q2".to_string(),
8125 at: i64::MAX,
8126 count: 2,
8127 },
8128 Some(sheet_with_columns(26)),
8129 ));
8130 assert!(
8131 overflow.contains("Would delete 2 column(s) from column"),
8132 "{overflow}"
8133 );
8134 assert!(!overflow.contains('-'), "{overflow}");
8135
8136 let no_sheet = describe(&would_change(delete_columns(), None));
8139 assert_eq!(no_sheet, "Would delete 2 column(s) 3-4 of 'Q2' in 'Budget'");
8140
8141 let after_underflows = describe(&would_change(
8144 StructureVerb::DeleteColumns {
8145 sheet: "Q2".to_string(),
8146 at: 0,
8147 count: i64::MAX,
8148 },
8149 Some(sheet_with_columns(-2)),
8150 ));
8151 assert_eq!(
8152 after_underflows,
8153 format!(
8154 "Would delete {} column(s) 0-{} of 'Q2' in 'Budget'",
8155 i64::MAX,
8156 i64::MAX - 1
8157 )
8158 );
8159
8160 let to_the_end = describe(&would_change(
8163 StructureVerb::DeleteColumns {
8164 sheet: "Q2".to_string(),
8165 at: 25,
8166 count: 2,
8167 },
8168 Some(sheet_with_columns(26)),
8169 ));
8170 assert!(
8171 to_the_end.contains("nothing remains after the deleted columns"),
8172 "{to_the_end}"
8173 );
8174 }
8175
8176 #[test]
8177 fn log_status_covers_every_variant() {
8178 let statuses = [
8179 StructureResult::WouldChange {
8180 sheet: None,
8181 sheet_count: 1,
8182 }
8183 .log_status(),
8184 StructureResult::RefusedNotASpreadsheet {
8185 mime_type: "application/pdf".to_string(),
8186 }
8187 .log_status(),
8188 StructureResult::RefusedShortcut.log_status(),
8189 StructureResult::RefusedNoVisibleParents.log_status(),
8190 StructureResult::RefusedSheetNotFound {
8191 title: "x".to_string(),
8192 available: Vec::new(),
8193 }
8194 .log_status(),
8195 StructureResult::RefusedSheetExists {
8196 title: "x".to_string(),
8197 }
8198 .log_status(),
8199 StructureResult::RefusedInvalidRange {
8200 detail: "x".to_string(),
8201 }
8202 .log_status(),
8203 StructureResult::Blocked { decided_by: None }.log_status(),
8204 StructureResult::RefusedNoLease.log_status(),
8205 StructureResult::RefusedLeaseExpired.log_status(),
8206 StructureResult::RefusedLeaseWrongFile.log_status(),
8207 StructureResult::RefusedLeaseStale.log_status(),
8208 StructureResult::Unchanged {
8209 detail: "x".to_string(),
8210 }
8211 .log_status(),
8212 StructureResult::Changed {
8213 sheet: None,
8214 sheet_id: None,
8215 backup: None,
8216 }
8217 .log_status(),
8218 StructureResult::Failed {
8219 detail: "x".to_string(),
8220 }
8221 .log_status(),
8222 ];
8223 let unique: HashSet<&&str> = statuses.iter().collect();
8224 assert_eq!(unique.len(), statuses.len(), "statuses must be distinct");
8225 assert!(statuses.iter().all(|s| !s.is_empty()));
8226 }
8227
8228 #[test]
8229 fn a_verdict_of_allow_is_what_lets_a_run_proceed() {
8230 assert_ne!(Verdict::Allow, Verdict::Deny);
8233 }
8234
8235 #[test]
8236 fn build_request_fails_rather_than_guessing_a_missing_sheet_id() {
8237 let sheet = SheetSnapshot {
8240 sheet_id: None,
8241 title: "Q2".to_string(),
8242 row_count: None,
8243 column_count: None,
8244 ..Default::default()
8245 };
8246 let err = build_request(&rename(), Some(&sheet)).unwrap_err();
8247 assert!(err.contains("sheetId"), "{err}");
8248 }
8249
8250 #[test]
8251 fn added_sheet_id_reads_the_add_sheet_reply_only() {
8252 let empty: BatchUpdateResponse =
8253 serde_json::from_value(serde_json::json!({"spreadsheetId": "s", "replies": [{}]}))
8254 .unwrap();
8255 assert_eq!(added_sheet_id(&empty), None);
8256 let added: BatchUpdateResponse = serde_json::from_value(serde_json::json!({
8257 "replies": [{"addSheet": {"properties": {"sheetId": 42, "title": "New"}}}]
8258 }))
8259 .unwrap();
8260 assert_eq!(added_sheet_id(&added), Some(42));
8261 }
8262
8263 #[tokio::test]
8266 async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
8267 let server = wiremock::MockServer::start().await;
8270 let (drive, sheets) = clients(&server).await;
8271 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8272 .mount(&server)
8273 .await;
8274 mount_workbook().mount(&server).await;
8275 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8276 .expect(1)
8277 .mount(&server)
8278 .await;
8279
8280 let outcome = structure(
8281 &drive,
8282 &sheets,
8283 &opts(rename(), false),
8284 &[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsStructure])],
8285 )
8286 .await;
8287
8288 assert!(
8289 matches!(outcome.result, StructureResult::Changed { .. }),
8290 "{:?}",
8291 outcome.result
8292 );
8293 }
8294
8295 #[tokio::test]
8296 async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
8297 let server = wiremock::MockServer::start().await;
8299 let (drive, sheets) = clients(&server).await;
8300 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8301 .mount(&server)
8302 .await;
8303 let outcome = structure(
8307 &drive,
8308 &sheets,
8309 &opts(rename(), false),
8310 &[
8311 allow_rule("parent-1"),
8312 FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsStructure]),
8313 ],
8314 )
8315 .await;
8316
8317 match &outcome.result {
8318 StructureResult::Blocked { decided_by } => {
8319 let rule = decided_by.as_ref().expect("a file rule decided this");
8320 assert_eq!(rule.kind_label(), "file");
8321 assert_eq!(rule.id(), "sheet-1");
8322 }
8323 other => panic!("expected Blocked, got {other:?}"),
8324 }
8325 let text = describe(&outcome);
8326 assert!(text.contains("refused by rule on file sheet-1"), "{text}");
8327 assert!(!text.contains("depth"), "a file rule has no depth: {text}");
8328 }
8329
8330 #[tokio::test]
8331 async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
8332 let server = wiremock::MockServer::start().await;
8333 let (drive, sheets) = clients(&server).await;
8334 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
8335 .mount(&server)
8336 .await;
8337 let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
8338 let text = describe(&outcome);
8339 assert!(text.contains("file_id"), "{text}");
8340 assert!(text.contains("sheets-structure"), "{text}");
8341 }
8342
8343 #[tokio::test]
8346 async fn a_leased_structure_change_concludes_its_audit_pair_with_allowed() {
8347 let server = wiremock::MockServer::start().await;
8348 let (drive, sheets) = clients(&server).await;
8349 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8350 .mount(&server)
8351 .await;
8352 mount_folder("parent-1").mount(&server).await;
8353 mount_workbook().mount(&server).await;
8354 mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
8355 .mount(&server)
8356 .await;
8357 let dir = tempfile::tempdir().unwrap();
8358 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8359 let verb = StructureVerb::RenameSheet {
8360 sheet: "Q2".to_string(),
8361 new_title: "Q2 (final)".to_string(),
8362 };
8363
8364 let outcome = structure(
8365 &drive,
8366 &sheets,
8367 &opts(verb, false),
8368 &[allow_rule("parent-1")],
8369 )
8370 .await;
8371 assert!(matches!(outcome.result, StructureResult::Changed { .. }));
8372
8373 let records = audit.records();
8374 assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
8375 assert_eq!(records[0].command, ["drive", "sheets-rename-sheet"]);
8379 }
8380
8381 #[tokio::test]
8390 async fn a_missing_sheet_id_fails_before_opening_the_audit_pair() {
8391 let server = wiremock::MockServer::start().await;
8392 let (drive, sheets) = clients(&server).await;
8393 mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
8394 .mount(&server)
8395 .await;
8396 mount_folder("parent-1").mount(&server).await;
8397 wiremock::Mock::given(wiremock::matchers::method("GET"))
8398 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
8399 .respond_with(
8400 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8401 "spreadsheetId": "sheet-1",
8402 "properties": {"title": "Budget"},
8403 "sheets": [
8404 {"properties": {"title": "Q2", "index": 0}}
8405 ],
8406 })),
8407 )
8408 .mount(&server)
8409 .await;
8410 let dir = tempfile::tempdir().unwrap();
8413 let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
8414
8415 let outcome = structure(
8416 &drive,
8417 &sheets,
8418 &opts(rename(), false),
8419 &[allow_rule("parent-1")],
8420 )
8421 .await;
8422 match outcome.result {
8423 StructureResult::Failed { detail } => {
8424 assert!(detail.contains("sheetId"), "{detail}");
8425 }
8426 other => panic!("expected Failed, got {other:?}"),
8427 }
8428 assert!(audit.records().is_empty(), "{:?}", audit.records());
8429 }
8430}