use std::io::Read as _;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::sheets::a1;
use crate::drive::sheets::api::{
applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
BatchUpdateRequestItem, CopyPasteRequest, CutPasteRequest, GridCoordinate, GridRange,
PasteDataRequest, PasteOrientation, PasteType,
};
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
const GATE_WRITE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsWrite];
const GATE_STRUCTURE_ONLY: &[DriveOperation] = &[DriveOperation::SheetsStructure];
const GATE_BOTH: &[DriveOperation] =
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PasteVerb {
CutPaste {
sheet: Option<String>,
source: String,
destination: String,
paste_type: PasteType,
},
CopyPaste {
sheet: Option<String>,
source: String,
destination: String,
paste_type: PasteType,
orientation: PasteOrientation,
},
PasteData {
sheet: Option<String>,
destination: String,
data: String,
delimiter: String,
paste_type: PasteType,
},
}
impl PasteVerb {
const fn log_operation(&self) -> &'static str {
match self {
Self::CutPaste { .. } => "sheets-cut-paste",
Self::CopyPaste { .. } => "sheets-copy-paste",
Self::PasteData { .. } => "sheets-paste-data",
}
}
const fn label(&self) -> &'static str {
match self {
Self::CutPaste { .. } => "cut-paste",
Self::CopyPaste { .. } => "copy-paste",
Self::PasteData { .. } => "paste-data",
}
}
fn sheet(&self) -> Option<&str> {
match self {
Self::CutPaste { sheet, .. }
| Self::CopyPaste { sheet, .. }
| Self::PasteData { sheet, .. } => sheet.as_deref(),
}
}
fn source(&self) -> Option<&str> {
match self {
Self::CutPaste { source, .. } | Self::CopyPaste { source, .. } => Some(source),
Self::PasteData { .. } => None,
}
}
fn destination(&self) -> &str {
match self {
Self::CutPaste { destination, .. }
| Self::CopyPaste { destination, .. }
| Self::PasteData { destination, .. } => destination,
}
}
const fn paste_type(&self) -> PasteType {
match self {
Self::CutPaste { paste_type, .. }
| Self::CopyPaste { paste_type, .. }
| Self::PasteData { paste_type, .. } => *paste_type,
}
}
fn gate_operations(&self) -> &'static [DriveOperation] {
match self {
Self::CutPaste { .. } => GATE_BOTH,
Self::CopyPaste { paste_type, .. } | Self::PasteData { paste_type, .. } => {
match (paste_type.writes_values(), paste_type.writes_presentation()) {
(false, true) => GATE_STRUCTURE_ONLY,
(true, false) => GATE_WRITE_ONLY,
(true, true) | (false, false) => GATE_BOTH,
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct PasteOptions {
pub spreadsheet_id: String,
pub verb: PasteVerb,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct PasteChange {
pub destination: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub written_extent: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub overwritten: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cleared: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub grid_edge_caveat: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum PasteResult {
WouldChange(PasteChange),
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
RefusedEmptyData,
RefusedInvalidDelimiter {
detail: String,
},
Blocked {
operation: DriveOperation,
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed(PasteChange),
AppliedReplyUnreadable {
summary: String,
#[serde(flatten)]
change: PasteChange,
detail: String,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for PasteResult {
fn from_no_lease() -> Self {
Self::RefusedNoLease
}
fn from_lease_expired() -> Self {
Self::RefusedLeaseExpired
}
fn from_lease_wrong_file() -> Self {
Self::RefusedLeaseWrongFile
}
fn from_lease_stale() -> Self {
Self::RefusedLeaseStale
}
fn from_lease_failed(detail: String) -> Self {
Self::Failed { detail }
}
}
impl PasteResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldChange(_) => "would-change",
Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
Self::RefusedShortcut => "refused-shortcut",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
Self::RefusedInvalidRange { .. } => "refused-invalid-range",
Self::RefusedEmptyData => "refused-empty-data",
Self::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Changed(_) => "changed",
Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasteOutcome {
pub spreadsheet_id: String,
pub file_name: Option<String>,
pub resolved_folder_id: Option<String>,
pub verb: PasteVerb,
pub result: PasteResult,
}
impl Serialize for PasteOutcome {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
#[derive(Serialize)]
struct Wire<'a> {
spreadsheet_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
file_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
resolved_folder_id: Option<&'a str>,
paste_type: PasteType,
#[serde(skip_serializing_if = "Option::is_none")]
orientation: Option<PasteOrientation>,
result: &'a PasteResult,
}
let (paste_type, orientation) = match &self.verb {
PasteVerb::CutPaste { paste_type, .. } | PasteVerb::PasteData { paste_type, .. } => {
(*paste_type, None)
}
PasteVerb::CopyPaste {
paste_type,
orientation,
..
} => (*paste_type, Some(*orientation)),
};
Wire {
spreadsheet_id: &self.spreadsheet_id,
file_name: self.file_name.as_deref(),
resolved_folder_id: self.resolved_folder_id.as_deref(),
paste_type,
orientation,
result: &self.result,
}
.serialize(serializer)
}
}
impl JsonlSerialize for PasteOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn paste(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &PasteOptions,
rules: &[FolderPermissionRule],
) -> PasteOutcome {
let started = Instant::now();
let outcome = paste_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn paste_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &PasteOptions,
rules: &[FolderPermissionRule],
) -> PasteOutcome {
let bare = |result| PasteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
if let PasteVerb::PasteData {
data, delimiter, ..
} = &opts.verb
{
if data.is_empty() {
return bare(PasteResult::RefusedEmptyData);
}
if delimiter.is_empty() {
return bare(PasteResult::RefusedInvalidDelimiter {
detail: "--delimiter must not be empty".to_string(),
});
}
}
if let Some(sheet) = unusable_sheet_default(&opts.verb) {
return bare(PasteResult::RefusedInvalidRange { detail: sheet });
}
let destination_composed = match compose_with_default_sheet(
opts.verb.sheet(),
opts.verb.destination(),
"--destination",
) {
Ok(composed) => composed,
Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
};
let source_composed = match opts.verb.source() {
Some(source) => match compose_with_default_sheet(opts.verb.sheet(), source, "--source") {
Ok(composed) => Some(composed),
Err(detail) => return bare(PasteResult::RefusedInvalidRange { detail }),
},
None => None,
};
let operations = opts.verb.gate_operations();
let (target, verdict, denied, resolved_folder_id, requires_lease) =
match target_gate::resolve_all(drive, &opts.spreadsheet_id, operations, rules).await {
target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
return bare(PasteResult::Failed { detail })
}
target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => PasteResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
PasteResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => PasteResult::RefusedNoVisibleParents,
};
return PasteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
}
target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
return PasteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result: PasteResult::Failed { detail },
};
}
target_gate::TargetGateUnionOutcome::Gated {
target,
verdict,
denied,
resolved_folder_id,
requires_lease,
} => (target, verdict, denied, resolved_folder_id, requires_lease),
};
let gated = |result| PasteOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
verb: opts.verb.clone(),
result,
};
if verdict == write_gate::Verdict::Deny {
let (operation, decided_by) = denied.unwrap_or((operations[0], None));
return gated(PasteResult::Blocked {
operation,
decided_by,
});
}
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return gated(PasteResult::Failed {
detail: format!("{err:#}"),
})
}
};
let (dest_title, dest_grid) = match grid_range::resolve_grid_range(
&workbook,
&destination_composed,
|detail| PasteResult::RefusedInvalidRange { detail },
|title, available| PasteResult::RefusedSheetNotFound { title, available },
) {
Ok(resolved) => resolved,
Err(result) => return gated(result),
};
if !grid_range::is_bounded(&dest_grid) {
return gated(PasteResult::RefusedInvalidRange {
detail: format!(
"--destination '{}' must be a bounded range, not an open-ended column/row span",
opts.verb.destination()
),
});
}
let needs_single_cell_destination = matches!(
opts.verb,
PasteVerb::CutPaste { .. } | PasteVerb::PasteData { .. }
);
if needs_single_cell_destination && !grid_range::is_single_cell(&dest_grid) {
return gated(PasteResult::RefusedInvalidRange {
detail: format!(
"--destination '{}' must name a single cell, not a range",
opts.verb.destination()
),
});
}
let source_resolved = match &source_composed {
Some(source_composed) => {
match grid_range::resolve_grid_range(
&workbook,
source_composed,
|detail| PasteResult::RefusedInvalidRange { detail },
|title, available| PasteResult::RefusedSheetNotFound { title, available },
) {
Ok((source_title, grid)) => {
if !grid_range::is_bounded(&grid) {
return gated(PasteResult::RefusedInvalidRange {
detail: format!(
"--source '{}' must be a bounded range, not an open-ended \
column/row span",
opts.verb.source().unwrap_or_default()
),
});
}
Some((source_title, grid))
}
Err(result) => return gated(result),
}
}
None => None,
};
let source_grid = source_resolved.as_ref().map(|(_, grid)| *grid);
let extent = match &opts.verb {
PasteVerb::CutPaste { .. } => {
#[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
let (rows, cols) = grid_dims(&source_grid);
anchored_extent(&dest_grid, rows, cols)
}
PasteVerb::CopyPaste { orientation, .. } => {
#[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
copy_paste_extent(&source_grid, &dest_grid, *orientation)
}
PasteVerb::PasteData {
data, delimiter, ..
} => {
let (rows, cols) = paste_data_upper_bound(data, delimiter);
anchored_extent(&dest_grid, rows, cols)
}
};
let grid_edge_caveat = sheet_exceeds_dimensions(&workbook, &extent).then(|| {
"written extent runs past the sheet's currently allocated rows/columns; Sheets grows \
the sheet to fit (verified live, #1937)"
.to_string()
});
let extent_a1 = grid_range::bounded_range_to_a1(&dest_title, &extent)
.unwrap_or_else(|| destination_composed.clone());
let overwritten = if opts.verb.paste_type().writes_values() {
match read_non_blank(&api, &opts.spreadsheet_id, &workbook, &dest_title, &extent).await {
Ok(locations) => Some(locations),
Err(detail) => return gated(PasteResult::Failed { detail }),
}
} else {
None
};
let cleared = if let (PasteVerb::CutPaste { .. }, Some((source_title, source_grid))) =
(&opts.verb, &source_resolved)
{
match read_non_blank(
&api,
&opts.spreadsheet_id,
&workbook,
source_title,
source_grid,
)
.await
{
Ok(locations) => Some(locations),
Err(detail) => return gated(PasteResult::Failed { detail }),
}
} else {
None
};
let change = PasteChange {
destination: destination_composed.clone(),
source: source_composed.clone(),
written_extent: extent_a1,
overwritten,
cleared,
grid_edge_caveat,
};
if opts.dry_run {
return gated(PasteResult::WouldChange(change));
}
let request = match &opts.verb {
PasteVerb::CutPaste { paste_type, .. } => {
#[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
BatchUpdateRequestItem::CutPaste(CutPasteRequest {
source: source_grid,
destination: GridCoordinate {
sheet_id: dest_grid.sheet_id,
row_index: dest_grid.start_row_index.unwrap_or(0),
column_index: dest_grid.start_column_index.unwrap_or(0),
},
paste_type: *paste_type,
})
}
PasteVerb::CopyPaste {
paste_type,
orientation,
..
} => {
#[allow(clippy::unwrap_used)] let source_grid = source_grid.unwrap();
BatchUpdateRequestItem::CopyPaste(CopyPasteRequest {
source: source_grid,
destination: dest_grid,
paste_type: *paste_type,
paste_orientation: *orientation,
})
}
PasteVerb::PasteData {
data,
delimiter,
paste_type,
..
} => BatchUpdateRequestItem::PasteData(PasteDataRequest {
coordinate: GridCoordinate {
sheet_id: dest_grid.sheet_id,
row_index: dest_grid.start_row_index.unwrap_or(0),
column_index: dest_grid.start_column_index.unwrap_or(0),
},
data: data.clone(),
delimiter: delimiter.clone(),
r#type: *paste_type,
}),
};
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets paste",
operation: opts.verb.log_operation(),
ledger_path: &opts.ledger_path,
file_id: &opts.spreadsheet_id,
};
let lease_grant = match gate_optional_leased_write(
leased,
&files_api,
requires_lease,
opts.lease_token.as_deref(),
)
.await
{
Ok(grant) => grant,
Err(err) => return gated(err.into_result()),
};
let result = match conclude_native_leased_write(
leased,
&lease_grant,
&files_api,
api.batch_update(&opts.spreadsheet_id, vec![request]).await,
|err| format!("{err:#}"),
)
.await
{
Ok(BatchUpdateOutcome::Applied(_response)) => PasteResult::Changed(change),
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
PasteResult::AppliedReplyUnreadable {
summary: unreadable_summary(&opts.verb, &change),
change,
detail,
}
}
Err(err) => PasteResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn unreadable_summary(verb: &PasteVerb, change: &PasteChange) -> String {
format!(
"{} into {} (writing {})",
verb.label(),
change.destination,
change.written_extent
)
}
fn compose_with_default_sheet(
sheet: Option<&str>,
range: &str,
flag: &str,
) -> Result<String, String> {
if a1::split_sheet_prefix(range).is_some() {
a1::validate_range(range)
.map(|()| range.to_string())
.map_err(|err| err.to_string())
} else {
match sheet {
Some(sheet) => a1::compose(Some(sheet), Some(range)).map_err(|err| err.to_string()),
None => Err(format!(
"{flag} '{range}' does not name a sheet; pass --sheet, or give {flag} its own \
'Sheet'! prefix"
)),
}
}
}
fn unusable_sheet_default(verb: &PasteVerb) -> Option<String> {
let sheet = verb.sheet()?;
let prefixed = |range: &str| a1::split_sheet_prefix(range).is_some();
let every_range_self_prefixes =
prefixed(verb.destination()) && verb.source().is_none_or(prefixed);
every_range_self_prefixes.then(|| {
format!(
"--sheet '{sheet}' has nothing to apply to: every range already carries its own \
'Sheet'! prefix. Drop --sheet, or drop the prefix from the range it was meant for"
)
})
}
async fn read_non_blank(
api: &SheetsApi<'_>,
spreadsheet_id: &str,
workbook: &crate::drive::sheets::types::Spreadsheet,
sheet_title: &str,
grid: &GridRange,
) -> Result<Vec<String>, String> {
let Some(clamped) = grid_range::clamp_to_sheet(workbook, grid) else {
return Ok(Vec::new());
};
let Some(range) = grid_range::bounded_range_to_a1(sheet_title, &clamped) else {
return Ok(Vec::new());
};
let values = api
.values_get(spreadsheet_id, &range, ValueRenderOption::Formatted)
.await
.map_err(|err| format!("{err:#}"))?;
Ok(grid_range::non_blank_locations(
&values,
clamped.start_row_index.unwrap_or(0),
clamped.start_column_index.unwrap_or(0),
))
}
fn grid_dims(grid: &GridRange) -> (i64, i64) {
(
grid.end_row_index.unwrap_or(0) - grid.start_row_index.unwrap_or(0),
grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0),
)
}
fn anchored_extent(dest: &GridRange, rows: i64, cols: i64) -> GridRange {
let start_row = dest.start_row_index.unwrap_or(0);
let start_col = dest.start_column_index.unwrap_or(0);
GridRange {
sheet_id: dest.sheet_id,
start_row_index: Some(start_row),
end_row_index: Some(start_row + rows),
start_column_index: Some(start_col),
end_column_index: Some(start_col + cols),
}
}
fn extent_len(src_len: i64, dst_len: i64) -> i64 {
if src_len > 0 && dst_len > src_len && dst_len % src_len == 0 {
dst_len
} else {
src_len
}
}
pub(crate) fn copy_paste_extent(
source: &GridRange,
destination: &GridRange,
orientation: PasteOrientation,
) -> GridRange {
let (mut src_rows, mut src_cols) = grid_dims(source);
if orientation == PasteOrientation::Transpose {
std::mem::swap(&mut src_rows, &mut src_cols);
}
let (dst_rows, dst_cols) = grid_dims(destination);
let rows = extent_len(src_rows, dst_rows);
let cols = extent_len(src_cols, dst_cols);
anchored_extent(destination, rows, cols)
}
pub(crate) fn paste_data_upper_bound(data: &str, delimiter: &str) -> (i64, i64) {
if data.is_empty() {
return (0, 0);
}
let body = data.strip_suffix('\n').unwrap_or(data);
if body.is_empty() {
return (1, 1);
}
let rows: Vec<&str> = body
.split('\n')
.map(|line| line.trim_end_matches('\r'))
.collect();
let cols = rows
.iter()
.map(|row| {
if delimiter.is_empty() {
1
} else {
row.split(delimiter).count()
}
})
.max()
.unwrap_or(0);
(rows.len() as i64, cols as i64)
}
fn sheet_exceeds_dimensions(
workbook: &crate::drive::sheets::types::Spreadsheet,
extent: &GridRange,
) -> bool {
let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
return false;
};
let grid = sheet
.properties
.as_ref()
.and_then(|p| p.grid_properties.as_ref());
let row_count = grid.and_then(|g| g.row_count);
let column_count = grid.and_then(|g| g.column_count);
let rows_exceed = matches!(
(extent.end_row_index, row_count),
(Some(end), Some(count)) if end > count
);
let cols_exceed = matches!(
(extent.end_column_index, column_count),
(Some(end), Some(count)) if end > count
);
rows_exceed || cols_exceed
}
fn record_attempt(outcome: &PasteOutcome, opts: &PasteOptions, duration: Duration) {
request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
}
fn mutation_record(
outcome: &PasteOutcome,
opts: &PasteOptions,
duration: Duration,
) -> DriveMutationOutcome {
let error = match &outcome.result {
PasteResult::Failed { detail } | PasteResult::AppliedReplyUnreadable { detail, .. } => {
Some(detail.clone())
}
_ => None,
};
let decided_by = match &outcome.result {
PasteResult::Blocked { decided_by, .. } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let fields_changed = match &outcome.result {
PasteResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
_ => None,
};
DriveMutationOutcome {
operation: opts.verb.log_operation(),
file_id: outcome.spreadsheet_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
fields_changed,
error,
duration,
..Default::default()
}
}
fn paste_change_lines(
verb: &PasteVerb,
change: &PasteChange,
book: &str,
applied: bool,
) -> Vec<String> {
let mut lines = vec![format!(
"{} into {} in {book}, writing {}",
verb.label(),
change.destination,
change.written_extent
)];
if let Some(source) = &change.source {
lines.push(format!(" source: {source}"));
}
lines.push(format!(" paste type: {}", verb.paste_type().as_str()));
if let PasteVerb::CopyPaste { orientation, .. } = verb {
lines.push(format!(" orientation: {}", orientation.as_str()));
}
let overwritten_verb = if applied {
"were overwritten"
} else {
"would be overwritten"
};
match &change.overwritten {
Some(locations) if locations.is_empty() => {
lines.push(format!(
" no non-blank cells in the destination {overwritten_verb}"
));
}
Some(locations) => {
lines.push(format!(
" {} non-blank cell(s) in the destination {overwritten_verb}: {}",
locations.len(),
grid_range::render_locations(locations)
));
}
None => lines.push(
" destination overwrite: not previewed (this paste type writes no cell values)"
.to_string(),
),
}
if let Some(locations) = &change.cleared {
if locations.is_empty() {
lines.push(format!(
" the source {} no non-blank cells to clear",
if applied { "had" } else { "has" }
));
} else {
lines.push(format!(
" {} non-blank cell(s) in the source {}: {}",
locations.len(),
if applied {
"were cleared"
} else {
"will be cleared"
},
grid_range::render_locations(locations)
));
}
}
if let Some(caveat) = &change.grid_edge_caveat {
lines.push(format!(" note: {caveat}"));
}
lines
}
pub fn describe_lines(outcome: &PasteOutcome) -> Vec<String> {
let verb = &outcome.verb;
let book = outcome.file_name.as_deref().map_or_else(
|| format!("'{}'", outcome.spreadsheet_id),
|n| format!("'{n}'"),
);
match &outcome.result {
PasteResult::WouldChange(change) => {
let mut lines = paste_change_lines(verb, change, &book, false);
lines[0] = format!("Would {}", lines[0]);
lines
}
PasteResult::Changed(change) => {
let mut lines = paste_change_lines(verb, change, &book, true);
lines[0] = format!("Applied: {}", lines[0]);
lines
}
PasteResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); `drive sheets {}` \
only works on spreadsheets",
verb.label()
)],
PasteResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
verb.label()
)],
PasteResult::RefusedNoVisibleParents => {
let ops = match verb.gate_operations() {
[DriveOperation::SheetsWrite] => "\"sheets-write\"",
[DriveOperation::SheetsStructure] => "\"sheets-structure\"",
_ => "\"sheets-write\", \"sheets-structure\"",
};
vec![format!(
"Refused: {book} has no parent folder visible to this account, so no folder \
rule can apply to it. Grant it by id instead: add {{\"file_id\": \
\"<spreadsheet id>\", \"allow\": [{ops}]}} to write_permissions.rules."
)]
}
PasteResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
PasteResult::RefusedInvalidRange { detail }
| PasteResult::RefusedInvalidDelimiter { detail } => {
vec![format!("Refused: {detail}")]
}
PasteResult::RefusedEmptyData => vec![format!(
"Refused: `drive sheets {}` was given no data to paste (--data/--data-file is empty)",
verb.label()
)],
PasteResult::Blocked {
operation,
decided_by,
} => vec![match decided_by {
Some(rule) => format!(
"Blocked: {} on {book} refused by rule on {} {}{}",
verb.label(),
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: {} on {book} refused by default policy (no matching rule for \
{operation})",
verb.label()
),
}],
PasteResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
PasteResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
PasteResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
PasteResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
PasteResult::AppliedReplyUnreadable {
summary,
change,
detail,
} => {
let mut lines = paste_change_lines(verb, change, &book, true);
lines[0] =
applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail);
lines
}
PasteResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
pub(crate) fn read_data_text(source: &str) -> anyhow::Result<String> {
use anyhow::Context;
if source == "-" {
let mut buf = Vec::new();
std::io::stdin()
.take(crate::drive::files_api::MAX_UPLOAD_BYTES + 1)
.read_to_end(&mut buf)
.context("Failed to read --data-file from stdin")?;
anyhow::ensure!(
buf.len() as u64 <= crate::drive::files_api::MAX_UPLOAD_BYTES,
"--data-file from stdin is over the {} byte cap",
crate::drive::files_api::MAX_UPLOAD_BYTES
);
String::from_utf8(buf).context("--data-file from stdin is not valid UTF-8")
} else {
let metadata = std::fs::metadata(source)
.with_context(|| format!("Failed to stat --data-file {source}"))?;
anyhow::ensure!(
metadata.len() <= crate::drive::files_api::MAX_UPLOAD_BYTES,
"--data-file {source} is {} bytes, over the {} byte cap",
metadata.len(),
crate::drive::files_api::MAX_UPLOAD_BYTES
);
std::fs::read_to_string(source)
.with_context(|| format!("Failed to read --data-file {source}"))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn extent_len_repeats_a_source_that_evenly_divides_a_larger_destination() {
assert_eq!(extent_len(2, 6), 6);
assert_eq!(extent_len(3, 9), 9);
}
#[test]
fn extent_len_spills_a_source_larger_than_its_destination() {
assert_eq!(extent_len(5, 2), 5);
}
#[test]
fn extent_len_copies_once_when_the_destination_is_not_a_clean_multiple() {
assert_eq!(extent_len(2, 5), 2);
}
#[test]
fn extent_len_treats_equal_lengths_as_a_single_copy() {
assert_eq!(extent_len(3, 3), 3);
}
fn range(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
GridRange {
sheet_id,
start_row_index: Some(r0),
end_row_index: Some(r1),
start_column_index: Some(c0),
end_column_index: Some(c1),
}
}
#[test]
fn copy_paste_extent_anchors_at_the_destinations_top_left() {
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);
assert_eq!(extent, range(1, 10, 12, 10, 12));
}
#[test]
fn copy_paste_extent_repeats_to_fill_a_multiple_destination() {
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);
assert_eq!(extent, range(1, 0, 3, 0, 3));
}
#[test]
fn copy_paste_extent_transposes_the_source_before_computing() {
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);
assert_eq!(extent, range(1, 5, 8, 5, 6));
}
#[test]
fn paste_data_upper_bound_counts_rows_and_the_widest_row() {
assert_eq!(paste_data_upper_bound("a\tb\nc\td\te", "\t"), (2, 3));
}
#[test]
fn paste_data_upper_bound_tolerates_crlf_line_endings() {
assert_eq!(paste_data_upper_bound("a,b\r\nc,d\r\n", ","), (2, 2));
}
#[test]
fn paste_data_upper_bound_does_not_count_a_terminating_newline_as_a_row() {
assert_eq!(paste_data_upper_bound("1\t2\n3\t4\n", "\t"), (2, 2));
assert_eq!(paste_data_upper_bound("1\t2\n3\t4", "\t"), (2, 2));
}
#[test]
fn paste_data_upper_bound_counts_a_second_trailing_newline_as_a_blank_row() {
assert_eq!(paste_data_upper_bound("a\nb\n\n", "\t"), (3, 1));
assert_eq!(paste_data_upper_bound("a\n\nb\n", "\t"), (3, 1));
}
#[test]
fn paste_data_upper_bound_of_a_lone_newline_is_one_empty_row() {
assert_eq!(paste_data_upper_bound("\n", "\t"), (1, 1));
}
#[test]
fn paste_data_upper_bound_of_empty_data_is_zero_by_zero() {
assert_eq!(paste_data_upper_bound("", "\t"), (0, 0));
}
#[test]
fn compose_with_default_sheet_prefers_the_ranges_own_prefix() {
let composed = compose_with_default_sheet(Some("Q1"), "'Q2'!A1:B2", "--source").unwrap();
assert_eq!(composed, "'Q2'!A1:B2");
}
#[test]
fn compose_with_default_sheet_falls_back_to_sheet() {
let composed = compose_with_default_sheet(Some("Q1"), "A1:B2", "--source").unwrap();
assert_eq!(composed, "'Q1'!A1:B2");
}
#[test]
fn compose_with_default_sheet_refuses_neither() {
let err = compose_with_default_sheet(None, "A1:B2", "--destination").unwrap_err();
assert!(err.contains("--destination"), "{err}");
assert!(err.contains("--sheet"), "{err}");
}
#[test]
fn unusable_sheet_default_is_none_when_sheet_is_the_only_source_of_a_prefix() {
let verb = PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
};
assert_eq!(unusable_sheet_default(&verb), None);
}
#[test]
fn unusable_sheet_default_is_none_for_a_cross_sheet_paste_that_still_uses_sheet() {
let verb = PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "'Q2'!D1".to_string(),
paste_type: PasteType::Normal,
};
assert_eq!(unusable_sheet_default(&verb), None);
}
#[test]
fn unusable_sheet_default_refuses_a_sheet_no_range_can_use() {
let verb = PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "'Q2'!A1:B2".to_string(),
destination: "'Q3'!D1".to_string(),
paste_type: PasteType::Normal,
};
let detail = unusable_sheet_default(&verb).expect("refusal");
assert!(detail.contains("--sheet 'Q1'"), "{detail}");
assert!(detail.contains("nothing to apply to"), "{detail}");
}
#[test]
fn unusable_sheet_default_refuses_a_sheet_beside_a_prefixed_paste_data_destination() {
let verb = PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "'Q2'!A1".to_string(),
data: "1".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
};
assert!(unusable_sheet_default(&verb).is_some());
}
#[test]
fn unusable_sheet_default_is_none_without_a_sheet_flag() {
let verb = PasteVerb::PasteData {
sheet: None,
destination: "'Q2'!A1".to_string(),
data: "1".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
};
assert_eq!(unusable_sheet_default(&verb), None);
}
#[test]
fn gate_operations_for_cut_paste_is_always_both() {
let verb = PasteVerb::CutPaste {
sheet: None,
source: "A1".to_string(),
destination: "B1".to_string(),
paste_type: PasteType::Values,
};
assert_eq!(verb.gate_operations(), GATE_BOTH);
}
#[test]
fn gate_operations_for_copy_paste_follows_the_paste_type_table() {
let verb_of = |paste_type| PasteVerb::CopyPaste {
sheet: None,
source: "A1".to_string(),
destination: "B1".to_string(),
paste_type,
orientation: PasteOrientation::Normal,
};
assert_eq!(verb_of(PasteType::Normal).gate_operations(), GATE_BOTH);
assert_eq!(
verb_of(PasteType::Values).gate_operations(),
GATE_WRITE_ONLY
);
assert_eq!(
verb_of(PasteType::Formula).gate_operations(),
GATE_WRITE_ONLY
);
assert_eq!(
verb_of(PasteType::Format).gate_operations(),
GATE_STRUCTURE_ONLY
);
}
#[test]
fn sheet_exceeds_dimensions_flags_an_extent_past_the_grid() {
let workbook: crate::drive::sheets::types::Spreadsheet =
serde_json::from_value(serde_json::json!({
"sheets": [
{"properties": {"sheetId": 1, "title": "Q1",
"gridProperties": {"rowCount": 10, "columnCount": 5}}},
],
}))
.unwrap();
assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 11, 0, 3)));
assert!(sheet_exceeds_dimensions(&workbook, &range(1, 0, 3, 0, 6)));
assert!(!sheet_exceeds_dimensions(&workbook, &range(1, 0, 10, 0, 5)));
}
#[test]
fn sheet_exceeds_dimensions_is_false_when_the_extents_sheet_is_unknown() {
let workbook: crate::drive::sheets::types::Spreadsheet =
serde_json::from_value(serde_json::json!({
"sheets": [
{"properties": {"sheetId": 1, "title": "Q1",
"gridProperties": {"rowCount": 10, "columnCount": 5}}},
],
}))
.unwrap();
assert!(!sheet_exceeds_dimensions(
&workbook,
&range(99, 0, 20, 0, 20)
));
}
#[test]
fn paste_data_upper_bound_with_an_empty_delimiter_counts_one_column() {
assert_eq!(paste_data_upper_bound("ab\ncd", ""), (2, 1));
}
#[test]
fn log_operation_is_defined_for_every_verb() {
for verb in every_paste_verb() {
assert!(!verb.log_operation().is_empty());
}
}
#[test]
fn log_status_is_defined_for_every_result_variant() {
for result in every_paste_result() {
assert!(!result.log_status().is_empty());
}
}
#[test]
fn from_lease_refusal_maps_every_refusal_kind() {
assert_eq!(PasteResult::from_no_lease(), PasteResult::RefusedNoLease);
assert_eq!(
PasteResult::from_lease_expired(),
PasteResult::RefusedLeaseExpired
);
assert_eq!(
PasteResult::from_lease_wrong_file(),
PasteResult::RefusedLeaseWrongFile
);
assert_eq!(
PasteResult::from_lease_stale(),
PasteResult::RefusedLeaseStale
);
assert_eq!(
PasteResult::from_lease_failed("boom".to_string()),
PasteResult::Failed {
detail: "boom".to_string()
}
);
}
#[test]
fn write_jsonl_serializes_the_outcome_as_one_json_line() {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: every_paste_verb().remove(0),
result: PasteResult::RefusedShortcut,
};
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("refused-shortcut"), "{text}");
}
#[test]
fn json_names_the_paste_type_and_copy_pastes_orientation() {
let expected = [
("PASTE_NORMAL", None),
("PASTE_VALUES", Some("TRANSPOSE")),
("PASTE_VALUES", None),
];
for (verb, (paste_type, orientation)) in every_paste_verb().into_iter().zip(expected) {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: None,
resolved_folder_id: None,
verb,
result: PasteResult::RefusedShortcut,
};
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["paste_type"], paste_type, "{json}");
assert_eq!(
json.get("orientation").and_then(|v| v.as_str()),
orientation,
"{json}"
);
assert!(json.get("file_name").is_none(), "{json}");
assert!(
json.get("data").is_none() && json.get("source").is_none(),
"{json}"
);
assert_eq!(json["result"]["status"], "refused-shortcut", "{json}");
}
}
#[test]
fn read_data_text_reads_a_local_file_verbatim() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("clip.tsv");
std::fs::write(&path, "a\tb\nc\td").unwrap();
let text = read_data_text(path.to_str().unwrap()).unwrap();
assert_eq!(text, "a\tb\nc\td");
}
#[test]
fn read_data_text_reports_a_missing_file_clearly() {
let err = read_data_text("/definitely/not/here.tsv").unwrap_err();
assert!(err.to_string().contains("Failed to stat"), "{err}");
}
#[test]
fn read_data_text_refuses_a_file_over_the_upload_cap() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge.tsv");
let oversize = vec![b'a'; (crate::drive::files_api::MAX_UPLOAD_BYTES + 1) as usize];
std::fs::write(&path, oversize).unwrap();
let err = read_data_text(path.to_str().unwrap()).unwrap_err();
assert!(err.to_string().contains("over the"), "{err}");
}
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
fn test_credentials() -> DriveCredentials {
DriveCredentials {
client_id: "client-1".to_string(),
client_secret: Secret::new("secret-1"),
refresh_token: Secret::new("refresh-1"),
scope: DriveGrantedScopes::READONLY,
}
}
async fn clients(server: &wiremock::MockServer) -> (DriveClient, SheetsClient) {
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path("/token"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-token", "expires_in": 3600,
})),
)
.mount(server)
.await;
let mut drive = DriveClient::new(&server.uri(), &test_credentials()).unwrap();
crate::drive::client::test_support::replace_session(
&mut drive,
&test_credentials(),
&format!("{}/token", server.uri()),
);
let env = MapEnv::new().with(SHEETS_API_URL, &server.uri());
let sheets = SheetsClient::from_drive_client_with(&env, &drive).unwrap();
(drive, sheets)
}
fn mount_file(id: &str, mime_type: &str, parents: &[&str]) -> wiremock::Mock {
let parents: Vec<&str> = parents.to_vec();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!("/drive/v3/files/{id}")))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": id, "name": id, "mimeType": mime_type, "parents": parents,
"version": "1",
})),
)
}
fn mount_folder(id: &str) -> wiremock::Mock {
mount_file(id, "application/vnd.google-apps.folder", &[])
}
async fn mount_workbook(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 1000, "columnCount": 26}}},
],
})),
)
.mount(server)
.await;
}
fn rules_allowing_everything() -> Vec<FolderPermissionRule> {
vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)]
}
fn allow_rule(folder_id: &str, ops: &[DriveOperation]) -> FolderPermissionRule {
FolderPermissionRule::folder(folder_id)
.allowing(ops.iter().copied())
.requiring_lease(false)
}
#[tokio::test]
async fn copy_paste_default_normal_is_blocked_naming_sheets_structure_under_write_only() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::Blocked { operation, .. } => {
assert_eq!(operation, DriveOperation::SheetsStructure);
}
other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
}
}
#[tokio::test]
async fn copy_paste_values_only_proceeds_under_a_write_only_grant() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::WouldChange(_)),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn format_only_copy_paste_dry_run_reads_no_values() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Format,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::WouldChange(change) => assert_eq!(change.overwritten, None),
other => panic!("expected WouldChange with no overwritten preview, got {other:?}"),
}
}
#[tokio::test]
async fn dry_run_never_calls_batch_update() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::WouldChange(_)));
}
#[tokio::test]
async fn cut_paste_always_needs_both_operations_even_for_values_only() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Values,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::Blocked { operation, .. } => {
assert_eq!(operation, DriveOperation::SheetsStructure);
}
other => panic!("expected Blocked naming sheets-structure, got {other:?}"),
}
}
#[tokio::test]
async fn cut_paste_refuses_a_multi_cell_destination() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("single cell"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn cut_paste_reports_destination_overwrite_and_source_clear_separately() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"range": "'Q1'!D1:E2", "values": [["x", ""], ["", "y"]],
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"range": "'Q1'!A1:B2", "values": [["1", "2"], ["3", "4"]],
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::WouldChange(change) => {
assert_eq!(
change.overwritten,
Some(vec!["D1".to_string(), "E2".to_string()])
);
assert_eq!(
change.cleared,
Some(vec![
"A1".to_string(),
"B1".to_string(),
"A2".to_string(),
"B2".to_string()
])
);
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn paste_data_defaults_and_real_run_succeeds() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_destination_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &[]).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--destination"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
assert_eq!(outcome.file_name, None);
}
#[tokio::test]
async fn a_source_with_no_sheet_and_no_prefix_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "A1:B2".to_string(),
destination: "'Q1'!D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &[]).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--source"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_metadata_fetch_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &[]).await;
assert!(matches!(outcome.result, PasteResult::Failed { .. }));
}
#[tokio::test]
async fn a_shortcut_target_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["folder-1"],
)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::RefusedShortcut));
}
#[tokio::test]
async fn a_non_spreadsheet_target_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.document",
&["folder-1"],
)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
PasteResult::RefusedNotASpreadsheet { .. }
));
}
#[tokio::test]
async fn a_target_with_no_visible_parents_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
PasteResult::RefusedNoVisibleParents
));
}
#[tokio::test]
async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::Failed { .. }));
}
#[tokio::test]
async fn a_workbook_fetch_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::Failed { .. }));
}
#[tokio::test]
async fn a_destination_naming_an_unknown_sheet_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!A1:B2".to_string(),
destination: "'Ghost'!D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn a_destination_with_an_unparseable_range_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!A1:B2".to_string(),
destination: "'Q1'!not a range".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn an_unbounded_destination_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!A1:B2".to_string(),
destination: "'Q1'!A:A".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("bounded"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_source_naming_an_unknown_sheet_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Ghost'!A1:B2".to_string(),
destination: "'Q1'!D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::RefusedSheetNotFound { title, .. } => assert_eq!(title, "Ghost"),
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn a_source_with_an_unparseable_range_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!not a range".to_string(),
destination: "'Q1'!D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::RefusedInvalidRange { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn an_unbounded_source_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: None,
source: "'Q1'!A:A".to_string(),
destination: "'Q1'!D1:E2".to_string(),
paste_type: PasteType::Normal,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(
detail.contains("bounded") && detail.contains("--source"),
"{detail}"
);
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_destination_past_the_sheets_allocated_grid_gets_a_caveat() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 5, "columnCount": 5}}},
],
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:F1".to_string(),
destination: "A1:B1".to_string(),
paste_type: PasteType::Format,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::WouldChange(change) => {
assert!(change.grid_edge_caveat.is_some(), "{change:?}");
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn a_values_get_failure_for_the_destination_preview_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::Failed { .. }));
}
#[tokio::test]
async fn a_values_get_failure_for_the_cut_paste_source_clear_preview_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::Failed { .. }));
}
#[tokio::test]
async fn paste_data_with_empty_data_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: String::new(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
assert_eq!(outcome.result, PasteResult::RefusedEmptyData);
let lines = describe_lines(&outcome).join("\n");
assert!(lines.contains("no data to paste"), "{lines}");
}
#[tokio::test]
async fn paste_data_with_an_empty_delimiter_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "a\tb".to_string(),
delimiter: String::new(),
paste_type: PasteType::Values,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
assert_eq!(
outcome.result,
PasteResult::RefusedInvalidDelimiter {
detail: "--delimiter must not be empty".to_string()
}
);
let lines = describe_lines(&outcome).join("\n");
assert!(lines.contains("--delimiter must not be empty"), "{lines}");
}
#[tokio::test]
async fn a_sheet_flag_no_range_can_use_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "'Q2'!A1:B2".to_string(),
destination: "'Q3'!D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
match outcome.result {
PasteResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--sheet 'Q1'"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_preview_read_is_clipped_to_the_grid_so_the_caveat_survives() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 5, "columnCount": 5}}},
],
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E1", "values": [["x"]]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:F1".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::WouldChange(change) => {
assert_eq!(change.written_extent, "'Q1'!D1:I1");
assert!(change.grid_edge_caveat.is_some(), "{change:?}");
assert_eq!(change.overwritten, Some(vec!["D1".to_string()]));
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn a_destination_entirely_past_the_grid_previews_no_overwrite_without_reading() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 5, "columnCount": 5}}},
],
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:A1".to_string(),
destination: "A20".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
match outcome.result {
PasteResult::WouldChange(change) => {
assert_eq!(change.overwritten, Some(Vec::new()));
assert!(change.grid_edge_caveat.is_some(), "{change:?}");
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn no_cell_value_ever_reaches_the_rendered_output() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"range": "'Q1'!D1:E2",
"values": [["destination-secret", ""], ["", "another-secret"]],
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"range": "'Q1'!A1:B2",
"values": [["source-secret", "x"], ["y", "z"]],
})),
)
.mount(&server)
.await;
let rules = rules_allowing_everything();
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
let rendered = describe_lines(&outcome).join("\n");
for secret in ["destination-secret", "another-secret", "source-secret"] {
assert!(
!rendered.contains(secret),
"{secret} leaked into: {rendered}"
);
}
assert!(
rendered.contains("2 non-blank cell(s) in the destination"),
"{rendered}"
);
assert!(
rendered.contains("in the destination would be overwritten: D1"),
"{rendered}"
);
assert!(
rendered.contains("4 non-blank cell(s) in the source"),
"{rendered}"
);
}
#[tokio::test]
async fn cut_paste_real_run_succeeds_and_reports_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn cut_paste_with_an_unreadable_reply_reports_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.expect(1)
.mount(&server)
.await;
let rules = vec![allow_rule(
"folder-1",
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure],
)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::AppliedReplyUnreadable { .. }),
"{:?}",
outcome.result
);
assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
let lines = describe_lines(&outcome);
assert!(
lines[0].starts_with("Applied, but the reply could not be read"),
"{lines:?}"
);
assert!(lines[0].contains("do not retry"), "{lines:?}");
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
let outcome = |result| PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: every_paste_verb().remove(0),
result,
};
let unreadable = describe_lines(&outcome(PasteResult::AppliedReplyUnreadable {
summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
change: change_for_unreadable(),
detail: "bad reply".to_string(),
}));
let changed = describe_lines(&outcome(PasteResult::Changed(change_for_unreadable())));
assert_eq!(
unreadable[0],
"Applied, but the reply could not be read: cut-paste into 'Q1'!D1 (writing \
'Q1'!D1:E2) in 'Budget' — do not retry; check the spreadsheet first (bad reply)"
);
assert!(unreadable.len() > 3, "{unreadable:?}");
assert_eq!(unreadable[1..], changed[1..]);
}
#[test]
fn an_unreadable_reply_serialises_the_change_fields_beside_the_status() {
let value = serde_json::to_value(PasteResult::AppliedReplyUnreadable {
summary: "s".to_string(),
change: change_for_unreadable(),
detail: "bad reply".to_string(),
})
.unwrap();
assert_eq!(value["status"], "applied-reply-unreadable");
assert_eq!(value["destination"], "'Q1'!D1");
assert_eq!(value["overwritten"], serde_json::json!(["D1"]));
assert_eq!(value["cleared"], serde_json::json!(["A1"]));
assert_eq!(value["detail"], "bad reply");
}
#[test]
fn an_unreadable_reply_logs_the_summary_as_fields_changed() {
let verb = every_paste_verb().remove(0);
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: verb.clone(),
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let record = mutation_record(
&PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb,
result: PasteResult::AppliedReplyUnreadable {
summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
change: change_for_unreadable(),
detail: "bad reply".to_string(),
},
},
&opts,
Duration::from_millis(1),
);
assert_eq!(
record.fields_changed.as_deref(),
Some("cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)")
);
assert_eq!(record.error.as_deref(), Some("bad reply"));
}
#[tokio::test]
async fn copy_paste_real_run_succeeds_and_reports_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
}
async fn sent_batch_update(server: &wiremock::MockServer) -> serde_json::Value {
let requests = server.received_requests().await.expect("recorded requests");
let body = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| r.body.clone())
.expect("a batchUpdate request");
serde_json::from_slice(&body).expect("a JSON body")
}
#[tokio::test]
async fn cut_paste_sends_the_source_range_and_a_destination_coordinate() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
for range in ["'Q1'!D1:E2", "'Q1'!A1:B2"] {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/sheet-1/values/{range}"
)))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": range, "values": []})),
)
.mount(&server)
.await;
}
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
assert_eq!(
sent_batch_update(&server).await,
serde_json::json!({
"requests": [{
"cutPaste": {
"source": {
"sheetId": 0,
"startRowIndex": 0, "endRowIndex": 2,
"startColumnIndex": 0, "endColumnIndex": 2,
},
"destination": {"sheetId": 0, "rowIndex": 0, "columnIndex": 3},
"pasteType": "PASTE_VALUES",
},
}],
})
);
}
#[tokio::test]
async fn copy_paste_sends_both_ranges_with_the_requested_orientation() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Transpose,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
assert_eq!(
sent_batch_update(&server).await,
serde_json::json!({
"requests": [{
"copyPaste": {
"source": {
"sheetId": 0,
"startRowIndex": 0, "endRowIndex": 2,
"startColumnIndex": 0, "endColumnIndex": 2,
},
"destination": {
"sheetId": 0,
"startRowIndex": 0, "endRowIndex": 2,
"startColumnIndex": 3, "endColumnIndex": 5,
},
"pasteType": "PASTE_VALUES",
"pasteOrientation": "TRANSPOSE",
},
}],
})
);
}
#[tokio::test]
async fn paste_data_sends_the_delimiter_form_verbatim() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"replies": [{}]})),
)
.mount(&server)
.await;
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2\n3\t4\n".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules_allowing_everything()).await;
assert!(
matches!(outcome.result, PasteResult::Changed(_)),
"{:?}",
outcome.result
);
assert_eq!(
sent_batch_update(&server).await,
serde_json::json!({
"requests": [{
"pasteData": {
"coordinate": {"sheetId": 0, "rowIndex": 0, "columnIndex": 0},
"data": "1\t2\n3\t4\n",
"delimiter": "\t",
"type": "PASTE_VALUES",
},
}],
})
);
}
#[tokio::test]
async fn a_batch_update_failure_is_reported_as_failed_not_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!D1:E2",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!D1:E2", "values": []})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsWrite])];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, PasteResult::Failed { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn paste_data_refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
)
.mount(&server)
.await;
let rules = vec![FolderPermissionRule::folder("folder-1")
.allowing([DriveOperation::SheetsWrite])
.requiring_lease(true)];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: Some("bogus-token".to_string()),
ledger_path,
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn paste_data_refuses_a_lease_bound_to_a_different_file() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
)
.mount(&server)
.await;
let rules = vec![FolderPermissionRule::folder("folder-1")
.allowing([DriveOperation::SheetsWrite])
.requiring_lease(true)];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = crate::drive::test_support::seed_lease(&ledger_path, "some-other-sheet", "1");
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::RefusedLeaseWrongFile));
}
#[tokio::test]
async fn paste_data_refuses_a_stale_lease_when_the_file_has_moved() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
)
.mount(&server)
.await;
let rules = vec![FolderPermissionRule::folder("folder-1")
.allowing([DriveOperation::SheetsWrite])
.requiring_lease(true)];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = crate::drive::test_support::seed_lease(&ledger_path, "sheet-1", "0");
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, PasteResult::RefusedLeaseStale));
}
#[tokio::test]
async fn a_required_lease_without_one_refuses_before_batch_update() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B1",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"range": "'Q1'!A1:B1", "values": []})),
)
.mount(&server)
.await;
let rules = vec![FolderPermissionRule::folder("folder-1")
.allowing([DriveOperation::SheetsWrite])
.requiring_lease(true)];
let opts = PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "A1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
dry_run: false,
lease_token: None,
ledger_path: PathBuf::from("/nonexistent/ledger.yaml"),
};
let outcome = paste(&drive, &sheets, &opts, &rules).await;
assert_eq!(outcome.result, PasteResult::RefusedNoLease);
}
#[tokio::test]
async fn dry_run_surfaces_the_same_blocked_reasoning_as_a_real_denied_run() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let rules = vec![allow_rule("folder-1", &[DriveOperation::SheetsStructure])];
let verb = PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
};
let dry = paste(
&drive,
&sheets,
&PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: verb.clone(),
dry_run: true,
lease_token: None,
ledger_path: PathBuf::new(),
},
&rules,
)
.await;
let real = paste(
&drive,
&sheets,
&PasteOptions {
spreadsheet_id: "sheet-1".to_string(),
verb,
dry_run: false,
lease_token: None,
ledger_path: PathBuf::new(),
},
&rules,
)
.await;
assert_eq!(dry.result, real.result);
}
fn change_for_unreadable() -> PasteChange {
PasteChange {
destination: "'Q1'!D1".to_string(),
source: Some("'Q1'!A1:B2".to_string()),
written_extent: "'Q1'!D1:E2".to_string(),
overwritten: Some(vec!["D1".to_string()]),
cleared: Some(vec!["A1".to_string()]),
grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
}
}
fn every_paste_result() -> Vec<PasteResult> {
let change = PasteChange {
destination: "'Q1'!D1".to_string(),
source: Some("'Q1'!A1:B2".to_string()),
written_extent: "'Q1'!D1:E2".to_string(),
overwritten: Some(vec!["D1".to_string()]),
cleared: Some(vec!["A1".to_string()]),
grid_edge_caveat: Some("runs past the sheet's allocated rows".to_string()),
};
vec![
PasteResult::WouldChange(change.clone()),
PasteResult::Changed(change),
PasteResult::RefusedNotASpreadsheet {
mime_type: "text/plain".to_string(),
},
PasteResult::RefusedShortcut,
PasteResult::RefusedNoVisibleParents,
PasteResult::RefusedSheetNotFound {
title: "Ghost".to_string(),
available: vec!["Q1".to_string()],
},
PasteResult::RefusedInvalidRange {
detail: "bad range".to_string(),
},
PasteResult::RefusedEmptyData,
PasteResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: None,
},
PasteResult::RefusedNoLease,
PasteResult::RefusedLeaseExpired,
PasteResult::RefusedLeaseWrongFile,
PasteResult::RefusedLeaseStale,
PasteResult::AppliedReplyUnreadable {
summary: "cut-paste into 'Q1'!D1 (writing 'Q1'!D1:E2)".to_string(),
change: change_for_unreadable(),
detail: "bad reply".to_string(),
},
PasteResult::Failed {
detail: "boom".to_string(),
},
]
}
fn every_paste_verb() -> Vec<PasteVerb> {
vec![
PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Transpose,
},
PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "D1".to_string(),
data: "1\t2".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
]
}
#[test]
fn no_describe_line_contains_a_control_character() {
for verb in every_paste_verb() {
for result in every_paste_result() {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: verb.clone(),
result,
};
for rendered in describe_lines(&outcome) {
assert!(
!rendered.chars().any(char::is_control),
"describe_lines emitted a control character for {:?}/{:?}: {rendered:?}",
outcome.verb,
outcome.result
);
}
}
}
}
#[test]
fn describe_lines_reports_no_overwritten_cells_when_the_extent_is_all_blank() {
let change = PasteChange {
destination: "'Q1'!D1".to_string(),
source: None,
written_extent: "'Q1'!D1:E2".to_string(),
overwritten: Some(vec![]),
cleared: None,
grid_edge_caveat: None,
};
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "D1".to_string(),
data: "1".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
result: PasteResult::WouldChange(change),
};
let lines = describe_lines(&outcome);
assert!(
lines.iter().any(|l| l.contains("no non-blank cells")),
"{lines:?}"
);
}
#[test]
fn describe_lines_elides_a_destination_overwrite_past_the_render_limit() {
let overwritten: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
let change = PasteChange {
destination: "'Q1'!D1".to_string(),
source: Some("'Q1'!A1:B60".to_string()),
written_extent: "'Q1'!D1:D60".to_string(),
overwritten: Some(overwritten.clone()),
cleared: None,
grid_edge_caveat: None,
};
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B60".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
result: PasteResult::WouldChange(change),
};
let rendered = describe_lines(&outcome).join("\n");
assert!(rendered.contains("60 non-blank cell(s)"), "{rendered}");
assert!(rendered.contains("… and 10 more"), "{rendered}");
assert_eq!(overwritten.len(), 60);
}
#[test]
fn describe_lines_elides_a_cut_paste_cleared_source_past_the_render_limit() {
let cleared: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
let change = PasteChange {
destination: "'Q1'!D1".to_string(),
source: Some("'Q1'!A1:B60".to_string()),
written_extent: "'Q1'!D1:D60".to_string(),
overwritten: Some(vec![]),
cleared: Some(cleared.clone()),
grid_edge_caveat: None,
};
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B60".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
result: PasteResult::WouldChange(change),
};
let rendered = describe_lines(&outcome).join("\n");
assert!(
rendered.contains("60 non-blank cell(s) in the source"),
"{rendered}"
);
assert!(rendered.contains("… and 10 more"), "{rendered}");
assert_eq!(cleared.len(), 60);
}
#[test]
fn describe_lines_notes_that_a_format_only_paste_previews_no_overwrite() {
let change = PasteChange {
destination: "'Q1'!D1:E2".to_string(),
source: Some("'Q1'!A1:B2".to_string()),
written_extent: "'Q1'!D1:E2".to_string(),
overwritten: None,
cleared: None,
grid_edge_caveat: None,
};
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Format,
orientation: PasteOrientation::Normal,
},
result: PasteResult::WouldChange(change),
};
let lines = describe_lines(&outcome);
assert!(
lines.iter().any(|l| l.contains("not previewed")),
"{lines:?}"
);
}
#[test]
fn describe_lines_reports_no_cells_to_clear_when_the_source_is_blank() {
let change = PasteChange {
destination: "'Q1'!D1".to_string(),
source: Some("'Q1'!A1:B2".to_string()),
written_extent: "'Q1'!D1:E2".to_string(),
overwritten: Some(vec![]),
cleared: Some(vec![]),
grid_edge_caveat: None,
};
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
result: PasteResult::WouldChange(change),
};
let lines = describe_lines(&outcome);
assert!(
lines
.iter()
.any(|l| l.contains("no non-blank cells to clear")),
"{lines:?}"
);
}
#[test]
fn describe_lines_falls_back_to_the_spreadsheet_id_when_no_file_name_is_known() {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: None,
resolved_folder_id: None,
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Values,
orientation: PasteOrientation::Normal,
},
result: PasteResult::RefusedShortcut,
};
let lines = describe_lines(&outcome);
assert!(lines[0].contains("'sheet-1'"), "{lines:?}");
}
#[test]
fn describe_lines_names_sheets_structure_alone_for_a_no_visible_parents_refusal_on_a_format_only_verb(
) {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CopyPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1:E2".to_string(),
paste_type: PasteType::Format,
orientation: PasteOrientation::Normal,
},
result: PasteResult::RefusedNoVisibleParents,
};
let lines = describe_lines(&outcome);
assert!(lines[0].contains("\"sheets-structure\""), "{lines:?}");
}
#[test]
fn describe_lines_reports_no_available_sheets_when_the_workbook_has_none() {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::PasteData {
sheet: Some("Q1".to_string()),
destination: "D1".to_string(),
data: "1".to_string(),
delimiter: "\t".to_string(),
paste_type: PasteType::Values,
},
result: PasteResult::RefusedSheetNotFound {
title: "Ghost".to_string(),
available: vec![],
},
};
let lines = describe_lines(&outcome);
assert!(lines[0].contains("Available: none"), "{lines:?}");
}
#[test]
fn describe_lines_names_the_deciding_folder_rule_when_present() {
let outcome = PasteOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: PasteVerb::CutPaste {
sheet: Some("Q1".to_string()),
source: "A1:B2".to_string(),
destination: "D1".to_string(),
paste_type: PasteType::Normal,
},
result: PasteResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".to_string(),
depth: 2,
}),
},
};
let lines = describe_lines(&outcome);
assert!(lines[0].contains("folder folder-1 (depth 2)"), "{lines:?}");
}
}