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, recovery_note, FromLeaseRefusal,
LeaseGateRefusal, LeasedWrite,
};
use crate::drive::lease::ledger::LeaseBackup;
use crate::drive::sheets::api::{
applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::format::{normalize_hex_for_display, parse_hex_color};
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
AddSheetRequest, BatchUpdateRequestItem, BatchUpdateResponse, ColorStyle,
DeleteDimensionRequest, DeleteRangeRequest, DeleteSheetRequest, Dimension, DimensionRange,
DuplicateSheetRequest, GridProperties, GridPropertiesUpdate, GridRange, InsertDimensionRequest,
InsertRangeRequest, IterativeCalculationSettings, MoveDimensionRequest, NewSheetProperties,
RecalculationInterval, SheetProperties, SheetPropertiesUpdate, ShiftDimension, Spreadsheet,
SpreadsheetPropertiesUpdate, UpdateSheetPropertiesRequest, UpdateSpreadsheetPropertiesRequest,
};
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone, PartialEq)]
pub enum StructureVerb {
AddSheet {
title: String,
index: Option<i64>,
rows: Option<i64>,
columns: Option<i64>,
},
RenameSheet {
sheet: String,
new_title: String,
},
InsertRows {
sheet: String,
at: i64,
count: i64,
},
InsertColumns {
sheet: String,
at: i64,
count: i64,
},
InsertRange {
sheet: String,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
},
MoveRows {
sheet: String,
at: i64,
count: i64,
before: i64,
},
MoveColumns {
sheet: String,
at: i64,
count: i64,
before: i64,
},
DeleteSheet {
sheet: String,
},
DeleteRows {
sheet: String,
at: i64,
count: i64,
},
DeleteColumns {
sheet: String,
at: i64,
count: i64,
},
DeleteRange {
sheet: String,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
},
DuplicateSheet {
sheet: String,
title: Option<String>,
index: Option<i64>,
},
ReorderSheet {
sheet: String,
index: i64,
},
SetSheetVisibility {
sheet: String,
hidden: bool,
},
UpdateSheetProperties {
sheet: String,
freeze_rows: Option<i64>,
freeze_columns: Option<i64>,
tab_color: Option<String>,
clear_tab_color: bool,
right_to_left: Option<bool>,
hide_gridlines: Option<bool>,
},
UpdateWorkbookProperties {
locale: Option<String>,
time_zone: Option<String>,
auto_recalc: Option<RecalculationInterval>,
iterative_calculation: Option<IterativeCalculationToggle>,
iterative_calculation_max_iterations: Option<i64>,
iterative_calculation_convergence_threshold: Option<f64>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IterativeCalculationToggle {
On,
Off,
}
impl StructureVerb {
const fn log_operation(&self) -> &'static str {
match self {
Self::AddSheet { .. } => "sheets-add-sheet",
Self::RenameSheet { .. } => "sheets-rename-sheet",
Self::InsertRows { .. } => "sheets-insert-rows",
Self::InsertColumns { .. } => "sheets-insert-columns",
Self::InsertRange { .. } => "sheets-insert-range",
Self::MoveRows { .. } => "sheets-move-rows",
Self::MoveColumns { .. } => "sheets-move-columns",
Self::DeleteSheet { .. } => "sheets-delete-sheet",
Self::DeleteRows { .. } => "sheets-delete-rows",
Self::DeleteColumns { .. } => "sheets-delete-columns",
Self::DeleteRange { .. } => "sheets-delete-range",
Self::DuplicateSheet { .. } => "sheets-duplicate-sheet",
Self::ReorderSheet { .. } => "sheets-reorder-sheet",
Self::SetSheetVisibility { hidden: true, .. } => "sheets-hide-sheet",
Self::SetSheetVisibility { hidden: false, .. } => "sheets-show-sheet",
Self::UpdateSheetProperties { .. } => "sheets-update-sheet-properties",
Self::UpdateWorkbookProperties { .. } => "sheets-update-workbook-properties",
}
}
const fn gate_operation(&self) -> DriveOperation {
match self {
Self::AddSheet { .. }
| Self::RenameSheet { .. }
| Self::InsertRows { .. }
| Self::InsertColumns { .. }
| Self::InsertRange { .. }
| Self::MoveRows { .. }
| Self::MoveColumns { .. }
| Self::DuplicateSheet { .. }
| Self::ReorderSheet { .. }
| Self::SetSheetVisibility { .. }
| Self::UpdateSheetProperties { .. }
| Self::UpdateWorkbookProperties { .. } => DriveOperation::SheetsStructure,
Self::DeleteSheet { .. }
| Self::DeleteRows { .. }
| Self::DeleteColumns { .. }
| Self::DeleteRange { .. } => DriveOperation::SheetsDelete,
}
}
const fn label(&self) -> &'static str {
match self {
Self::AddSheet { .. } => "add-sheet",
Self::RenameSheet { .. } => "rename-sheet",
Self::InsertRows { .. } => "insert-rows",
Self::InsertColumns { .. } => "insert-columns",
Self::InsertRange { .. } => "insert-range",
Self::MoveRows { .. } => "move-rows",
Self::MoveColumns { .. } => "move-columns",
Self::DeleteSheet { .. } => "delete-sheet",
Self::DeleteRows { .. } => "delete-rows",
Self::DeleteColumns { .. } => "delete-columns",
Self::DeleteRange { .. } => "delete-range",
Self::DuplicateSheet { .. } => "duplicate-sheet",
Self::ReorderSheet { .. } => "reorder-sheet",
Self::SetSheetVisibility { hidden: true, .. } => "hide-sheet",
Self::SetSheetVisibility { hidden: false, .. } => "show-sheet",
Self::UpdateSheetProperties { .. } => "update-sheet-properties",
Self::UpdateWorkbookProperties { .. } => "update-workbook-properties",
}
}
fn sheet_title(&self) -> Option<&str> {
match self {
Self::AddSheet { title, .. } => Some(title),
Self::RenameSheet { sheet, .. }
| Self::InsertRows { sheet, .. }
| Self::InsertColumns { sheet, .. }
| Self::InsertRange { sheet, .. }
| Self::MoveRows { sheet, .. }
| Self::MoveColumns { sheet, .. }
| Self::DeleteSheet { sheet }
| Self::DeleteRows { sheet, .. }
| Self::DeleteColumns { sheet, .. }
| Self::DeleteRange { sheet, .. }
| Self::DuplicateSheet { sheet, .. }
| Self::ReorderSheet { sheet, .. }
| Self::SetSheetVisibility { sheet, .. }
| Self::UpdateSheetProperties { sheet, .. } => Some(sheet),
Self::UpdateWorkbookProperties { .. } => None,
}
}
fn new_sheet_title(&self) -> Option<&String> {
match self {
Self::RenameSheet { new_title, .. } => Some(new_title),
Self::DuplicateSheet { title, .. } => title.as_ref(),
Self::AddSheet { .. }
| Self::InsertRows { .. }
| Self::InsertColumns { .. }
| Self::InsertRange { .. }
| Self::MoveRows { .. }
| Self::MoveColumns { .. }
| Self::DeleteSheet { .. }
| Self::DeleteRows { .. }
| Self::DeleteColumns { .. }
| Self::DeleteRange { .. }
| Self::ReorderSheet { .. }
| Self::SetSheetVisibility { .. }
| Self::UpdateSheetProperties { .. }
| Self::UpdateWorkbookProperties { .. } => None,
}
}
const fn dimension(&self) -> Option<Dimension> {
match self {
Self::InsertRows { .. } | Self::DeleteRows { .. } | Self::MoveRows { .. } => {
Some(Dimension::Rows)
}
Self::InsertColumns { .. } | Self::DeleteColumns { .. } | Self::MoveColumns { .. } => {
Some(Dimension::Columns)
}
Self::AddSheet { .. }
| Self::RenameSheet { .. }
| Self::DeleteSheet { .. }
| Self::InsertRange { .. }
| Self::DeleteRange { .. }
| Self::DuplicateSheet { .. }
| Self::ReorderSheet { .. }
| Self::SetSheetVisibility { .. }
| Self::UpdateSheetProperties { .. }
| Self::UpdateWorkbookProperties { .. } => None,
}
}
}
#[derive(Debug, Clone)]
pub struct StructureOptions {
pub spreadsheet_id: String,
pub verb: StructureVerb,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct SheetSnapshot {
#[serde(skip_serializing_if = "Option::is_none")]
pub sheet_id: Option<i64>,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub row_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub column_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frozen_row_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frozen_column_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_to_left: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hide_gridlines: Option<bool>,
}
impl SheetSnapshot {
fn from_properties(props: &SheetProperties) -> Self {
let grid = props.grid_properties.as_ref();
Self {
sheet_id: props.sheet_id,
title: props.title.clone(),
row_count: grid.and_then(|g| g.row_count),
column_count: grid.and_then(|g| g.column_count),
frozen_row_count: grid.and_then(|g| g.frozen_row_count),
frozen_column_count: grid.and_then(|g| g.frozen_column_count),
right_to_left: props.right_to_left,
hide_gridlines: grid.and_then(|g| g.hide_gridlines),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum StructureResult {
WouldChange {
#[serde(skip_serializing_if = "Option::is_none")]
sheet: Option<Box<SheetSnapshot>>,
sheet_count: usize,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedSheetExists {
title: String,
},
RefusedInvalidRange {
detail: String,
},
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Unchanged {
detail: String,
},
Changed {
#[serde(skip_serializing_if = "Option::is_none")]
sheet: Option<Box<SheetSnapshot>>,
#[serde(skip_serializing_if = "Option::is_none")]
sheet_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
backup: Option<Box<LeaseBackup>>,
},
AppliedReplyUnreadable {
summary: String,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
sheet: Option<Box<SheetSnapshot>>,
#[serde(skip_serializing_if = "Option::is_none")]
sheet_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
backup: Option<Box<LeaseBackup>>,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for StructureResult {
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 StructureResult {
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::RefusedSheetExists { .. } => "refused-sheet-exists",
Self::RefusedInvalidRange { .. } => "refused-invalid-range",
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::Unchanged { .. } => "unchanged",
Self::Changed { .. } => "changed",
Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct StructureOutcome {
pub spreadsheet_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_folder_id: Option<String>,
#[serde(skip)]
pub verb: StructureVerb,
pub result: StructureResult,
}
impl JsonlSerialize for StructureOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
fn dimension_range(sheet_id: i64, dimension: Dimension, at: i64, count: i64) -> DimensionRange {
let start = at - 1;
DimensionRange {
sheet_id,
dimension,
start_index: start,
end_index: start + count,
}
}
const fn move_destination_index(before: i64) -> i64 {
before - 1
}
const fn move_landing_span(before: i64, count: i64, last: i64) -> (i64, i64) {
if before > last {
(before - count, before - 1)
} else {
(before, before + count - 1)
}
}
pub async fn structure(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &StructureOptions,
rules: &[FolderPermissionRule],
) -> StructureOutcome {
let started = Instant::now();
let outcome = structure_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn structure_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &StructureOptions,
rules: &[FolderPermissionRule],
) -> StructureOutcome {
let bare = |result| StructureOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
drive,
&opts.spreadsheet_id,
opts.verb.gate_operation(),
rules,
)
.await
{
target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
return bare(StructureResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => StructureResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
StructureResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => StructureResult::RefusedNoVisibleParents,
};
return StructureOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
}
target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
return StructureOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result: StructureResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let gated = |result| StructureOutcome {
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 decision.verdict == write_gate::Verdict::Deny {
return gated(StructureResult::Blocked {
decided_by: decision.decided_by,
});
}
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return gated(StructureResult::Failed {
detail: format!("{err:#}"),
})
}
};
let sheet = match resolve_sheet(&workbook, &opts.verb) {
Ok(sheet) => sheet,
Err(result) => return gated(result),
};
let sheet_count = workbook.sheets.len();
if let Err(result) = validate_verb_args(&workbook, &opts.verb, sheet.as_ref()) {
return gated(result);
}
if let Some(detail) = already_applied(&workbook, &opts.verb, sheet.as_ref()) {
return gated(StructureResult::Unchanged { detail });
}
if opts.dry_run {
return gated(StructureResult::WouldChange {
sheet: sheet.map(Box::new),
sheet_count,
});
}
let request = match build_request(&opts.verb, sheet.as_ref()) {
Ok(request) => request,
Err(detail) => return gated(StructureResult::Failed { detail }),
};
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets structure",
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)) => StructureResult::Changed {
sheet_id: added_sheet_id(&response).or_else(|| sheet.as_ref().and_then(|s| s.sheet_id)),
sheet: sheet.map(Box::new),
backup: lease_grant
.as_ref()
.map(|grant| Box::new(grant.backup.clone())),
},
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
StructureResult::AppliedReplyUnreadable {
summary: applied_summary(&opts.verb),
detail,
sheet_id: if adds_a_sheet(&opts.verb) {
None
} else {
sheet.as_ref().and_then(|s| s.sheet_id)
},
sheet: sheet.map(Box::new),
backup: lease_grant
.as_ref()
.map(|grant| Box::new(grant.backup.clone())),
}
}
Err(err) => StructureResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn resolve_sheet(
workbook: &Spreadsheet,
verb: &StructureVerb,
) -> Result<Option<SheetSnapshot>, StructureResult> {
let Some(wanted) = verb.sheet_title() else {
return Ok(None);
};
let found = workbook
.sheets
.iter()
.filter_map(|sheet| sheet.properties.as_ref())
.find(|props| props.title == wanted);
match verb {
StructureVerb::AddSheet { .. } => match found {
Some(_) => Err(StructureResult::RefusedSheetExists {
title: wanted.to_string(),
}),
None => Ok(None),
},
StructureVerb::RenameSheet { new_title, .. } => match found {
Some(props) => {
let collides = new_title != wanted && workbook.has_sheet_titled(new_title);
if collides {
return Err(StructureResult::RefusedSheetExists {
title: new_title.clone(),
});
}
Ok(Some(SheetSnapshot::from_properties(props)))
}
None => Err(StructureResult::RefusedSheetNotFound {
title: wanted.to_string(),
available: workbook.sheet_titles(),
}),
},
StructureVerb::DuplicateSheet { title, .. } => match found {
Some(props) => {
if let Some(new_title) = title {
let collides = workbook.has_sheet_titled(new_title);
if collides {
return Err(StructureResult::RefusedSheetExists {
title: new_title.clone(),
});
}
}
Ok(Some(SheetSnapshot::from_properties(props)))
}
None => Err(StructureResult::RefusedSheetNotFound {
title: wanted.to_string(),
available: workbook.sheet_titles(),
}),
},
_ => match found {
Some(props) => Ok(Some(SheetSnapshot::from_properties(props))),
None => Err(StructureResult::RefusedSheetNotFound {
title: wanted.to_string(),
available: workbook.sheet_titles(),
}),
},
}
}
fn already_applied(
workbook: &Spreadsheet,
verb: &StructureVerb,
sheet: Option<&SheetSnapshot>,
) -> Option<String> {
let StructureVerb::SetSheetVisibility {
sheet: title,
hidden,
} = verb
else {
return None;
};
let target_id = sheet.and_then(|s| s.sheet_id)?;
let currently_hidden = workbook
.sheets
.iter()
.filter_map(|s| s.properties.as_ref())
.find(|props| props.sheet_id == Some(target_id))?
.hidden
.unwrap_or(false);
(currently_hidden == *hidden).then(|| {
let state = if *hidden { "hidden" } else { "shown" };
format!("sheet '{title}' is already {state}")
})
}
fn validate_verb_args(
workbook: &Spreadsheet,
verb: &StructureVerb,
sheet: Option<&SheetSnapshot>,
) -> Result<(), StructureResult> {
let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
match verb {
StructureVerb::AddSheet {
index,
rows,
columns,
..
} => {
if let Some(rows) = rows {
if *rows < 1 {
return invalid(format!("--rows must be at least 1, got {rows}"));
}
}
if let Some(columns) = columns {
if *columns < 1 {
return invalid(format!("--columns must be at least 1, got {columns}"));
}
}
if let Some(index) = index {
let max = workbook.sheets.len() as i64;
if *index < 0 || *index > max {
return invalid(format!(
"--index must be between 0 and {max} inclusive (the workbook has \
{max} sheet(s)), got {index}"
));
}
}
Ok(())
}
StructureVerb::RenameSheet { .. }
| StructureVerb::DeleteSheet { .. }
| StructureVerb::DuplicateSheet { index: None, .. }
| StructureVerb::SetSheetVisibility { hidden: false, .. } => Ok(()),
StructureVerb::InsertRows { at, count, .. } => {
validate_insert_bounds(Dimension::Rows, *at, *count, sheet)
}
StructureVerb::InsertColumns { at, count, .. } => {
validate_insert_bounds(Dimension::Columns, *at, *count, sheet)
}
StructureVerb::MoveRows {
at, count, before, ..
} => validate_move_bounds(Dimension::Rows, *at, *count, *before, sheet),
StructureVerb::MoveColumns {
at, count, before, ..
} => validate_move_bounds(Dimension::Columns, *at, *count, *before, sheet),
StructureVerb::DeleteRows { at, count, .. } => {
validate_delete_dimension_bounds(Dimension::Rows, *at, *count, sheet)
}
StructureVerb::DeleteColumns { at, count, .. } => {
validate_delete_dimension_bounds(Dimension::Columns, *at, *count, sheet)
}
StructureVerb::InsertRange {
start_row,
end_row,
start_column,
end_column,
..
}
| StructureVerb::DeleteRange {
start_row,
end_row,
start_column,
end_column,
..
} => validate_range_bounds(*start_row, *end_row, *start_column, *end_column, sheet),
StructureVerb::DuplicateSheet {
index: Some(index), ..
} => {
let max = workbook.sheets.len() as i64;
if *index < 0 || *index > max {
return invalid(format!(
"--index must be between 0 and {max} inclusive (the workbook has \
{max} sheet(s)), got {index}"
));
}
Ok(())
}
StructureVerb::ReorderSheet { index, .. } => {
let len = workbook.sheets.len() as i64;
let max = len - 1;
if *index < 0 || *index > max {
return invalid(format!(
"--index must be between 0 and {max} inclusive (the workbook has \
{len} sheet(s)), got {index}"
));
}
Ok(())
}
StructureVerb::SetSheetVisibility {
sheet: title,
hidden: true,
} => {
let target_id = sheet.and_then(|s| s.sheet_id);
let another_stays_visible = workbook
.sheets
.iter()
.filter_map(|s| s.properties.as_ref())
.filter(|props| props.sheet_id != target_id)
.any(|props| !props.hidden.unwrap_or(false));
if !another_stays_visible {
return invalid(format!(
"hiding '{title}' would leave the workbook with no visible sheets; \
Sheets requires at least one"
));
}
Ok(())
}
StructureVerb::UpdateSheetProperties {
sheet: _title,
freeze_rows,
freeze_columns,
tab_color,
clear_tab_color,
right_to_left,
hide_gridlines,
} => {
if freeze_rows.is_none()
&& freeze_columns.is_none()
&& tab_color.is_none()
&& !clear_tab_color
&& right_to_left.is_none()
&& hide_gridlines.is_none()
{
return invalid(
"update-sheet-properties requires at least one property to change".to_string(),
);
}
if tab_color.is_some() && *clear_tab_color {
return invalid(
"--tab-color and --clear-tab-color are mutually exclusive".to_string(),
);
}
if let Some(hex) = tab_color {
if let Err(detail) = parse_hex_color(hex) {
return invalid(detail);
}
}
if let Some(rows) = freeze_rows {
check_freeze_bound(
"--freeze-rows",
"row",
*rows,
sheet.and_then(|s| s.row_count),
)?;
}
if let Some(columns) = freeze_columns {
check_freeze_bound(
"--freeze-columns",
"column",
*columns,
sheet.and_then(|s| s.column_count),
)?;
}
Ok(())
}
StructureVerb::UpdateWorkbookProperties {
locale,
time_zone,
auto_recalc,
iterative_calculation,
iterative_calculation_max_iterations,
iterative_calculation_convergence_threshold,
} => {
let touches_iterative_settings = iterative_calculation_max_iterations.is_some()
|| iterative_calculation_convergence_threshold.is_some();
if touches_iterative_settings
&& *iterative_calculation != Some(IterativeCalculationToggle::On)
{
return invalid(
"--iterative-calculation-max-iterations/\
--iterative-calculation-convergence-threshold require \
--iterative-calculation on"
.to_string(),
);
}
if let Some(max_iterations) = iterative_calculation_max_iterations {
if *max_iterations < 1 {
return invalid(format!(
"--iterative-calculation-max-iterations must be at least 1, got \
{max_iterations}"
));
}
}
if let Some(threshold) = iterative_calculation_convergence_threshold {
if !threshold.is_finite() || *threshold <= 0.0 {
return invalid(format!(
"--iterative-calculation-convergence-threshold must be a positive, \
finite number, got {threshold}"
));
}
}
if let Some(tz) = time_zone {
if tz.parse::<chrono_tz::Tz>().is_err() {
return invalid(format!(
"--time-zone '{tz}' is not a recognised IANA time zone name \
(for example 'America/New_York' or 'Europe/London'); the \
Sheets API would silently reset the workbook to 'Etc/GMT' \
instead of rejecting it"
));
}
}
if locale.is_none()
&& time_zone.is_none()
&& auto_recalc.is_none()
&& iterative_calculation.is_none()
{
return invalid(
"update-workbook-properties needs at least one property to set \
(--locale, --time-zone, --auto-recalc, --iterative-calculation)"
.to_string(),
);
}
Ok(())
}
}
}
fn validate_span_start(dimension: Dimension, at: i64, count: i64) -> Result<i64, StructureResult> {
let invalid = |detail: String| StructureResult::RefusedInvalidRange { detail };
if count < 1 {
return Err(invalid(format!("--count must be at least 1, got {count}")));
}
if at < 1 {
return Err(invalid(format!("--at must be at least 1, got {at}")));
}
at.checked_sub(1)
.and_then(|start| start.checked_add(count))
.ok_or_else(|| {
invalid(format!(
"--at {at} with --count {count} overflows the {noun} index space",
noun = dimension.noun(),
))
})
}
fn check_freeze_bound(
flag: &str,
noun: &str,
value: i64,
current: Option<i64>,
) -> Result<(), StructureResult> {
if value < 0 {
return Err(StructureResult::RefusedInvalidRange {
detail: format!("{flag} must be at least 0, got {value}"),
});
}
if let Some(current) = current {
if value >= current {
return Err(StructureResult::RefusedInvalidRange {
detail: format!(
"{flag} {value} would freeze every {noun}; the sheet has {current} \
{noun}(s), so the most that can be frozen is {}",
current - 1
),
});
}
}
Ok(())
}
fn validate_insert_bounds(
dimension: Dimension,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
) -> Result<(), StructureResult> {
let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
validate_span_start(dimension, at, count)?;
let current = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
if let Some(current) = current {
let max_at = current + 1;
if at > max_at {
return invalid(format!(
"--at {at} is past the end of the sheet, which has {current} {noun}(s); \
the furthest valid position is {max_at}",
noun = dimension.noun(),
));
}
}
Ok(())
}
fn validate_move_bounds(
dimension: Dimension,
at: i64,
count: i64,
before: i64,
sheet: Option<&SheetSnapshot>,
) -> Result<(), StructureResult> {
let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
let last = validate_span_start(dimension, at, count)?;
if before < 1 {
return invalid(format!("--before must be at least 1, got {before}"));
}
if before >= at && before - 1 <= last {
return invalid(format!(
"--before {before} is inside or immediately after the block being moved \
({noun}s {at}-{last}), so nothing would move",
noun = dimension.noun(),
));
}
let current = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
if let Some(current) = current {
if last > current {
return invalid(format!(
"--at {at} with --count {count} reaches {noun} {last}, past the end of the \
sheet, which has {current} {noun}(s)",
noun = dimension.noun(),
));
}
let max_before = current + 1;
if before > max_before {
return invalid(format!(
"--before {before} is past the end of the sheet, which has {current} \
{noun}(s); the furthest valid position is {max_before}",
noun = dimension.noun(),
));
}
}
Ok(())
}
fn validate_delete_dimension_bounds(
dimension: Dimension,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
) -> Result<(), StructureResult> {
let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
let last = validate_span_start(dimension, at, count)?;
let current = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
if let Some(current) = current {
if last > current {
return invalid(format!(
"--at {at} with --count {count} reaches {noun} {last}, past the end of the \
sheet, which has {current} {noun}(s)",
noun = dimension.noun(),
));
}
}
Ok(())
}
fn validate_range_bounds(
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
sheet: Option<&SheetSnapshot>,
) -> Result<(), StructureResult> {
let invalid = |detail: String| Err(StructureResult::RefusedInvalidRange { detail });
if start_row < 1 {
return invalid(format!("--start-row must be at least 1, got {start_row}"));
}
if start_column < 1 {
return invalid(format!(
"--start-column must be at least 1, got {start_column}"
));
}
if end_row < start_row {
return invalid(format!(
"--end-row ({end_row}) must be at or after --start-row ({start_row})"
));
}
if end_column < start_column {
return invalid(format!(
"--end-column ({end_column}) must be at or after --start-column ({start_column})"
));
}
if let Some(current) = sheet.and_then(|s| s.row_count) {
if end_row > current {
return invalid(format!(
"--end-row {end_row} is past the end of the sheet, which has {current} row(s)"
));
}
}
if let Some(current) = sheet.and_then(|s| s.column_count) {
if end_column > current {
return invalid(format!(
"--end-column {end_column} is past the end of the sheet, which has {current} \
column(s)"
));
}
}
Ok(())
}
fn build_request(
verb: &StructureVerb,
sheet: Option<&SheetSnapshot>,
) -> Result<BatchUpdateRequestItem, String> {
let sheet_id = |verb_name: &str| -> Result<i64, String> {
sheet.and_then(|s| s.sheet_id).ok_or_else(|| {
format!("Sheets did not report a sheetId for the target sheet, so {verb_name} cannot address it")
})
};
match verb {
StructureVerb::AddSheet {
title,
index,
rows,
columns,
} => {
let grid_properties = (rows.is_some() || columns.is_some()).then_some(GridProperties {
row_count: *rows,
column_count: *columns,
..Default::default()
});
Ok(BatchUpdateRequestItem::AddSheet(AddSheetRequest {
properties: NewSheetProperties {
title: title.clone(),
index: *index,
grid_properties,
},
}))
}
StructureVerb::RenameSheet { new_title, .. } => Ok(
BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
properties: SheetPropertiesUpdate {
sheet_id: sheet_id("rename-sheet")?,
title: Some(new_title.clone()),
..Default::default()
},
fields: "title".to_string(),
}),
),
StructureVerb::InsertRows { at, count, .. } => Ok(BatchUpdateRequestItem::InsertDimension(
InsertDimensionRequest {
range: dimension_range(sheet_id("insert-rows")?, Dimension::Rows, *at, *count),
inherit_from_before: false,
},
)),
StructureVerb::InsertColumns { at, count, .. } => Ok(
BatchUpdateRequestItem::InsertDimension(InsertDimensionRequest {
range: dimension_range(
sheet_id("insert-columns")?,
Dimension::Columns,
*at,
*count,
),
inherit_from_before: false,
}),
),
StructureVerb::InsertRange {
start_row,
end_row,
start_column,
end_column,
shift,
..
} => Ok(BatchUpdateRequestItem::InsertRange(InsertRangeRequest {
range: grid_range(
sheet_id("insert-range")?,
*start_row,
*end_row,
*start_column,
*end_column,
),
shift_dimension: *shift,
})),
StructureVerb::MoveRows {
at, count, before, ..
} => Ok(BatchUpdateRequestItem::MoveDimension(
MoveDimensionRequest {
source: dimension_range(sheet_id("move-rows")?, Dimension::Rows, *at, *count),
destination_index: move_destination_index(*before),
},
)),
StructureVerb::MoveColumns {
at, count, before, ..
} => Ok(BatchUpdateRequestItem::MoveDimension(
MoveDimensionRequest {
source: dimension_range(sheet_id("move-columns")?, Dimension::Columns, *at, *count),
destination_index: move_destination_index(*before),
},
)),
StructureVerb::DeleteSheet { .. } => {
Ok(BatchUpdateRequestItem::DeleteSheet(DeleteSheetRequest {
sheet_id: sheet_id("delete-sheet")?,
}))
}
StructureVerb::DeleteRows { at, count, .. } => Ok(BatchUpdateRequestItem::DeleteDimension(
DeleteDimensionRequest {
range: dimension_range(sheet_id("delete-rows")?, Dimension::Rows, *at, *count),
},
)),
StructureVerb::DeleteColumns { at, count, .. } => Ok(
BatchUpdateRequestItem::DeleteDimension(DeleteDimensionRequest {
range: dimension_range(
sheet_id("delete-columns")?,
Dimension::Columns,
*at,
*count,
),
}),
),
StructureVerb::DeleteRange {
start_row,
end_row,
start_column,
end_column,
shift,
..
} => Ok(BatchUpdateRequestItem::DeleteRange(DeleteRangeRequest {
range: grid_range(
sheet_id("delete-range")?,
*start_row,
*end_row,
*start_column,
*end_column,
),
shift_dimension: *shift,
})),
StructureVerb::DuplicateSheet { title, index, .. } => Ok(
BatchUpdateRequestItem::DuplicateSheet(DuplicateSheetRequest {
source_sheet_id: sheet_id("duplicate-sheet")?,
insert_sheet_index: *index,
new_sheet_name: title.clone(),
}),
),
StructureVerb::ReorderSheet { index, .. } => Ok(
BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
properties: SheetPropertiesUpdate {
sheet_id: sheet_id("reorder-sheet")?,
index: Some(*index),
..Default::default()
},
fields: "index".to_string(),
}),
),
StructureVerb::SetSheetVisibility { hidden, .. } => Ok(
BatchUpdateRequestItem::UpdateSheetProperties(UpdateSheetPropertiesRequest {
properties: SheetPropertiesUpdate {
sheet_id: sheet_id(if *hidden { "hide-sheet" } else { "show-sheet" })?,
hidden: Some(*hidden),
..Default::default()
},
fields: "hidden".to_string(),
}),
),
StructureVerb::UpdateSheetProperties {
freeze_rows,
freeze_columns,
tab_color,
clear_tab_color,
right_to_left,
hide_gridlines,
..
} => {
let mut fields = Vec::new();
let mut grid = GridPropertiesUpdate::default();
if let Some(rows) = freeze_rows {
grid.frozen_row_count = Some(*rows);
fields.push("gridProperties.frozenRowCount");
}
if let Some(columns) = freeze_columns {
grid.frozen_column_count = Some(*columns);
fields.push("gridProperties.frozenColumnCount");
}
if let Some(hide) = hide_gridlines {
grid.hide_gridlines = Some(*hide);
fields.push("gridProperties.hideGridlines");
}
let mut tab_color_style = None;
if let Some(hex) = tab_color {
let color = parse_hex_color(hex)
.map_err(|detail| format!("update-sheet-properties: {detail}"))?;
tab_color_style = Some(ColorStyle::rgb(color));
fields.push("tabColorStyle");
} else if *clear_tab_color {
fields.push("tabColorStyle");
}
if right_to_left.is_some() {
fields.push("rightToLeft");
}
if fields.is_empty() {
return Err("update-sheet-properties: no property was set".to_string());
}
Ok(BatchUpdateRequestItem::UpdateSheetProperties(
UpdateSheetPropertiesRequest {
properties: SheetPropertiesUpdate {
sheet_id: sheet_id("update-sheet-properties")?,
grid_properties: (grid != GridPropertiesUpdate::default()).then_some(grid),
tab_color_style,
right_to_left: *right_to_left,
..Default::default()
},
fields: fields.join(","),
},
))
}
StructureVerb::UpdateWorkbookProperties {
locale,
time_zone,
auto_recalc,
iterative_calculation,
iterative_calculation_max_iterations,
iterative_calculation_convergence_threshold,
} => {
let mut properties = SpreadsheetPropertiesUpdate::default();
let mut fields = Vec::new();
if let Some(locale) = locale {
properties.locale = Some(locale.clone());
fields.push("locale");
}
if let Some(time_zone) = time_zone {
properties.time_zone = Some(time_zone.clone());
fields.push("timeZone");
}
if let Some(auto_recalc) = auto_recalc {
properties.auto_recalc = Some(*auto_recalc);
fields.push("autoRecalc");
}
match iterative_calculation {
Some(IterativeCalculationToggle::On) => {
properties.iterative_calculation_settings =
Some(IterativeCalculationSettings {
max_iterations: *iterative_calculation_max_iterations,
convergence_threshold: *iterative_calculation_convergence_threshold,
});
fields.push("iterativeCalculationSettings");
}
Some(IterativeCalculationToggle::Off) => {
fields.push("iterativeCalculationSettings");
}
None => {}
}
Ok(BatchUpdateRequestItem::UpdateSpreadsheetProperties(
UpdateSpreadsheetPropertiesRequest {
properties,
fields: fields.join(","),
},
))
}
}
}
fn workbook_properties_summary(verb: &StructureVerb) -> String {
let StructureVerb::UpdateWorkbookProperties {
locale,
time_zone,
auto_recalc,
iterative_calculation,
iterative_calculation_max_iterations,
iterative_calculation_convergence_threshold,
} = verb
else {
unreachable!("workbook_properties_summary called on a non-UpdateWorkbookProperties verb")
};
let mut parts = Vec::new();
if let Some(locale) = locale {
parts.push(format!("locale -> '{locale}'"));
}
if let Some(time_zone) = time_zone {
parts.push(format!("time zone -> '{time_zone}'"));
}
if let Some(auto_recalc) = auto_recalc {
parts.push(format!("auto-recalc -> {}", auto_recalc.as_str()));
}
match iterative_calculation {
Some(IterativeCalculationToggle::On) => {
let bounds = IterativeCalculationSettings {
max_iterations: *iterative_calculation_max_iterations,
convergence_threshold: *iterative_calculation_convergence_threshold,
}
.describe_bounds();
parts.push(format!("iterative calculation -> on{bounds}"));
}
Some(IterativeCalculationToggle::Off) => {
parts.push("iterative calculation -> off".to_string());
}
None => {}
}
parts.join(", ")
}
fn grid_range(
sheet_id: i64,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
) -> GridRange {
GridRange {
sheet_id,
start_row_index: Some(start_row - 1),
end_row_index: Some(end_row),
start_column_index: Some(start_column - 1),
end_column_index: Some(end_column),
}
}
fn added_sheet_id(response: &BatchUpdateResponse) -> Option<i64> {
response
.replies
.iter()
.find_map(|reply| reply.add_sheet.as_ref().or(reply.duplicate_sheet.as_ref()))
.and_then(|added| added.properties.as_ref())
.and_then(|props| props.sheet_id)
}
const fn adds_a_sheet(verb: &StructureVerb) -> bool {
matches!(
verb,
StructureVerb::AddSheet { .. } | StructureVerb::DuplicateSheet { .. }
)
}
fn applied_summary(verb: &StructureVerb) -> String {
let mut summary = verb.label().to_string();
if let Some(title) = verb.sheet_title() {
summary.push_str(&format!(" '{title}'"));
}
if let Some(new_title) = verb.new_sheet_title() {
summary.push_str(&format!(" to '{new_title}'"));
}
if let Some(span) = dimension_range_label(verb).or_else(|| grid_range_label(verb)) {
summary.push_str(&format!(" ({span})"));
}
summary
}
fn dimension_range_label(verb: &StructureVerb) -> Option<String> {
let dimension = verb.dimension()?;
let (at, count) = match verb {
StructureVerb::InsertRows { at, count, .. }
| StructureVerb::InsertColumns { at, count, .. }
| StructureVerb::DeleteRows { at, count, .. }
| StructureVerb::DeleteColumns { at, count, .. }
| StructureVerb::MoveRows { at, count, .. }
| StructureVerb::MoveColumns { at, count, .. } => (*at, *count),
_ => return None,
};
if count < 1 {
return None;
}
let end = at.checked_add(count).and_then(|end| end.checked_sub(1))?;
Some(dimension.span_label(at, end))
}
fn grid_range_label(verb: &StructureVerb) -> Option<String> {
match verb {
StructureVerb::InsertRange {
start_row,
end_row,
start_column,
end_column,
..
}
| StructureVerb::DeleteRange {
start_row,
end_row,
start_column,
end_column,
..
} => Some(format!(
"rows {start_row}-{end_row}, columns {start_column}-{end_column}"
)),
_ => None,
}
}
fn record_attempt(outcome: &StructureOutcome, opts: &StructureOptions, duration: Duration) {
request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
}
fn mutation_record(
outcome: &StructureOutcome,
opts: &StructureOptions,
duration: Duration,
) -> DriveMutationOutcome {
let error = match &outcome.result {
StructureResult::Failed { detail }
| StructureResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
_ => None,
};
let decided_by = match &outcome.result {
StructureResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let sheet_id = match &outcome.result {
StructureResult::Changed { sheet_id, .. }
| StructureResult::AppliedReplyUnreadable { sheet_id, .. } => *sheet_id,
StructureResult::WouldChange { sheet, .. } => sheet.as_ref().and_then(|s| s.sheet_id),
_ => None,
};
let move_to = match &opts.verb {
StructureVerb::MoveRows { before, .. } | StructureVerb::MoveColumns { before, .. } => {
Some(*before)
}
_ => None,
};
let fields_changed = match &opts.verb {
StructureVerb::UpdateWorkbookProperties { .. } => {
Some(workbook_properties_summary(&opts.verb))
}
_ => fields_changed_label(&opts.verb),
}
.or_else(|| match &outcome.result {
StructureResult::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,
sheet_id,
sheet_title: opts.verb.sheet_title().map(ToString::to_string),
sheet_new_title: opts.verb.new_sheet_title().map(ToString::to_string),
dimension_range: dimension_range_label(&opts.verb),
move_to,
grid_range: grid_range_label(&opts.verb),
fields_changed,
error,
duration,
..Default::default()
}
}
fn fields_changed_label(verb: &StructureVerb) -> Option<String> {
let StructureVerb::UpdateSheetProperties {
freeze_rows,
freeze_columns,
tab_color,
clear_tab_color,
right_to_left,
hide_gridlines,
..
} = verb
else {
return None;
};
let mut parts = Vec::new();
if let Some(rows) = freeze_rows {
parts.push(format!("frozenRowCount={rows}"));
}
if let Some(columns) = freeze_columns {
parts.push(format!("frozenColumnCount={columns}"));
}
if let Some(hide) = hide_gridlines {
parts.push(format!("hideGridlines={hide}"));
}
if let Some(hex) = tab_color {
parts.push(format!("tabColorStyle={hex}"));
} else if *clear_tab_color {
parts.push("tabColorStyle=cleared".to_string());
}
if let Some(rtl) = right_to_left {
parts.push(format!("rightToLeft={rtl}"));
}
if parts.is_empty() {
return None;
}
Some(parts.join(", "))
}
#[must_use]
pub fn describe(outcome: &StructureOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &StructureOutcome) -> 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 {
StructureResult::WouldChange { sheet, sheet_count } => {
describe_would_change(verb, sheet.as_deref(), *sheet_count, &book)
}
StructureResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets {}` only works on spreadsheets",
verb.label()
)],
StructureResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts — \
resolve the target spreadsheet's id and use that instead",
verb.label()
)],
StructureResult::RefusedNoVisibleParents => {
let op = verb.gate_operation();
vec![format!(
"Refused: {book} has no parent folder visible to this account, so no folder \
rule can apply to it. This is normal for a Sheet shared by link or email. \
Grant it by id instead: add {{\"file_id\": \"<spreadsheet id>\", \"allow\": \
[\"{op}\"]}} to write_permissions.rules. (Adding it to a folder in your own \
Drive and granting that folder `{op}` also works.)"
)]
}
StructureResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
StructureResult::RefusedSheetExists { title } => {
vec![format!(
"Refused: {book} already has a sheet titled '{title}'"
)]
}
StructureResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
StructureResult::Blocked { decided_by } => {
let op = verb.gate_operation();
let action = if matches!(op, DriveOperation::SheetsDelete) {
"destructive edits"
} else {
"structural edits"
};
vec![match decided_by {
Some(rule) => format!(
"Blocked: {action} to {book} refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: {action} to {book} refused by default policy (no matching rule \
for {op})"
),
}]
}
StructureResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
StructureResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
StructureResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
StructureResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
StructureResult::Changed {
sheet,
sheet_id,
backup,
} => {
vec![describe_changed(
verb,
sheet.as_deref(),
*sheet_id,
backup.as_deref(),
&book,
)]
}
StructureResult::AppliedReplyUnreadable {
summary,
detail,
sheet: _,
sheet_id,
backup,
} => {
let summary =
sheet_id.map_or_else(|| summary.clone(), |id| format!("{summary} (sheetId {id})"));
let mut first =
applied_reply_unreadable_line(&summary, &book, RetryHint::NotIdempotent, detail);
if matches!(verb.gate_operation(), DriveOperation::SheetsDelete) {
let recovery = recovery_note(
backup.as_deref(),
matches!(verb, StructureVerb::DeleteSheet { .. }),
);
first.push_str(&format!("; {recovery}"));
}
let mut lines = vec![first];
if adds_a_sheet(verb) {
lines.push(
" the new sheet's id was in the unreadable reply; check the spreadsheet's \
tabs to find it"
.to_string(),
);
}
lines
}
StructureResult::Unchanged { detail } => vec![format!("Unchanged: {detail} in {book}")],
StructureResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
fn describe_would_change(
verb: &StructureVerb,
sheet: Option<&SheetSnapshot>,
sheet_count: usize,
book: &str,
) -> Vec<String> {
let id = sheet
.and_then(|s| s.sheet_id)
.map_or_else(String::new, |id| format!(" (sheetId {id})"));
match verb {
StructureVerb::AddSheet {
title,
index,
rows,
columns,
} => {
let size = match (rows, columns) {
(Some(r), Some(c)) => format!(" ({r} x {c})"),
(Some(r), None) => format!(" ({r} rows)"),
(None, Some(c)) => format!(" ({c} columns)"),
(None, None) => String::new(),
};
let position = index.map_or_else(
|| " at the end".to_string(),
|index| format!(" at index {index}"),
);
vec![format!(
"Would add sheet '{title}'{size}{position} of {book} \
({sheet_count} sheet(s) -> {})",
sheet_count + 1
)]
}
StructureVerb::RenameSheet {
sheet: from,
new_title,
} => {
vec![format!(
"Would rename sheet '{from}'{id} to '{new_title}' in {book}"
)]
}
StructureVerb::InsertRows {
sheet: from,
at,
count,
} => describe_would_insert(Dimension::Rows, from, &id, *at, *count, sheet, book),
StructureVerb::InsertColumns {
sheet: from,
at,
count,
} => describe_would_insert(Dimension::Columns, from, &id, *at, *count, sheet, book),
StructureVerb::InsertRange {
sheet: from,
start_row,
end_row,
start_column,
end_column,
shift,
} => vec![describe_would_insert_range(
from,
&id,
*start_row,
*end_row,
*start_column,
*end_column,
*shift,
book,
)],
StructureVerb::MoveRows {
sheet: from,
at,
count,
before,
} => describe_would_move(
Dimension::Rows,
from,
&id,
*at,
*count,
*before,
sheet,
book,
),
StructureVerb::MoveColumns {
sheet: from,
at,
count,
before,
} => describe_would_move(
Dimension::Columns,
from,
&id,
*at,
*count,
*before,
sheet,
book,
),
StructureVerb::DeleteSheet { sheet: from } => {
vec![format!(
"Would delete sheet '{from}'{id} from {book} ({sheet_count} sheet(s) -> {}); \
{FORMULA_CAVEAT}",
sheet_count.saturating_sub(1)
)]
}
StructureVerb::DeleteRows {
sheet: from,
at,
count,
} => describe_would_delete_dimension(Dimension::Rows, from, &id, *at, *count, sheet, book),
StructureVerb::DeleteColumns {
sheet: from,
at,
count,
} => {
describe_would_delete_dimension(Dimension::Columns, from, &id, *at, *count, sheet, book)
}
StructureVerb::DeleteRange {
sheet: from,
start_row,
end_row,
start_column,
end_column,
shift,
} => vec![describe_would_delete_range(
from,
&id,
*start_row,
*end_row,
*start_column,
*end_column,
*shift,
book,
)],
StructureVerb::DuplicateSheet {
sheet: from,
title,
index,
} => {
let to = title.as_deref().map_or_else(
|| " as a copy Sheets names automatically".to_string(),
|title| format!(" as '{title}'"),
);
let position = index.map_or_else(
|| " at the front (Sheets' default; not the end)".to_string(),
|index| format!(" at index {index}"),
);
vec![format!(
"Would duplicate sheet '{from}'{id}{to}{position} of {book} \
({sheet_count} sheet(s) -> {})",
sheet_count + 1
)]
}
StructureVerb::ReorderSheet { sheet: from, index } => {
vec![format!(
"Would move sheet '{from}'{id} to index {index} in {book}"
)]
}
StructureVerb::SetSheetVisibility {
sheet: from,
hidden,
} => {
let verb_word = if *hidden { "hide" } else { "show" };
vec![format!("Would {verb_word} sheet '{from}'{id} in {book}")]
}
StructureVerb::UpdateSheetProperties {
sheet: from,
freeze_rows,
freeze_columns,
tab_color,
clear_tab_color,
right_to_left,
hide_gridlines,
} => {
let fragments = update_sheet_properties_fragments(
freeze_rows,
freeze_columns,
tab_color,
*clear_tab_color,
*right_to_left,
*hide_gridlines,
sheet,
);
vec![format!(
"Would update sheet '{from}'{id} in {book}: {}",
fragments.join(", ")
)]
}
StructureVerb::UpdateWorkbookProperties { .. } => vec![format!(
"Would update workbook properties of {book}: {}",
workbook_properties_summary(verb)
)],
}
}
fn before_after_fragment(
label: &str,
before: Option<impl std::fmt::Display>,
after: impl std::fmt::Display,
) -> String {
match before {
Some(before) => format!("{label} {before} -> {after}"),
None => format!("{label} -> {after}"),
}
}
fn gridlines_label(hidden: bool) -> &'static str {
if hidden {
"hidden"
} else {
"shown"
}
}
fn update_sheet_properties_fragments(
freeze_rows: &Option<i64>,
freeze_columns: &Option<i64>,
tab_color: &Option<String>,
clear_tab_color: bool,
right_to_left: Option<bool>,
hide_gridlines: Option<bool>,
sheet: Option<&SheetSnapshot>,
) -> Vec<String> {
let mut fragments = Vec::new();
if let Some(rows) = freeze_rows {
fragments.push(before_after_fragment(
"frozen rows",
sheet.and_then(|s| s.frozen_row_count),
rows,
));
}
if let Some(columns) = freeze_columns {
fragments.push(before_after_fragment(
"frozen columns",
sheet.and_then(|s| s.frozen_column_count),
columns,
));
}
if let Some(hex) = tab_color {
fragments.push(format!("tab color -> {}", normalize_hex_for_display(hex)));
} else if clear_tab_color {
fragments.push("tab color cleared".to_string());
}
if let Some(rtl) = right_to_left {
fragments.push(before_after_fragment(
"right-to-left",
sheet.and_then(|s| s.right_to_left),
rtl,
));
}
if let Some(hide) = hide_gridlines {
fragments.push(before_after_fragment(
"gridlines",
sheet.and_then(|s| s.hide_gridlines).map(gridlines_label),
gridlines_label(hide),
));
}
fragments
}
#[allow(clippy::too_many_arguments)]
fn describe_would_insert(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
book: &str,
) -> Vec<String> {
let summary = format!(
"Would insert {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}",
noun = dimension.noun(),
);
let before = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
let Some(before) = before else {
return vec![summary];
};
let Some(after) = before.checked_add(count) else {
return vec![summary];
};
let existing = if at <= before {
format!(
"; existing {plural} {at}-{before} shift {direction}",
plural = plural(dimension),
direction = shift_word(dimension, true),
)
} else {
format!(
"; appended at the end, no existing {plural} shift",
plural = plural(dimension)
)
};
vec![
summary,
format!(
" ({before} {plural} -> {after}{existing})",
plural = plural(dimension),
),
]
}
#[allow(clippy::too_many_arguments)]
fn describe_would_move(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
before: i64,
sheet: Option<&SheetSnapshot>,
book: &str,
) -> Vec<String> {
let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
return vec![format!(
"Would move {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
noun = dimension.noun(),
)];
};
let summary = format!(
"Would move {count} {noun}(s) {at}-{last} of '{from}'{id} in {book} to before \
{noun} {before}",
noun = dimension.noun(),
);
let current = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
let Some(current) = current else {
return vec![summary];
};
let (land_first, land_last) = move_landing_span(before, count, last);
let (shift_first, shift_last, shifted_first, shifted_last, forward) = if before > last {
(last + 1, before - 1, at, before - 1 - count, false)
} else {
(before, at - 1, before + count, at - 1 + count, true)
};
let detail = format!(
" ({current} {plural} unchanged; {plural} {shift_first}-{shift_last} shift \
{direction} to {shifted_first}-{shifted_last}; moved {plural} land at {land_first}-\
{land_last})",
plural = plural(dimension),
direction = shift_word(dimension, forward),
);
vec![summary, detail]
}
const FORMULA_CAVEAT: &str =
"formulas elsewhere in the workbook that reference this may break, which cannot be \
checked automatically";
const INSERT_RANGE_EDGE_CAVEAT_DRY_RUN: &str =
"cells pushed past the sheet's grid extent may be dropped by Sheets";
const INSERT_RANGE_EDGE_CAVEAT: &str =
"cells pushed past the sheet's grid extent may have been dropped by Sheets";
#[allow(clippy::too_many_arguments)]
fn describe_would_delete_dimension(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
book: &str,
) -> Vec<String> {
let Some(last) = at.checked_add(count).and_then(|end| end.checked_sub(1)) else {
return vec![format!(
"Would delete {count} {noun}(s) from {noun} {at} of '{from}'{id} in {book}",
noun = dimension.noun(),
)];
};
let summary = format!(
"Would delete {count} {noun}(s) {at}-{last} of '{from}'{id} in {book}",
noun = dimension.noun(),
);
let before = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
};
let Some(before) = before else {
return vec![summary];
};
let Some(after) = before.checked_sub(count) else {
return vec![summary];
};
let existing = if last < before {
format!(
"; existing {plural} {next}-{before} shift {direction}",
plural = plural(dimension),
next = last + 1,
direction = shift_word(dimension, false),
)
} else {
format!(
"; nothing remains after the deleted {plural}",
plural = plural(dimension)
)
};
vec![
summary,
format!(
" ({before} {plural} -> {after}{existing}; {FORMULA_CAVEAT})",
plural = plural(dimension),
),
]
}
#[allow(clippy::too_many_arguments)]
fn describe_would_delete_range(
from: &str,
id: &str,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
book: &str,
) -> String {
let direction = shift_word(shift.into(), false);
format!(
"Would delete rows {start_row}-{end_row}, columns {start_column}-{end_column} of \
'{from}'{id} in {book}, shifting remaining cells {direction} to close the gap; \
{FORMULA_CAVEAT}"
)
}
#[allow(clippy::too_many_arguments)]
fn describe_would_insert_range(
from: &str,
id: &str,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
book: &str,
) -> String {
let direction = shift_word(shift.into(), true);
format!(
"Would insert empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
of '{from}'{id} in {book}, shifting existing cells {direction}; \
{INSERT_RANGE_EDGE_CAVEAT_DRY_RUN}"
)
}
fn describe_changed(
verb: &StructureVerb,
sheet: Option<&SheetSnapshot>,
sheet_id: Option<i64>,
backup: Option<&LeaseBackup>,
book: &str,
) -> String {
let id = sheet_id.map_or_else(String::new, |id| format!(" (sheetId {id})"));
let recovery = recovery_note(backup, matches!(verb, StructureVerb::DeleteSheet { .. }));
match verb {
StructureVerb::AddSheet { title, .. } => {
format!("Added sheet '{title}'{id} to {book}")
}
StructureVerb::RenameSheet {
sheet: from,
new_title,
} => {
format!("Renamed sheet '{from}' to '{new_title}'{id} in {book}")
}
StructureVerb::InsertRows {
sheet: from,
at,
count,
} => describe_inserted(Dimension::Rows, from, &id, *at, *count, sheet, book),
StructureVerb::InsertColumns {
sheet: from,
at,
count,
} => describe_inserted(Dimension::Columns, from, &id, *at, *count, sheet, book),
StructureVerb::InsertRange {
sheet: from,
start_row,
end_row,
start_column,
end_column,
shift,
} => describe_inserted_range(
from,
&id,
*start_row,
*end_row,
*start_column,
*end_column,
*shift,
book,
),
StructureVerb::MoveRows {
sheet: from,
at,
count,
before,
} => describe_moved(Dimension::Rows, from, &id, *at, *count, *before, book),
StructureVerb::MoveColumns {
sheet: from,
at,
count,
before,
} => describe_moved(Dimension::Columns, from, &id, *at, *count, *before, book),
StructureVerb::DeleteSheet { sheet: from } => {
format!("Deleted sheet '{from}'{id} from {book}; {recovery}")
}
StructureVerb::DeleteRows {
sheet: from,
at,
count,
} => describe_deleted_dimension(
Dimension::Rows,
from,
&id,
*at,
*count,
sheet,
book,
&recovery,
),
StructureVerb::DeleteColumns {
sheet: from,
at,
count,
} => describe_deleted_dimension(
Dimension::Columns,
from,
&id,
*at,
*count,
sheet,
book,
&recovery,
),
StructureVerb::DeleteRange {
sheet: from,
start_row,
end_row,
start_column,
end_column,
shift,
} => describe_deleted_range(
from,
&id,
*start_row,
*end_row,
*start_column,
*end_column,
*shift,
book,
&recovery,
),
StructureVerb::DuplicateSheet {
sheet: from, title, ..
} => {
let to = title
.as_deref()
.map_or_else(String::new, |title| format!(" as '{title}'"));
format!("Duplicated sheet '{from}'{to}{id} in {book}")
}
StructureVerb::ReorderSheet { sheet: from, index } => {
format!("Moved sheet '{from}'{id} to index {index} in {book}")
}
StructureVerb::SetSheetVisibility {
sheet: from,
hidden,
} => {
let verb_word = if *hidden { "Hid" } else { "Showed" };
format!("{verb_word} sheet '{from}'{id} in {book}")
}
StructureVerb::UpdateSheetProperties {
sheet: from,
freeze_rows,
freeze_columns,
tab_color,
clear_tab_color,
right_to_left,
hide_gridlines,
} => {
let fragments = update_sheet_properties_fragments(
freeze_rows,
freeze_columns,
tab_color,
*clear_tab_color,
*right_to_left,
*hide_gridlines,
sheet,
);
format!(
"Updated sheet '{from}'{id} in {book}: {}",
fragments.join(", ")
)
}
StructureVerb::UpdateWorkbookProperties { .. } => format!(
"Updated workbook properties of {book}: {}",
workbook_properties_summary(verb)
),
}
}
#[allow(clippy::too_many_arguments)]
fn describe_inserted(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
book: &str,
) -> String {
let now = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
}
.and_then(|before| before.checked_add(count))
.map_or_else(String::new, |after| {
format!(" ({after} {} now)", plural(dimension))
});
format!(
"Inserted {count} {noun}(s) before {noun} {at} of '{from}'{id} in {book}{now}",
noun = dimension.noun(),
)
}
fn describe_moved(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
before: i64,
book: &str,
) -> String {
let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
let now = last.map_or_else(String::new, |last| {
let (land_first, land_last) = move_landing_span(before, count, last);
format!(" (now {} {land_first}-{land_last})", plural(dimension))
});
format!(
"Moved {count} {noun}(s) {range} of '{from}'{id} in {book} to before {noun} \
{before}{now}",
noun = dimension.noun(),
)
}
#[allow(clippy::too_many_arguments)]
fn describe_deleted_dimension(
dimension: Dimension,
from: &str,
id: &str,
at: i64,
count: i64,
sheet: Option<&SheetSnapshot>,
book: &str,
recovery: &str,
) -> String {
let last = at.checked_add(count).and_then(|end| end.checked_sub(1));
let range = last.map_or_else(|| at.to_string(), |last| format!("{at}-{last}"));
let now = match dimension {
Dimension::Rows => sheet.and_then(|s| s.row_count),
Dimension::Columns => sheet.and_then(|s| s.column_count),
}
.and_then(|before| before.checked_sub(count))
.map_or_else(String::new, |after| {
format!(" ({after} {} now)", plural(dimension))
});
format!(
"Deleted {count} {noun}(s) {range} of '{from}'{id} in {book}{now}; {recovery}",
noun = dimension.noun(),
)
}
#[allow(clippy::too_many_arguments)]
fn describe_deleted_range(
from: &str,
id: &str,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
book: &str,
recovery: &str,
) -> String {
let direction = shift_word(shift.into(), false);
format!(
"Deleted rows {start_row}-{end_row}, columns {start_column}-{end_column} of '{from}'{id} \
in {book}, shifted remaining cells {direction}; {recovery}"
)
}
#[allow(clippy::too_many_arguments)]
fn describe_inserted_range(
from: &str,
id: &str,
start_row: i64,
end_row: i64,
start_column: i64,
end_column: i64,
shift: ShiftDimension,
book: &str,
) -> String {
let direction = shift_word(shift.into(), true);
format!(
"Inserted empty cells at rows {start_row}-{end_row}, columns {start_column}-{end_column} \
of '{from}'{id} in {book}, shifting existing cells {direction}; \
{INSERT_RANGE_EDGE_CAVEAT}"
)
}
const fn plural(dimension: Dimension) -> &'static str {
match dimension {
Dimension::Rows => "rows",
Dimension::Columns => "columns",
}
}
const fn shift_word(dimension: Dimension, forward: bool) -> &'static str {
match (dimension, forward) {
(Dimension::Rows, true) => "down",
(Dimension::Rows, false) => "up",
(Dimension::Columns, true) => "right",
(Dimension::Columns, false) => "left",
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::drive::auth::{DriveCredentials, DriveGrantedScopes};
use crate::drive::sheets::client::SHEETS_API_URL;
use crate::drive::test_support::{seed_lease, seed_lease_with_backup};
use crate::drive::types::GOOGLE_SHEET_MIME_TYPE;
use crate::drive::write_gate::Verdict;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use std::collections::HashSet;
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", &[])
}
fn allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
fn workbook_body(q2: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {
"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 1000, "columnCount": 26}}},
q2,
],
})
}
fn default_q2() -> serde_json::Value {
serde_json::json!({"properties": {
"sheetId": 118_293, "title": "Q2", "index": 1,
"gridProperties": {"rowCount": 500, "columnCount": 10}}})
}
fn hidden_q2() -> serde_json::Value {
serde_json::json!({"properties": {
"sheetId": 118_293, "title": "Q2", "index": 1, "hidden": true,
"gridProperties": {"rowCount": 500, "columnCount": 10}}})
}
fn mount_workbook() -> wiremock::Mock {
mount_workbook_with_q2(default_q2())
}
fn mount_batch_update(body: serde_json::Value) -> wiremock::Mock {
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(body))
}
fn opts(verb: StructureVerb, dry_run: bool) -> StructureOptions {
opts_for("sheet-1", verb, dry_run)
}
fn opts_for(spreadsheet_id: &str, verb: StructureVerb, dry_run: bool) -> StructureOptions {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, spreadsheet_id, "1");
StructureOptions {
spreadsheet_id: spreadsheet_id.to_string(),
verb,
dry_run,
lease_token: Some(token),
ledger_path,
}
}
fn rename() -> StructureVerb {
StructureVerb::RenameSheet {
sheet: "Q2".to_string(),
new_title: "Q3".to_string(),
}
}
fn insert_rows() -> StructureVerb {
StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at: 5,
count: 3,
}
}
fn move_rows() -> StructureVerb {
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 2,
count: 2,
before: 6,
}
}
fn add_sheet() -> StructureVerb {
StructureVerb::AddSheet {
title: "Q3".to_string(),
index: None,
rows: None,
columns: None,
}
}
fn delete_sheet() -> StructureVerb {
StructureVerb::DeleteSheet {
sheet: "Q2".to_string(),
}
}
fn delete_rows() -> StructureVerb {
StructureVerb::DeleteRows {
sheet: "Q2".to_string(),
at: 5,
count: 3,
}
}
fn delete_columns() -> StructureVerb {
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: 3,
count: 2,
}
}
fn insert_range() -> StructureVerb {
StructureVerb::InsertRange {
sheet: "Q2".to_string(),
start_row: 2,
end_row: 4,
start_column: 2,
end_column: 3,
shift: ShiftDimension::Rows,
}
}
fn insert_range_columns_shift() -> StructureVerb {
StructureVerb::InsertRange {
sheet: "Q2".to_string(),
start_row: 2,
end_row: 4,
start_column: 2,
end_column: 3,
shift: ShiftDimension::Columns,
}
}
fn delete_range() -> StructureVerb {
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 2,
end_row: 4,
start_column: 2,
end_column: 3,
shift: ShiftDimension::Rows,
}
}
fn delete_range_columns_shift() -> StructureVerb {
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 2,
end_row: 4,
start_column: 2,
end_column: 3,
shift: ShiftDimension::Columns,
}
}
fn duplicate_sheet() -> StructureVerb {
StructureVerb::DuplicateSheet {
sheet: "Q2".to_string(),
title: Some("Q2 Copy".to_string()),
index: Some(1),
}
}
fn duplicate_sheet_default() -> StructureVerb {
StructureVerb::DuplicateSheet {
sheet: "Q2".to_string(),
title: None,
index: None,
}
}
fn reorder_sheet() -> StructureVerb {
StructureVerb::ReorderSheet {
sheet: "Q2".to_string(),
index: 0,
}
}
fn hide_sheet() -> StructureVerb {
StructureVerb::SetSheetVisibility {
sheet: "Q2".to_string(),
hidden: true,
}
}
fn show_sheet() -> StructureVerb {
StructureVerb::SetSheetVisibility {
sheet: "Q2".to_string(),
hidden: false,
}
}
fn update_workbook_properties() -> StructureVerb {
StructureVerb::UpdateWorkbookProperties {
locale: Some("en_US".to_string()),
time_zone: Some("America/New_York".to_string()),
auto_recalc: Some(RecalculationInterval::OnChange),
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
}
}
fn delete_allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsDelete).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
#[test]
fn every_delete_verb_gates_on_sheets_delete_not_sheets_structure() {
for verb in [
StructureVerb::DeleteSheet {
sheet: "Q1".to_string(),
},
StructureVerb::DeleteRows {
sheet: "Q1".to_string(),
at: 1,
count: 1,
},
StructureVerb::DeleteColumns {
sheet: "Q1".to_string(),
at: 1,
count: 1,
},
StructureVerb::DeleteRange {
sheet: "Q1".to_string(),
start_row: 1,
end_row: 2,
start_column: 1,
end_column: 2,
shift: ShiftDimension::Rows,
},
] {
assert_eq!(verb.gate_operation(), DriveOperation::SheetsDelete);
}
}
#[test]
fn every_additive_verb_still_gates_on_sheets_structure() {
for verb in [
add_sheet(),
StructureVerb::RenameSheet {
sheet: "Q1".to_string(),
new_title: "Q2".to_string(),
},
StructureVerb::InsertRows {
sheet: "Q1".to_string(),
at: 1,
count: 1,
},
StructureVerb::InsertColumns {
sheet: "Q1".to_string(),
at: 1,
count: 1,
},
StructureVerb::InsertRange {
sheet: "Q1".to_string(),
start_row: 1,
end_row: 2,
start_column: 1,
end_column: 2,
shift: ShiftDimension::Rows,
},
StructureVerb::MoveRows {
sheet: "Q1".to_string(),
at: 1,
count: 1,
before: 3,
},
StructureVerb::MoveColumns {
sheet: "Q1".to_string(),
at: 1,
count: 1,
before: 3,
},
StructureVerb::DuplicateSheet {
sheet: "Q1".to_string(),
title: None,
index: None,
},
StructureVerb::ReorderSheet {
sheet: "Q1".to_string(),
index: 0,
},
StructureVerb::SetSheetVisibility {
sheet: "Q1".to_string(),
hidden: true,
},
StructureVerb::UpdateSheetProperties {
sheet: "Q1".to_string(),
freeze_rows: None,
freeze_columns: None,
tab_color: None,
clear_tab_color: false,
right_to_left: Some(true),
hide_gridlines: None,
},
StructureVerb::UpdateWorkbookProperties {
locale: Some("en_US".to_string()),
time_zone: None,
auto_recalc: None,
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
},
] {
assert_eq!(verb.gate_operation(), DriveOperation::SheetsStructure);
}
}
#[test]
fn at_is_one_based_inclusive_and_becomes_zero_based_half_open() {
let range = dimension_range(7, Dimension::Rows, 5, 3);
assert_eq!(range.start_index, 4);
assert_eq!(range.end_index, 7);
assert_eq!(range.sheet_id, 7);
assert_eq!(range.dimension, Dimension::Rows);
}
#[test]
fn inserting_at_row_one_starts_at_index_zero() {
let range = dimension_range(0, Dimension::Rows, 1, 1);
assert_eq!(range.start_index, 0);
assert_eq!(range.end_index, 1);
}
#[test]
fn a_single_insert_spans_exactly_one_index() {
let range = dimension_range(0, Dimension::Columns, 3, 1);
assert_eq!((range.start_index, range.end_index), (2, 3));
}
#[test]
fn dimension_range_label_is_one_based_inclusive_like_the_flag() {
assert_eq!(
dimension_range_label(&insert_rows()).as_deref(),
Some("ROWS 5:7")
);
assert_eq!(
dimension_range_label(&StructureVerb::InsertColumns {
sheet: "Q2".to_string(),
at: 2,
count: 1,
})
.as_deref(),
Some("COLUMNS 2:2")
);
assert_eq!(dimension_range_label(&rename()), None);
assert_eq!(dimension_range_label(&add_sheet()), None);
}
#[test]
fn dimension_range_label_omits_a_span_it_cannot_compute() {
for (at, count) in [(i64::MAX, 5), (1, i64::MIN), (-1, i64::MIN)] {
assert_eq!(
dimension_range_label(&StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at,
count,
}),
None,
"--at {at} --count {count} must not produce a span"
);
}
}
#[tokio::test]
async fn non_spreadsheet_is_refused_before_any_gate_or_sheets_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", "application/pdf", &["parent-1"])
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedNotASpreadsheet { .. }
));
let text = describe(&outcome);
assert!(text.contains("is not a Google Sheet"), "{text}");
assert!(text.contains("drive sheets rename-sheet"), "{text}");
}
#[tokio::test]
async fn shortcut_is_refused_with_its_own_message() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["parent-1"],
)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::RefusedShortcut));
let text = describe(&outcome);
assert!(text.contains("is a shortcut"), "{text}");
}
#[tokio::test]
async fn a_sheet_with_no_visible_parents_is_refused_distinctly_from_blocked() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
assert!(matches!(
outcome.result,
StructureResult::RefusedNoVisibleParents
));
let text = describe(&outcome);
assert!(text.contains("no parent folder visible"), "{text}");
assert!(text.contains("sheets-structure"), "{text}");
}
#[tokio::test]
async fn a_delete_verbs_no_visible_parents_message_names_sheets_delete() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
let text = describe(&outcome);
assert!(text.contains("sheets-delete"), "{text}");
assert!(!text.contains("sheets-structure"), "{text}");
}
#[tokio::test]
async fn denied_target_makes_zero_sheets_calls() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
assert!(matches!(
outcome.result,
StructureResult::Blocked { decided_by: None }
));
let text = describe(&outcome);
assert!(text.contains("default policy"), "{text}");
}
#[tokio::test]
async fn a_blocked_delete_names_destructive_edits_and_sheets_delete() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let outcome = structure(&drive, &sheets, &opts(delete_sheet(), false), &[]).await;
assert!(matches!(
outcome.result,
StructureResult::Blocked { decided_by: None }
));
let text = describe(&outcome);
assert!(text.contains("destructive edits"), "{text}");
assert!(text.contains("default policy"), "{text}");
assert!(text.contains("sheets-delete"), "{text}");
assert!(!text.contains("structural edits"), "{text}");
}
#[tokio::test]
async fn a_sheets_write_rule_alone_does_not_permit_a_structural_edit() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}];
let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
}
#[tokio::test]
async fn a_sheets_structure_rule_alone_does_not_permit_deletion() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [allow_rule("parent-1")]; for verb in [delete_sheet(), delete_rows(), delete_range()] {
let outcome = structure(&drive, &sheets, &opts(verb, false), &rules).await;
assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
}
}
#[tokio::test]
async fn a_sheets_delete_rule_alone_does_not_permit_a_structural_edit() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [delete_allow_rule("parent-1")];
let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
assert!(matches!(outcome.result, StructureResult::Blocked { .. }));
}
#[tokio::test]
async fn an_explicit_deny_is_reported_with_the_deciding_rule() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let rules = [FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: HashSet::default(),
deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
require_lease: true,
}];
let outcome = structure(&drive, &sheets, &opts(rename(), false), &rules).await;
let StructureResult::Blocked { decided_by } = &outcome.result else {
panic!("expected Blocked, got {:?}", outcome.result);
};
let rule = decided_by.as_ref().expect("an explicit rule decided this");
assert_eq!(rule.kind_label(), "folder");
assert_eq!(rule.id(), "parent-1");
assert_eq!(rule.depth_suffix(), " (depth 0)");
}
#[tokio::test]
async fn ancestor_chain_fetch_failure_produces_failed_not_allow() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Failed { .. }));
}
#[tokio::test]
async fn dry_run_reads_the_workbook_but_never_calls_batch_update() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().expect(1).mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(insert_rows(), true),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
}
#[tokio::test]
async fn dry_run_names_the_resulting_dimensions_and_the_shift() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(insert_rows(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(
text.contains("Would insert 3 row(s) before row 5"),
"{text}"
);
assert!(text.contains("500 rows -> 503"), "{text}");
assert!(text.contains("shift down"), "{text}");
assert!(text.contains("sheetId 118293"), "{text}");
}
#[tokio::test]
async fn dry_run_never_calls_batch_update_for_a_delete_verb() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
for verb in [delete_sheet(), delete_rows(), delete_range()] {
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
}
}
#[tokio::test]
async fn dry_run_names_the_deleted_dimensions_and_the_shift_plus_the_caveat() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_rows(), true),
&[delete_allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would delete 3 row(s) 5-7"), "{text}");
assert!(text.contains("500 rows -> 497"), "{text}");
assert!(text.contains("shift up"), "{text}");
assert!(
text.contains("formulas elsewhere in the workbook"),
"{text}"
);
}
#[tokio::test]
async fn dry_run_names_the_deleted_sheet_and_the_caveat() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_sheet(), true),
&[delete_allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would delete sheet 'Q2'"), "{text}");
assert!(text.contains("2 sheet(s) -> 1"), "{text}");
assert!(
text.contains("formulas elsewhere in the workbook"),
"{text}"
);
}
#[tokio::test]
async fn dry_run_names_the_deleted_range_and_the_shift() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_range(), true),
&[delete_allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(
text.contains("Would delete rows 2-4, columns 2-3"),
"{text}"
);
assert!(text.contains("shifting remaining cells up"), "{text}");
}
#[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", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
let dry = structure(&drive, &sheets, &opts(rename(), true), &[]).await;
let wet = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
assert_eq!(dry.result, wet.result);
assert!(matches!(dry.result, StructureResult::Blocked { .. }));
}
#[tokio::test]
async fn a_missing_sheet_is_refused_and_lists_what_does_exist() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::RenameSheet {
sheet: "Nope".to_string(),
new_title: "Q3".to_string(),
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedSheetNotFound { .. }
));
let text = describe(&outcome);
assert!(text.contains("no sheet titled 'Nope'"), "{text}");
assert!(text.contains("'Q1'"), "{text}");
assert!(text.contains("'Q2'"), "{text}");
}
#[tokio::test]
async fn add_sheet_refuses_a_title_the_workbook_already_uses() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::AddSheet {
title: "Q1".to_string(),
index: None,
rows: None,
columns: None,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedSheetExists { .. }
));
assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
}
#[tokio::test]
async fn rename_sheet_refuses_a_title_another_sheet_already_uses() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::RenameSheet {
sheet: "Q2".to_string(),
new_title: "Q1".to_string(),
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedSheetExists { .. }
));
assert!(describe(&outcome).contains("already has a sheet titled 'Q1'"));
let dry = structure(
&drive,
&sheets,
&opts(verb.clone(), true),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(dry.result, outcome.result);
}
#[tokio::test]
async fn rename_sheet_to_its_own_current_title_is_not_a_duplicate() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let verb = StructureVerb::RenameSheet {
sheet: "Q2".to_string(),
new_title: "Q2".to_string(),
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
}
#[tokio::test]
async fn insert_rows_refuses_a_count_below_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at: 5,
count: 0,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--count"), "{detail}");
assert!(describe(&outcome).starts_with("Refused: --count"));
}
#[tokio::test]
async fn insert_columns_refuses_an_at_below_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::InsertColumns {
sheet: "Q2".to_string(),
at: 0,
count: 1,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--at"), "{detail}");
}
#[tokio::test]
async fn insert_rows_refuses_an_at_past_the_sheets_end() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at: 502,
count: 1,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("500 row(s)"), "{detail}");
assert!(detail.contains("501"), "{detail}");
let dry = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(dry.result, outcome.result);
}
#[test]
fn an_at_and_count_that_would_overflow_the_index_space_are_refused() {
let sheet = SheetSnapshot {
sheet_id: Some(1),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
};
for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
let Err(StructureResult::RefusedInvalidRange { detail }) =
validate_insert_bounds(Dimension::Rows, at, count, Some(&sheet))
else {
panic!("expected RefusedInvalidRange for --at {at} --count {count}");
};
assert!(detail.contains("overflows the row index space"), "{detail}");
}
}
#[tokio::test]
async fn a_refused_out_of_range_insert_still_records_without_panicking() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at: i64::MAX,
count: 5,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn insert_rows_allows_at_equal_to_row_count_plus_one_as_an_append() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::InsertRows {
sheet: "Q2".to_string(),
at: 501,
count: 2,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), true),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
let text = describe(&outcome);
assert!(text.contains("500 rows -> 502"), "{text}");
assert!(text.contains("appended at the end"), "{text}");
assert!(!text.contains("501-500"), "{text}");
}
#[tokio::test]
async fn add_sheet_refuses_non_positive_rows_and_columns() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::AddSheet {
title: "Q3".to_string(),
index: None,
rows: Some(0),
columns: None,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--rows"), "{detail}");
}
#[tokio::test]
async fn add_sheet_refuses_an_out_of_range_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::AddSheet {
title: "Q3".to_string(),
index: Some(3),
rows: None,
columns: None,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--index"), "{detail}");
}
#[tokio::test]
async fn add_sheet_allows_an_index_equal_to_the_sheet_count() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::AddSheet {
title: "Q3".to_string(),
index: Some(2),
rows: None,
columns: None,
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
}
#[tokio::test]
async fn rename_sends_a_title_masked_update_for_the_resolved_sheet_id() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["properties"]["sheetId"], 118_293);
assert_eq!(update["properties"]["title"], "Q3");
assert_eq!(update["fields"], "title");
assert_eq!(body["requests"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn insert_rows_sends_a_zero_based_half_open_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(insert_rows(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let insert = &body["requests"][0]["insertDimension"];
assert_eq!(insert["range"]["dimension"], "ROWS");
assert_eq!(insert["range"]["sheetId"], 118_293);
assert_eq!(insert["range"]["startIndex"], 4);
assert_eq!(insert["range"]["endIndex"], 7);
assert_eq!(insert["inheritFromBefore"], false);
}
#[tokio::test]
async fn delete_sheet_sends_the_resolved_sheet_id() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_sheet(), false),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
assert!(describe(&outcome).contains("Deleted sheet 'Q2'"));
assert!(describe(&outcome).contains("backed the file up when it was acquired"));
assert!(describe(&outcome).contains("/tmp/test-backup"));
assert!(describe(&outcome).contains("Google Drive's own version history"));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
assert_eq!(body["requests"][0]["deleteSheet"]["sheetId"], 118_293);
assert_eq!(body["requests"].as_array().unwrap().len(), 1);
}
#[tokio::test]
async fn delete_rows_sends_a_zero_based_half_open_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_rows(), false),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let delete = &body["requests"][0]["deleteDimension"];
assert_eq!(delete["range"]["dimension"], "ROWS");
assert_eq!(delete["range"]["sheetId"], 118_293);
assert_eq!(delete["range"]["startIndex"], 4);
assert_eq!(delete["range"]["endIndex"], 7);
}
#[tokio::test]
async fn delete_columns_sends_a_zero_based_half_open_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_columns(), false),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
assert!(describe(&outcome).contains("Deleted 2 column(s) 3-4"));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let delete = &body["requests"][0]["deleteDimension"];
assert_eq!(delete["range"]["dimension"], "COLUMNS");
assert_eq!(delete["range"]["sheetId"], 118_293);
assert_eq!(delete["range"]["startIndex"], 2);
assert_eq!(delete["range"]["endIndex"], 4);
}
#[tokio::test]
async fn insert_range_sends_a_zero_based_half_open_grid_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(insert_range(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
assert!(describe(&outcome).contains("shifting existing cells down"));
assert!(describe(&outcome).contains("may have been dropped by Sheets"));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
assert_eq!(
body["requests"][0]["insertRange"],
serde_json::json!({
"range": {
"sheetId": 118_293,
"startRowIndex": 1,
"endRowIndex": 4,
"startColumnIndex": 1,
"endColumnIndex": 3,
},
"shiftDimension": "ROWS",
})
);
}
#[tokio::test]
async fn insert_range_with_columns_shift_sends_the_column_direction() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(insert_range_columns_shift(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
assert!(describe(&outcome).contains("shifting existing cells right"));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
assert_eq!(
body["requests"][0]["insertRange"]["shiftDimension"],
"COLUMNS"
);
}
#[tokio::test]
async fn insert_range_dry_run_at_the_grid_edge_warns_without_mutating() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::InsertRange {
sheet: "Q2".to_string(),
start_row: 500,
end_row: 500,
start_column: 10,
end_column: 10,
shift: ShiftDimension::Columns,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
let text = describe(&outcome);
assert!(text.contains("shifting existing cells right"), "{text}");
assert!(text.contains("may be dropped by Sheets"), "{text}");
assert!(server
.received_requests()
.await
.unwrap()
.iter()
.all(|request| !request.url.path().ends_with(":batchUpdate")));
}
#[tokio::test]
async fn insert_range_dry_run_with_rows_shift_warns_without_mutating() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::InsertRange {
sheet: "Q2".to_string(),
start_row: 500,
end_row: 500,
start_column: 10,
end_column: 10,
shift: ShiftDimension::Rows,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::WouldChange { .. }
));
let text = describe(&outcome);
assert!(text.contains("shifting existing cells down"), "{text}");
assert!(text.contains("may be dropped by Sheets"), "{text}");
assert!(server
.received_requests()
.await
.unwrap()
.iter()
.all(|request| !request.url.path().ends_with(":batchUpdate")));
}
#[tokio::test]
async fn delete_range_sends_a_zero_based_half_open_grid_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_range(), false),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let delete = &body["requests"][0]["deleteRange"];
assert_eq!(delete["range"]["sheetId"], 118_293);
assert_eq!(delete["range"]["startRowIndex"], 1);
assert_eq!(delete["range"]["endRowIndex"], 4);
assert_eq!(delete["range"]["startColumnIndex"], 1);
assert_eq!(delete["range"]["endColumnIndex"], 3);
assert_eq!(delete["shiftDimension"], "ROWS");
}
#[tokio::test]
async fn delete_range_with_columns_shift_sends_the_column_direction() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(delete_range_columns_shift(), false),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
assert!(describe(&outcome).contains("shifted remaining cells left"));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
assert_eq!(
body["requests"][0]["deleteRange"]["shiftDimension"],
"COLUMNS"
);
}
#[tokio::test]
async fn delete_dimension_past_the_end_of_the_sheet_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRows {
sheet: "Q2".to_string(),
at: 499,
count: 5,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn delete_range_with_end_before_start_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 5,
end_row: 2,
start_column: 1,
end_column: 2,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn delete_dimension_refuses_a_non_positive_count() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRows {
sheet: "Q2".to_string(),
at: 1,
count: 0,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--count must be at least 1"), "{detail}");
}
#[tokio::test]
async fn delete_dimension_refuses_an_at_below_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: 0,
count: 1,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("--at must be at least 1"), "{detail}");
}
#[tokio::test]
async fn delete_dimension_refuses_an_at_and_count_that_overflow_the_index_space() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRows {
sheet: "Q2".to_string(),
at: i64::MAX,
count: 2,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("overflows the row index space"), "{detail}");
}
#[tokio::test]
async fn delete_range_refuses_a_start_row_below_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 0,
end_row: 2,
start_column: 1,
end_column: 2,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--start-row must be at least 1"),
"{detail}"
);
}
#[tokio::test]
async fn delete_range_refuses_a_start_column_below_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 1,
end_row: 2,
start_column: 0,
end_column: 2,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--start-column must be at least 1"),
"{detail}"
);
}
#[tokio::test]
async fn delete_range_refuses_an_end_column_before_start_column() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 1,
end_row: 2,
start_column: 5,
end_column: 2,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--end-column (2) must be at or after --start-column (5)"),
"{detail}"
);
}
#[tokio::test]
async fn delete_range_refuses_an_end_row_past_the_end_of_the_sheet() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 1,
end_row: 501,
start_column: 1,
end_column: 2,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--end-row 501 is past the end of the sheet"),
"{detail}"
);
}
#[tokio::test]
async fn delete_range_refuses_an_end_column_past_the_end_of_the_sheet() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DeleteRange {
sheet: "Q2".to_string(),
start_row: 1,
end_row: 2,
start_column: 1,
end_column: 11,
shift: ShiftDimension::Rows,
},
true,
),
&[delete_allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--end-column 11 is past the end of the sheet"),
"{detail}"
);
}
#[tokio::test]
async fn add_sheet_reports_the_server_assigned_sheet_id() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({
"spreadsheetId": "sheet-1",
"replies": [{"addSheet": {"properties": {"sheetId": 999, "title": "Q3"}}}],
}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(add_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::Changed {
sheet_id: Some(999),
..
}
));
assert!(describe(&outcome).contains("sheetId 999"));
}
#[tokio::test]
async fn add_sheet_omits_grid_properties_when_no_size_was_asked_for() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let _ = structure(
&drive,
&sheets,
&opts(add_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let props = &body["requests"][0]["addSheet"]["properties"];
assert!(props.get("gridProperties").is_none(), "{props}");
assert!(props.get("index").is_none(), "{props}");
assert_eq!(props["title"], "Q3");
}
#[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", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": {
"code": 403,
"message": "The caller does not have permission",
"status": "PERMISSION_DENIED",
}
})),
)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::Failed { detail } = &outcome.result else {
panic!("expected Failed, got {:?}", outcome.result);
};
assert!(detail.contains("drive auth login"), "{detail}");
}
#[tokio::test]
async fn duplicate_sheet_apply_sends_source_title_and_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(duplicate_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let dup = &body["requests"][0]["duplicateSheet"];
assert_eq!(dup["sourceSheetId"], 118_293);
assert_eq!(dup["insertSheetIndex"], 1);
assert_eq!(dup["newSheetName"], "Q2 Copy");
}
#[tokio::test]
async fn duplicate_sheet_apply_with_default_title_and_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(duplicate_sheet_default(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let dup = &body["requests"][0]["duplicateSheet"];
assert!(dup.get("insertSheetIndex").is_none());
assert!(dup.get("newSheetName").is_none());
}
#[tokio::test]
async fn duplicate_sheet_dry_run_names_title_and_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(duplicate_sheet(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would duplicate sheet 'Q2'"), "{text}");
assert!(text.contains("as 'Q2 Copy'"), "{text}");
assert!(text.contains("at index 1"), "{text}");
let requests = server.received_requests().await.unwrap();
assert!(!requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")));
}
#[tokio::test]
async fn duplicate_sheet_dry_run_uses_defaults_when_omitted() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(duplicate_sheet_default(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(
text.contains("as a copy Sheets names automatically"),
"{text}"
);
assert!(text.contains("at the front"), "{text}");
}
#[tokio::test]
async fn duplicate_sheet_refuses_a_missing_source_sheet() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DuplicateSheet {
sheet: "Nope".to_string(),
title: None,
index: None,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedSheetNotFound { .. }
));
}
#[tokio::test]
async fn duplicate_sheet_refuses_a_colliding_new_title() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DuplicateSheet {
sheet: "Q2".to_string(),
title: Some("Q1".to_string()),
index: None,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedSheetExists { ref title } if title == "Q1"
));
}
#[tokio::test]
async fn duplicate_sheet_refuses_an_out_of_range_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::DuplicateSheet {
sheet: "Q2".to_string(),
title: None,
index: Some(99),
},
false,
),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--index must be between 0 and 2"),
"{detail}"
);
}
#[tokio::test]
async fn reorder_sheet_apply_sends_index_masked_update() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(reorder_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["properties"]["sheetId"], 118_293);
assert_eq!(update["properties"]["index"], 0);
assert_eq!(update["fields"], "index");
}
#[tokio::test]
async fn reorder_sheet_dry_run_names_the_target_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(reorder_sheet(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would move sheet 'Q2'"), "{text}");
assert!(text.contains("to index 0"), "{text}");
}
#[tokio::test]
async fn reorder_sheet_refuses_an_out_of_range_index() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::ReorderSheet {
sheet: "Q2".to_string(),
index: 5,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("--index must be between 0 and 1"),
"{detail}"
);
}
#[tokio::test]
async fn hide_sheet_apply_sends_hidden_true() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(hide_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["properties"]["sheetId"], 118_293);
assert_eq!(update["properties"]["hidden"], true);
assert_eq!(update["fields"], "hidden");
}
#[tokio::test]
async fn show_sheet_apply_sends_hidden_false() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_q2(hidden_q2()).mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(show_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["properties"]["hidden"], false);
}
#[tokio::test]
async fn hide_sheet_dry_run_names_the_hide() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(hide_sheet(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would hide sheet 'Q2'"), "{text}");
}
#[tokio::test]
async fn show_sheet_dry_run_names_the_show() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_q2(hidden_q2()).mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(show_sheet(), true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("Would show sheet 'Q2'"), "{text}");
}
#[tokio::test]
async fn a_visibility_no_op_is_unchanged_in_dry_run_and_real_run() {
for (verb, q2, state) in [
(hide_sheet(), hidden_q2(), "hidden"),
(show_sheet(), default_q2(), "shown"),
] {
for dry_run in [true, false] {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_q2(q2.clone()).mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(verb.clone(), dry_run),
&[allow_rule("parent-1")],
)
.await;
assert_eq!(
outcome.result,
StructureResult::Unchanged {
detail: format!("sheet 'Q2' is already {state}")
},
"dry_run={dry_run}"
);
let text = describe(&outcome);
assert!(
text.starts_with(&format!("Unchanged: sheet 'Q2' is already {state} in ")),
"{text}"
);
let requests = server.received_requests().await.unwrap();
assert!(
!requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")),
"a no-op must not call batchUpdate"
);
}
}
}
#[tokio::test]
async fn hide_sheet_refuses_leaving_no_visible_sheets() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-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, "hidden": true}},
{"properties": {"sheetId": 118_293, "title": "Q2", "index": 1}},
],
})),
)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(hide_sheet(), false),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(
detail.contains("would leave the workbook with no visible sheets"),
"{detail}"
);
}
fn update_sheet_properties(
freeze_rows: Option<i64>,
freeze_columns: Option<i64>,
tab_color: Option<&str>,
clear_tab_color: bool,
right_to_left: Option<bool>,
hide_gridlines: Option<bool>,
) -> StructureVerb {
StructureVerb::UpdateSheetProperties {
sheet: "Q2".to_string(),
freeze_rows,
freeze_columns,
tab_color: tab_color.map(ToString::to_string),
clear_tab_color,
right_to_left,
hide_gridlines,
}
}
fn mount_workbook_with_frozen_rows(
sheet_id: i64,
row_count: i64,
frozen_row_count: i64,
) -> wiremock::Mock {
mount_workbook_with_q2(serde_json::json!({
"properties": {
"sheetId": sheet_id, "title": "Q2", "index": 1,
"gridProperties": {
"rowCount": row_count, "columnCount": 10,
"frozenRowCount": frozen_row_count,
}}}))
}
fn mount_workbook_with_view_state() -> wiremock::Mock {
mount_workbook_with_q2(serde_json::json!({
"properties": {
"sheetId": 118_293, "title": "Q2", "index": 1,
"rightToLeft": true,
"gridProperties": {
"rowCount": 500, "columnCount": 10,
"frozenColumnCount": 2,
"hideGridlines": true,
}}}))
}
#[tokio::test]
async fn update_sheet_properties_apply_sends_only_the_set_fields_in_the_mask() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let verb = update_sheet_properties(Some(2), None, Some("#FF8800"), false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(
update["fields"],
"gridProperties.frozenRowCount,tabColorStyle"
);
assert_eq!(update["properties"]["sheetId"], 118_293);
assert_eq!(update["properties"]["gridProperties"]["frozenRowCount"], 2);
let rgb = &update["properties"]["tabColorStyle"]["rgbColor"];
assert!((rgb["red"].as_f64().unwrap() - 1.0).abs() < 1e-6, "{rgb}");
assert!(
(rgb["green"].as_f64().unwrap() - (136.0 / 255.0)).abs() < 1e-6,
"{rgb}"
);
assert!((rgb["blue"].as_f64().unwrap() - 0.0).abs() < 1e-6, "{rgb}");
assert!(
update["properties"]["gridProperties"]
.get("frozenColumnCount")
.is_none(),
"{update}"
);
assert!(
update["properties"].get("rightToLeft").is_none(),
"{update}"
);
assert!(update["properties"].get("hidden").is_none(), "{update}");
assert!(update["properties"].get("title").is_none(), "{update}");
assert!(update["properties"].get("index").is_none(), "{update}");
}
#[tokio::test]
async fn update_sheet_properties_apply_clear_tab_color_names_tab_color_style_with_no_value() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let verb = update_sheet_properties(None, None, None, true, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(update["fields"], "tabColorStyle");
assert!(
update["properties"].get("tabColorStyle").is_none(),
"{update}"
);
}
#[tokio::test]
async fn update_sheet_properties_dry_run_reports_before_and_after() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_frozen_rows(118_293, 500, 1)
.mount(&server)
.await;
let verb = update_sheet_properties(Some(3), None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.contains("frozen rows 1 -> 3"), "{text}");
}
#[tokio::test]
async fn update_sheet_properties_apply_sends_every_property_and_reports_each_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let verb = update_sheet_properties(
Some(2),
Some(3),
Some("0000FF"),
false,
Some(true),
Some(true),
);
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request");
let update = &body["requests"][0]["updateSheetProperties"];
assert_eq!(
update["fields"],
"gridProperties.frozenRowCount,gridProperties.frozenColumnCount,\
gridProperties.hideGridlines,tabColorStyle,rightToLeft"
);
let grid = &update["properties"]["gridProperties"];
assert_eq!(grid["frozenRowCount"], 2);
assert_eq!(grid["frozenColumnCount"], 3);
assert_eq!(grid["hideGridlines"], true);
assert_eq!(update["properties"]["rightToLeft"], true);
let text = describe(&outcome);
assert!(text.starts_with("Updated sheet 'Q2'"), "{text}");
for fragment in [
"frozen rows -> 2",
"frozen columns -> 3",
"tab color -> #0000FF",
"right-to-left -> true",
"gridlines -> hidden",
] {
assert!(text.contains(fragment), "{fragment} missing from {text}");
}
}
#[tokio::test]
async fn update_sheet_properties_dry_run_reports_clears_and_the_values_being_replaced() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_view_state().mount(&server).await;
let verb = update_sheet_properties(None, Some(4), None, true, Some(false), Some(false));
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let text = describe(&outcome);
assert!(text.starts_with("Would update sheet 'Q2'"), "{text}");
assert!(!text.contains("frozen rows"), "{text}");
for fragment in [
"frozen columns 2 -> 4",
"tab color cleared",
"right-to-left true -> false",
"gridlines hidden -> shown",
] {
assert!(text.contains(fragment), "{fragment} missing from {text}");
}
}
#[test]
fn fields_changed_label_names_exactly_the_properties_the_verb_set() {
let every = update_sheet_properties(
Some(2),
Some(3),
Some("#FF8800"),
false,
Some(true),
Some(false),
);
assert_eq!(
fields_changed_label(&every).as_deref(),
Some(
"frozenRowCount=2, frozenColumnCount=3, hideGridlines=false, \
tabColorStyle=#FF8800, rightToLeft=true"
)
);
let cleared = update_sheet_properties(None, None, None, true, None, None);
assert_eq!(
fields_changed_label(&cleared).as_deref(),
Some("tabColorStyle=cleared")
);
let empty = update_sheet_properties(None, None, None, false, None, None);
assert_eq!(fields_changed_label(&empty), None);
assert_eq!(fields_changed_label(&rename()), None);
}
#[tokio::test]
async fn update_sheet_properties_refuses_a_tab_color_and_a_clear_together() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(None, None, Some("#FF8800"), true, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(detail.contains("mutually exclusive"), "{detail}");
}
#[tokio::test]
async fn update_sheet_properties_names_its_own_subcommand_in_a_refusal() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", "application/pdf", &["parent-1"])
.mount(&server)
.await;
let verb = update_sheet_properties(Some(2), None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedNotASpreadsheet { .. }
));
let text = describe(&outcome);
assert!(
text.contains("drive sheets update-sheet-properties"),
"{text}"
);
}
#[tokio::test]
async fn update_sheet_properties_refuses_a_negative_freeze_count() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(Some(-1), None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(
detail.contains("--freeze-rows must be at least 0"),
"{detail}"
);
}
#[tokio::test]
async fn update_sheet_properties_refuses_freezing_every_row() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_frozen_rows(118_293, 5, 0)
.mount(&server)
.await;
let verb = update_sheet_properties(Some(5), None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(detail.contains("would freeze every row"), "{detail}");
}
#[tokio::test]
async fn update_sheet_properties_allows_freezing_up_to_one_less_than_the_row_count() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook_with_frozen_rows(118_293, 5, 0)
.mount(&server)
.await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let verb = update_sheet_properties(Some(4), None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn update_sheet_properties_invents_no_freeze_ceiling_when_the_count_is_unknown() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-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": 118_293, "title": "Q2", "index": 0}}
],
})),
)
.mount(&server)
.await;
let verb = update_sheet_properties(Some(9_000), Some(9_000), None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::WouldChange { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn update_sheet_properties_refuses_a_negative_freeze_column_count() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(None, Some(-1), None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(
detail.contains("--freeze-columns must be at least 0"),
"{detail}"
);
}
#[tokio::test]
async fn update_sheet_properties_refuses_freezing_every_column() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(None, Some(10), None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(detail.contains("would freeze every column"), "{detail}");
}
#[tokio::test]
async fn update_sheet_properties_refuses_an_invalid_hex_color() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(None, None, Some("nope"), false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(
outcome.result,
StructureResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn update_sheet_properties_refuses_when_nothing_is_set() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let verb = update_sheet_properties(None, None, None, false, None, None);
let outcome = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(detail.contains("at least one"), "{detail}");
}
fn allow_rule_no_lease(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
require_lease: false,
..allow_rule(folder)
}
}
#[tokio::test]
async fn refuses_without_a_lease_when_the_rule_requires_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let mut o = opts(rename(), false);
o.lease_token = None;
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, StructureResult::RefusedNoLease);
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let mut o = opts(rename(), false);
o.lease_token = Some("bogus-token".to_string());
o.ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, StructureResult::RefusedLeaseExpired);
}
#[tokio::test]
async fn refuses_a_lease_bound_to_a_different_file() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "other-sheet", "1");
let mut o = opts(rename(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, StructureResult::RefusedLeaseWrongFile);
}
#[tokio::test]
async fn refuses_a_stale_lease() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let mut o = opts(rename(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
}
#[tokio::test]
async fn reports_a_lock_acquisition_failure_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let o = opts(rename(), false);
let mut lock_path = o.ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(matches!(outcome.result, StructureResult::Failed { .. }));
}
#[tokio::test]
async fn a_failed_pre_lease_refetch_is_reported_as_failed_with_no_batch_update_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.up_to_n_times(1)
.with_priority(1)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.with_priority(2)
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().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))
.expect(0)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Failed { .. }));
}
#[tokio::test]
async fn a_rule_that_does_not_require_a_lease_skips_the_check_entirely() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let mut o = opts(rename(), false);
o.lease_token = None;
let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
assert!(matches!(
outcome.result,
StructureResult::Changed { backup: None, .. }
));
}
#[tokio::test]
async fn a_rule_that_does_not_require_a_lease_still_refuses_a_stale_one_if_presented() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let mut o = opts(rename(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = structure(&drive, &sheets, &o, &[allow_rule_no_lease("parent-1")]).await;
assert_eq!(outcome.result, StructureResult::RefusedLeaseStale);
}
#[tokio::test]
async fn a_leased_delete_names_the_drive_copy_to_restore_from() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease_with_backup(
&ledger_path,
"sheet-1",
"1",
LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
},
);
let mut o = opts(delete_sheet(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
assert!(matches!(
&outcome.result,
StructureResult::Changed {
backup: Some(backup),
..
} if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
));
let text = describe(&outcome);
assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
assert!(
text.contains(
"backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
),
"{text}"
);
assert!(
text.contains("restores a single deleted sheet automatically"),
"{text}"
);
assert!(
text.contains("Google Drive's own version history"),
"{text}"
);
assert!(!text.contains("only recovery path"), "{text}");
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["result"]["backup"]["kind"], "drive_copy");
assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
}
#[tokio::test]
async fn a_presented_lease_under_a_non_requiring_rule_still_names_the_backup() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease_with_backup(
&ledger_path,
"sheet-1",
"1",
LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
},
);
let mut o = opts(delete_sheet(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let rule = FolderPermissionRule {
require_lease: false,
..delete_allow_rule("parent-1")
};
let outcome = structure(&drive, &sheets, &o, &[rule]).await;
assert!(matches!(
&outcome.result,
StructureResult::Changed {
backup: Some(backup),
..
} if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
));
let text = describe(&outcome);
assert!(
text.contains(
"backed the whole spreadsheet up when it was acquired (Drive copy backup-copy-1)"
),
"{text}"
);
assert!(!text.contains("only recovery path"), "{text}");
}
#[tokio::test]
async fn an_unleased_delete_does_not_claim_a_backup_exists() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let mut o = opts(delete_sheet(), false);
o.lease_token = None;
let rule = FolderPermissionRule {
require_lease: false,
..delete_allow_rule("parent-1")
};
let outcome = structure(&drive, &sheets, &o, &[rule]).await;
assert!(matches!(
outcome.result,
StructureResult::Changed { backup: None, .. }
));
let text = describe(&outcome);
assert!(text.contains("Deleted sheet 'Q2'"), "{text}");
assert!(text.contains("no lease backup was taken"), "{text}");
assert!(text.contains("`require_lease: false`"), "{text}");
assert!(
text.contains("version history is the only recovery path"),
"{text}"
);
assert!(!text.contains("restore from that copy"), "{text}");
assert!(serde_json::to_value(&outcome).unwrap()["result"]
.get("backup")
.is_none());
}
fn mount_unreadable_batch_update() -> wiremock::Mock {
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"))
}
#[tokio::test]
async fn add_sheet_with_an_unreadable_reply_reports_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_unreadable_batch_update()
.expect(1)
.mount(&server)
.await;
let o = opts(add_sheet(), false);
let outcome = structure(&drive, &sheets, &o, &[allow_rule("parent-1")]).await;
assert!(
matches!(
&outcome.result,
StructureResult::AppliedReplyUnreadable {
summary,
sheet: None,
sheet_id: None,
backup: Some(_),
..
} if summary == "add-sheet 'Q3'"
),
"{:?}",
outcome.result
);
let record = mutation_record(&outcome, &o, Duration::ZERO);
assert_eq!(record.status, "applied-reply-unreadable");
assert_eq!(record.sheet_id, None);
assert_eq!(record.fields_changed.as_deref(), Some("add-sheet 'Q3'"));
assert!(record.error.is_some());
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: add-sheet 'Q3'"),
"{lines:?}"
);
assert!(lines[0].contains("do not retry"), "{lines:?}");
assert!(
!lines[0].contains("cannot be undone"),
"an additive verb owes no recovery note: {lines:?}"
);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(
lines[1].starts_with(" the new sheet's id was in the unreadable reply"),
"{lines:?}"
);
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["result"]["status"], "applied-reply-unreadable");
assert!(json["result"].get("sheet_id").is_none(), "{json}");
assert!(json["result"].get("sheet").is_none(), "{json}");
}
#[tokio::test]
async fn a_delete_with_an_unreadable_reply_still_names_the_drive_copy_to_restore_from() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_unreadable_batch_update()
.expect(1)
.mount(&server)
.await;
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease_with_backup(
&ledger_path,
"sheet-1",
"1",
LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
},
);
let mut o = opts(delete_sheet(), false);
o.lease_token = Some(token);
o.ledger_path = ledger_path;
let outcome = structure(&drive, &sheets, &o, &[delete_allow_rule("parent-1")]).await;
assert!(
matches!(
&outcome.result,
StructureResult::AppliedReplyUnreadable {
backup: Some(backup),
..
} if **backup == LeaseBackup::DriveCopy { file_id: "backup-copy-1".to_string() }
),
"{:?}",
outcome.result
);
let text = describe(&outcome);
assert!(
text.starts_with("Applied, but the reply could not be read: delete-sheet 'Q2'"),
"{text}"
);
assert!(
text.contains(&format!(
"; {}",
recovery_note(
Some(&LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
}),
true,
)
)),
"{text}"
);
assert!(
text.contains("delete-sheet 'Q2' (sheetId 118293) in 'sheet-1'"),
"{text}"
);
let json = serde_json::to_value(&outcome).unwrap();
assert_eq!(json["result"]["backup"]["file_id"], "backup-copy-1");
assert_eq!(json["result"]["sheet_id"], 118_293);
assert_eq!(json["result"]["sheet"]["title"], "Q2");
let record = mutation_record(&outcome, &o, Duration::ZERO);
assert_eq!(record.sheet_id, Some(118_293));
assert_eq!(record.fields_changed.as_deref(), Some("delete-sheet 'Q2'"));
assert!(record.error.is_some());
}
#[test]
fn an_unreadable_reply_logs_the_verbs_own_field_list_when_it_has_one() {
let verb = StructureVerb::UpdateSheetProperties {
sheet: "Q2".to_string(),
freeze_rows: Some(2),
freeze_columns: None,
tab_color: None,
clear_tab_color: false,
right_to_left: None,
hide_gridlines: None,
};
let outcome =
unreadable_outcome_with_id(verb.clone(), "update-sheet-properties 'Q2'", 118_293);
let record = mutation_record(&outcome, &opts(verb, false), Duration::ZERO);
assert_eq!(record.fields_changed.as_deref(), Some("frozenRowCount=2"));
assert_eq!(record.sheet_id, Some(118_293));
}
#[test]
fn describe_lines_names_the_known_sheet_id_of_an_unreadable_reply() {
assert_eq!(
describe_lines(&unreadable_outcome_with_id(
rename(),
"rename-sheet 'Q2' to 'Q3'",
7
)),
vec![
"Applied, but the reply could not be read: rename-sheet 'Q2' to 'Q3' \
(sheetId 7) in 'Budget' — do not retry; check the spreadsheet first \
(bad reply)"
.to_string()
]
);
}
fn unreadable_outcome(verb: StructureVerb, summary: &str) -> StructureOutcome {
StructureOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb,
result: StructureResult::AppliedReplyUnreadable {
summary: summary.to_string(),
detail: "bad reply".to_string(),
sheet: None,
sheet_id: None,
backup: None,
},
}
}
fn unreadable_outcome_with_id(
verb: StructureVerb,
summary: &str,
sheet_id: i64,
) -> StructureOutcome {
let mut outcome = unreadable_outcome(verb, summary);
if let StructureResult::AppliedReplyUnreadable {
sheet: s,
sheet_id: id,
..
} = &mut outcome.result
{
*s = Some(Box::new(SheetSnapshot {
sheet_id: Some(sheet_id),
title: "Q2".to_string(),
..Default::default()
}));
*id = Some(sheet_id);
}
outcome
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_for_an_additive_verb() {
let lines = describe_lines(&unreadable_outcome(
duplicate_sheet(),
"duplicate-sheet 'Q2'",
));
assert_eq!(
lines,
vec![
"Applied, but the reply could not be read: duplicate-sheet 'Q2' in 'Budget' — \
do not retry; check the spreadsheet first (bad reply)"
.to_string(),
" the new sheet's id was in the unreadable reply; check the spreadsheet's tabs \
to find it"
.to_string(),
]
);
assert_eq!(
describe_lines(&unreadable_outcome(rename(), "rename-sheet 'Q2' to 'Q3'")),
vec![
"Applied, but the reply could not be read: rename-sheet 'Q2' to 'Q3' in \
'Budget' — do not retry; check the spreadsheet first (bad reply)"
.to_string()
]
);
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_for_a_destructive_verb() {
let lines = describe_lines(&unreadable_outcome(
delete_rows(),
"delete-rows 'Q2' (ROWS 5:7)",
));
assert_eq!(
lines,
vec![format!(
"Applied, but the reply could not be read: delete-rows 'Q2' (ROWS 5:7) in \
'Budget' — do not retry; check the spreadsheet first (bad reply); {}",
recovery_note(None, false)
)]
);
}
#[test]
fn applied_summary_names_the_verb_sheet_and_span() {
assert_eq!(applied_summary(&add_sheet()), "add-sheet 'Q3'");
assert_eq!(applied_summary(&rename()), "rename-sheet 'Q2' to 'Q3'");
assert_eq!(
applied_summary(&delete_rows()),
"delete-rows 'Q2' (ROWS 5:7)"
);
assert_eq!(
applied_summary(&update_workbook_properties()),
"update-workbook-properties"
);
}
fn sent_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.map(|r| serde_json::from_slice(&r.body).unwrap())
.expect("a batchUpdate request")
}
fn mount_workbook_with_q2(q2: serde_json::Value) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(workbook_body(q2)))
}
async fn mount_move_prereqs(server: &wiremock::MockServer) {
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(server)
.await;
mount_folder("parent-1").mount(server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(server)
.await;
}
#[tokio::test]
async fn move_rows_apply_sends_move_dimension_with_pre_move_destination() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(move_rows(), false),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
assert_eq!(
body["requests"][0]["moveDimension"],
serde_json::json!({
"source": {
"sheetId": 118_293, "dimension": "ROWS",
"startIndex": 1, "endIndex": 3,
},
"destinationIndex": 5,
})
);
assert_eq!(body["requests"].as_array().unwrap().len(), 1);
let rendered = describe(&outcome);
assert!(rendered.contains("Moved 2 row(s) 2-3"), "{rendered}");
assert!(rendered.contains("to before row 6"), "{rendered}");
assert!(rendered.contains("(now rows 4-5)"), "{rendered}");
}
#[tokio::test]
async fn move_columns_apply_sends_move_dimension() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveColumns {
sheet: "Q2".to_string(),
at: 2,
count: 2,
before: 6,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
assert_eq!(
body["requests"][0]["moveDimension"],
serde_json::json!({
"source": {
"sheetId": 118_293, "dimension": "COLUMNS",
"startIndex": 1, "endIndex": 3,
},
"destinationIndex": 5,
})
);
}
#[tokio::test]
async fn move_rows_to_the_end_uses_row_count_plus_one() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 1,
count: 1,
before: 501,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
assert_eq!(
body["requests"][0]["moveDimension"]["destinationIndex"],
500
);
}
#[tokio::test]
async fn move_rows_refuses_a_source_past_the_sheets_end() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 499,
count: 3,
before: 1,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("500 row(s)"), "{detail}");
assert!(detail.contains("501"), "{detail}");
}
#[tokio::test]
async fn move_rows_refuses_a_destination_past_the_end() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 5,
count: 1,
before: 502,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result);
};
assert!(detail.contains("500 row(s)"), "{detail}");
assert!(detail.contains("501"), "{detail}");
}
#[tokio::test]
async fn move_rows_refuses_a_destination_inside_or_adjacent_to_the_block() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
for before in 2..=4 {
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 2,
count: 2,
before,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
let StructureResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!(
"expected RefusedInvalidRange for --before {before}, got {:?}",
outcome.result
);
};
assert!(detail.contains("nothing would move"), "{detail}");
assert!(detail.contains("rows 2-3"), "{detail}");
}
}
#[tokio::test]
async fn move_rows_allows_a_destination_immediately_adjacent_but_outside_the_block() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
for before in [1, 5] {
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 2,
count: 2,
before,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::WouldChange { .. }),
"--before {before} must be a real move, got {:?}",
outcome.result
);
}
}
#[test]
fn move_bounds_refuse_every_argument_below_one() {
let sheet = SheetSnapshot {
sheet_id: Some(1),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
};
for (at, count, before, expected) in [
(2, 0, 9, "--count must be at least 1, got 0"),
(0, 1, 9, "--at must be at least 1, got 0"),
(2, 1, 0, "--before must be at least 1, got 0"),
] {
let Err(StructureResult::RefusedInvalidRange { detail }) =
validate_move_bounds(Dimension::Rows, at, count, before, Some(&sheet))
else {
panic!(
"expected RefusedInvalidRange for --at {at} --count {count} --before {before}"
);
};
assert_eq!(detail, expected);
}
}
#[test]
fn a_move_at_and_count_that_would_overflow_the_index_space_are_refused() {
let sheet = SheetSnapshot {
sheet_id: Some(1),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
};
for (at, count) in [(i64::MAX, 5), (500, i64::MAX)] {
let Err(StructureResult::RefusedInvalidRange { detail }) =
validate_move_bounds(Dimension::Rows, at, count, 1, Some(&sheet))
else {
panic!("expected RefusedInvalidRange for --at {at} --count {count}");
};
assert!(detail.contains("overflows the row index space"), "{detail}");
}
}
#[tokio::test]
async fn a_refused_move_still_records_without_panicking() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: i64::MAX,
count: 5,
before: 1,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::RefusedInvalidRange { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn move_rows_dry_run_describes_a_downward_move_and_its_landing_rows() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(move_rows(), true),
&[allow_rule("parent-1")],
)
.await;
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].contains("Would move 2 row(s) 2-3"), "{lines:?}");
assert!(lines[0].contains("to before row 6"), "{lines:?}");
assert_eq!(
lines[1],
" (500 rows unchanged; rows 4-5 shift up to 2-3; moved rows land at 4-5)"
);
}
#[tokio::test]
async fn move_rows_dry_run_describes_an_upward_move_and_its_landing_rows() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 6,
count: 2,
before: 2,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].contains("Would move 2 row(s) 6-7"), "{lines:?}");
assert!(lines[0].contains("to before row 2"), "{lines:?}");
assert_eq!(
lines[1],
" (500 rows unchanged; rows 2-5 shift down to 4-7; moved rows land at 2-3)"
);
}
#[tokio::test]
async fn move_columns_dry_run_describes_a_downward_move_and_its_landing_columns() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveColumns {
sheet: "Q2".to_string(),
at: 2,
count: 2,
before: 6,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].contains("Would move 2 column(s) 2-3"), "{lines:?}");
assert!(lines[0].contains("to before column 6"), "{lines:?}");
assert_eq!(
lines[1],
" (10 columns unchanged; columns 4-5 shift left to 2-3; moved columns land at 4-5)"
);
}
#[tokio::test]
async fn move_columns_dry_run_describes_an_upward_move_and_its_landing_columns() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveColumns {
sheet: "Q2".to_string(),
at: 6,
count: 2,
before: 2,
},
true,
),
&[allow_rule("parent-1")],
)
.await;
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].contains("Would move 2 column(s) 6-7"), "{lines:?}");
assert!(lines[0].contains("to before column 2"), "{lines:?}");
assert_eq!(
lines[1],
" (10 columns unchanged; columns 2-5 shift right to 4-7; moved columns land at 2-3)"
);
}
#[tokio::test]
async fn move_rows_dry_run_matches_the_real_run_refusal() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook().mount(&server).await;
let verb = StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 5,
count: 1,
before: 502,
};
let real = structure(
&drive,
&sheets,
&opts(verb.clone(), false),
&[allow_rule("parent-1")],
)
.await;
let dry = structure(
&drive,
&sheets,
&opts(verb, true),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(real.result, StructureResult::RefusedInvalidRange { .. }),
"{:?}",
real.result
);
assert_eq!(dry.result, real.result);
}
#[tokio::test]
async fn move_rows_does_not_consult_frozen_rows() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook_with_q2(serde_json::json!({
"properties": {
"sheetId": 118_293, "title": "Q2", "index": 1,
"gridProperties": {
"rowCount": 500, "columnCount": 10, "frozenRowCount": 2,
},
},
}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 1,
count: 1,
before: 3,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
assert_eq!(
body["requests"][0]["moveDimension"],
serde_json::json!({
"source": {
"sheetId": 118_293, "dimension": "ROWS",
"startIndex": 0, "endIndex": 1,
},
"destinationIndex": 2,
})
);
}
#[tokio::test]
async fn move_rows_does_not_consult_dimension_groups() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_move_prereqs(&server).await;
mount_workbook_with_q2(serde_json::json!({
"properties": {
"sheetId": 118_293, "title": "Q2", "index": 1,
"gridProperties": {"rowCount": 500, "columnCount": 10},
},
"rowGroups": [{
"range": {
"sheetId": 118_293, "dimension": "ROWS",
"startIndex": 1, "endIndex": 4,
},
}],
}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(
StructureVerb::MoveRows {
sheet: "Q2".to_string(),
at: 2,
count: 1,
before: 1,
},
false,
),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
assert_eq!(
body["requests"][0]["moveDimension"],
serde_json::json!({
"source": {
"sheetId": 118_293, "dimension": "ROWS",
"startIndex": 1, "endIndex": 2,
},
"destinationIndex": 0,
})
);
}
#[test]
fn sheet_title_is_none_for_update_workbook_properties() {
assert_eq!(update_workbook_properties().sheet_title(), None);
assert_eq!(update_workbook_properties().new_sheet_title(), None);
assert_eq!(update_workbook_properties().dimension(), None);
}
#[test]
fn resolve_sheet_returns_none_for_update_workbook_properties_without_consulting_sheets() {
let workbook = Spreadsheet {
spreadsheet_id: Some("sheet-1".to_string()),
properties: None,
sheets: Vec::new(),
named_ranges: Vec::new(),
};
assert_eq!(
resolve_sheet(&workbook, &update_workbook_properties()),
Ok(None)
);
}
#[test]
fn validate_verb_args_refuses_update_workbook_properties_with_no_fields() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
};
let workbook = Spreadsheet::default();
let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
assert!(
matches!(&err, StructureResult::RefusedInvalidRange { detail }
if detail.contains("at least one property")),
"{err:?}"
);
}
fn update_time_zone(time_zone: &str) -> StructureVerb {
StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: Some(time_zone.to_string()),
auto_recalc: None,
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
}
}
#[test]
fn validate_verb_args_refuses_an_unrecognised_time_zone() {
let workbook = Spreadsheet::default();
let err =
validate_verb_args(&workbook, &update_time_zone("Bogus/Zone9"), None).unwrap_err();
assert!(
matches!(&err, StructureResult::RefusedInvalidRange { detail }
if detail.contains("'Bogus/Zone9'")
&& detail.contains("IANA")
&& detail.contains("America/New_York")),
"{err:?}"
);
}
#[test]
fn validate_verb_args_accepts_recognised_iana_time_zones() {
let workbook = Spreadsheet::default();
for tz in ["Europe/London", "America/New_York", "Etc/GMT", "UTC"] {
assert_eq!(
validate_verb_args(&workbook, &update_time_zone(tz), None),
Ok(()),
"{tz}"
);
}
}
#[test]
fn validate_verb_args_refuses_iterative_calculation_settings_without_the_toggle() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: None,
iterative_calculation_max_iterations: Some(50),
iterative_calculation_convergence_threshold: None,
};
let workbook = Spreadsheet::default();
let err = validate_verb_args(&workbook, &verb, None).unwrap_err();
assert!(
matches!(&err, StructureResult::RefusedInvalidRange { detail }
if detail.contains("--iterative-calculation on")),
"{err:?}"
);
let verb_off = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::Off),
iterative_calculation_max_iterations: Some(50),
iterative_calculation_convergence_threshold: None,
};
assert!(validate_verb_args(&workbook, &verb_off, None).is_err());
}
#[test]
fn validate_verb_args_refuses_non_positive_iterative_calculation_bounds() {
let workbook = Spreadsheet::default();
let bad_max = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: Some(0),
iterative_calculation_convergence_threshold: None,
};
assert!(validate_verb_args(&workbook, &bad_max, None).is_err());
let bad_threshold = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: Some(0.0),
};
assert!(validate_verb_args(&workbook, &bad_threshold, None).is_err());
let nan_threshold = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: Some(f64::NAN),
};
assert!(validate_verb_args(&workbook, &nan_threshold, None).is_err());
}
#[test]
fn validate_verb_args_accepts_positive_iterative_calculation_bounds() {
let workbook = Spreadsheet::default();
let verb = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: Some(50),
iterative_calculation_convergence_threshold: Some(0.01),
};
assert!(validate_verb_args(&workbook, &verb, None).is_ok());
}
#[test]
fn validate_verb_args_accepts_a_single_field() {
let workbook = Spreadsheet::default();
let verb = StructureVerb::UpdateWorkbookProperties {
locale: Some("fr_FR".to_string()),
time_zone: None,
auto_recalc: None,
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
};
assert!(validate_verb_args(&workbook, &verb, None).is_ok());
}
#[test]
fn build_request_masks_exactly_the_fields_given() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: Some("en_US".to_string()),
time_zone: None,
auto_recalc: Some(RecalculationInterval::Hour),
iterative_calculation: None,
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
};
let request = build_request(&verb, None).unwrap();
let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
assert_eq!(req.fields, "locale,autoRecalc");
assert_eq!(req.properties.locale.as_deref(), Some("en_US"));
assert_eq!(req.properties.time_zone, None);
assert_eq!(
req.properties.auto_recalc,
Some(RecalculationInterval::Hour)
);
assert_eq!(req.properties.iterative_calculation_settings, None);
}
#[test]
fn build_request_turns_iterative_calculation_on_with_explicit_settings() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: Some(50),
iterative_calculation_convergence_threshold: Some(0.01),
};
let request = build_request(&verb, None).unwrap();
let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
assert_eq!(req.fields, "iterativeCalculationSettings");
assert_eq!(
req.properties.iterative_calculation_settings,
Some(IterativeCalculationSettings {
max_iterations: Some(50),
convergence_threshold: Some(0.01),
})
);
}
#[test]
fn build_request_turns_iterative_calculation_off_by_naming_the_field_without_a_value() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: None,
time_zone: None,
auto_recalc: None,
iterative_calculation: Some(IterativeCalculationToggle::Off),
iterative_calculation_max_iterations: None,
iterative_calculation_convergence_threshold: None,
};
let request = build_request(&verb, None).unwrap();
let BatchUpdateRequestItem::UpdateSpreadsheetProperties(req) = request else {
panic!("expected UpdateSpreadsheetProperties, got {request:?}"); };
assert_eq!(req.fields, "iterativeCalculationSettings");
assert_eq!(req.properties.iterative_calculation_settings, None);
let json = serde_json::to_value(&req.properties).unwrap();
assert!(
json.as_object().unwrap().is_empty(),
"expected an empty properties object, got {json}"
);
}
#[test]
fn workbook_properties_summary_formats_every_field() {
let verb = StructureVerb::UpdateWorkbookProperties {
locale: Some("en_US".to_string()),
time_zone: Some("America/New_York".to_string()),
auto_recalc: Some(RecalculationInterval::OnChange),
iterative_calculation: Some(IterativeCalculationToggle::On),
iterative_calculation_max_iterations: Some(50),
iterative_calculation_convergence_threshold: Some(0.01),
};
let summary = workbook_properties_summary(&verb);
assert_eq!(
summary,
"locale -> 'en_US', time zone -> 'America/New_York', \
auto-recalc -> ON_CHANGE, iterative calculation -> on \
(max 50 iterations, threshold 0.01)"
);
}
#[tokio::test]
async fn update_workbook_properties_dry_run_reports_would_change_with_no_sheet() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(update_workbook_properties(), true),
&[allow_rule("parent-1")],
)
.await;
match &outcome.result {
StructureResult::WouldChange { sheet, sheet_count } => {
assert_eq!(*sheet, None);
assert_eq!(*sheet_count, 2);
}
other => panic!("expected WouldChange, got {other:?}"), }
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 1);
assert!(
lines[0].starts_with("Would update workbook properties of 'sheet-1'"),
"{lines:?}"
);
let requests = server.received_requests().await.unwrap();
assert!(!requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")));
}
#[tokio::test]
async fn update_workbook_properties_refuses_an_unrecognised_time_zone_on_dry_run_and_apply() {
for dry_run in [true, false] {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
let outcome = structure(
&drive,
&sheets,
&opts(update_time_zone("Bogus/Zone9"), dry_run),
&[allow_rule("parent-1")],
)
.await;
assert!(
matches!(&outcome.result, StructureResult::RefusedInvalidRange { detail }
if detail.contains("'Bogus/Zone9'")),
"dry_run={dry_run}: {:?}",
outcome.result
);
let requests = server.received_requests().await.unwrap();
assert!(
!requests
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")),
"dry_run={dry_run}"
);
}
}
#[tokio::test]
async fn update_workbook_properties_apply_sends_the_masked_request_and_reports_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(update_workbook_properties(), false),
&[allow_rule("parent-1")],
)
.await;
match &outcome.result {
StructureResult::Changed {
sheet, sheet_id, ..
} => {
assert_eq!(*sheet, None);
assert_eq!(*sheet_id, None);
}
other => panic!("expected Changed, got {other:?}"), }
let requests = server.received_requests().await.unwrap();
let body = sent_batch_update(&requests);
let update = &body["requests"][0]["updateSpreadsheetProperties"];
assert_eq!(update["properties"]["locale"], "en_US");
assert_eq!(update["properties"]["timeZone"], "America/New_York");
assert_eq!(update["properties"]["autoRecalc"], "ON_CHANGE");
assert_eq!(update["fields"], "locale,timeZone,autoRecalc");
}
#[test]
fn record_attempt_carries_the_fields_changed_summary_and_no_sheet_title() {
let opts = opts(update_workbook_properties(), false);
assert_eq!(opts.verb.sheet_title(), None);
assert_eq!(
workbook_properties_summary(&opts.verb),
"locale -> 'en_US', time zone -> 'America/New_York', auto-recalc -> ON_CHANGE"
);
}
#[test]
fn log_operation_is_distinct_per_verb() {
let verbs = [
add_sheet(),
rename(),
insert_rows(),
StructureVerb::InsertColumns {
sheet: "Q2".to_string(),
at: 1,
count: 1,
},
insert_range(),
move_rows(),
StructureVerb::MoveColumns {
sheet: "Q2".to_string(),
at: 1,
count: 1,
before: 3,
},
delete_sheet(),
delete_rows(),
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: 1,
count: 1,
},
delete_range(),
duplicate_sheet(),
reorder_sheet(),
hide_sheet(),
show_sheet(),
update_workbook_properties(),
];
let names: Vec<&str> = verbs.iter().map(StructureVerb::log_operation).collect();
assert_eq!(
names,
vec![
"sheets-add-sheet",
"sheets-rename-sheet",
"sheets-insert-rows",
"sheets-insert-columns",
"sheets-insert-range",
"sheets-move-rows",
"sheets-move-columns",
"sheets-delete-sheet",
"sheets-delete-rows",
"sheets-delete-columns",
"sheets-delete-range",
"sheets-duplicate-sheet",
"sheets-reorder-sheet",
"sheets-hide-sheet",
"sheets-show-sheet",
"sheets-update-workbook-properties",
]
);
let unique: HashSet<&&str> = names.iter().collect();
assert_eq!(unique.len(), names.len());
assert!(!names.contains(&"sheets-write"));
}
#[test]
fn insert_range_records_its_one_based_inclusive_grid_context() {
assert_eq!(
grid_range_label(&insert_range()).as_deref(),
Some("rows 2-4, columns 2-3")
);
}
fn every_structure_result() -> Vec<StructureResult> {
let all = vec![
StructureResult::WouldChange {
sheet: Some(Box::new(SheetSnapshot {
sheet_id: Some(7),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
})),
sheet_count: 2,
},
StructureResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
StructureResult::RefusedShortcut,
StructureResult::RefusedNoVisibleParents,
StructureResult::RefusedSheetNotFound {
title: "Nope".to_string(),
available: vec!["Q1".to_string(), "Q2".to_string()],
},
StructureResult::RefusedSheetExists {
title: "Q1".to_string(),
},
StructureResult::RefusedInvalidRange {
detail: "--count must be at least 1, got 0".to_string(),
},
StructureResult::Blocked { decided_by: None },
StructureResult::RefusedNoLease,
StructureResult::RefusedLeaseExpired,
StructureResult::RefusedLeaseWrongFile,
StructureResult::RefusedLeaseStale,
StructureResult::Unchanged {
detail: "sheet 'Q2' is already hidden".to_string(),
},
StructureResult::Changed {
sheet: Some(Box::new(SheetSnapshot {
sheet_id: Some(7),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
})),
sheet_id: Some(7),
backup: Some(Box::new(LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
})),
},
StructureResult::AppliedReplyUnreadable {
summary: "delete-sheet 'Q2'".to_string(),
detail: "bad reply".to_string(),
sheet: Some(Box::new(SheetSnapshot {
sheet_id: Some(7),
title: "Q2".to_string(),
row_count: Some(500),
column_count: Some(26),
..Default::default()
})),
sheet_id: Some(7),
backup: Some(Box::new(LeaseBackup::DriveCopy {
file_id: "backup-copy-1".to_string(),
})),
},
StructureResult::Failed {
detail: "boom".to_string(),
},
];
for result in &all {
match result {
StructureResult::WouldChange { .. }
| StructureResult::RefusedNotASpreadsheet { .. }
| StructureResult::RefusedShortcut
| StructureResult::RefusedNoVisibleParents
| StructureResult::RefusedSheetNotFound { .. }
| StructureResult::RefusedSheetExists { .. }
| StructureResult::RefusedInvalidRange { .. }
| StructureResult::Blocked { .. }
| StructureResult::RefusedNoLease
| StructureResult::RefusedLeaseExpired
| StructureResult::RefusedLeaseWrongFile
| StructureResult::RefusedLeaseStale
| StructureResult::Unchanged { .. }
| StructureResult::Changed { .. }
| StructureResult::AppliedReplyUnreadable { .. }
| StructureResult::Failed { .. } => {}
}
}
all
}
#[test]
fn no_describe_line_contains_a_newline() {
for verb in [
add_sheet(),
rename(),
insert_rows(),
StructureVerb::InsertColumns {
sheet: "Q2".to_string(),
at: 2,
count: 1,
},
move_rows(),
StructureVerb::MoveColumns {
sheet: "Q2".to_string(),
at: 2,
count: 1,
before: 5,
},
delete_sheet(),
delete_rows(),
delete_columns(),
insert_range(),
insert_range_columns_shift(),
delete_range(),
delete_range_columns_shift(),
duplicate_sheet(),
duplicate_sheet_default(),
reorder_sheet(),
hide_sheet(),
show_sheet(),
update_workbook_properties(),
] {
let has_dimension_shift = verb.dimension().is_some();
let adds_a_sheet = matches!(
verb,
StructureVerb::AddSheet { .. } | StructureVerb::DuplicateSheet { .. }
);
for result in every_structure_result() {
let has_second_line = (has_dimension_shift
&& matches!(result, StructureResult::WouldChange { .. }))
|| (adds_a_sheet
&& matches!(result, StructureResult::AppliedReplyUnreadable { .. }));
let outcome = StructureOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: verb.clone(),
result,
};
let lines = describe_lines(&outcome);
assert_eq!(
lines.len(),
usize::from(has_second_line) + 1,
"unexpected line count for {:?}/{:?}: {lines:?}",
outcome.verb,
outcome.result
);
for rendered in &lines {
assert!(
!rendered.chars().any(char::is_control),
"describe_lines emitted a control character for {:?}/{:?}: \
{rendered:?}",
outcome.verb,
outcome.result
);
}
assert_eq!(describe(&outcome), lines.join("\n"));
}
}
}
#[test]
fn describe_would_delete_dimension_handles_unvalidated_edge_cases() {
let would_change = |verb: StructureVerb, sheet: Option<SheetSnapshot>| StructureOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb,
result: StructureResult::WouldChange {
sheet: sheet.map(Box::new),
sheet_count: 2,
},
};
let sheet_with_columns = |column_count| SheetSnapshot {
sheet_id: None,
title: "Q2".to_string(),
row_count: None,
column_count: Some(column_count),
..Default::default()
};
let overflow = describe(&would_change(
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: i64::MAX,
count: 2,
},
Some(sheet_with_columns(26)),
));
assert!(
overflow.contains("Would delete 2 column(s) from column"),
"{overflow}"
);
assert!(!overflow.contains('-'), "{overflow}");
let no_sheet = describe(&would_change(delete_columns(), None));
assert_eq!(no_sheet, "Would delete 2 column(s) 3-4 of 'Q2' in 'Budget'");
let after_underflows = describe(&would_change(
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: 0,
count: i64::MAX,
},
Some(sheet_with_columns(-2)),
));
assert_eq!(
after_underflows,
format!(
"Would delete {} column(s) 0-{} of 'Q2' in 'Budget'",
i64::MAX,
i64::MAX - 1
)
);
let to_the_end = describe(&would_change(
StructureVerb::DeleteColumns {
sheet: "Q2".to_string(),
at: 25,
count: 2,
},
Some(sheet_with_columns(26)),
));
assert!(
to_the_end.contains("nothing remains after the deleted columns"),
"{to_the_end}"
);
}
#[test]
fn log_status_covers_every_variant() {
let statuses = [
StructureResult::WouldChange {
sheet: None,
sheet_count: 1,
}
.log_status(),
StructureResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
}
.log_status(),
StructureResult::RefusedShortcut.log_status(),
StructureResult::RefusedNoVisibleParents.log_status(),
StructureResult::RefusedSheetNotFound {
title: "x".to_string(),
available: Vec::new(),
}
.log_status(),
StructureResult::RefusedSheetExists {
title: "x".to_string(),
}
.log_status(),
StructureResult::RefusedInvalidRange {
detail: "x".to_string(),
}
.log_status(),
StructureResult::Blocked { decided_by: None }.log_status(),
StructureResult::RefusedNoLease.log_status(),
StructureResult::RefusedLeaseExpired.log_status(),
StructureResult::RefusedLeaseWrongFile.log_status(),
StructureResult::RefusedLeaseStale.log_status(),
StructureResult::Unchanged {
detail: "x".to_string(),
}
.log_status(),
StructureResult::Changed {
sheet: None,
sheet_id: None,
backup: None,
}
.log_status(),
StructureResult::AppliedReplyUnreadable {
summary: "x".to_string(),
detail: "x".to_string(),
sheet: None,
sheet_id: None,
backup: None,
}
.log_status(),
StructureResult::Failed {
detail: "x".to_string(),
}
.log_status(),
];
let unique: HashSet<&&str> = statuses.iter().collect();
assert_eq!(unique.len(), statuses.len(), "statuses must be distinct");
assert!(statuses.iter().all(|s| !s.is_empty()));
}
#[test]
fn a_verdict_of_allow_is_what_lets_a_run_proceed() {
assert_ne!(Verdict::Allow, Verdict::Deny);
}
#[test]
fn build_request_fails_rather_than_guessing_a_missing_sheet_id() {
let sheet = SheetSnapshot {
sheet_id: None,
title: "Q2".to_string(),
row_count: None,
column_count: None,
..Default::default()
};
let err = build_request(&rename(), Some(&sheet)).unwrap_err();
assert!(err.contains("sheetId"), "{err}");
}
#[test]
fn added_sheet_id_reads_the_add_sheet_reply_only() {
let empty: BatchUpdateResponse =
serde_json::from_value(serde_json::json!({"spreadsheetId": "s", "replies": [{}]}))
.unwrap();
assert_eq!(added_sheet_id(&empty), None);
let added: BatchUpdateResponse = serde_json::from_value(serde_json::json!({
"replies": [{"addSheet": {"properties": {"sheetId": 42, "title": "New"}}}]
}))
.unwrap();
assert_eq!(added_sheet_id(&added), Some(42));
}
#[tokio::test]
async fn a_file_rule_grants_a_sheet_with_no_visible_parents() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.expect(1)
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[FolderPermissionRule::file("sheet-1").allowing([DriveOperation::SheetsStructure])],
)
.await;
assert!(
matches!(outcome.result, StructureResult::Changed { .. }),
"{:?}",
outcome.result
);
}
#[tokio::test]
async fn a_file_rule_denies_even_when_a_parent_folder_would_allow() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[
allow_rule("parent-1"),
FolderPermissionRule::file("sheet-1").denying([DriveOperation::SheetsStructure]),
],
)
.await;
match &outcome.result {
StructureResult::Blocked { decided_by } => {
let rule = decided_by.as_ref().expect("a file rule decided this");
assert_eq!(rule.kind_label(), "file");
assert_eq!(rule.id(), "sheet-1");
}
other => panic!("expected Blocked, got {other:?}"),
}
let text = describe(&outcome);
assert!(text.contains("refused by rule on file sheet-1"), "{text}");
assert!(!text.contains("depth"), "a file rule has no depth: {text}");
}
#[tokio::test]
async fn the_no_visible_parents_message_names_a_file_rule_as_the_fix() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let outcome = structure(&drive, &sheets, &opts(rename(), false), &[]).await;
let text = describe(&outcome);
assert!(text.contains("file_id"), "{text}");
assert!(text.contains("sheets-structure"), "{text}");
}
#[tokio::test]
async fn a_leased_structure_change_concludes_its_audit_pair_with_allowed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let verb = StructureVerb::RenameSheet {
sheet: "Q2".to_string(),
new_title: "Q2 (final)".to_string(),
};
let outcome = structure(
&drive,
&sheets,
&opts(verb, false),
&[allow_rule("parent-1")],
)
.await;
assert!(matches!(outcome.result, StructureResult::Changed { .. }));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
assert_eq!(records[0].command, ["drive", "sheets-rename-sheet"]);
}
#[tokio::test]
async fn a_missing_sheet_id_fails_before_opening_the_audit_pair() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", GOOGLE_SHEET_MIME_TYPE, &["parent-1"])
.mount(&server)
.await;
mount_folder("parent-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": {"title": "Q2", "index": 0}}
],
})),
)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let outcome = structure(
&drive,
&sheets,
&opts(rename(), false),
&[allow_rule("parent-1")],
)
.await;
match outcome.result {
StructureResult::Failed { detail } => {
assert!(detail.contains("sheetId"), "{detail}");
}
other => panic!("expected Failed, got {other:?}"),
}
assert!(audit.records().is_empty(), "{:?}", audit.records());
}
}