1use std::io::Read as _;
85use std::path::PathBuf;
86use std::time::{Duration, Instant};
87
88use serde::Serialize;
89
90use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
91use crate::drive::client::DriveClient;
92use crate::drive::files_api::FilesApi;
93use crate::drive::lease::check::{
94 conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
95 LeasedWrite,
96};
97use crate::drive::sheets::a1;
98use crate::drive::sheets::api::{
99 applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
100};
101use crate::drive::sheets::client::SheetsClient;
102use crate::drive::sheets::grid_range;
103use crate::drive::sheets::target_gate;
104use crate::drive::sheets::types::{
105 BatchUpdateRequestItem, CopyPasteRequest, CutPasteRequest, GridCoordinate, GridRange,
106 PasteDataRequest, PasteOrientation, PasteType,
107};
108use crate::drive::types::SheetTargetRefusal;
109use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
110use crate::request_log::{self, DriveMutationOutcome};
111
112const GATE_WRITE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsWrite];
113const GATE_STRUCTURE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsStructure];
114const GATE_BOTH: &[DriveOperation] =
119 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum PasteVerb {
124 CutPaste {
127 sheet: Option<String>,
130 source: String,
132 destination: String,
135 paste_type: PasteType,
138 },
139 CopyPaste {
142 sheet: Option<String>,
145 source: String,
147 destination: String,
150 paste_type: PasteType,
152 orientation: PasteOrientation,
154 },
155 PasteData {
157 sheet: Option<String>,
160 destination: String,
163 data: String,
165 delimiter: String,
167 paste_type: PasteType,
169 },
170}
171
172impl PasteVerb {
173 const fn log_operation(&self) -> &'static str {
175 match self {
176 Self::CutPaste { .. } => "sheets-cut-paste",
177 Self::CopyPaste { .. } => "sheets-copy-paste",
178 Self::PasteData { .. } => "sheets-paste-data",
179 }
180 }
181
182 const fn label(&self) -> &'static str {
184 match self {
185 Self::CutPaste { .. } => "cut-paste",
186 Self::CopyPaste { .. } => "copy-paste",
187 Self::PasteData { .. } => "paste-data",
188 }
189 }
190
191 fn sheet(&self) -> Option<&str> {
192 match self {
193 Self::CutPaste { sheet, .. }
194 | Self::CopyPaste { sheet, .. }
195 | Self::PasteData { sheet, .. } => sheet.as_deref(),
196 }
197 }
198
199 fn source(&self) -> Option<&str> {
200 match self {
201 Self::CutPaste { source, .. } | Self::CopyPaste { source, .. } => Some(source),
202 Self::PasteData { .. } => None,
203 }
204 }
205
206 fn destination(&self) -> &str {
207 match self {
208 Self::CutPaste { destination, .. }
209 | Self::CopyPaste { destination, .. }
210 | Self::PasteData { destination, .. } => destination,
211 }
212 }
213
214 const fn paste_type(&self) -> PasteType {
215 match self {
216 Self::CutPaste { paste_type, .. }
217 | Self::CopyPaste { paste_type, .. }
218 | Self::PasteData { paste_type, .. } => *paste_type,
219 }
220 }
221
222 fn gate_operations(&self) -> &'static [DriveOperation] {
224 match self {
225 Self::CutPaste { .. } => GATE_BOTH,
228 Self::CopyPaste { paste_type, .. } | Self::PasteData { paste_type, .. } => {
229 match (paste_type.writes_values(), paste_type.writes_presentation()) {
230 (false, true) => GATE_STRUCTURE_ONLY,
231 (true, false) => GATE_WRITE_ONLY,
232 (true, true) | (false, false) => GATE_BOTH,
241 }
242 }
243 }
244 }
245}
246
247#[derive(Debug, Clone)]
249pub struct PasteOptions {
250 pub spreadsheet_id: String,
252 pub verb: PasteVerb,
254 pub dry_run: bool,
256 pub lease_token: Option<String>,
259 pub ledger_path: PathBuf,
261}
262
263#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
265pub struct PasteChange {
266 pub destination: String,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub source: Option<String>,
271 pub written_extent: String,
275 #[serde(skip_serializing_if = "Option::is_none")]
279 pub overwritten: Option<Vec<String>>,
280 #[serde(skip_serializing_if = "Option::is_none")]
283 pub cleared: Option<Vec<String>>,
284 #[serde(skip_serializing_if = "Option::is_none")]
289 pub grid_edge_caveat: Option<String>,
290}
291
292#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
294#[serde(tag = "status", rename_all = "kebab-case")]
295pub enum PasteResult {
296 WouldChange(PasteChange),
298 RefusedNotASpreadsheet {
300 mime_type: String,
302 },
303 RefusedShortcut,
305 RefusedNoVisibleParents,
307 RefusedSheetNotFound {
309 title: String,
311 available: Vec<String>,
313 },
314 RefusedInvalidRange {
320 detail: String,
322 },
323 RefusedEmptyData,
326 RefusedInvalidDelimiter {
331 detail: String,
333 },
334 Blocked {
336 operation: DriveOperation,
338 decided_by: Option<DecidingRule>,
340 },
341 RefusedNoLease,
343 RefusedLeaseExpired,
346 RefusedLeaseWrongFile,
348 RefusedLeaseStale,
350 Changed(PasteChange),
352 AppliedReplyUnreadable {
357 summary: String,
360 #[serde(flatten)]
364 change: PasteChange,
365 detail: String,
367 },
368 Failed {
370 detail: String,
372 },
373}
374
375impl FromLeaseRefusal for PasteResult {
376 fn from_no_lease() -> Self {
377 Self::RefusedNoLease
378 }
379 fn from_lease_expired() -> Self {
380 Self::RefusedLeaseExpired
381 }
382 fn from_lease_wrong_file() -> Self {
383 Self::RefusedLeaseWrongFile
384 }
385 fn from_lease_stale() -> Self {
386 Self::RefusedLeaseStale
387 }
388 fn from_lease_failed(detail: String) -> Self {
389 Self::Failed { detail }
390 }
391}
392
393impl PasteResult {
394 fn log_status(&self) -> &'static str {
395 match self {
396 Self::WouldChange(_) => "would-change",
397 Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
398 Self::RefusedShortcut => "refused-shortcut",
399 Self::RefusedNoVisibleParents => "refused-no-visible-parents",
400 Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
401 Self::RefusedInvalidRange { .. } => "refused-invalid-range",
402 Self::RefusedEmptyData => "refused-empty-data",
403 Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
404 Self::Blocked { .. } => "blocked",
405 Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
406 Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
407 Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
408 Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
409 Self::Changed(_) => "changed",
410 Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
411 Self::Failed { .. } => "failed",
412 }
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct PasteOutcome {
424 pub spreadsheet_id: String,
426 pub file_name: Option<String>,
428 pub resolved_folder_id: Option<String>,
430 pub verb: PasteVerb,
433 pub result: PasteResult,
435}
436
437impl Serialize for PasteOutcome {
438 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
439 #[derive(Serialize)]
440 struct Wire<'a> {
441 spreadsheet_id: &'a str,
442 #[serde(skip_serializing_if = "Option::is_none")]
443 file_name: Option<&'a str>,
444 #[serde(skip_serializing_if = "Option::is_none")]
445 resolved_folder_id: Option<&'a str>,
446 paste_type: PasteType,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 orientation: Option<PasteOrientation>,
449 result: &'a PasteResult,
450 }
451 let (paste_type, orientation) = match &self.verb {
452 PasteVerb::CutPaste { paste_type, .. } | PasteVerb::PasteData { paste_type, .. } => {
453 (*paste_type, None)
454 }
455 PasteVerb::CopyPaste {
456 paste_type,
457 orientation,
458 ..
459 } => (*paste_type, Some(*orientation)),
460 };
461 Wire {
462 spreadsheet_id: &self.spreadsheet_id,
463 file_name: self.file_name.as_deref(),
464 resolved_folder_id: self.resolved_folder_id.as_deref(),
465 paste_type,
466 orientation,
467 result: &self.result,
468 }
469 .serialize(serializer)
470 }
471}
472
473impl JsonlSerialize for PasteOutcome {
474 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
475 write_scalar_jsonl(self, out)
476 }
477}
478
479pub async fn paste(
482 drive: &DriveClient,
483 sheets: &SheetsClient,
484 opts: &PasteOptions,
485 rules: &[FolderPermissionRule],
486) -> PasteOutcome {
487 let started = Instant::now();
488 let outcome = paste_inner(drive, sheets, opts, rules).await;
489 if !opts.dry_run {
490 record_attempt(&outcome, opts, started.elapsed());
491 }
492 outcome
493}
494
495async fn paste_inner(
496 drive: &DriveClient,
497 sheets: &SheetsClient,
498 opts: &PasteOptions,
499 rules: &[FolderPermissionRule],
500) -> PasteOutcome {
501 let bare = |result| PasteOutcome {
502 spreadsheet_id: opts.spreadsheet_id.clone(),
503 file_name: None,
504 resolved_folder_id: None,
505 verb: opts.verb.clone(),
506 result,
507 };
508
509 if let PasteVerb::PasteData {
510 data, delimiter, ..
511 } = &opts.verb
512 {
513 if data.is_empty() {
514 return bare(PasteResult::RefusedEmptyData);
515 }
516 if delimiter.is_empty() {
517 return bare(PasteResult::RefusedInvalidDelimiter {
518 detail: "--delimiter must not be empty".to_string(),
519 });
520 }
521 }
522 if let Some(sheet) = unusable_sheet_default(&opts.verb) {
523 return bare(PasteResult::RefusedInvalidRange { detail: sheet });
524 }
525
526 let destination_composed = match compose_with_default_sheet(
527 opts.verb.sheet(),
528 opts.verb.destination(),
529 "--destination",
530 ) {
531 Ok(composed) => composed,
532 Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
533 };
534 let source_composed = match opts.verb.source() {
535 Some(source) => match compose_with_default_sheet(opts.verb.sheet(), source, "--source") {
536 Ok(composed) => Some(composed),
537 Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
538 },
539 None => None,
540 };
541
542 let operations = opts.verb.gate_operations();
543
544 let (target, verdict, denied, resolved_folder_id, requires_lease) =
545 match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
546 target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
547 return bare(PasteResult::Failed { detail })
548 }
549 target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
550 let result = match refusal {
551 SheetTargetRefusal::Shortcut => PasteResult::RefusedShortcut,
552 SheetTargetRefusal::NotASpreadsheet { mime_type } => {
553 PasteResult::RefusedNotASpreadsheet { mime_type }
554 }
555 SheetTargetRefusal::NoVisibleParents => PasteResult::RefusedNoVisibleParents,
556 };
557 return PasteOutcome {
558 spreadsheet_id: opts.spreadsheet_id.clone(),
559 file_name: Some(target.name),
560 resolved_folder_id: None,
561 verb: opts.verb.clone(),
562 result,
563 };
564 }
565 target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
566 return PasteOutcome {
567 spreadsheet_id: opts.spreadsheet_id.clone(),
568 file_name: Some(target.name),
569 resolved_folder_id: None,
570 verb: opts.verb.clone(),
571 result: PasteResult::Failed { detail },
572 };
573 }
574 target_gate::TargetGateUnionOutcome::Gated {
575 target,
576 verdict,
577 denied,
578 resolved_folder_id,
579 requires_lease,
580 } => (target, verdict, denied, resolved_folder_id, requires_lease),
581 };
582
583 let gated = |result| PasteOutcome {
584 spreadsheet_id: opts.spreadsheet_id.clone(),
585 file_name: Some(target.name.clone()),
586 resolved_folder_id: resolved_folder_id.clone(),
587 verb: opts.verb.clone(),
588 result,
589 };
590
591 if verdict == write_gate::Verdict::Deny {
592 let (operation, decided_by) = denied.unwrap_or((operations[0], None));
593 return gated(PasteResult::Blocked {
594 operation,
595 decided_by,
596 });
597 }
598
599 let api = SheetsApi::new(sheets);
600 let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
601 Ok(workbook) => workbook,
602 Err(err) => {
603 return gated(PasteResult::Failed {
604 detail: format!("{err:#}"),
605 })
606 }
607 };
608
609 let (dest_title, dest_grid) = match grid_range::resolve_grid_range(
610 &workbook,
611 &destination_composed,
612 |detail| PasteResult::RefusedInvalidRange { detail },
613 |title, available| PasteResult::RefusedSheetNotFound { title, available },
614 ) {
615 Ok(resolved) => resolved,
616 Err(result) => return gated(result),
617 };
618 if !grid_range::is_bounded(&dest_grid) {
619 return gated(PasteResult::RefusedInvalidRange {
620 detail: format!(
621 "--destination '{}' must be a bounded range, not an open-ended column/row span",
622 opts.verb.destination()
623 ),
624 });
625 }
626 let needs_single_cell_destination = matches!(
627 opts.verb,
628 PasteVerb::CutPaste { .. } | PasteVerb::PasteData { .. }
629 );
630 if needs_single_cell_destination && !grid_range::is_single_cell(&dest_grid) {
631 return gated(PasteResult::RefusedInvalidRange {
632 detail: format!(
633 "--destination '{}' must name a single cell, not a range",
634 opts.verb.destination()
635 ),
636 });
637 }
638
639 let source_resolved = match &source_composed {
640 Some(source_composed) => {
641 match grid_range::resolve_grid_range(
642 &workbook,
643 source_composed,
644 |detail| PasteResult::RefusedInvalidRange { detail },
645 |title, available| PasteResult::RefusedSheetNotFound { title, available },
646 ) {
647 Ok((source_title, grid)) => {
648 if !grid_range::is_bounded(&grid) {
649 return gated(PasteResult::RefusedInvalidRange {
650 detail: format!(
651 "--source '{}' must be a bounded range, not an open-ended \
652 column/row span",
653 opts.verb.source().unwrap_or_default()
654 ),
655 });
656 }
657 Some((source_title, grid))
658 }
659 Err(result) => return gated(result),
660 }
661 }
662 None => None,
663 };
664 let source_grid = source_resolved.as_ref().map(|(_, grid)| *grid);
665
666 let extent = match &opts.verb {
667 PasteVerb::CutPaste { .. } => {
668 #[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
670 let (rows, cols) = grid_dims(&source_grid);
671 anchored_extent(&dest_grid, rows, cols)
672 }
673 PasteVerb::CopyPaste { orientation, .. } => {
674 #[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
676 copy_paste_extent(&source_grid, &dest_grid, *orientation)
677 }
678 PasteVerb::PasteData {
679 data, delimiter, ..
680 } => {
681 let (rows, cols) = paste_data_upper_bound(data, delimiter);
682 anchored_extent(&dest_grid, rows, cols)
683 }
684 };
685
686 let grid_edge_caveat = sheet_exceeds_dimensions(&workbook, &extent).then(|| {
687 "written extent runs past the sheet's currently allocated rows/columns; Sheets grows \
688 the sheet to fit (verified live, #1937)"
689 .to_string()
690 });
691
692 let extent_a1 = grid_range::bounded_range_to_a1(&dest_title, &extent)
697 .unwrap_or_else(|| destination_composed.clone()); let overwritten = if opts.verb.paste_type().writes_values() {
704 match read_non_blank(&api, &opts.spreadsheet_id, &workbook, &dest_title, &extent).await {
705 Ok(locations) => Some(locations),
706 Err(detail) => return gated(PasteResult::Failed { detail }),
707 }
708 } else {
709 None
710 };
711
712 let cleared = if let (PasteVerb::CutPaste { .. }, Some((source_title, source_grid))) =
713 (&opts.verb, &source_resolved)
714 {
715 match read_non_blank(
716 &api,
717 &opts.spreadsheet_id,
718 &workbook,
719 source_title,
720 source_grid,
721 )
722 .await
723 {
724 Ok(locations) => Some(locations),
725 Err(detail) => return gated(PasteResult::Failed { detail }),
726 }
727 } else {
728 None
729 };
730
731 let change = PasteChange {
732 destination: destination_composed.clone(),
733 source: source_composed.clone(),
734 written_extent: extent_a1,
735 overwritten,
736 cleared,
737 grid_edge_caveat,
738 };
739
740 if opts.dry_run {
741 return gated(PasteResult::WouldChange(change));
742 }
743
744 let request = match &opts.verb {
745 PasteVerb::CutPaste { paste_type, .. } => {
746 #[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
748 BatchUpdateRequestItem::CutPaste(CutPasteRequest {
749 source: source_grid,
750 destination: GridCoordinate {
751 sheet_id: dest_grid.sheet_id,
752 row_index: dest_grid.start_row_index.unwrap_or(0),
753 column_index: dest_grid.start_column_index.unwrap_or(0),
754 },
755 paste_type: *paste_type,
756 })
757 }
758 PasteVerb::CopyPaste {
759 paste_type,
760 orientation,
761 ..
762 } => {
763 #[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
765 BatchUpdateRequestItem::CopyPaste(CopyPasteRequest {
766 source: source_grid,
767 destination: dest_grid,
768 paste_type: *paste_type,
769 paste_orientation: *orientation,
770 })
771 }
772 PasteVerb::PasteData {
773 data,
774 delimiter,
775 paste_type,
776 ..
777 } => BatchUpdateRequestItem::PasteData(PasteDataRequest {
778 coordinate: GridCoordinate {
779 sheet_id: dest_grid.sheet_id,
780 row_index: dest_grid.start_row_index.unwrap_or(0),
781 column_index: dest_grid.start_column_index.unwrap_or(0),
782 },
783 data: data.clone(),
784 delimiter: delimiter.clone(),
785 r#type: *paste_type,
786 }),
787 };
788
789 let files_api = FilesApi::new(drive);
794 let leased = LeasedWrite {
795 log_prefix: "drive sheets paste",
796 operation: opts.verb.log_operation(),
797 ledger_path: &opts.ledger_path,
798 file_id: &opts.spreadsheet_id,
799 };
800 let lease_grant = match gate_optional_leased_write(
801 leased,
802 &files_api,
803 requires_lease,
804 opts.lease_token.as_deref(),
805 )
806 .await
807 {
808 Ok(grant) => grant,
809 Err(err) => return gated(err.into_result()),
810 };
811
812 let result = match conclude_native_leased_write(
813 leased,
814 &lease_grant,
815 &files_api,
816 api.batch_update(&opts.spreadsheet_id, vec![request]).await,
817 |err| format!("{err:#}"),
818 )
819 .await
820 {
821 Ok(BatchUpdateOutcome::Applied(_response)) => PasteResult::Changed(change),
822 Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
826 PasteResult::AppliedReplyUnreadable {
827 summary: unreadable_summary(&opts.verb, &change),
828 change,
829 detail,
830 }
831 }
832 Err(err) => PasteResult::Failed {
833 detail: format!("{err:#}"),
834 },
835 };
836 drop(lease_grant);
837 gated(result)
838}
839
840fn unreadable_summary(verb: &PasteVerb, change: &PasteChange) -> String {
843 format!(
844 "{} into {} (writing {})",
845 verb.label(),
846 change.destination,
847 change.written_extent
848 )
849}
850
851fn compose_with_default_sheet(
860 sheet: Option<&str>,
861 range: &str,
862 flag: &str,
863) -> Result<String, String> {
864 if a1::split_sheet_prefix(range).is_some() {
865 a1::validate_range(range)
866 .map(|()| range.to_string())
867 .map_err(|err| err.to_string())
868 } else {
869 match sheet {
870 Some(sheet) => a1::compose(Some(sheet), Some(range)).map_err(|err| err.to_string()),
871 None => Err(format!(
872 "{flag} '{range}' does not name a sheet; pass --sheet, or give {flag} its own \
873 'Sheet'! prefix"
874 )),
875 }
876 }
877}
878
879fn unusable_sheet_default(verb: &PasteVerb) -> Option<String> {
893 let sheet = verb.sheet()?;
894 let prefixed = |range: &str| a1::split_sheet_prefix(range).is_some();
895 let every_range_self_prefixes =
896 prefixed(verb.destination()) && verb.source().is_none_or(prefixed);
897 every_range_self_prefixes.then(|| {
898 format!(
899 "--sheet '{sheet}' has nothing to apply to: every range already carries its own \
900 'Sheet'! prefix. Drop --sheet, or drop the prefix from the range it was meant for"
901 )
902 })
903}
904
905async fn read_non_blank(
915 api: &SheetsApi<'_>,
916 spreadsheet_id: &str,
917 workbook: &crate::drive::sheets::types::Spreadsheet,
918 sheet_title: &str,
919 grid: &GridRange,
920) -> Result<Vec<String>, String> {
921 let Some(clamped) = grid_range::clamp_to_sheet(workbook, grid) else {
922 return Ok(Vec::new());
923 };
924 let Some(range) = grid_range::bounded_range_to_a1(sheet_title, &clamped) else {
925 return Ok(Vec::new());
926 };
927 let values = api
928 .values_get(spreadsheet_id, &range, ValueRenderOption::Formatted)
929 .await
930 .map_err(|err| format!("{err:#}"))?;
931 Ok(grid_range::non_blank_locations(
932 &values,
933 clamped.start_row_index.unwrap_or(0),
934 clamped.start_column_index.unwrap_or(0),
935 ))
936}
937
938fn grid_dims(grid: &GridRange) -> (i64, i64) {
942 (
943 grid.end_row_index.unwrap_or(0) - grid.start_row_index.unwrap_or(0),
944 grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0),
945 )
946}
947
948fn anchored_extent(dest: &GridRange, rows: i64, cols: i64) -> GridRange {
951 let start_row = dest.start_row_index.unwrap_or(0);
952 let start_col = dest.start_column_index.unwrap_or(0);
953 GridRange {
954 sheet_id: dest.sheet_id,
955 start_row_index: Some(start_row),
956 end_row_index: Some(start_row + rows),
957 start_column_index: Some(start_col),
958 end_column_index: Some(start_col + cols),
959 }
960}
961
962fn extent_len(src_len: i64, dst_len: i64) -> i64 {
968 if src_len > 0 && dst_len > src_len && dst_len % src_len == 0 {
969 dst_len
970 } else {
971 src_len
972 }
973}
974
975pub(crate) fn copy_paste_extent(
981 source: &GridRange,
982 destination: &GridRange,
983 orientation: PasteOrientation,
984) -> GridRange {
985 let (mut src_rows, mut src_cols) = grid_dims(source);
986 if orientation == PasteOrientation::Transpose {
987 std::mem::swap(&mut src_rows, &mut src_cols);
988 }
989 let (dst_rows, dst_cols) = grid_dims(destination);
990 let rows = extent_len(src_rows, dst_rows);
991 let cols = extent_len(src_cols, dst_cols);
992 anchored_extent(destination, rows, cols)
993}
994
995pub(crate) fn paste_data_upper_bound(data: &str, delimiter: &str) -> (i64, i64) {
1010 if data.is_empty() {
1011 return (0, 0);
1012 }
1013 let body = data.strip_suffix('\n').unwrap_or(data);
1014 if body.is_empty() {
1015 return (1, 1);
1017 }
1018 let rows: Vec<&str> = body
1019 .split('\n')
1020 .map(|line| line.trim_end_matches('\r'))
1021 .collect();
1022 let cols = rows
1023 .iter()
1024 .map(|row| {
1025 if delimiter.is_empty() {
1026 1
1027 } else {
1028 row.split(delimiter).count()
1029 }
1030 })
1031 .max()
1032 .unwrap_or(0);
1033 (rows.len() as i64, cols as i64)
1034}
1035
1036fn sheet_exceeds_dimensions(
1041 workbook: &crate::drive::sheets::types::Spreadsheet,
1042 extent: &GridRange,
1043) -> bool {
1044 let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
1045 return false;
1046 };
1047 let grid = sheet
1048 .properties
1049 .as_ref()
1050 .and_then(|p| p.grid_properties.as_ref());
1051 let row_count = grid.and_then(|g| g.row_count);
1052 let column_count = grid.and_then(|g| g.column_count);
1053 let rows_exceed = matches!(
1054 (extent.end_row_index, row_count),
1055 (Some(end), Some(count)) if end > count
1056 );
1057 let cols_exceed = matches!(
1058 (extent.end_column_index, column_count),
1059 (Some(end), Some(count)) if end > count
1060 );
1061 rows_exceed || cols_exceed
1062}
1063
1064fn record_attempt(outcome: &PasteOutcome, opts: &PasteOptions, duration: Duration) {
1065 request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
1066}
1067
1068fn mutation_record(
1071 outcome: &PasteOutcome,
1072 opts: &PasteOptions,
1073 duration: Duration,
1074) -> DriveMutationOutcome {
1075 let error = match &outcome.result {
1076 PasteResult::Failed { detail } | PasteResult::AppliedReplyUnreadable { detail, .. } => {
1077 Some(detail.clone())
1078 }
1079 _ => None,
1080 };
1081 let decided_by = match &outcome.result {
1082 PasteResult::Blocked { decided_by, .. } => decided_by.as_ref(),
1083 _ => None,
1084 };
1085 let decided_by = write_gate::decided_by_log_fields(decided_by);
1086 let fields_changed = match &outcome.result {
1088 PasteResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
1089 _ => None,
1090 };
1091
1092 DriveMutationOutcome {
1093 operation: opts.verb.log_operation(),
1094 file_id: outcome.spreadsheet_id.clone(),
1095 file_name: outcome.file_name.clone().unwrap_or_default(),
1096 status: outcome.result.log_status().to_string(),
1097 resolved_folder_id: outcome.resolved_folder_id.clone(),
1098 decided_by_folder_id: decided_by.folder_id,
1099 decided_by_depth: decided_by.depth,
1100 decided_by_file_id: decided_by.file_id,
1101 fields_changed,
1102 error,
1103 duration,
1104 ..Default::default()
1105 }
1106}
1107
1108fn paste_change_lines(
1113 verb: &PasteVerb,
1114 change: &PasteChange,
1115 book: &str,
1116 applied: bool,
1117) -> Vec<String> {
1118 let mut lines = vec![format!(
1119 "{} into {} in {book}, writing {}",
1120 verb.label(),
1121 change.destination,
1122 change.written_extent
1123 )];
1124 if let Some(source) = &change.source {
1125 lines.push(format!(" source: {source}"));
1126 }
1127 lines.push(format!(" paste type: {}", verb.paste_type().as_str()));
1132 if let PasteVerb::CopyPaste { orientation, .. } = verb {
1133 lines.push(format!(" orientation: {}", orientation.as_str()));
1134 }
1135 let overwritten_verb = if applied {
1136 "were overwritten"
1137 } else {
1138 "would be overwritten"
1139 };
1140 match &change.overwritten {
1141 Some(locations) if locations.is_empty() => {
1142 lines.push(format!(
1143 " no non-blank cells in the destination {overwritten_verb}"
1144 ));
1145 }
1146 Some(locations) => {
1147 lines.push(format!(
1148 " {} non-blank cell(s) in the destination {overwritten_verb}: {}",
1149 locations.len(),
1150 grid_range::render_locations(locations)
1151 ));
1152 }
1153 None => lines.push(
1154 " destination overwrite: not previewed (this paste type writes no cell values)"
1155 .to_string(),
1156 ),
1157 }
1158 if let Some(locations) = &change.cleared {
1159 if locations.is_empty() {
1160 lines.push(format!(
1161 " the source {} no non-blank cells to clear",
1162 if applied { "had" } else { "has" }
1163 ));
1164 } else {
1165 lines.push(format!(
1166 " {} non-blank cell(s) in the source {}: {}",
1167 locations.len(),
1168 if applied {
1169 "were cleared"
1170 } else {
1171 "will be cleared"
1172 },
1173 grid_range::render_locations(locations)
1174 ));
1175 }
1176 }
1177 if let Some(caveat) = &change.grid_edge_caveat {
1178 lines.push(format!(" note: {caveat}"));
1179 }
1180 lines
1181}
1182
1183pub fn describe_lines(outcome: &PasteOutcome) -> Vec<String> {
1186 let verb = &outcome.verb;
1187 let book = outcome.file_name.as_deref().map_or_else(
1188 || format!("'{}'", outcome.spreadsheet_id),
1189 |n| format!("'{n}'"),
1190 );
1191 match &outcome.result {
1192 PasteResult::WouldChange(change) => {
1193 let mut lines = paste_change_lines(verb, change, &book, false);
1194 lines[0] = format!("Would {}", lines[0]);
1195 lines
1196 }
1197 PasteResult::Changed(change) => {
1198 let mut lines = paste_change_lines(verb, change, &book, true);
1199 lines[0] = format!("Applied: {}", lines[0]);
1200 lines
1201 }
1202 PasteResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
1203 "Refused: {book} is not a Google Sheet (mimeType: {mime_type}); `drive sheets {}` \
1204 only works on spreadsheets",
1205 verb.label()
1206 )],
1207 PasteResult::RefusedShortcut => vec![format!(
1208 "Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
1209 verb.label()
1210 )],
1211 PasteResult::RefusedNoVisibleParents => {
1212 let ops = match verb.gate_operations() {
1213 [DriveOperation::SheetsWrite] => "\"sheets-write\"",
1214 [DriveOperation::SheetsStructure] => "\"sheets-structure\"",
1215 _ => "\"sheets-write\", \"sheets-structure\"",
1216 };
1217 vec![format!(
1218 "Refused: {book} has no parent folder visible to this account, so no folder \
1219 rule can apply to it. Grant it by id instead: add {{\"file_id\": \
1220 \"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
1221 )]
1222 }
1223 PasteResult::RefusedSheetNotFound { title, available } => {
1224 vec![grid_range::no_sheet_refusal(&book, title, available)]
1225 }
1226 PasteResult::RefusedInvalidRange { detail }
1227 | PasteResult::RefusedInvalidDelimiter { detail } => {
1228 vec![format!("Refused: {detail}")]
1229 }
1230 PasteResult::RefusedEmptyData => vec![format!(
1231 "Refused: `drive sheets {}` was given no data to paste (--data/--data-file is empty)",
1232 verb.label()
1233 )],
1234 PasteResult::Blocked {
1235 operation,
1236 decided_by,
1237 } => vec![match decided_by {
1238 Some(rule) => format!(
1239 "Blocked: {} on {book} refused by rule on {} {}{}",
1240 verb.label(),
1241 rule.kind_label(),
1242 rule.id(),
1243 rule.depth_suffix()
1244 ),
1245 None => format!(
1246 "Blocked: {} on {book} refused by default policy (no matching rule for \
1247 {operation})",
1248 verb.label()
1249 ),
1250 }],
1251 PasteResult::RefusedNoLease => LeaseGateRefusal::NoLease
1252 .describe_line(&outcome.spreadsheet_id, &book)
1253 .into_iter()
1254 .collect(),
1255 PasteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
1256 .describe_line(&outcome.spreadsheet_id, &book)
1257 .into_iter()
1258 .collect(),
1259 PasteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
1260 .describe_line(&outcome.spreadsheet_id, &book)
1261 .into_iter()
1262 .collect(),
1263 PasteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
1264 .describe_line(&outcome.spreadsheet_id, &book)
1265 .into_iter()
1266 .collect(),
1267 PasteResult::AppliedReplyUnreadable {
1268 summary,
1269 change,
1270 detail,
1271 } => {
1272 let mut lines = paste_change_lines(verb, change, &book, true);
1275 lines[0] =
1276 applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail);
1277 lines
1278 }
1279 PasteResult::Failed { detail } => vec![format!("Failed: {detail}")],
1280 }
1281}
1282
1283pub(crate) fn read_data_text(source: &str) -> anyhow::Result<String> {
1293 use anyhow::Context;
1294
1295 if source == "-" {
1296 let mut buf = Vec::new();
1297 std::io::stdin()
1298 .take(crate::drive::files_api::MAX_UPLOAD_BYTES + 1)
1299 .read_to_end(&mut buf)
1300 .context("Failed to read --data-file from stdin")?;
1301 anyhow::ensure!(
1302 buf.len() as u64 <= crate::drive::files_api::MAX_UPLOAD_BYTES,
1303 "--data-file from stdin is over the {} byte cap",
1304 crate::drive::files_api::MAX_UPLOAD_BYTES
1305 );
1306 String::from_utf8(buf).context("--data-file from stdin is not valid UTF-8")
1307 } else {
1308 let metadata = std::fs::metadata(source)
1309 .with_context(|| format!("Failed to stat --data-file {source}"))?;
1310 anyhow::ensure!(
1311 metadata.len() <= crate::drive::files_api::MAX_UPLOAD_BYTES,
1312 "--data-file {source} is {} bytes, over the {} byte cap",
1313 metadata.len(),
1314 crate::drive::files_api::MAX_UPLOAD_BYTES
1315 );
1316 std::fs::read_to_string(source)
1317 .with_context(|| format!("Failed to read --data-file {source}"))
1318 }
1319}
1320
1321#[cfg(test)]
1322#[allow(clippy::unwrap_used, clippy::expect_used)]
1323mod tests {
1324 use super::*;
1325
1326 #[test]
1329 fn extent_len_repeats_a_source_that_evenly_divides_a_larger_destination() {
1330 assert_eq!(extent_len(2, 6), 6);
1331 assert_eq!(extent_len(3, 9), 9);
1332 }
1333
1334 #[test]
1335 fn extent_len_spills_a_source_larger_than_its_destination() {
1336 assert_eq!(extent_len(5, 2), 5);
1337 }
1338
1339 #[test]
1340 fn extent_len_copies_once_when_the_destination_is_not_a_clean_multiple() {
1341 assert_eq!(extent_len(2, 5), 2);
1342 }
1343
1344 #[test]
1345 fn extent_len_treats_equal_lengths_as_a_single_copy() {
1346 assert_eq!(extent_len(3, 3), 3);
1347 }
1348
1349 fn range(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
1350 GridRange {
1351 sheet_id,
1352 start_row_index: Some(r0),
1353 end_row_index: Some(r1),
1354 start_column_index: Some(c0),
1355 end_column_index: Some(c1),
1356 }
1357 }
1358
1359 #[test]
1360 fn copy_paste_extent_anchors_at_the_destinations_top_left() {
1361 let source = range(1, 0, 2, 0, 2); let destination = range(1, 10, 11, 10, 11); let extent = copy_paste_extent(&source, &destination, PasteOrientation::Normal);
1364 assert_eq!(extent, range(1, 10, 12, 10, 12));
1365 }
1366
1367 #[test]
1368 fn copy_paste_extent_repeats_to_fill_a_multiple_destination() {
1369 let source = range(1, 0, 1, 0, 1); let destination = range(1, 0, 3, 0, 3); let extent = copy_paste_extent(&source, &destination, PasteOrientation::Normal);
1372 assert_eq!(extent, range(1, 0, 3, 0, 3));
1373 }
1374
1375 #[test]
1376 fn copy_paste_extent_transposes_the_source_before_computing() {
1377 let source = range(1, 0, 1, 0, 3); let destination = range(1, 5, 6, 5, 6); let extent = copy_paste_extent(&source, &destination, PasteOrientation::Transpose);
1380 assert_eq!(extent, range(1, 5, 8, 5, 6));
1382 }
1383
1384 #[test]
1385 fn paste_data_upper_bound_counts_rows_and_the_widest_row() {
1386 assert_eq!(paste_data_upper_bound("a\tb\nc\td\te", "\t"), (2, 3));
1387 }
1388
1389 #[test]
1390 fn paste_data_upper_bound_tolerates_crlf_line_endings() {
1391 assert_eq!(paste_data_upper_bound("a,b\r\nc,d\r\n", ","), (2, 2));
1392 }
1393
1394 #[test]
1395 fn paste_data_upper_bound_does_not_count_a_terminating_newline_as_a_row() {
1396 assert_eq!(paste_data_upper_bound("1\t2\n3\t4\n", "\t"), (2, 2));
1400 assert_eq!(paste_data_upper_bound("1\t2\n3\t4", "\t"), (2, 2));
1401 }
1402
1403 #[test]
1404 fn paste_data_upper_bound_counts_a_second_trailing_newline_as_a_blank_row() {
1405 assert_eq!(paste_data_upper_bound("a\nb\n\n", "\t"), (3, 1));
1407 assert_eq!(paste_data_upper_bound("a\n\nb\n", "\t"), (3, 1));
1408 }
1409
1410 #[test]
1411 fn paste_data_upper_bound_of_a_lone_newline_is_one_empty_row() {
1412 assert_eq!(paste_data_upper_bound("\n", "\t"), (1, 1));
1413 }
1414
1415 #[test]
1416 fn paste_data_upper_bound_of_empty_data_is_zero_by_zero() {
1417 assert_eq!(paste_data_upper_bound("", "\t"), (0, 0));
1418 }
1419
1420 #[test]
1421 fn compose_with_default_sheet_prefers_the_ranges_own_prefix() {
1422 let composed = compose_with_default_sheet(Some("Q1"), "'Q2'!A1:B2", "--source").unwrap();
1423 assert_eq!(composed, "'Q2'!A1:B2");
1424 }
1425
1426 #[test]
1427 fn compose_with_default_sheet_falls_back_to_sheet() {
1428 let composed = compose_with_default_sheet(Some("Q1"), "A1:B2", "--source").unwrap();
1429 assert_eq!(composed, "'Q1'!A1:B2");
1430 }
1431
1432 #[test]
1433 fn compose_with_default_sheet_refuses_neither() {
1434 let err = compose_with_default_sheet(None, "A1:B2", "--destination").unwrap_err();
1435 assert!(err.contains("--destination"), "{err}");
1436 assert!(err.contains("--sheet"), "{err}");
1437 }
1438
1439 #[test]
1440 fn unusable_sheet_default_is_none_when_sheet_is_the_only_source_of_a_prefix() {
1441 let verb = PasteVerb::CutPaste {
1442 sheet: Some("Q1".to_string()),
1443 source: "A1:B2".to_string(),
1444 destination: "D1".to_string(),
1445 paste_type: PasteType::Normal,
1446 };
1447 assert_eq!(unusable_sheet_default(&verb), None);
1448 }
1449
1450 #[test]
1451 fn unusable_sheet_default_is_none_for_a_cross_sheet_paste_that_still_uses_sheet() {
1452 let verb = PasteVerb::CutPaste {
1455 sheet: Some("Q1".to_string()),
1456 source: "A1:B2".to_string(),
1457 destination: "'Q2'!D1".to_string(),
1458 paste_type: PasteType::Normal,
1459 };
1460 assert_eq!(unusable_sheet_default(&verb), None);
1461 }
1462
1463 #[test]
1464 fn unusable_sheet_default_refuses_a_sheet_no_range_can_use() {
1465 let verb = PasteVerb::CutPaste {
1466 sheet: Some("Q1".to_string()),
1467 source: "'Q2'!A1:B2".to_string(),
1468 destination: "'Q3'!D1".to_string(),
1469 paste_type: PasteType::Normal,
1470 };
1471 let detail = unusable_sheet_default(&verb).expect("refusal");
1472 assert!(detail.contains("--sheet 'Q1'"), "{detail}");
1473 assert!(detail.contains("nothing to apply to"), "{detail}");
1474 }
1475
1476 #[test]
1477 fn unusable_sheet_default_refuses_a_sheet_beside_a_prefixed_paste_data_destination() {
1478 let verb = PasteVerb::PasteData {
1481 sheet: Some("Q1".to_string()),
1482 destination: "'Q2'!A1".to_string(),
1483 data: "1".to_string(),
1484 delimiter: "\t".to_string(),
1485 paste_type: PasteType::Values,
1486 };
1487 assert!(unusable_sheet_default(&verb).is_some());
1488 }
1489
1490 #[test]
1491 fn unusable_sheet_default_is_none_without_a_sheet_flag() {
1492 let verb = PasteVerb::PasteData {
1493 sheet: None,
1494 destination: "'Q2'!A1".to_string(),
1495 data: "1".to_string(),
1496 delimiter: "\t".to_string(),
1497 paste_type: PasteType::Values,
1498 };
1499 assert_eq!(unusable_sheet_default(&verb), None);
1500 }
1501
1502 #[test]
1503 fn gate_operations_for_cut_paste_is_always_both() {
1504 let verb = PasteVerb::CutPaste {
1505 sheet: None,
1506 source: "A1".to_string(),
1507 destination: "B1".to_string(),
1508 paste_type: PasteType::Values,
1509 };
1510 assert_eq!(verb.gate_operations(), GATE_BOTH);
1511 }
1512
1513 #[test]
1514 fn gate_operations_for_copy_paste_follows_the_paste_type_table() {
1515 let verb_of = |paste_type| PasteVerb::CopyPaste {
1516 sheet: None,
1517 source: "A1".to_string(),
1518 destination: "B1".to_string(),
1519 paste_type,
1520 orientation: PasteOrientation::Normal,
1521 };
1522 assert_eq!(verb_of(PasteType::Normal).gate_operations(), GATE_BOTH);
1523 assert_eq!(
1524 verb_of(PasteType::Values).gate_operations(),
1525 GATE_WRITE_ONLY
1526 );
1527 assert_eq!(
1528 verb_of(PasteType::Formula).gate_operations(),
1529 GATE_WRITE_ONLY
1530 );
1531 assert_eq!(
1532 verb_of(PasteType::Format).gate_operations(),
1533 GATE_STRUCTURE_ONLY
1534 );
1535 }
1536
1537 #[test]
1538 fn sheet_exceeds_dimensions_flags_an_extent_past_the_grid() {
1539 let workbook: crate::drive::sheets::types::Spreadsheet =
1540 serde_json::from_value(serde_json::json!({
1541 "sheets": [
1542 {"properties": {"sheetId": 1, "title": "Q1",
1543 "gridProperties": {"rowCount": 10, "columnCount": 5}}},
1544 ],
1545 }))
1546 .unwrap();
1547 assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 11, 0, 3)));
1548 assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 3, 0, 6)));
1549 assert!(!sheet_exceeds_dimensions(&workbook, &range(1, 0, 10, 0, 5)));
1550 }
1551
1552 #[test]
1553 fn sheet_exceeds_dimensions_is_false_when_the_extents_sheet_is_unknown() {
1554 let workbook: crate::drive::sheets::types::Spreadsheet =
1555 serde_json::from_value(serde_json::json!({
1556 "sheets": [
1557 {"properties": {"sheetId": 1, "title": "Q1",
1558 "gridProperties": {"rowCount": 10, "columnCount": 5}}},
1559 ],
1560 }))
1561 .unwrap();
1562 assert!(!sheet_exceeds_dimensions(
1565 &workbook,
1566 &range(99, 0, 20, 0, 20)
1567 ));
1568 }
1569
1570 #[test]
1571 fn paste_data_upper_bound_with_an_empty_delimiter_counts_one_column() {
1572 assert_eq!(paste_data_upper_bound("ab\ncd", ""), (2, 1));
1573 }
1574
1575 #[test]
1576 fn log_operation_is_defined_for_every_verb() {
1577 for verb in every_paste_verb() {
1578 assert!(!verb.log_operation().is_empty());
1579 }
1580 }
1581
1582 #[test]
1583 fn log_status_is_defined_for_every_result_variant() {
1584 for result in every_paste_result() {
1585 assert!(!result.log_status().is_empty());
1586 }
1587 }
1588
1589 #[test]
1590 fn from_lease_refusal_maps_every_refusal_kind() {
1591 assert_eq!(PasteResult::from_no_lease(), PasteResult::RefusedNoLease);
1592 assert_eq!(
1593 PasteResult::from_lease_expired(),
1594 PasteResult::RefusedLeaseExpired
1595 );
1596 assert_eq!(
1597 PasteResult::from_lease_wrong_file(),
1598 PasteResult::RefusedLeaseWrongFile
1599 );
1600 assert_eq!(
1601 PasteResult::from_lease_stale(),
1602 PasteResult::RefusedLeaseStale
1603 );
1604 assert_eq!(
1605 PasteResult::from_lease_failed("boom".to_string()),
1606 PasteResult::Failed {
1607 detail: "boom".to_string()
1608 }
1609 );
1610 }
1611
1612 #[test]
1613 fn write_jsonl_serializes_the_outcome_as_one_json_line() {
1614 let outcome = PasteOutcome {
1615 spreadsheet_id: "sheet-1".to_string(),
1616 file_name: Some("Budget".to_string()),
1617 resolved_folder_id: None,
1618 verb: every_paste_verb().remove(0),
1619 result: PasteResult::RefusedShortcut,
1620 };
1621 let mut buf = Vec::new();
1622 outcome.write_jsonl(&mut buf).unwrap();
1623 let text = String::from_utf8(buf).unwrap();
1624 assert!(text.contains("refused-shortcut"), "{text}");
1625 }
1626
1627 #[test]
1631 fn json_names_the_paste_type_and_copy_pastes_orientation() {
1632 let expected = [
1633 ("PASTE_NORMAL", None),
1634 ("PASTE_VALUES", Some("TRANSPOSE")),
1635 ("PASTE_VALUES", None),
1636 ];
1637 for (verb, (paste_type, orientation)) in every_paste_verb().into_iter().zip(expected) {
1638 let outcome = PasteOutcome {
1639 spreadsheet_id: "sheet-1".to_string(),
1640 file_name: None,
1641 resolved_folder_id: None,
1642 verb,
1643 result: PasteResult::RefusedShortcut,
1644 };
1645 let json = serde_json::to_value(&outcome).unwrap();
1646 assert_eq!(json["paste_type"], paste_type, "{json}");
1647 assert_eq!(
1648 json.get("orientation").and_then(|v| v.as_str()),
1649 orientation,
1650 "{json}"
1651 );
1652 assert!(json.get("file_name").is_none(), "{json}");
1653 assert!(
1654 json.get("data").is_none() && json.get("source").is_none(),
1655 "{json}"
1656 );
1657 assert_eq!(json["result"]["status"], "refused-shortcut", "{json}");
1658 }
1659 }
1660
1661 #[test]
1662 fn read_data_text_reads_a_local_file_verbatim() {
1663 let dir = tempfile::tempdir().unwrap();
1664 let path = dir.path().join("clip.tsv");
1665 std::fs::write(&path, "a\tb\nc\td").unwrap();
1666 let text = read_data_text(path.to_str().unwrap()).unwrap();
1667 assert_eq!(text, "a\tb\nc\td");
1668 }
1669
1670 #[test]
1671 fn read_data_text_reports_a_missing_file_clearly() {
1672 let err = read_data_text("/definitely/not/here.tsv").unwrap_err();
1673 assert!(err.to_string().contains("Failed to stat"), "{err}");
1674 }
1675
1676 #[test]
1677 fn read_data_text_refuses_a_file_over_the_upload_cap() {
1678 let dir = tempfile::tempdir().unwrap();
1679 let path = dir.path().join("huge.tsv");
1680 let oversize = vec![b'a'; (crate::drive::files_api::MAX_UPLOAD_BYTES + 1) as usize];
1681 std::fs::write(&path, oversize).unwrap();
1682 let err = read_data_text(path.to_str().unwrap()).unwrap_err();
1683 assert!(err.to_string().contains("over the"), "{err}");
1684 }
1685
1686 use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
1689 use crate::drive::sheets::client::SHEETS_API_URL;
1690 use crate::test_support::env::MapEnv;
1691 use crate::utils::secret::Secret;
1692
1693 fn test_credentials() -> DriveCredentials {
1694 DriveCredentials {
1695 client_id: "client-1".to_string(),
1696 client_secret: Secret::new("secret-1"),
1697 refresh_token: Secret::new("refresh-1"),
1698 scope: DriveGrantedScopes::READONLY,
1699 }
1700 }
1701
1702 async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
1703 wiremock::Mock::given(wiremock::matchers::method("POST"))
1704 .and(wiremock::matchers::path("/token"))
1705 .respond_with(
1706 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1707 "access_token": "test-token", "expires_in": 3600,
1708 })),
1709 )
1710 .mount(server)
1711 .await;
1712 let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
1713 crate::drive::client::test_support::replace_session(
1714 &mut drive,
1715 &test_credentials(),
1716 &format!("{}/token", server.uri()),
1717 );
1718 let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
1719 let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
1720 (drive, sheets)
1721 }
1722
1723 fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
1724 let parents: Vec<&str> = parents.to_vec();
1725 wiremock::Mock::given(wiremock::matchers::method("GET"))
1726 .and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
1727 .respond_with(
1728 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1729 "id": id, "name": id, "mimeType": mime_type, "parents": parents,
1730 "version": "1",
1731 })),
1732 )
1733 }
1734
1735 fn mount_folder(id: &str) -> wiremock::Mock {
1736 mount_file(id, "application/vnd.google-apps.folder", &[])
1737 }
1738
1739 async fn mount_workbook(server: &wiremock::MockServer) {
1740 wiremock::Mock::given(wiremock::matchers::method("GET"))
1741 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
1742 .respond_with(
1743 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
1744 "spreadsheetId": "sheet-1",
1745 "properties": {"title": "Budget"},
1746 "sheets": [
1747 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
1748 "gridProperties": {"rowCount": 1000, "columnCount": 26}}},
1749 ],
1750 })),
1751 )
1752 .mount(server)
1753 .await;
1754 }
1755
1756 fn rules_allowing_everything() -> Vec<FolderPermissionRule> {
1760 vec![allow_rule(
1761 "folder-1",
1762 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1763 )]
1764 }
1765
1766 fn allow_rule(folder_id: &str, ops: &[DriveOperation]) -> FolderPermissionRule {
1770 FolderPermissionRule::folder(folder_id)
1771 .allowing(ops.iter().copied())
1772 .requiring_lease(false)
1773 }
1774
1775 #[tokio::test]
1776 async fn copy_paste_default_normal_is_blocked_naming_sheets_structure_under_write_only() {
1777 let server = wiremock::MockServer::start().await;
1778 let (drive, sheets) = clients(&server).await;
1779 mount_file(
1780 "sheet-1",
1781 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1782 &["folder-1"],
1783 )
1784 .mount(&server)
1785 .await;
1786 mount_folder("folder-1").mount(&server).await;
1787 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1788
1789 let opts = PasteOptions {
1790 spreadsheet_id: "sheet-1".to_string(),
1791 verb: PasteVerb::CopyPaste {
1792 sheet: Some("Q1".to_string()),
1793 source: "A1:B2".to_string(),
1794 destination: "D1:E2".to_string(),
1795 paste_type: PasteType::Normal,
1796 orientation: PasteOrientation::Normal,
1797 },
1798 dry_run: true,
1799 lease_token: None,
1800 ledger_path: PathBuf::new(),
1801 };
1802 let outcome = paste(&drive, &sheets, &opts, &rules).await;
1803 match outcome.result {
1804 PasteResult::Blocked { operation, .. } => {
1805 assert_eq!(operation, DriveOperation::SheetsStructure);
1806 }
1807 other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
1808 }
1809 }
1810
1811 #[tokio::test]
1812 async fn copy_paste_values_only_proceeds_under_a_write_only_grant() {
1813 let server = wiremock::MockServer::start().await;
1814 let (drive, sheets) = clients(&server).await;
1815 mount_file(
1816 "sheet-1",
1817 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1818 &["folder-1"],
1819 )
1820 .mount(&server)
1821 .await;
1822 mount_folder("folder-1").mount(&server).await;
1823 mount_workbook(&server).await;
1824 wiremock::Mock::given(wiremock::matchers::method("GET"))
1825 .and(wiremock::matchers::path(
1826 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
1827 ))
1828 .respond_with(
1829 wiremock::ResponseTemplate::new(200)
1830 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
1831 )
1832 .mount(&server)
1833 .await;
1834 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1835
1836 let opts = PasteOptions {
1837 spreadsheet_id: "sheet-1".to_string(),
1838 verb: PasteVerb::CopyPaste {
1839 sheet: Some("Q1".to_string()),
1840 source: "A1:B2".to_string(),
1841 destination: "D1:E2".to_string(),
1842 paste_type: PasteType::Values,
1843 orientation: PasteOrientation::Normal,
1844 },
1845 dry_run: true,
1846 lease_token: None,
1847 ledger_path: PathBuf::new(),
1848 };
1849 let outcome = paste(&drive, &sheets, &opts, &rules).await;
1850 assert!(
1851 matches!(outcome.result, PasteResult::WouldChange(_)),
1852 "{:?}",
1853 outcome.result
1854 );
1855 }
1856
1857 #[tokio::test]
1858 async fn format_only_copy_paste_dry_run_reads_no_values() {
1859 let server = wiremock::MockServer::start().await;
1860 let (drive, sheets) = clients(&server).await;
1861 mount_file(
1862 "sheet-1",
1863 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1864 &["folder-1"],
1865 )
1866 .mount(&server)
1867 .await;
1868 mount_folder("folder-1").mount(&server).await;
1869 mount_workbook(&server).await;
1870 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
1874
1875 let opts = PasteOptions {
1876 spreadsheet_id: "sheet-1".to_string(),
1877 verb: PasteVerb::CopyPaste {
1878 sheet: Some("Q1".to_string()),
1879 source: "A1:B2".to_string(),
1880 destination: "D1:E2".to_string(),
1881 paste_type: PasteType::Format,
1882 orientation: PasteOrientation::Normal,
1883 },
1884 dry_run: true,
1885 lease_token: None,
1886 ledger_path: PathBuf::new(),
1887 };
1888 let outcome = paste(&drive, &sheets, &opts, &rules).await;
1889 match outcome.result {
1890 PasteResult::WouldChange(change) => assert_eq!(change.overwritten, None),
1891 other => panic!("expected WouldChange with no overwritten preview, got {other:?}"),
1892 }
1893 }
1894
1895 #[tokio::test]
1896 async fn dry_run_never_calls_batch_update() {
1897 let server = wiremock::MockServer::start().await;
1898 let (drive, sheets) = clients(&server).await;
1899 mount_file(
1900 "sheet-1",
1901 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1902 &["folder-1"],
1903 )
1904 .mount(&server)
1905 .await;
1906 mount_folder("folder-1").mount(&server).await;
1907 mount_workbook(&server).await;
1908 wiremock::Mock::given(wiremock::matchers::method("GET"))
1909 .and(wiremock::matchers::path(
1910 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
1911 ))
1912 .respond_with(
1913 wiremock::ResponseTemplate::new(200)
1914 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
1915 )
1916 .mount(&server)
1917 .await;
1918 let rules = vec![allow_rule(
1920 "folder-1",
1921 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1922 )];
1923
1924 let opts = PasteOptions {
1925 spreadsheet_id: "sheet-1".to_string(),
1926 verb: PasteVerb::CopyPaste {
1927 sheet: Some("Q1".to_string()),
1928 source: "A1:B2".to_string(),
1929 destination: "D1:E2".to_string(),
1930 paste_type: PasteType::Normal,
1931 orientation: PasteOrientation::Normal,
1932 },
1933 dry_run: true,
1934 lease_token: None,
1935 ledger_path: PathBuf::new(),
1936 };
1937 let outcome = paste(&drive, &sheets, &opts, &rules).await;
1938 assert!(matches!(outcome.result, PasteResult::WouldChange(_)));
1939 }
1940
1941 #[tokio::test]
1942 async fn cut_paste_always_needs_both_operations_even_for_values_only() {
1943 let server = wiremock::MockServer::start().await;
1944 let (drive, sheets) = clients(&server).await;
1945 mount_file(
1946 "sheet-1",
1947 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1948 &["folder-1"],
1949 )
1950 .mount(&server)
1951 .await;
1952 mount_folder("folder-1").mount(&server).await;
1953 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
1954
1955 let opts = PasteOptions {
1956 spreadsheet_id: "sheet-1".to_string(),
1957 verb: PasteVerb::CutPaste {
1958 sheet: Some("Q1".to_string()),
1959 source: "A1:B2".to_string(),
1960 destination: "D1".to_string(),
1961 paste_type: PasteType::Values,
1962 },
1963 dry_run: true,
1964 lease_token: None,
1965 ledger_path: PathBuf::new(),
1966 };
1967 let outcome = paste(&drive, &sheets, &opts, &rules).await;
1968 match outcome.result {
1969 PasteResult::Blocked { operation, .. } => {
1970 assert_eq!(operation, DriveOperation::SheetsStructure);
1971 }
1972 other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
1973 }
1974 }
1975
1976 #[tokio::test]
1977 async fn cut_paste_refuses_a_multi_cell_destination() {
1978 let server = wiremock::MockServer::start().await;
1979 let (drive, sheets) = clients(&server).await;
1980 mount_file(
1981 "sheet-1",
1982 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
1983 &["folder-1"],
1984 )
1985 .mount(&server)
1986 .await;
1987 mount_folder("folder-1").mount(&server).await;
1988 mount_workbook(&server).await;
1989 let rules = vec![allow_rule(
1990 "folder-1",
1991 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
1992 )];
1993
1994 let opts = PasteOptions {
1995 spreadsheet_id: "sheet-1".to_string(),
1996 verb: PasteVerb::CutPaste {
1997 sheet: Some("Q1".to_string()),
1998 source: "A1:B2".to_string(),
1999 destination: "D1:E2".to_string(),
2000 paste_type: PasteType::Normal,
2001 },
2002 dry_run: true,
2003 lease_token: None,
2004 ledger_path: PathBuf::new(),
2005 };
2006 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2007 match outcome.result {
2008 PasteResult::RefusedInvalidRange { detail } => {
2009 assert!(detail.contains("single cell"), "{detail}");
2010 }
2011 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2012 }
2013 }
2014
2015 #[tokio::test]
2016 async fn cut_paste_reports_destination_overwrite_and_source_clear_separately() {
2017 let server = wiremock::MockServer::start().await;
2018 let (drive, sheets) = clients(&server).await;
2019 mount_file(
2020 "sheet-1",
2021 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2022 &["folder-1"],
2023 )
2024 .mount(&server)
2025 .await;
2026 mount_folder("folder-1").mount(&server).await;
2027 mount_workbook(&server).await;
2028 wiremock::Mock::given(wiremock::matchers::method("GET"))
2029 .and(wiremock::matchers::path(
2030 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2031 ))
2032 .respond_with(
2033 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2034 "range": "'Q1'!D1:E2", "values": [["x", ""], ["", "y"]],
2035 })),
2036 )
2037 .mount(&server)
2038 .await;
2039 wiremock::Mock::given(wiremock::matchers::method("GET"))
2040 .and(wiremock::matchers::path(
2041 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2042 ))
2043 .respond_with(
2044 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2045 "range": "'Q1'!A1:B2", "values": [["1", "2"], ["3", "4"]],
2046 })),
2047 )
2048 .mount(&server)
2049 .await;
2050 let rules = vec![allow_rule(
2051 "folder-1",
2052 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2053 )];
2054
2055 let opts = PasteOptions {
2056 spreadsheet_id: "sheet-1".to_string(),
2057 verb: PasteVerb::CutPaste {
2058 sheet: Some("Q1".to_string()),
2059 source: "A1:B2".to_string(),
2060 destination: "D1".to_string(),
2061 paste_type: PasteType::Normal,
2062 },
2063 dry_run: true,
2064 lease_token: None,
2065 ledger_path: PathBuf::new(),
2066 };
2067 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2068 match outcome.result {
2069 PasteResult::WouldChange(change) => {
2070 assert_eq!(
2071 change.overwritten,
2072 Some(vec!["D1".to_string(), "E2".to_string()])
2073 );
2074 assert_eq!(
2075 change.cleared,
2076 Some(vec![
2077 "A1".to_string(),
2078 "B1".to_string(),
2079 "A2".to_string(),
2080 "B2".to_string()
2081 ])
2082 );
2083 }
2084 other => panic!("expected WouldChange, got {other:?}"),
2085 }
2086 }
2087
2088 #[tokio::test]
2089 async fn paste_data_defaults_and_real_run_succeeds() {
2090 let server = wiremock::MockServer::start().await;
2091 let (drive, sheets) = clients(&server).await;
2092 mount_file(
2093 "sheet-1",
2094 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2095 &["folder-1"],
2096 )
2097 .mount(&server)
2098 .await;
2099 mount_folder("folder-1").mount(&server).await;
2100 mount_workbook(&server).await;
2101 wiremock::Mock::given(wiremock::matchers::method("GET"))
2102 .and(wiremock::matchers::path(
2103 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
2104 ))
2105 .respond_with(
2106 wiremock::ResponseTemplate::new(200)
2107 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
2108 )
2109 .mount(&server)
2110 .await;
2111 wiremock::Mock::given(wiremock::matchers::method("POST"))
2112 .and(wiremock::matchers::path(
2113 "/v4/spreadsheets/sheet-1:batchUpdate",
2114 ))
2115 .respond_with(
2116 wiremock::ResponseTemplate::new(200)
2117 .set_body_json(serde_json::json!({"replies": [{}]})),
2118 )
2119 .mount(&server)
2120 .await;
2121 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2122
2123 let opts = PasteOptions {
2124 spreadsheet_id: "sheet-1".to_string(),
2125 verb: PasteVerb::PasteData {
2126 sheet: Some("Q1".to_string()),
2127 destination: "A1".to_string(),
2128 data: "1\t2".to_string(),
2129 delimiter: "\t".to_string(),
2130 paste_type: PasteType::Values,
2131 },
2132 dry_run: false,
2133 lease_token: None,
2134 ledger_path: PathBuf::new(),
2135 };
2136 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2137 assert!(
2138 matches!(outcome.result, PasteResult::Changed(_)),
2139 "{:?}",
2140 outcome.result
2141 );
2142 }
2143
2144 #[tokio::test]
2147 async fn a_destination_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
2148 let server = wiremock::MockServer::start().await;
2149 let (drive, sheets) = clients(&server).await;
2150 let opts = PasteOptions {
2154 spreadsheet_id: "sheet-1".to_string(),
2155 verb: PasteVerb::CopyPaste {
2156 sheet: None,
2157 source: "'Q1'!A1:B2".to_string(),
2158 destination: "D1:E2".to_string(),
2159 paste_type: PasteType::Values,
2160 orientation: PasteOrientation::Normal,
2161 },
2162 dry_run: true,
2163 lease_token: None,
2164 ledger_path: PathBuf::new(),
2165 };
2166 let outcome = paste(&drive, &sheets, &opts, &[]).await;
2167 match outcome.result {
2168 PasteResult::RefusedInvalidRange { detail } => {
2169 assert!(detail.contains("--destination"), "{detail}");
2170 }
2171 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2172 }
2173 assert_eq!(outcome.file_name, None);
2174 }
2175
2176 #[tokio::test]
2177 async fn a_source_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
2178 let server = wiremock::MockServer::start().await;
2179 let (drive, sheets) = clients(&server).await;
2180 let opts = PasteOptions {
2181 spreadsheet_id: "sheet-1".to_string(),
2182 verb: PasteVerb::CopyPaste {
2183 sheet: None,
2184 source: "A1:B2".to_string(),
2185 destination: "'Q1'!D1:E2".to_string(),
2186 paste_type: PasteType::Values,
2187 orientation: PasteOrientation::Normal,
2188 },
2189 dry_run: true,
2190 lease_token: None,
2191 ledger_path: PathBuf::new(),
2192 };
2193 let outcome = paste(&drive, &sheets, &opts, &[]).await;
2194 match outcome.result {
2195 PasteResult::RefusedInvalidRange { detail } => {
2196 assert!(detail.contains("--source"), "{detail}");
2197 }
2198 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2199 }
2200 }
2201
2202 #[tokio::test]
2205 async fn a_metadata_fetch_failure_surfaces_as_failed() {
2206 let server = wiremock::MockServer::start().await;
2207 let (drive, sheets) = clients(&server).await;
2208 wiremock::Mock::given(wiremock::matchers::method("GET"))
2209 .and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
2210 .respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
2211 .mount(&server)
2212 .await;
2213 let opts = PasteOptions {
2214 spreadsheet_id: "sheet-1".to_string(),
2215 verb: PasteVerb::CopyPaste {
2216 sheet: Some("Q1".to_string()),
2217 source: "A1:B2".to_string(),
2218 destination: "D1:E2".to_string(),
2219 paste_type: PasteType::Normal,
2220 orientation: PasteOrientation::Normal,
2221 },
2222 dry_run: false,
2224 lease_token: None,
2225 ledger_path: PathBuf::new(),
2226 };
2227 let outcome = paste(&drive, &sheets, &opts, &[]).await;
2228 assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2229 }
2230
2231 #[tokio::test]
2232 async fn a_shortcut_target_is_refused() {
2233 let server = wiremock::MockServer::start().await;
2234 let (drive, sheets) = clients(&server).await;
2235 mount_file(
2236 "sheet-1",
2237 "application/vnd.google-apps.shortcut",
2238 &["folder-1"],
2239 )
2240 .mount(&server)
2241 .await;
2242 let rules = vec![allow_rule(
2243 "folder-1",
2244 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2245 )];
2246 let opts = PasteOptions {
2247 spreadsheet_id: "sheet-1".to_string(),
2248 verb: PasteVerb::CopyPaste {
2249 sheet: Some("Q1".to_string()),
2250 source: "A1:B2".to_string(),
2251 destination: "D1:E2".to_string(),
2252 paste_type: PasteType::Normal,
2253 orientation: PasteOrientation::Normal,
2254 },
2255 dry_run: true,
2256 lease_token: None,
2257 ledger_path: PathBuf::new(),
2258 };
2259 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2260 assert!(matches!(outcome.result, PasteResult::RefusedShortcut));
2261 }
2262
2263 #[tokio::test]
2264 async fn a_non_spreadsheet_target_is_refused() {
2265 let server = wiremock::MockServer::start().await;
2266 let (drive, sheets) = clients(&server).await;
2267 mount_file(
2268 "sheet-1",
2269 "application/vnd.google-apps.document",
2270 &["folder-1"],
2271 )
2272 .mount(&server)
2273 .await;
2274 let rules = vec![allow_rule(
2275 "folder-1",
2276 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2277 )];
2278 let opts = PasteOptions {
2279 spreadsheet_id: "sheet-1".to_string(),
2280 verb: PasteVerb::CopyPaste {
2281 sheet: Some("Q1".to_string()),
2282 source: "A1:B2".to_string(),
2283 destination: "D1:E2".to_string(),
2284 paste_type: PasteType::Normal,
2285 orientation: PasteOrientation::Normal,
2286 },
2287 dry_run: true,
2288 lease_token: None,
2289 ledger_path: PathBuf::new(),
2290 };
2291 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2292 assert!(matches!(
2293 outcome.result,
2294 PasteResult::RefusedNotASpreadsheet { .. }
2295 ));
2296 }
2297
2298 #[tokio::test]
2299 async fn a_target_with_no_visible_parents_is_refused() {
2300 let server = wiremock::MockServer::start().await;
2301 let (drive, sheets) = clients(&server).await;
2302 mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
2303 .mount(&server)
2304 .await;
2305 let opts = PasteOptions {
2306 spreadsheet_id: "sheet-1".to_string(),
2307 verb: PasteVerb::CopyPaste {
2308 sheet: Some("Q1".to_string()),
2309 source: "A1:B2".to_string(),
2310 destination: "D1:E2".to_string(),
2311 paste_type: PasteType::Normal,
2312 orientation: PasteOrientation::Normal,
2313 },
2314 dry_run: true,
2315 lease_token: None,
2316 ledger_path: PathBuf::new(),
2317 };
2318 let outcome = paste(&drive, &sheets, &opts, &[]).await;
2319 assert!(matches!(
2320 outcome.result,
2321 PasteResult::RefusedNoVisibleParents
2322 ));
2323 }
2324
2325 #[tokio::test]
2326 async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
2327 let server = wiremock::MockServer::start().await;
2328 let (drive, sheets) = clients(&server).await;
2329 mount_file(
2330 "sheet-1",
2331 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2332 &["folder-1"],
2333 )
2334 .mount(&server)
2335 .await;
2336 wiremock::Mock::given(wiremock::matchers::method("GET"))
2337 .and(wiremock::matchers::path("/drive/v3/files/folder-1"))
2338 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2339 .mount(&server)
2340 .await;
2341 let rules = vec![allow_rule(
2342 "folder-1",
2343 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2344 )];
2345 let opts = PasteOptions {
2346 spreadsheet_id: "sheet-1".to_string(),
2347 verb: PasteVerb::CopyPaste {
2348 sheet: Some("Q1".to_string()),
2349 source: "A1:B2".to_string(),
2350 destination: "D1:E2".to_string(),
2351 paste_type: PasteType::Normal,
2352 orientation: PasteOrientation::Normal,
2353 },
2354 dry_run: true,
2355 lease_token: None,
2356 ledger_path: PathBuf::new(),
2357 };
2358 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2359 assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2360 }
2361
2362 #[tokio::test]
2363 async fn a_workbook_fetch_failure_surfaces_as_failed() {
2364 let server = wiremock::MockServer::start().await;
2365 let (drive, sheets) = clients(&server).await;
2366 mount_file(
2367 "sheet-1",
2368 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2369 &["folder-1"],
2370 )
2371 .mount(&server)
2372 .await;
2373 mount_folder("folder-1").mount(&server).await;
2374 wiremock::Mock::given(wiremock::matchers::method("GET"))
2375 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2376 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2377 .mount(&server)
2378 .await;
2379 let rules = vec![allow_rule(
2380 "folder-1",
2381 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2382 )];
2383 let opts = PasteOptions {
2384 spreadsheet_id: "sheet-1".to_string(),
2385 verb: PasteVerb::CopyPaste {
2386 sheet: Some("Q1".to_string()),
2387 source: "A1:B2".to_string(),
2388 destination: "D1:E2".to_string(),
2389 paste_type: PasteType::Normal,
2390 orientation: PasteOrientation::Normal,
2391 },
2392 dry_run: true,
2393 lease_token: None,
2394 ledger_path: PathBuf::new(),
2395 };
2396 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2397 assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2398 }
2399
2400 #[tokio::test]
2403 async fn a_destination_naming_an_unknown_sheet_is_refused() {
2404 let server = wiremock::MockServer::start().await;
2405 let (drive, sheets) = clients(&server).await;
2406 mount_file(
2407 "sheet-1",
2408 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2409 &["folder-1"],
2410 )
2411 .mount(&server)
2412 .await;
2413 mount_folder("folder-1").mount(&server).await;
2414 mount_workbook(&server).await;
2415 let rules = vec![allow_rule(
2416 "folder-1",
2417 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2418 )];
2419 let opts = PasteOptions {
2420 spreadsheet_id: "sheet-1".to_string(),
2421 verb: PasteVerb::CopyPaste {
2422 sheet: None,
2423 source: "'Q1'!A1:B2".to_string(),
2424 destination: "'Ghost'!D1:E2".to_string(),
2425 paste_type: PasteType::Normal,
2426 orientation: PasteOrientation::Normal,
2427 },
2428 dry_run: true,
2429 lease_token: None,
2430 ledger_path: PathBuf::new(),
2431 };
2432 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2433 match outcome.result {
2434 PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
2435 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2436 }
2437 }
2438
2439 #[tokio::test]
2440 async fn a_destination_with_an_unparseable_range_is_refused() {
2441 let server = wiremock::MockServer::start().await;
2442 let (drive, sheets) = clients(&server).await;
2443 mount_file(
2444 "sheet-1",
2445 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2446 &["folder-1"],
2447 )
2448 .mount(&server)
2449 .await;
2450 mount_folder("folder-1").mount(&server).await;
2451 mount_workbook(&server).await;
2452 let rules = vec![allow_rule(
2453 "folder-1",
2454 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2455 )];
2456 let opts = PasteOptions {
2457 spreadsheet_id: "sheet-1".to_string(),
2458 verb: PasteVerb::CopyPaste {
2459 sheet: None,
2460 source: "'Q1'!A1:B2".to_string(),
2461 destination: "'Q1'!not a range".to_string(),
2462 paste_type: PasteType::Normal,
2463 orientation: PasteOrientation::Normal,
2464 },
2465 dry_run: true,
2466 lease_token: None,
2467 ledger_path: PathBuf::new(),
2468 };
2469 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2470 assert!(
2471 matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
2472 "{:?}",
2473 outcome.result
2474 );
2475 }
2476
2477 #[tokio::test]
2478 async fn an_unbounded_destination_is_refused() {
2479 let server = wiremock::MockServer::start().await;
2480 let (drive, sheets) = clients(&server).await;
2481 mount_file(
2482 "sheet-1",
2483 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2484 &["folder-1"],
2485 )
2486 .mount(&server)
2487 .await;
2488 mount_folder("folder-1").mount(&server).await;
2489 mount_workbook(&server).await;
2490 let rules = vec![allow_rule(
2491 "folder-1",
2492 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2493 )];
2494 let opts = PasteOptions {
2495 spreadsheet_id: "sheet-1".to_string(),
2496 verb: PasteVerb::CopyPaste {
2497 sheet: None,
2498 source: "'Q1'!A1:B2".to_string(),
2499 destination: "'Q1'!A:A".to_string(),
2500 paste_type: PasteType::Normal,
2501 orientation: PasteOrientation::Normal,
2502 },
2503 dry_run: true,
2504 lease_token: None,
2505 ledger_path: PathBuf::new(),
2506 };
2507 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2508 match outcome.result {
2509 PasteResult::RefusedInvalidRange { detail } => {
2510 assert!(detail.contains("bounded"), "{detail}");
2511 }
2512 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2513 }
2514 }
2515
2516 #[tokio::test]
2517 async fn a_source_naming_an_unknown_sheet_is_refused() {
2518 let server = wiremock::MockServer::start().await;
2519 let (drive, sheets) = clients(&server).await;
2520 mount_file(
2521 "sheet-1",
2522 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2523 &["folder-1"],
2524 )
2525 .mount(&server)
2526 .await;
2527 mount_folder("folder-1").mount(&server).await;
2528 mount_workbook(&server).await;
2529 let rules = vec![allow_rule(
2530 "folder-1",
2531 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2532 )];
2533 let opts = PasteOptions {
2534 spreadsheet_id: "sheet-1".to_string(),
2535 verb: PasteVerb::CopyPaste {
2536 sheet: None,
2537 source: "'Ghost'!A1:B2".to_string(),
2538 destination: "'Q1'!D1:E2".to_string(),
2539 paste_type: PasteType::Normal,
2540 orientation: PasteOrientation::Normal,
2541 },
2542 dry_run: true,
2543 lease_token: None,
2544 ledger_path: PathBuf::new(),
2545 };
2546 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2547 match outcome.result {
2548 PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
2549 other => panic!("expected RefusedSheetNotFound, got {other:?}"),
2550 }
2551 }
2552
2553 #[tokio::test]
2554 async fn a_source_with_an_unparseable_range_is_refused() {
2555 let server = wiremock::MockServer::start().await;
2556 let (drive, sheets) = clients(&server).await;
2557 mount_file(
2558 "sheet-1",
2559 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2560 &["folder-1"],
2561 )
2562 .mount(&server)
2563 .await;
2564 mount_folder("folder-1").mount(&server).await;
2565 mount_workbook(&server).await;
2566 let rules = vec![allow_rule(
2567 "folder-1",
2568 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2569 )];
2570 let opts = PasteOptions {
2571 spreadsheet_id: "sheet-1".to_string(),
2572 verb: PasteVerb::CopyPaste {
2573 sheet: None,
2574 source: "'Q1'!not a range".to_string(),
2575 destination: "'Q1'!D1:E2".to_string(),
2576 paste_type: PasteType::Normal,
2577 orientation: PasteOrientation::Normal,
2578 },
2579 dry_run: true,
2580 lease_token: None,
2581 ledger_path: PathBuf::new(),
2582 };
2583 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2584 assert!(
2585 matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
2586 "{:?}",
2587 outcome.result
2588 );
2589 }
2590
2591 #[tokio::test]
2592 async fn an_unbounded_source_is_refused() {
2593 let server = wiremock::MockServer::start().await;
2594 let (drive, sheets) = clients(&server).await;
2595 mount_file(
2596 "sheet-1",
2597 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2598 &["folder-1"],
2599 )
2600 .mount(&server)
2601 .await;
2602 mount_folder("folder-1").mount(&server).await;
2603 mount_workbook(&server).await;
2604 let rules = vec![allow_rule(
2605 "folder-1",
2606 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2607 )];
2608 let opts = PasteOptions {
2609 spreadsheet_id: "sheet-1".to_string(),
2610 verb: PasteVerb::CopyPaste {
2611 sheet: None,
2612 source: "'Q1'!A:A".to_string(),
2613 destination: "'Q1'!D1:E2".to_string(),
2614 paste_type: PasteType::Normal,
2615 orientation: PasteOrientation::Normal,
2616 },
2617 dry_run: true,
2618 lease_token: None,
2619 ledger_path: PathBuf::new(),
2620 };
2621 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2622 match outcome.result {
2623 PasteResult::RefusedInvalidRange { detail } => {
2624 assert!(
2625 detail.contains("bounded") && detail.contains("--source"),
2626 "{detail}"
2627 );
2628 }
2629 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2630 }
2631 }
2632
2633 #[tokio::test]
2636 async fn a_destination_past_the_sheets_allocated_grid_gets_a_caveat() {
2637 let server = wiremock::MockServer::start().await;
2638 let (drive, sheets) = clients(&server).await;
2639 mount_file(
2640 "sheet-1",
2641 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2642 &["folder-1"],
2643 )
2644 .mount(&server)
2645 .await;
2646 mount_folder("folder-1").mount(&server).await;
2647 wiremock::Mock::given(wiremock::matchers::method("GET"))
2648 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2649 .respond_with(
2650 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2651 "spreadsheetId": "sheet-1",
2652 "properties": {"title": "Budget"},
2653 "sheets": [
2654 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2655 "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2656 ],
2657 })),
2658 )
2659 .mount(&server)
2660 .await;
2661 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
2663 let opts = PasteOptions {
2664 spreadsheet_id: "sheet-1".to_string(),
2665 verb: PasteVerb::CopyPaste {
2666 sheet: Some("Q1".to_string()),
2667 source: "A1:F1".to_string(),
2668 destination: "A1:B1".to_string(),
2669 paste_type: PasteType::Format,
2670 orientation: PasteOrientation::Normal,
2671 },
2672 dry_run: true,
2673 lease_token: None,
2674 ledger_path: PathBuf::new(),
2675 };
2676 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2677 match outcome.result {
2678 PasteResult::WouldChange(change) => {
2679 assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2680 }
2681 other => panic!("expected WouldChange, got {other:?}"),
2682 }
2683 }
2684
2685 #[tokio::test]
2686 async fn a_values_get_failure_for_the_destination_preview_surfaces_as_failed() {
2687 let server = wiremock::MockServer::start().await;
2688 let (drive, sheets) = clients(&server).await;
2689 mount_file(
2690 "sheet-1",
2691 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2692 &["folder-1"],
2693 )
2694 .mount(&server)
2695 .await;
2696 mount_folder("folder-1").mount(&server).await;
2697 mount_workbook(&server).await;
2698 wiremock::Mock::given(wiremock::matchers::method("GET"))
2699 .and(wiremock::matchers::path(
2700 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2701 ))
2702 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2703 .mount(&server)
2704 .await;
2705 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2706 let opts = PasteOptions {
2707 spreadsheet_id: "sheet-1".to_string(),
2708 verb: PasteVerb::CopyPaste {
2709 sheet: Some("Q1".to_string()),
2710 source: "A1:B2".to_string(),
2711 destination: "D1:E2".to_string(),
2712 paste_type: PasteType::Values,
2713 orientation: PasteOrientation::Normal,
2714 },
2715 dry_run: true,
2716 lease_token: None,
2717 ledger_path: PathBuf::new(),
2718 };
2719 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2720 assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2721 }
2722
2723 #[tokio::test]
2724 async fn a_values_get_failure_for_the_cut_paste_source_clear_preview_surfaces_as_failed() {
2725 let server = wiremock::MockServer::start().await;
2726 let (drive, sheets) = clients(&server).await;
2727 mount_file(
2728 "sheet-1",
2729 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2730 &["folder-1"],
2731 )
2732 .mount(&server)
2733 .await;
2734 mount_folder("folder-1").mount(&server).await;
2735 mount_workbook(&server).await;
2736 wiremock::Mock::given(wiremock::matchers::method("GET"))
2737 .and(wiremock::matchers::path(
2738 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2739 ))
2740 .respond_with(
2741 wiremock::ResponseTemplate::new(200)
2742 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
2743 )
2744 .mount(&server)
2745 .await;
2746 wiremock::Mock::given(wiremock::matchers::method("GET"))
2747 .and(wiremock::matchers::path(
2748 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
2749 ))
2750 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
2751 .mount(&server)
2752 .await;
2753 let rules = vec![allow_rule(
2754 "folder-1",
2755 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
2756 )];
2757 let opts = PasteOptions {
2758 spreadsheet_id: "sheet-1".to_string(),
2759 verb: PasteVerb::CutPaste {
2760 sheet: Some("Q1".to_string()),
2761 source: "A1:B2".to_string(),
2762 destination: "D1".to_string(),
2763 paste_type: PasteType::Normal,
2764 },
2765 dry_run: true,
2766 lease_token: None,
2767 ledger_path: PathBuf::new(),
2768 };
2769 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2770 assert!(matches!(outcome.result, PasteResult::Failed { .. }));
2771 }
2772
2773 #[tokio::test]
2774 async fn paste_data_with_empty_data_is_refused_before_any_network_call() {
2775 let server = wiremock::MockServer::start().await;
2776 let (drive, sheets) = clients(&server).await;
2777 let opts = PasteOptions {
2781 spreadsheet_id: "sheet-1".to_string(),
2782 verb: PasteVerb::PasteData {
2783 sheet: Some("Q1".to_string()),
2784 destination: "A1".to_string(),
2785 data: String::new(),
2786 delimiter: "\t".to_string(),
2787 paste_type: PasteType::Values,
2788 },
2789 dry_run: true,
2790 lease_token: None,
2791 ledger_path: PathBuf::new(),
2792 };
2793 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2794 assert_eq!(outcome.result, PasteResult::RefusedEmptyData);
2795 let lines = describe_lines(&outcome).join("\n");
2796 assert!(lines.contains("no data to paste"), "{lines}");
2797 }
2798
2799 #[tokio::test]
2800 async fn paste_data_with_an_empty_delimiter_is_refused_before_any_network_call() {
2801 let server = wiremock::MockServer::start().await;
2802 let (drive, sheets) = clients(&server).await;
2803 let opts = PasteOptions {
2808 spreadsheet_id: "sheet-1".to_string(),
2809 verb: PasteVerb::PasteData {
2810 sheet: Some("Q1".to_string()),
2811 destination: "A1".to_string(),
2812 data: "a\tb".to_string(),
2813 delimiter: String::new(),
2814 paste_type: PasteType::Values,
2815 },
2816 dry_run: true,
2817 lease_token: None,
2818 ledger_path: PathBuf::new(),
2819 };
2820 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2821 assert_eq!(
2822 outcome.result,
2823 PasteResult::RefusedInvalidDelimiter {
2824 detail: "--delimiter must not be empty".to_string()
2825 }
2826 );
2827 let lines = describe_lines(&outcome).join("\n");
2828 assert!(lines.contains("--delimiter must not be empty"), "{lines}");
2829 }
2830
2831 #[tokio::test]
2832 async fn a_sheet_flag_no_range_can_use_is_refused_before_any_network_call() {
2833 let server = wiremock::MockServer::start().await;
2834 let (drive, sheets) = clients(&server).await;
2835 let opts = PasteOptions {
2836 spreadsheet_id: "sheet-1".to_string(),
2837 verb: PasteVerb::CopyPaste {
2838 sheet: Some("Q1".to_string()),
2839 source: "'Q2'!A1:B2".to_string(),
2840 destination: "'Q3'!D1:E2".to_string(),
2841 paste_type: PasteType::Values,
2842 orientation: PasteOrientation::Normal,
2843 },
2844 dry_run: true,
2845 lease_token: None,
2846 ledger_path: PathBuf::new(),
2847 };
2848 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
2849 match outcome.result {
2850 PasteResult::RefusedInvalidRange { detail } => {
2851 assert!(detail.contains("--sheet 'Q1'"), "{detail}");
2852 }
2853 other => panic!("expected RefusedInvalidRange, got {other:?}"),
2854 }
2855 }
2856
2857 #[tokio::test]
2858 async fn a_preview_read_is_clipped_to_the_grid_so_the_caveat_survives() {
2859 let server = wiremock::MockServer::start().await;
2860 let (drive, sheets) = clients(&server).await;
2861 mount_file(
2862 "sheet-1",
2863 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2864 &["folder-1"],
2865 )
2866 .mount(&server)
2867 .await;
2868 mount_folder("folder-1").mount(&server).await;
2869 wiremock::Mock::given(wiremock::matchers::method("GET"))
2870 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2871 .respond_with(
2872 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2873 "spreadsheetId": "sheet-1",
2874 "properties": {"title": "Budget"},
2875 "sheets": [
2876 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2877 "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2878 ],
2879 })),
2880 )
2881 .mount(&server)
2882 .await;
2883 wiremock::Mock::given(wiremock::matchers::method("GET"))
2887 .and(wiremock::matchers::path(
2888 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E1",
2889 ))
2890 .respond_with(
2891 wiremock::ResponseTemplate::new(200)
2892 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E1", "values": [["x"]]})),
2893 )
2894 .mount(&server)
2895 .await;
2896 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2897 let opts = PasteOptions {
2898 spreadsheet_id: "sheet-1".to_string(),
2899 verb: PasteVerb::CopyPaste {
2900 sheet: Some("Q1".to_string()),
2903 source: "A1:F1".to_string(),
2904 destination: "D1".to_string(),
2905 paste_type: PasteType::Values,
2906 orientation: PasteOrientation::Normal,
2907 },
2908 dry_run: true,
2909 lease_token: None,
2910 ledger_path: PathBuf::new(),
2911 };
2912 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2913 match outcome.result {
2914 PasteResult::WouldChange(change) => {
2915 assert_eq!(change.written_extent, "'Q1'!D1:I1");
2918 assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2919 assert_eq!(change.overwritten, Some(vec!["D1".to_string()]));
2921 }
2922 other => panic!("expected WouldChange, got {other:?}"),
2923 }
2924 }
2925
2926 #[tokio::test]
2927 async fn a_destination_entirely_past_the_grid_previews_no_overwrite_without_reading() {
2928 let server = wiremock::MockServer::start().await;
2929 let (drive, sheets) = clients(&server).await;
2930 mount_file(
2931 "sheet-1",
2932 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2933 &["folder-1"],
2934 )
2935 .mount(&server)
2936 .await;
2937 mount_folder("folder-1").mount(&server).await;
2938 wiremock::Mock::given(wiremock::matchers::method("GET"))
2939 .and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
2940 .respond_with(
2941 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
2942 "spreadsheetId": "sheet-1",
2943 "properties": {"title": "Budget"},
2944 "sheets": [
2945 {"properties": {"sheetId": 0, "title": "Q1", "index": 0,
2946 "gridProperties": {"rowCount": 5, "columnCount": 5}}},
2947 ],
2948 })),
2949 )
2950 .mount(&server)
2951 .await;
2952 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
2955 let opts = PasteOptions {
2956 spreadsheet_id: "sheet-1".to_string(),
2957 verb: PasteVerb::CopyPaste {
2958 sheet: Some("Q1".to_string()),
2959 source: "A1:A1".to_string(),
2960 destination: "A20".to_string(),
2961 paste_type: PasteType::Values,
2962 orientation: PasteOrientation::Normal,
2963 },
2964 dry_run: true,
2965 lease_token: None,
2966 ledger_path: PathBuf::new(),
2967 };
2968 let outcome = paste(&drive, &sheets, &opts, &rules).await;
2969 match outcome.result {
2970 PasteResult::WouldChange(change) => {
2971 assert_eq!(change.overwritten, Some(Vec::new()));
2972 assert!(change.grid_edge_caveat.is_some(), "{change:?}");
2973 }
2974 other => panic!("expected WouldChange, got {other:?}"),
2975 }
2976 }
2977
2978 #[tokio::test]
2979 async fn no_cell_value_ever_reaches_the_rendered_output() {
2980 let server = wiremock::MockServer::start().await;
2985 let (drive, sheets) = clients(&server).await;
2986 mount_file(
2987 "sheet-1",
2988 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
2989 &["folder-1"],
2990 )
2991 .mount(&server)
2992 .await;
2993 mount_folder("folder-1").mount(&server).await;
2994 mount_workbook(&server).await;
2995 wiremock::Mock::given(wiremock::matchers::method("GET"))
2996 .and(wiremock::matchers::path(
2997 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
2998 ))
2999 .respond_with(
3000 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3001 "range": "'Q1'!D1:E2",
3002 "values": [["destination-secret", ""], ["", "another-secret"]],
3003 })),
3004 )
3005 .mount(&server)
3006 .await;
3007 wiremock::Mock::given(wiremock::matchers::method("GET"))
3008 .and(wiremock::matchers::path(
3009 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3010 ))
3011 .respond_with(
3012 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
3013 "range": "'Q1'!A1:B2",
3014 "values": [["source-secret", "x"], ["y", "z"]],
3015 })),
3016 )
3017 .mount(&server)
3018 .await;
3019 let rules = rules_allowing_everything();
3020 let opts = PasteOptions {
3021 spreadsheet_id: "sheet-1".to_string(),
3022 verb: PasteVerb::CutPaste {
3023 sheet: Some("Q1".to_string()),
3024 source: "A1:B2".to_string(),
3025 destination: "D1".to_string(),
3026 paste_type: PasteType::Normal,
3027 },
3028 dry_run: true,
3029 lease_token: None,
3030 ledger_path: PathBuf::new(),
3031 };
3032 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3033 let rendered = describe_lines(&outcome).join("\n");
3034 for secret in ["destination-secret", "another-secret", "source-secret"] {
3035 assert!(
3036 !rendered.contains(secret),
3037 "{secret} leaked into: {rendered}"
3038 );
3039 }
3040 assert!(
3042 rendered.contains("2 non-blank cell(s) in the destination"),
3043 "{rendered}"
3044 );
3045 assert!(
3046 rendered.contains("in the destination would be overwritten: D1"),
3047 "{rendered}"
3048 );
3049 assert!(
3050 rendered.contains("4 non-blank cell(s) in the source"),
3051 "{rendered}"
3052 );
3053 }
3054
3055 #[tokio::test]
3058 async fn cut_paste_real_run_succeeds_and_reports_changed() {
3059 let server = wiremock::MockServer::start().await;
3060 let (drive, sheets) = clients(&server).await;
3061 mount_file(
3062 "sheet-1",
3063 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3064 &["folder-1"],
3065 )
3066 .mount(&server)
3067 .await;
3068 mount_folder("folder-1").mount(&server).await;
3069 mount_workbook(&server).await;
3070 wiremock::Mock::given(wiremock::matchers::method("GET"))
3071 .and(wiremock::matchers::path(
3072 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3073 ))
3074 .respond_with(
3075 wiremock::ResponseTemplate::new(200)
3076 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3077 )
3078 .mount(&server)
3079 .await;
3080 wiremock::Mock::given(wiremock::matchers::method("GET"))
3081 .and(wiremock::matchers::path(
3082 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3083 ))
3084 .respond_with(
3085 wiremock::ResponseTemplate::new(200)
3086 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3087 )
3088 .mount(&server)
3089 .await;
3090 wiremock::Mock::given(wiremock::matchers::method("POST"))
3091 .and(wiremock::matchers::path(
3092 "/v4/spreadsheets/sheet-1:batchUpdate",
3093 ))
3094 .respond_with(
3095 wiremock::ResponseTemplate::new(200)
3096 .set_body_json(serde_json::json!({"replies": [{}]})),
3097 )
3098 .mount(&server)
3099 .await;
3100 let rules = vec![allow_rule(
3101 "folder-1",
3102 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3103 )];
3104 let opts = PasteOptions {
3105 spreadsheet_id: "sheet-1".to_string(),
3106 verb: PasteVerb::CutPaste {
3107 sheet: Some("Q1".to_string()),
3108 source: "A1:B2".to_string(),
3109 destination: "D1".to_string(),
3110 paste_type: PasteType::Normal,
3111 },
3112 dry_run: false,
3113 lease_token: None,
3114 ledger_path: PathBuf::new(),
3115 };
3116 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3117 assert!(
3118 matches!(outcome.result, PasteResult::Changed(_)),
3119 "{:?}",
3120 outcome.result
3121 );
3122 }
3123
3124 #[tokio::test]
3125 async fn cut_paste_with_an_unreadable_reply_reports_applied_reply_unreadable() {
3126 let server = wiremock::MockServer::start().await;
3127 let (drive, sheets) = clients(&server).await;
3128 mount_file(
3129 "sheet-1",
3130 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3131 &["folder-1"],
3132 )
3133 .mount(&server)
3134 .await;
3135 mount_folder("folder-1").mount(&server).await;
3136 mount_workbook(&server).await;
3137 wiremock::Mock::given(wiremock::matchers::method("GET"))
3138 .and(wiremock::matchers::path(
3139 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3140 ))
3141 .respond_with(
3142 wiremock::ResponseTemplate::new(200)
3143 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3144 )
3145 .mount(&server)
3146 .await;
3147 wiremock::Mock::given(wiremock::matchers::method("GET"))
3148 .and(wiremock::matchers::path(
3149 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3150 ))
3151 .respond_with(
3152 wiremock::ResponseTemplate::new(200)
3153 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3154 )
3155 .mount(&server)
3156 .await;
3157 wiremock::Mock::given(wiremock::matchers::method("POST"))
3159 .and(wiremock::matchers::path(
3160 "/v4/spreadsheets/sheet-1:batchUpdate",
3161 ))
3162 .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
3163 .expect(1)
3164 .mount(&server)
3165 .await;
3166 let rules = vec![allow_rule(
3167 "folder-1",
3168 &[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
3169 )];
3170 let opts = PasteOptions {
3171 spreadsheet_id: "sheet-1".to_string(),
3172 verb: PasteVerb::CutPaste {
3173 sheet: Some("Q1".to_string()),
3174 source: "A1:B2".to_string(),
3175 destination: "D1".to_string(),
3176 paste_type: PasteType::Normal,
3177 },
3178 dry_run: false,
3179 lease_token: None,
3180 ledger_path: PathBuf::new(),
3181 };
3182 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3183 assert!(
3184 matches!(outcome.result, PasteResult::AppliedReplyUnreadable { .. }),
3185 "{:?}",
3186 outcome.result
3187 );
3188 assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
3189 let lines = describe_lines(&outcome);
3190 assert!(
3191 lines[0].starts_with("Applied, but the reply could not be read"),
3192 "{lines:?}"
3193 );
3194 assert!(lines[0].contains("do not retry"), "{lines:?}");
3195 }
3196
3197 #[test]
3198 fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
3199 let outcome = |result| PasteOutcome {
3200 spreadsheet_id: "sheet-1".to_string(),
3201 file_name: Some("Budget".to_string()),
3202 resolved_folder_id: None,
3203 verb: every_paste_verb().remove(0),
3204 result,
3205 };
3206 let unreadable = describe_lines(&outcome(PasteResult::AppliedReplyUnreadable {
3207 summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3208 change: change_for_unreadable(),
3209 detail: "bad reply".to_string(),
3210 }));
3211 let changed = describe_lines(&outcome(PasteResult::Changed(change_for_unreadable())));
3212 assert_eq!(
3213 unreadable[0],
3214 "Applied, but the reply could not be read: cut-paste into 'Q1'!D1 (writing \
3215 'Q1'!D1:E2) in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
3216 );
3217 assert!(unreadable.len() > 3, "{unreadable:?}");
3219 assert_eq!(unreadable[1..], changed[1..]);
3220 }
3221
3222 #[test]
3225 fn an_unreadable_reply_serialises_the_change_fields_beside_the_status() {
3226 let value = serde_json::to_value(PasteResult::AppliedReplyUnreadable {
3227 summary: "s".to_string(),
3228 change: change_for_unreadable(),
3229 detail: "bad reply".to_string(),
3230 })
3231 .unwrap();
3232 assert_eq!(value["status"], "applied-reply-unreadable");
3233 assert_eq!(value["destination"], "'Q1'!D1");
3234 assert_eq!(value["overwritten"], serde_json::json!(["D1"]));
3235 assert_eq!(value["cleared"], serde_json::json!(["A1"]));
3236 assert_eq!(value["detail"], "bad reply");
3237 }
3238
3239 #[test]
3240 fn an_unreadable_reply_logs_the_summary_as_fields_changed() {
3241 let verb = every_paste_verb().remove(0);
3242 let opts = PasteOptions {
3243 spreadsheet_id: "sheet-1".to_string(),
3244 verb: verb.clone(),
3245 dry_run: false,
3246 lease_token: None,
3247 ledger_path: PathBuf::new(),
3248 };
3249 let record = mutation_record(
3250 &PasteOutcome {
3251 spreadsheet_id: "sheet-1".to_string(),
3252 file_name: Some("Budget".to_string()),
3253 resolved_folder_id: None,
3254 verb,
3255 result: PasteResult::AppliedReplyUnreadable {
3256 summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3257 change: change_for_unreadable(),
3258 detail: "bad reply".to_string(),
3259 },
3260 },
3261 &opts,
3262 Duration::from_millis(1),
3263 );
3264 assert_eq!(
3265 record.fields_changed.as_deref(),
3266 Some("cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)")
3267 );
3268 assert_eq!(record.error.as_deref(), Some("bad reply"));
3269 }
3270
3271 #[tokio::test]
3272 async fn copy_paste_real_run_succeeds_and_reports_changed() {
3273 let server = wiremock::MockServer::start().await;
3274 let (drive, sheets) = clients(&server).await;
3275 mount_file(
3276 "sheet-1",
3277 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3278 &["folder-1"],
3279 )
3280 .mount(&server)
3281 .await;
3282 mount_folder("folder-1").mount(&server).await;
3283 mount_workbook(&server).await;
3284 wiremock::Mock::given(wiremock::matchers::method("GET"))
3285 .and(wiremock::matchers::path(
3286 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3287 ))
3288 .respond_with(
3289 wiremock::ResponseTemplate::new(200)
3290 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3291 )
3292 .mount(&server)
3293 .await;
3294 wiremock::Mock::given(wiremock::matchers::method("POST"))
3295 .and(wiremock::matchers::path(
3296 "/v4/spreadsheets/sheet-1:batchUpdate",
3297 ))
3298 .respond_with(
3299 wiremock::ResponseTemplate::new(200)
3300 .set_body_json(serde_json::json!({"replies": [{}]})),
3301 )
3302 .mount(&server)
3303 .await;
3304 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
3305 let opts = PasteOptions {
3306 spreadsheet_id: "sheet-1".to_string(),
3307 verb: PasteVerb::CopyPaste {
3308 sheet: Some("Q1".to_string()),
3309 source: "A1:B2".to_string(),
3310 destination: "D1:E2".to_string(),
3311 paste_type: PasteType::Values,
3312 orientation: PasteOrientation::Normal,
3313 },
3314 dry_run: false,
3315 lease_token: None,
3316 ledger_path: PathBuf::new(),
3317 };
3318 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3319 assert!(
3320 matches!(outcome.result, PasteResult::Changed(_)),
3321 "{:?}",
3322 outcome.result
3323 );
3324 }
3325
3326 async fn sent_batch_update(server: &wiremock::MockServer) -> serde_json::Value {
3328 let requests = server.received_requests().await.expect("recorded requests");
3329 let body = requests
3330 .iter()
3331 .find(|r| r.url.path().ends_with(":batchUpdate"))
3332 .map(|r| r.body.clone())
3333 .expect("a batchUpdate request");
3334 serde_json::from_slice(&body).expect("a JSON body")
3335 }
3336
3337 #[tokio::test]
3338 async fn cut_paste_sends_the_source_range_and_a_destination_coordinate() {
3339 let server = wiremock::MockServer::start().await;
3344 let (drive, sheets) = clients(&server).await;
3345 mount_file(
3346 "sheet-1",
3347 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3348 &["folder-1"],
3349 )
3350 .mount(&server)
3351 .await;
3352 mount_folder("folder-1").mount(&server).await;
3353 mount_workbook(&server).await;
3354 for range in ["'Q1'!D1:E2", "'Q1'!A1:B2"] {
3355 wiremock::Mock::given(wiremock::matchers::method("GET"))
3356 .and(wiremock::matchers::path(format!(
3357 "/v4/spreadsheets/sheet-1/values/{range}"
3358 )))
3359 .respond_with(
3360 wiremock::ResponseTemplate::new(200)
3361 .set_body_json(serde_json::json!({"range": range, "values": []})),
3362 )
3363 .mount(&server)
3364 .await;
3365 }
3366 wiremock::Mock::given(wiremock::matchers::method("POST"))
3367 .and(wiremock::matchers::path(
3368 "/v4/spreadsheets/sheet-1:batchUpdate",
3369 ))
3370 .respond_with(
3371 wiremock::ResponseTemplate::new(200)
3372 .set_body_json(serde_json::json!({"replies": [{}]})),
3373 )
3374 .mount(&server)
3375 .await;
3376 let opts = PasteOptions {
3377 spreadsheet_id: "sheet-1".to_string(),
3378 verb: PasteVerb::CutPaste {
3379 sheet: Some("Q1".to_string()),
3380 source: "A1:B2".to_string(),
3381 destination: "D1".to_string(),
3382 paste_type: PasteType::Values,
3383 },
3384 dry_run: false,
3385 lease_token: None,
3386 ledger_path: PathBuf::new(),
3387 };
3388 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3389 assert!(
3390 matches!(outcome.result, PasteResult::Changed(_)),
3391 "{:?}",
3392 outcome.result
3393 );
3394 assert_eq!(
3395 sent_batch_update(&server).await,
3396 serde_json::json!({
3397 "requests": [{
3398 "cutPaste": {
3399 "source": {
3400 "sheetId": 0,
3401 "startRowIndex": 0, "endRowIndex": 2,
3402 "startColumnIndex": 0, "endColumnIndex": 2,
3403 },
3404 "destination": {"sheetId": 0, "rowIndex": 0, "columnIndex": 3},
3405 "pasteType": "PASTE_VALUES",
3406 },
3407 }],
3408 })
3409 );
3410 }
3411
3412 #[tokio::test]
3413 async fn copy_paste_sends_both_ranges_with_the_requested_orientation() {
3414 let server = wiremock::MockServer::start().await;
3415 let (drive, sheets) = clients(&server).await;
3416 mount_file(
3417 "sheet-1",
3418 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3419 &["folder-1"],
3420 )
3421 .mount(&server)
3422 .await;
3423 mount_folder("folder-1").mount(&server).await;
3424 mount_workbook(&server).await;
3425 wiremock::Mock::given(wiremock::matchers::method("GET"))
3426 .and(wiremock::matchers::path(
3427 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3428 ))
3429 .respond_with(
3430 wiremock::ResponseTemplate::new(200)
3431 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3432 )
3433 .mount(&server)
3434 .await;
3435 wiremock::Mock::given(wiremock::matchers::method("POST"))
3436 .and(wiremock::matchers::path(
3437 "/v4/spreadsheets/sheet-1:batchUpdate",
3438 ))
3439 .respond_with(
3440 wiremock::ResponseTemplate::new(200)
3441 .set_body_json(serde_json::json!({"replies": [{}]})),
3442 )
3443 .mount(&server)
3444 .await;
3445 let opts = PasteOptions {
3446 spreadsheet_id: "sheet-1".to_string(),
3447 verb: PasteVerb::CopyPaste {
3448 sheet: Some("Q1".to_string()),
3449 source: "A1:B2".to_string(),
3450 destination: "D1:E2".to_string(),
3451 paste_type: PasteType::Values,
3452 orientation: PasteOrientation::Transpose,
3453 },
3454 dry_run: false,
3455 lease_token: None,
3456 ledger_path: PathBuf::new(),
3457 };
3458 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3459 assert!(
3460 matches!(outcome.result, PasteResult::Changed(_)),
3461 "{:?}",
3462 outcome.result
3463 );
3464 assert_eq!(
3465 sent_batch_update(&server).await,
3466 serde_json::json!({
3467 "requests": [{
3468 "copyPaste": {
3469 "source": {
3470 "sheetId": 0,
3471 "startRowIndex": 0, "endRowIndex": 2,
3472 "startColumnIndex": 0, "endColumnIndex": 2,
3473 },
3474 "destination": {
3475 "sheetId": 0,
3476 "startRowIndex": 0, "endRowIndex": 2,
3477 "startColumnIndex": 3, "endColumnIndex": 5,
3478 },
3479 "pasteType": "PASTE_VALUES",
3480 "pasteOrientation": "TRANSPOSE",
3481 },
3482 }],
3483 })
3484 );
3485 }
3486
3487 #[tokio::test]
3488 async fn paste_data_sends_the_delimiter_form_verbatim() {
3489 let server = wiremock::MockServer::start().await;
3490 let (drive, sheets) = clients(&server).await;
3491 mount_file(
3492 "sheet-1",
3493 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3494 &["folder-1"],
3495 )
3496 .mount(&server)
3497 .await;
3498 mount_folder("folder-1").mount(&server).await;
3499 mount_workbook(&server).await;
3500 wiremock::Mock::given(wiremock::matchers::method("GET"))
3501 .and(wiremock::matchers::path(
3502 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
3503 ))
3504 .respond_with(
3505 wiremock::ResponseTemplate::new(200)
3506 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
3507 )
3508 .mount(&server)
3509 .await;
3510 wiremock::Mock::given(wiremock::matchers::method("POST"))
3511 .and(wiremock::matchers::path(
3512 "/v4/spreadsheets/sheet-1:batchUpdate",
3513 ))
3514 .respond_with(
3515 wiremock::ResponseTemplate::new(200)
3516 .set_body_json(serde_json::json!({"replies": [{}]})),
3517 )
3518 .mount(&server)
3519 .await;
3520 let opts = PasteOptions {
3521 spreadsheet_id: "sheet-1".to_string(),
3522 verb: PasteVerb::PasteData {
3523 sheet: Some("Q1".to_string()),
3524 destination: "A1".to_string(),
3525 data: "1\t2\n3\t4\n".to_string(),
3528 delimiter: "\t".to_string(),
3529 paste_type: PasteType::Values,
3530 },
3531 dry_run: false,
3532 lease_token: None,
3533 ledger_path: PathBuf::new(),
3534 };
3535 let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
3536 assert!(
3537 matches!(outcome.result, PasteResult::Changed(_)),
3538 "{:?}",
3539 outcome.result
3540 );
3541 assert_eq!(
3542 sent_batch_update(&server).await,
3543 serde_json::json!({
3544 "requests": [{
3545 "pasteData": {
3546 "coordinate": {"sheetId": 0, "rowIndex": 0, "columnIndex": 0},
3547 "data": "1\t2\n3\t4\n",
3548 "delimiter": "\t",
3549 "type": "PASTE_VALUES",
3550 },
3551 }],
3552 })
3553 );
3554 }
3555
3556 #[tokio::test]
3557 async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
3558 let server = wiremock::MockServer::start().await;
3559 let (drive, sheets) = clients(&server).await;
3560 mount_file(
3561 "sheet-1",
3562 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3563 &["folder-1"],
3564 )
3565 .mount(&server)
3566 .await;
3567 mount_folder("folder-1").mount(&server).await;
3568 mount_workbook(&server).await;
3569 wiremock::Mock::given(wiremock::matchers::method("GET"))
3570 .and(wiremock::matchers::path(
3571 "/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
3572 ))
3573 .respond_with(
3574 wiremock::ResponseTemplate::new(200)
3575 .set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
3576 )
3577 .mount(&server)
3578 .await;
3579 wiremock::Mock::given(wiremock::matchers::method("POST"))
3580 .and(wiremock::matchers::path(
3581 "/v4/spreadsheets/sheet-1:batchUpdate",
3582 ))
3583 .respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
3584 .mount(&server)
3585 .await;
3586 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
3587 let opts = PasteOptions {
3588 spreadsheet_id: "sheet-1".to_string(),
3589 verb: PasteVerb::CopyPaste {
3590 sheet: Some("Q1".to_string()),
3591 source: "A1:B2".to_string(),
3592 destination: "D1:E2".to_string(),
3593 paste_type: PasteType::Values,
3594 orientation: PasteOrientation::Normal,
3595 },
3596 dry_run: false,
3597 lease_token: None,
3598 ledger_path: PathBuf::new(),
3599 };
3600 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3601 assert!(
3602 matches!(outcome.result, PasteResult::Failed { .. }),
3603 "{:?}",
3604 outcome.result
3605 );
3606 }
3607
3608 #[tokio::test]
3611 async fn paste_data_refuses_an_unknown_lease_token() {
3612 let server = wiremock::MockServer::start().await;
3613 let (drive, sheets) = clients(&server).await;
3614 mount_file(
3615 "sheet-1",
3616 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3617 &["folder-1"],
3618 )
3619 .mount(&server)
3620 .await;
3621 mount_folder("folder-1").mount(&server).await;
3622 mount_workbook(&server).await;
3623 wiremock::Mock::given(wiremock::matchers::method("GET"))
3624 .and(wiremock::matchers::path(
3625 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3626 ))
3627 .respond_with(
3628 wiremock::ResponseTemplate::new(200)
3629 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3630 )
3631 .mount(&server)
3632 .await;
3633 let rules = vec![FolderPermissionRule::folder("folder-1")
3634 .allowing([DriveOperation::SheetsWrite])
3635 .requiring_lease(true)];
3636 let ledger_path = tempfile::tempdir()
3637 .unwrap()
3638 .keep()
3639 .join("lease-ledger.jsonl");
3640 let opts = PasteOptions {
3641 spreadsheet_id: "sheet-1".to_string(),
3642 verb: PasteVerb::PasteData {
3643 sheet: Some("Q1".to_string()),
3644 destination: "A1".to_string(),
3645 data: "1\t2".to_string(),
3646 delimiter: "\t".to_string(),
3647 paste_type: PasteType::Values,
3648 },
3649 dry_run: false,
3650 lease_token: Some("bogus-token".to_string()),
3651 ledger_path,
3652 };
3653 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3654 assert!(matches!(outcome.result, PasteResult::RefusedLeaseExpired));
3655 }
3656
3657 #[tokio::test]
3658 async fn paste_data_refuses_a_lease_bound_to_a_different_file() {
3659 let server = wiremock::MockServer::start().await;
3660 let (drive, sheets) = clients(&server).await;
3661 mount_file(
3662 "sheet-1",
3663 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3664 &["folder-1"],
3665 )
3666 .mount(&server)
3667 .await;
3668 mount_folder("folder-1").mount(&server).await;
3669 mount_workbook(&server).await;
3670 wiremock::Mock::given(wiremock::matchers::method("GET"))
3671 .and(wiremock::matchers::path(
3672 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3673 ))
3674 .respond_with(
3675 wiremock::ResponseTemplate::new(200)
3676 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3677 )
3678 .mount(&server)
3679 .await;
3680 let rules = vec![FolderPermissionRule::folder("folder-1")
3681 .allowing([DriveOperation::SheetsWrite])
3682 .requiring_lease(true)];
3683 let ledger_path = tempfile::tempdir()
3684 .unwrap()
3685 .keep()
3686 .join("lease-ledger.jsonl");
3687 let token = crate::drive::test_support::seed_lease(&ledger_path, "some-other-sheet", "1");
3688 let opts = PasteOptions {
3689 spreadsheet_id: "sheet-1".to_string(),
3690 verb: PasteVerb::PasteData {
3691 sheet: Some("Q1".to_string()),
3692 destination: "A1".to_string(),
3693 data: "1\t2".to_string(),
3694 delimiter: "\t".to_string(),
3695 paste_type: PasteType::Values,
3696 },
3697 dry_run: false,
3698 lease_token: Some(token),
3699 ledger_path,
3700 };
3701 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3702 assert!(matches!(outcome.result, PasteResult::RefusedLeaseWrongFile));
3703 }
3704
3705 #[tokio::test]
3706 async fn paste_data_refuses_a_stale_lease_when_the_file_has_moved() {
3707 let server = wiremock::MockServer::start().await;
3708 let (drive, sheets) = clients(&server).await;
3709 mount_file(
3710 "sheet-1",
3711 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3712 &["folder-1"],
3713 )
3714 .mount(&server)
3715 .await;
3716 mount_folder("folder-1").mount(&server).await;
3717 mount_workbook(&server).await;
3718 wiremock::Mock::given(wiremock::matchers::method("GET"))
3719 .and(wiremock::matchers::path(
3720 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3721 ))
3722 .respond_with(
3723 wiremock::ResponseTemplate::new(200)
3724 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3725 )
3726 .mount(&server)
3727 .await;
3728 let rules = vec![FolderPermissionRule::folder("folder-1")
3729 .allowing([DriveOperation::SheetsWrite])
3730 .requiring_lease(true)];
3731 let ledger_path = tempfile::tempdir()
3732 .unwrap()
3733 .keep()
3734 .join("lease-ledger.jsonl");
3735 let token = crate::drive::test_support::seed_lease(&ledger_path, "sheet-1", "0");
3736 let opts = PasteOptions {
3737 spreadsheet_id: "sheet-1".to_string(),
3738 verb: PasteVerb::PasteData {
3739 sheet: Some("Q1".to_string()),
3740 destination: "A1".to_string(),
3741 data: "1\t2".to_string(),
3742 delimiter: "\t".to_string(),
3743 paste_type: PasteType::Values,
3744 },
3745 dry_run: false,
3746 lease_token: Some(token),
3747 ledger_path,
3748 };
3749 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3750 assert!(matches!(outcome.result, PasteResult::RefusedLeaseStale));
3751 }
3752
3753 #[tokio::test]
3754 async fn a_required_lease_without_one_refuses_before_batch_update() {
3755 let server = wiremock::MockServer::start().await;
3756 let (drive, sheets) = clients(&server).await;
3757 mount_file(
3758 "sheet-1",
3759 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3760 &["folder-1"],
3761 )
3762 .mount(&server)
3763 .await;
3764 mount_folder("folder-1").mount(&server).await;
3765 mount_workbook(&server).await;
3766 wiremock::Mock::given(wiremock::matchers::method("GET"))
3767 .and(wiremock::matchers::path(
3768 "/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
3769 ))
3770 .respond_with(
3771 wiremock::ResponseTemplate::new(200)
3772 .set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
3773 )
3774 .mount(&server)
3775 .await;
3776 let rules = vec![FolderPermissionRule::folder("folder-1")
3779 .allowing([DriveOperation::SheetsWrite])
3780 .requiring_lease(true)];
3781
3782 let opts = PasteOptions {
3783 spreadsheet_id: "sheet-1".to_string(),
3784 verb: PasteVerb::PasteData {
3785 sheet: Some("Q1".to_string()),
3786 destination: "A1".to_string(),
3787 data: "1\t2".to_string(),
3788 delimiter: "\t".to_string(),
3789 paste_type: PasteType::Values,
3790 },
3791 dry_run: false,
3792 lease_token: None,
3793 ledger_path: PathBuf::from("/nonexistent/ledger.yaml"),
3794 };
3795 let outcome = paste(&drive, &sheets, &opts, &rules).await;
3796 assert_eq!(outcome.result, PasteResult::RefusedNoLease);
3797 }
3798
3799 #[tokio::test]
3800 async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
3801 let server = wiremock::MockServer::start().await;
3802 let (drive, sheets) = clients(&server).await;
3803 mount_file(
3804 "sheet-1",
3805 crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
3806 &["folder-1"],
3807 )
3808 .mount(&server)
3809 .await;
3810 mount_folder("folder-1").mount(&server).await;
3811 let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
3812 let verb = PasteVerb::CopyPaste {
3813 sheet: Some("Q1".to_string()),
3814 source: "A1:B2".to_string(),
3815 destination: "D1:E2".to_string(),
3816 paste_type: PasteType::Values,
3817 orientation: PasteOrientation::Normal,
3818 };
3819
3820 let dry = paste(
3821 &drive,
3822 &sheets,
3823 &PasteOptions {
3824 spreadsheet_id: "sheet-1".to_string(),
3825 verb: verb.clone(),
3826 dry_run: true,
3827 lease_token: None,
3828 ledger_path: PathBuf::new(),
3829 },
3830 &rules,
3831 )
3832 .await;
3833 let real = paste(
3834 &drive,
3835 &sheets,
3836 &PasteOptions {
3837 spreadsheet_id: "sheet-1".to_string(),
3838 verb,
3839 dry_run: false,
3840 lease_token: None,
3841 ledger_path: PathBuf::new(),
3842 },
3843 &rules,
3844 )
3845 .await;
3846 assert_eq!(dry.result, real.result);
3847 }
3848
3849 fn change_for_unreadable() -> PasteChange {
3852 PasteChange {
3853 destination: "'Q1'!D1".to_string(),
3854 source: Some("'Q1'!A1:B2".to_string()),
3855 written_extent: "'Q1'!D1:E2".to_string(),
3856 overwritten: Some(vec!["D1".to_string()]),
3857 cleared: Some(vec!["A1".to_string()]),
3858 grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
3859 }
3860 }
3861
3862 fn every_paste_result() -> Vec<PasteResult> {
3863 let change = PasteChange {
3864 destination: "'Q1'!D1".to_string(),
3865 source: Some("'Q1'!A1:B2".to_string()),
3866 written_extent: "'Q1'!D1:E2".to_string(),
3867 overwritten: Some(vec!["D1".to_string()]),
3868 cleared: Some(vec!["A1".to_string()]),
3869 grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
3870 };
3871 vec![
3872 PasteResult::WouldChange(change.clone()),
3873 PasteResult::Changed(change),
3874 PasteResult::RefusedNotASpreadsheet {
3875 mime_type: "text/plain".to_string(),
3876 },
3877 PasteResult::RefusedShortcut,
3878 PasteResult::RefusedNoVisibleParents,
3879 PasteResult::RefusedSheetNotFound {
3880 title: "Ghost".to_string(),
3881 available: vec!["Q1".to_string()],
3882 },
3883 PasteResult::RefusedInvalidRange {
3884 detail: "bad range".to_string(),
3885 },
3886 PasteResult::RefusedEmptyData,
3887 PasteResult::Blocked {
3888 operation: DriveOperation::SheetsWrite,
3889 decided_by: None,
3890 },
3891 PasteResult::RefusedNoLease,
3892 PasteResult::RefusedLeaseExpired,
3893 PasteResult::RefusedLeaseWrongFile,
3894 PasteResult::RefusedLeaseStale,
3895 PasteResult::AppliedReplyUnreadable {
3896 summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
3897 change: change_for_unreadable(),
3898 detail: "bad reply".to_string(),
3899 },
3900 PasteResult::Failed {
3901 detail: "boom".to_string(),
3902 },
3903 ]
3904 }
3905
3906 fn every_paste_verb() -> Vec<PasteVerb> {
3907 vec![
3908 PasteVerb::CutPaste {
3909 sheet: Some("Q1".to_string()),
3910 source: "A1:B2".to_string(),
3911 destination: "D1".to_string(),
3912 paste_type: PasteType::Normal,
3913 },
3914 PasteVerb::CopyPaste {
3915 sheet: Some("Q1".to_string()),
3916 source: "A1:B2".to_string(),
3917 destination: "D1:E2".to_string(),
3918 paste_type: PasteType::Values,
3919 orientation: PasteOrientation::Transpose,
3920 },
3921 PasteVerb::PasteData {
3922 sheet: Some("Q1".to_string()),
3923 destination: "D1".to_string(),
3924 data: "1\t2".to_string(),
3925 delimiter: "\t".to_string(),
3926 paste_type: PasteType::Values,
3927 },
3928 ]
3929 }
3930
3931 #[test]
3932 fn no_describe_line_contains_a_control_character() {
3933 for verb in every_paste_verb() {
3934 for result in every_paste_result() {
3935 let outcome = PasteOutcome {
3936 spreadsheet_id: "sheet-1".to_string(),
3937 file_name: Some("Budget".to_string()),
3938 resolved_folder_id: None,
3939 verb: verb.clone(),
3940 result,
3941 };
3942 for rendered in describe_lines(&outcome) {
3943 assert!(
3944 !rendered.chars().any(char::is_control),
3945 "describe_lines emitted a control character for {:?}/{:?}: {rendered:?}",
3946 outcome.verb,
3947 outcome.result
3948 );
3949 }
3950 }
3951 }
3952 }
3953
3954 #[test]
3955 fn describe_lines_reports_no_overwritten_cells_when_the_extent_is_all_blank() {
3956 let change = PasteChange {
3957 destination: "'Q1'!D1".to_string(),
3958 source: None,
3959 written_extent: "'Q1'!D1:E2".to_string(),
3960 overwritten: Some(vec![]),
3961 cleared: None,
3962 grid_edge_caveat: None,
3963 };
3964 let outcome = PasteOutcome {
3965 spreadsheet_id: "sheet-1".to_string(),
3966 file_name: Some("Budget".to_string()),
3967 resolved_folder_id: None,
3968 verb: PasteVerb::PasteData {
3969 sheet: Some("Q1".to_string()),
3970 destination: "D1".to_string(),
3971 data: "1".to_string(),
3972 delimiter: "\t".to_string(),
3973 paste_type: PasteType::Values,
3974 },
3975 result: PasteResult::WouldChange(change),
3976 };
3977 let lines = describe_lines(&outcome);
3978 assert!(
3979 lines.iter().any(|l| l.contains("no non-blank cells")),
3980 "{lines:?}"
3981 );
3982 }
3983
3984 #[test]
3985 fn describe_lines_elides_a_destination_overwrite_past_the_render_limit() {
3986 let overwritten: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
3987 let change = PasteChange {
3988 destination: "'Q1'!D1".to_string(),
3989 source: Some("'Q1'!A1:B60".to_string()),
3990 written_extent: "'Q1'!D1:D60".to_string(),
3991 overwritten: Some(overwritten.clone()),
3992 cleared: None,
3993 grid_edge_caveat: None,
3994 };
3995 let outcome = PasteOutcome {
3996 spreadsheet_id: "sheet-1".to_string(),
3997 file_name: Some("Budget".to_string()),
3998 resolved_folder_id: None,
3999 verb: PasteVerb::CopyPaste {
4000 sheet: Some("Q1".to_string()),
4001 source: "A1:B60".to_string(),
4002 destination: "D1".to_string(),
4003 paste_type: PasteType::Values,
4004 orientation: PasteOrientation::Normal,
4005 },
4006 result: PasteResult::WouldChange(change),
4007 };
4008 let rendered = describe_lines(&outcome).join("\n");
4009 assert!(rendered.contains("60 non-blank cell(s)"), "{rendered}");
4010 assert!(rendered.contains("… and 10 more"), "{rendered}");
4011 assert_eq!(overwritten.len(), 60);
4012 }
4013
4014 #[test]
4015 fn describe_lines_elides_a_cut_paste_cleared_source_past_the_render_limit() {
4016 let cleared: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
4017 let change = PasteChange {
4018 destination: "'Q1'!D1".to_string(),
4019 source: Some("'Q1'!A1:B60".to_string()),
4020 written_extent: "'Q1'!D1:D60".to_string(),
4021 overwritten: Some(vec![]),
4022 cleared: Some(cleared.clone()),
4023 grid_edge_caveat: None,
4024 };
4025 let outcome = PasteOutcome {
4026 spreadsheet_id: "sheet-1".to_string(),
4027 file_name: Some("Budget".to_string()),
4028 resolved_folder_id: None,
4029 verb: PasteVerb::CutPaste {
4030 sheet: Some("Q1".to_string()),
4031 source: "A1:B60".to_string(),
4032 destination: "D1".to_string(),
4033 paste_type: PasteType::Normal,
4034 },
4035 result: PasteResult::WouldChange(change),
4036 };
4037 let rendered = describe_lines(&outcome).join("\n");
4038 assert!(
4039 rendered.contains("60 non-blank cell(s) in the source"),
4040 "{rendered}"
4041 );
4042 assert!(rendered.contains("… and 10 more"), "{rendered}");
4043 assert_eq!(cleared.len(), 60);
4044 }
4045
4046 #[test]
4047 fn describe_lines_notes_that_a_format_only_paste_previews_no_overwrite() {
4048 let change = PasteChange {
4049 destination: "'Q1'!D1:E2".to_string(),
4050 source: Some("'Q1'!A1:B2".to_string()),
4051 written_extent: "'Q1'!D1:E2".to_string(),
4052 overwritten: None,
4053 cleared: None,
4054 grid_edge_caveat: None,
4055 };
4056 let outcome = PasteOutcome {
4057 spreadsheet_id: "sheet-1".to_string(),
4058 file_name: Some("Budget".to_string()),
4059 resolved_folder_id: None,
4060 verb: PasteVerb::CopyPaste {
4061 sheet: Some("Q1".to_string()),
4062 source: "A1:B2".to_string(),
4063 destination: "D1:E2".to_string(),
4064 paste_type: PasteType::Format,
4065 orientation: PasteOrientation::Normal,
4066 },
4067 result: PasteResult::WouldChange(change),
4068 };
4069 let lines = describe_lines(&outcome);
4070 assert!(
4071 lines.iter().any(|l| l.contains("not previewed")),
4072 "{lines:?}"
4073 );
4074 }
4075
4076 #[test]
4077 fn describe_lines_reports_no_cells_to_clear_when_the_source_is_blank() {
4078 let change = PasteChange {
4079 destination: "'Q1'!D1".to_string(),
4080 source: Some("'Q1'!A1:B2".to_string()),
4081 written_extent: "'Q1'!D1:E2".to_string(),
4082 overwritten: Some(vec![]),
4083 cleared: Some(vec![]),
4084 grid_edge_caveat: None,
4085 };
4086 let outcome = PasteOutcome {
4087 spreadsheet_id: "sheet-1".to_string(),
4088 file_name: Some("Budget".to_string()),
4089 resolved_folder_id: None,
4090 verb: PasteVerb::CutPaste {
4091 sheet: Some("Q1".to_string()),
4092 source: "A1:B2".to_string(),
4093 destination: "D1".to_string(),
4094 paste_type: PasteType::Normal,
4095 },
4096 result: PasteResult::WouldChange(change),
4097 };
4098 let lines = describe_lines(&outcome);
4099 assert!(
4100 lines
4101 .iter()
4102 .any(|l| l.contains("no non-blank cells to clear")),
4103 "{lines:?}"
4104 );
4105 }
4106
4107 #[test]
4108 fn describe_lines_falls_back_to_the_spreadsheet_id_when_no_file_name_is_known() {
4109 let outcome = PasteOutcome {
4110 spreadsheet_id: "sheet-1".to_string(),
4111 file_name: None,
4112 resolved_folder_id: None,
4113 verb: PasteVerb::CopyPaste {
4114 sheet: Some("Q1".to_string()),
4115 source: "A1:B2".to_string(),
4116 destination: "D1:E2".to_string(),
4117 paste_type: PasteType::Values,
4118 orientation: PasteOrientation::Normal,
4119 },
4120 result: PasteResult::RefusedShortcut,
4121 };
4122 let lines = describe_lines(&outcome);
4123 assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
4124 }
4125
4126 #[test]
4127 fn describe_lines_names_sheets_structure_alone_for_a_no_visible_parents_refusal_on_a_format_only_verb(
4128 ) {
4129 let outcome = PasteOutcome {
4130 spreadsheet_id: "sheet-1".to_string(),
4131 file_name: Some("Budget".to_string()),
4132 resolved_folder_id: None,
4133 verb: PasteVerb::CopyPaste {
4134 sheet: Some("Q1".to_string()),
4135 source: "A1:B2".to_string(),
4136 destination: "D1:E2".to_string(),
4137 paste_type: PasteType::Format,
4138 orientation: PasteOrientation::Normal,
4139 },
4140 result: PasteResult::RefusedNoVisibleParents,
4141 };
4142 let lines = describe_lines(&outcome);
4143 assert!(lines[0].contains("\"sheets-structure\""), "{lines:?}");
4144 }
4145
4146 #[test]
4147 fn describe_lines_reports_no_available_sheets_when_the_workbook_has_none() {
4148 let outcome = PasteOutcome {
4149 spreadsheet_id: "sheet-1".to_string(),
4150 file_name: Some("Budget".to_string()),
4151 resolved_folder_id: None,
4152 verb: PasteVerb::PasteData {
4153 sheet: Some("Q1".to_string()),
4154 destination: "D1".to_string(),
4155 data: "1".to_string(),
4156 delimiter: "\t".to_string(),
4157 paste_type: PasteType::Values,
4158 },
4159 result: PasteResult::RefusedSheetNotFound {
4160 title: "Ghost".to_string(),
4161 available: vec![],
4162 },
4163 };
4164 let lines = describe_lines(&outcome);
4165 assert!(lines[0].contains("Available: none"), "{lines:?}");
4166 }
4167
4168 #[test]
4169 fn describe_lines_names_the_deciding_folder_rule_when_present() {
4170 let outcome = PasteOutcome {
4171 spreadsheet_id: "sheet-1".to_string(),
4172 file_name: Some("Budget".to_string()),
4173 resolved_folder_id: None,
4174 verb: PasteVerb::CutPaste {
4175 sheet: Some("Q1".to_string()),
4176 source: "A1:B2".to_string(),
4177 destination: "D1".to_string(),
4178 paste_type: PasteType::Normal,
4179 },
4180 result: PasteResult::Blocked {
4181 operation: DriveOperation::SheetsWrite,
4182 decided_by: Some(DecidingRule::Folder {
4183 folder_id: "folder-1".to_string(),
4184 depth: 2,
4185 }),
4186 },
4187 };
4188 let lines = describe_lines(&outcome);
4189 assert!(lines[0].contains("folder folder-1 (depth 2)"), "{lines:?}");
4190 }
4191}