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