use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::sheets::a1;
use crate::drive::sheets::api::{
applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
AutoResizeDimensionsRequest, BatchUpdateRequestItem, Border, CellFormat, ColorStyle, Dimension,
DimensionProperties, DimensionRange, GridRange, MergeCellsRequest, NumberFormat, Padding,
RepeatCellData, RepeatCellRequest, Spreadsheet, TextFormat, TextRotation, UnmergeCellsRequest,
UpdateBordersRequest, UpdateDimensionPropertiesRequest, ValueRange,
};
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextRotationFlag {
Angle(i64),
Vertical,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CellFormatFlags {
pub bold: Option<bool>,
pub italic: Option<bool>,
pub strikethrough: Option<bool>,
pub underline: Option<bool>,
pub font_size: Option<i64>,
pub text_color: Option<String>,
pub background: Option<String>,
pub horizontal_align: Option<String>,
pub vertical_align: Option<String>,
pub number_format_pattern: Option<String>,
pub number_format_type: Option<String>,
pub wrap: Option<String>,
pub font_family: Option<String>,
pub text_rotation: Option<TextRotationFlag>,
pub hyperlink_display_type: Option<String>,
pub padding_top: Option<i64>,
pub padding_right: Option<i64>,
pub padding_bottom: Option<i64>,
pub padding_left: Option<i64>,
pub text_direction: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BorderSides {
pub top: bool,
pub bottom: bool,
pub left: bool,
pub right: bool,
pub inner_horizontal: bool,
pub inner_vertical: bool,
}
impl BorderSides {
fn any(self) -> bool {
self.top
|| self.bottom
|| self.left
|| self.right
|| self.inner_horizontal
|| self.inner_vertical
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatVerb {
FormatCells {
sheet: Option<String>,
range: Option<String>,
format: Box<CellFormatFlags>,
},
UpdateBorders {
sheet: Option<String>,
range: Option<String>,
sides: BorderSides,
style: String,
color: Option<String>,
},
MergeCells {
sheet: Option<String>,
range: Option<String>,
merge_type: String,
},
UnmergeCells {
sheet: Option<String>,
range: Option<String>,
},
AutoResizeDimension {
sheet: String,
dimension: Dimension,
start: i64,
end: i64,
},
UpdateDimensionProperties {
sheet: String,
dimension: Dimension,
start: i64,
end: i64,
pixel_size: i64,
},
}
impl FormatVerb {
const fn log_operation(&self) -> &'static str {
match self {
Self::FormatCells { .. } => "sheets-format-cells",
Self::UpdateBorders { .. } => "sheets-update-borders",
Self::MergeCells { .. } => "sheets-merge-cells",
Self::UnmergeCells { .. } => "sheets-unmerge-cells",
Self::AutoResizeDimension { .. } => "sheets-auto-resize-dimension",
Self::UpdateDimensionProperties { .. } => "sheets-update-dimension-properties",
}
}
const fn label(&self) -> &'static str {
match self {
Self::FormatCells { .. } => "format-cells",
Self::UpdateBorders { .. } => "update-borders",
Self::MergeCells { .. } => "merge-cells",
Self::UnmergeCells { .. } => "unmerge-cells",
Self::AutoResizeDimension { .. } => "auto-resize-dimension",
Self::UpdateDimensionProperties { .. } => "update-dimension-properties",
}
}
fn sheet_and_range(&self) -> Option<(Option<&str>, Option<&str>)> {
match self {
Self::FormatCells { sheet, range, .. }
| Self::UpdateBorders { sheet, range, .. }
| Self::MergeCells { sheet, range, .. }
| Self::UnmergeCells { sheet, range, .. } => Some((sheet.as_deref(), range.as_deref())),
Self::AutoResizeDimension { .. } | Self::UpdateDimensionProperties { .. } => None,
}
}
}
#[derive(Debug, Clone)]
pub struct FormatOptions {
pub spreadsheet_id: String,
pub verb: FormatVerb,
pub dry_run: bool,
pub lease_token: Option<String>,
pub ledger_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(tag = "status", rename_all = "kebab-case")]
pub enum FormatResult {
WouldChange {
summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
discarded_cells: Vec<String>,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed {
summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
discarded_cells: Vec<String>,
},
AppliedReplyUnreadable {
summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
discarded_cells: Vec<String>,
detail: String,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for FormatResult {
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 FormatResult {
fn log_status(&self) -> &'static str {
match self {
Self::WouldChange { .. } => "would-change",
Self::RefusedNotASpreadsheet { .. } => "refused-not-a-spreadsheet",
Self::RefusedShortcut => "refused-shortcut",
Self::RefusedNoVisibleParents => "refused-no-visible-parents",
Self::RefusedSheetNotFound { .. } => "refused-sheet-not-found",
Self::RefusedInvalidRange { .. } => "refused-invalid-range",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Changed { .. } => "changed",
Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct FormatOutcome {
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: FormatVerb,
pub result: FormatResult,
}
impl JsonlSerialize for FormatOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub(super) fn parse_hex_color(input: &str) -> Result<crate::drive::sheets::types::Color, String> {
let hex = input.strip_prefix('#').unwrap_or(input);
if hex.len() != 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"'{input}' is not a color; expected 6 hex digits, optionally prefixed with '#' \
(e.g. #FF8800)"
));
}
let byte =
|slice: &str| -> f32 { f32::from(u8::from_str_radix(slice, 16).unwrap_or(0)) / 255.0 };
Ok(crate::drive::sheets::types::Color {
red: byte(&hex[0..2]),
green: byte(&hex[2..4]),
blue: byte(&hex[4..6]),
})
}
pub(super) fn normalize_hex_for_display(input: &str) -> String {
format!("#{}", input.strip_prefix('#').unwrap_or(input))
}
pub(super) fn format_hex_color(color: crate::drive::sheets::types::Color) -> String {
let channel = |v: f32| -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 };
format!(
"#{:02X}{:02X}{:02X}",
channel(color.red),
channel(color.green),
channel(color.blue)
)
}
fn to_padding_i32(value: i64, flag: &str) -> Result<i32, String> {
match i32::try_from(value) {
Ok(n) if n >= 0 => Ok(n),
_ => Err(format!(
"{flag} must be a non-negative number that fits in 32 bits, got {value}"
)),
}
}
fn build_cell_format(flags: &CellFormatFlags) -> Result<(CellFormat, String), String> {
let mut format = CellFormat::default();
let mut fields = Vec::new();
let mut text_format = TextFormat::default();
let mut text_format_used = false;
if let Some(bold) = flags.bold {
text_format.bold = Some(bold);
text_format_used = true;
fields.push("userEnteredFormat.textFormat.bold");
}
if let Some(italic) = flags.italic {
text_format.italic = Some(italic);
text_format_used = true;
fields.push("userEnteredFormat.textFormat.italic");
}
if let Some(strikethrough) = flags.strikethrough {
text_format.strikethrough = Some(strikethrough);
text_format_used = true;
fields.push("userEnteredFormat.textFormat.strikethrough");
}
if let Some(underline) = flags.underline {
text_format.underline = Some(underline);
text_format_used = true;
fields.push("userEnteredFormat.textFormat.underline");
}
if let Some(font_size) = flags.font_size {
text_format.font_size = Some(font_size);
text_format_used = true;
fields.push("userEnteredFormat.textFormat.fontSize");
}
if let Some(hex) = &flags.text_color {
let color = parse_hex_color(hex)?;
text_format.foreground_color_style = Some(ColorStyle::rgb(color));
text_format_used = true;
fields.push("userEnteredFormat.textFormat.foregroundColorStyle");
}
if let Some(font_family) = &flags.font_family {
text_format.font_family = Some(font_family.clone());
text_format_used = true;
fields.push("userEnteredFormat.textFormat.fontFamily");
}
if text_format_used {
format.text_format = Some(text_format);
}
if let Some(hex) = &flags.background {
let color = parse_hex_color(hex)?;
format.background_color_style = Some(ColorStyle::rgb(color));
fields.push("userEnteredFormat.backgroundColorStyle");
}
if let Some(align) = &flags.horizontal_align {
format.horizontal_alignment = Some(align.clone());
fields.push("userEnteredFormat.horizontalAlignment");
}
if let Some(align) = &flags.vertical_align {
format.vertical_alignment = Some(align.clone());
fields.push("userEnteredFormat.verticalAlignment");
}
match (&flags.number_format_pattern, &flags.number_format_type) {
(Some(pattern), Some(format_type)) => {
format.number_format = Some(NumberFormat {
format_type: format_type.clone(),
pattern: pattern.clone(),
});
fields.push("userEnteredFormat.numberFormat");
}
(None, None) => {}
_ => {
return Err(
"--number-format and --number-format-type must be given together".to_string(),
)
}
}
if let Some(wrap) = &flags.wrap {
format.wrap_strategy = Some(wrap.clone());
fields.push("userEnteredFormat.wrapStrategy");
}
if let Some(rotation) = flags.text_rotation {
format.text_rotation = Some(match rotation {
TextRotationFlag::Angle(angle) => {
if !(-90..=90).contains(&angle) {
return Err(format!(
"--text-rotation-angle must be between -90 and 90, got {angle}"
));
}
TextRotation {
angle: Some(angle as i32),
vertical: None,
}
}
TextRotationFlag::Vertical => TextRotation {
angle: None,
vertical: Some(true),
},
});
fields.push("userEnteredFormat.textRotation");
}
if let Some(hyperlink_display_type) = &flags.hyperlink_display_type {
format.hyperlink_display_type = Some(hyperlink_display_type.clone());
fields.push("userEnteredFormat.hyperlinkDisplayType");
}
if flags.padding_top.is_some()
|| flags.padding_right.is_some()
|| flags.padding_bottom.is_some()
|| flags.padding_left.is_some()
{
format.padding = Some(Padding {
top: flags
.padding_top
.map(|n| to_padding_i32(n, "--padding-top"))
.transpose()?,
right: flags
.padding_right
.map(|n| to_padding_i32(n, "--padding-right"))
.transpose()?,
bottom: flags
.padding_bottom
.map(|n| to_padding_i32(n, "--padding-bottom"))
.transpose()?,
left: flags
.padding_left
.map(|n| to_padding_i32(n, "--padding-left"))
.transpose()?,
});
fields.push("userEnteredFormat.padding");
}
if let Some(text_direction) = &flags.text_direction {
format.text_direction = Some(text_direction.clone());
fields.push("userEnteredFormat.textDirection");
}
if fields.is_empty() {
return Err(
"format-cells needs at least one property to set (--bold, --background, …)".to_string(),
);
}
Ok((format, fields.join(",")))
}
pub async fn format(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &FormatOptions,
rules: &[FolderPermissionRule],
) -> FormatOutcome {
let started = Instant::now();
let outcome = format_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn format_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &FormatOptions,
rules: &[FolderPermissionRule],
) -> FormatOutcome {
let bare = |result| FormatOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
};
let composed_range = if let Some((sheet, range)) = opts.verb.sheet_and_range() {
match a1::compose(sheet, range) {
Ok(composed) => Some(composed),
Err(err) => {
return bare(FormatResult::RefusedInvalidRange {
detail: err.to_string(),
})
}
}
} else {
None
};
let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
drive,
&opts.spreadsheet_id,
DriveOperation::SheetsStructure,
rules,
)
.await
{
target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
return bare(FormatResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => FormatResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
FormatResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => FormatResult::RefusedNoVisibleParents,
};
return FormatOutcome {
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 FormatOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
verb: opts.verb.clone(),
result: FormatResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let gated = |result| FormatOutcome {
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(FormatResult::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(FormatResult::Failed {
detail: format!("{err:#}"),
})
}
};
let resolved = match resolve_target(&workbook, &opts.verb, composed_range.as_deref()) {
Ok(resolved) => resolved,
Err(result) => return gated(result),
};
let discarded_cells = if matches!(opts.verb, FormatVerb::MergeCells { .. }) {
match read_discarded_cells(&api, &opts.spreadsheet_id, &resolved).await {
Ok(cells) => cells,
Err(detail) => return gated(FormatResult::Failed { detail }),
}
} else {
Vec::new()
};
let summary = match describe_effect(&opts.verb, &discarded_cells) {
Ok(summary) => summary,
Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
};
if opts.dry_run {
return gated(FormatResult::WouldChange {
summary,
discarded_cells,
});
}
let request = match build_request(&opts.verb, &resolved) {
Ok(request) => request,
Err(detail) => return gated(FormatResult::RefusedInvalidRange { detail }),
};
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets format",
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)) => FormatResult::Changed {
summary,
discarded_cells,
},
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
FormatResult::AppliedReplyUnreadable {
summary,
discarded_cells,
detail,
}
}
Err(err) => FormatResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
enum ResolvedTarget {
Range {
sheet_title: String,
grid: GridRange,
},
Dimension { range: DimensionRange },
}
fn resolve_target(
workbook: &Spreadsheet,
verb: &FormatVerb,
composed_range: Option<&str>,
) -> Result<ResolvedTarget, FormatResult> {
match verb {
FormatVerb::AutoResizeDimension {
sheet,
dimension,
start,
end,
}
| FormatVerb::UpdateDimensionProperties {
sheet,
dimension,
start,
end,
..
} => {
let sheet_id = find_sheet_id(workbook, sheet)?;
if *start < 1 || *end < *start {
return Err(FormatResult::RefusedInvalidRange {
detail: format!(
"--start must be at least 1 and --end must be >= --start (got \
--start {start} --end {end})"
),
});
}
let range = DimensionRange {
sheet_id,
dimension: *dimension,
start_index: start - 1,
end_index: *end,
};
Ok(ResolvedTarget::Dimension { range })
}
FormatVerb::FormatCells { .. }
| FormatVerb::UpdateBorders { .. }
| FormatVerb::MergeCells { .. }
| FormatVerb::UnmergeCells { .. } => {
let composed = composed_range.unwrap_or_default();
let (title, grid) = grid_range::resolve_grid_range(
workbook,
composed,
|detail| FormatResult::RefusedInvalidRange { detail },
|title, available| FormatResult::RefusedSheetNotFound { title, available },
)?;
if matches!(verb, FormatVerb::MergeCells { .. }) {
let bare_range =
a1::split_sheet_prefix(composed).map_or(composed, |(_, bare_range)| bare_range);
let Some(cells) = grid_range::cell_count(&grid) else {
return Err(FormatResult::RefusedInvalidRange {
detail: format!(
"'{bare_range}' is open-ended; merge-cells needs a fully bounded \
range (e.g. A1:D20) so its preview can name exactly what would be \
discarded"
),
});
};
if cells > MAX_CELLS_PER_MERGE {
return Err(FormatResult::RefusedInvalidRange {
detail: format!(
"'{bare_range}' covers {cells} cells, over the \
{MAX_CELLS_PER_MERGE}-cell budget for merge-cells; narrow --range"
),
});
}
}
if let FormatVerb::UpdateBorders { sides, .. } = verb {
if sides.inner_horizontal
&& matches!(
(grid.start_row_index, grid.end_row_index),
(Some(start), Some(end)) if end - start <= 1
)
{
return Err(FormatResult::RefusedInvalidRange {
detail: "--inner-horizontal needs a range spanning more than one row; \
a single row has no interior horizontal grid line"
.to_string(),
});
}
if sides.inner_vertical
&& matches!(
(grid.start_column_index, grid.end_column_index),
(Some(start), Some(end)) if end - start <= 1
)
{
return Err(FormatResult::RefusedInvalidRange {
detail: "--inner-vertical needs a range spanning more than one column; \
a single column has no interior vertical grid line"
.to_string(),
});
}
}
Ok(ResolvedTarget::Range {
sheet_title: title,
grid,
})
}
}
}
fn find_sheet_id(workbook: &Spreadsheet, title: &str) -> Result<i64, FormatResult> {
grid_range::find_sheet_id(workbook, title, |title, available| {
FormatResult::RefusedSheetNotFound { title, available }
})
}
async fn read_discarded_cells(
api: &SheetsApi<'_>,
spreadsheet_id: &str,
resolved: &ResolvedTarget,
) -> Result<Vec<String>, String> {
let ResolvedTarget::Range { sheet_title, grid } = resolved else {
return Ok(Vec::new());
};
let is_single_cell = matches!(
(
grid.start_row_index,
grid.end_row_index,
grid.start_column_index,
grid.end_column_index,
),
(Some(r0), Some(r1), Some(c0), Some(c1)) if r1 - r0 <= 1 && c1 - c0 <= 1
);
if is_single_cell {
return Ok(Vec::new());
}
let Some(a1_range) = grid_range::bounded_range_to_a1(sheet_title, grid) else {
return Ok(Vec::new());
};
let values = api
.values_get(spreadsheet_id, &a1_range, ValueRenderOption::Formatted)
.await
.map_err(|err| format!("{err:#}"))?;
Ok(discarded_from_values(
&values,
grid.start_row_index.unwrap_or(0),
grid.start_column_index.unwrap_or(0),
))
}
fn discarded_from_values(values: &ValueRange, row_offset: i64, col_offset: i64) -> Vec<String> {
let mut discarded = Vec::new();
for (row_idx, row) in values.values.iter().enumerate() {
for (col_idx, cell) in row.iter().enumerate() {
if row_idx == 0 && col_idx == 0 {
continue;
}
let is_blank = cell.is_null() || cell.as_str().is_some_and(str::is_empty);
if is_blank {
continue;
}
let address = format!(
"{}{}",
grid_range::column_index_to_letters(col_idx as i64 + col_offset),
row_idx as i64 + row_offset + 1
);
let display = cell
.as_str()
.map_or_else(|| cell.to_string(), str::to_string);
discarded.push(format!("{address}: {display}"));
}
}
discarded
}
const MERGE_DISCARD_RENDER_LIMIT: usize = 200;
const MAX_CELLS_PER_MERGE: i64 = 50_000;
fn describe_effect(verb: &FormatVerb, discarded_cells: &[String]) -> Result<String, String> {
match verb {
FormatVerb::FormatCells { format, .. } => {
let (_, fields) = build_cell_format(format)?;
Ok(format!("set {fields}"))
}
FormatVerb::UpdateBorders { sides, style, .. } => {
if !sides.any() {
return Err(
"update-borders needs at least one side (--top, --bottom, --left, --right, \
--all, --inner-horizontal, --inner-vertical)"
.to_string(),
);
}
let mut names = Vec::new();
if sides.top {
names.push("top");
}
if sides.bottom {
names.push("bottom");
}
if sides.left {
names.push("left");
}
if sides.right {
names.push("right");
}
if sides.inner_horizontal {
names.push("inner-horizontal");
}
if sides.inner_vertical {
names.push("inner-vertical");
}
Ok(format!("set {} border(s), style {style}", names.join(", ")))
}
FormatVerb::MergeCells { merge_type, .. } => {
if discarded_cells.is_empty() {
Ok(format!("merge ({merge_type})"))
} else {
let elided = discarded_cells.len() > MERGE_DISCARD_RENDER_LIMIT;
Ok(format!(
"merge ({merge_type}), discarding {} cell(s): {}{}",
discarded_cells.len(),
grid_range::truncate_joined(discarded_cells, MERGE_DISCARD_RENDER_LIMIT, "; "),
if elided {
" (full list in -o json / the drivemutation log's discarded_cells)"
} else {
""
}
))
}
}
FormatVerb::UnmergeCells { .. } => Ok("unmerge".to_string()),
FormatVerb::AutoResizeDimension {
sheet,
dimension,
start,
end,
} => Ok(format!(
"auto-resize {}",
dimension_span(sheet, *dimension, *start, *end)
)),
FormatVerb::UpdateDimensionProperties {
sheet,
dimension,
start,
end,
pixel_size,
} => Ok(format!(
"set the pixelSize of {} to {pixel_size}",
dimension_span(sheet, *dimension, *start, *end)
)),
}
}
fn dimension_span(sheet: &str, dimension: Dimension, start: i64, end: i64) -> String {
let (first, last) = match dimension {
Dimension::Columns => (
grid_range::column_index_to_letters(start - 1),
grid_range::column_index_to_letters(end - 1),
),
Dimension::Rows => (start.to_string(), end.to_string()),
};
format!(
"{}(s) {first}:{last} of {}",
dimension.noun(),
a1::quote_sheet_title(sheet)
)
}
fn past_tense_summary(verb: &FormatVerb, summary: &str) -> String {
let (imperative, past) = match verb {
FormatVerb::FormatCells { .. }
| FormatVerb::UpdateBorders { .. }
| FormatVerb::UpdateDimensionProperties { .. } => ("set", "Set"),
FormatVerb::MergeCells { .. } => ("merge", "Merged cells"),
FormatVerb::UnmergeCells { .. } => ("unmerge", "Unmerged cells"),
FormatVerb::AutoResizeDimension { .. } => ("auto-resize", "Auto-resized"),
};
summary.strip_prefix(imperative).map_or_else(
|| format!("Applied: {summary}"),
|rest| format!("{past}{rest}"),
)
}
fn build_request(
verb: &FormatVerb,
resolved: &ResolvedTarget,
) -> Result<BatchUpdateRequestItem, String> {
match verb {
FormatVerb::FormatCells { format, .. } => {
let ResolvedTarget::Range { grid, .. } = resolved else {
unreachable!("resolve_target always resolves FormatCells to a Range")
};
let (cell_format, fields) = build_cell_format(format)?;
Ok(BatchUpdateRequestItem::RepeatCell(RepeatCellRequest {
range: *grid,
cell: RepeatCellData {
user_entered_format: cell_format,
},
fields,
}))
}
FormatVerb::UpdateBorders {
sides,
style,
color,
..
} => {
let ResolvedTarget::Range { grid, .. } = resolved else {
unreachable!("resolve_target always resolves UpdateBorders to a Range")
};
let color = match color {
Some(hex) => parse_hex_color(hex)?,
None => crate::drive::sheets::types::Color::default(),
};
let border = Border {
style: style.clone(),
color_style: ColorStyle::rgb(color),
};
let mut request = UpdateBordersRequest {
range: *grid,
..Default::default()
};
if sides.top {
request.top = Some(border.clone());
}
if sides.bottom {
request.bottom = Some(border.clone());
}
if sides.left {
request.left = Some(border.clone());
}
if sides.right {
request.right = Some(border.clone());
}
if sides.inner_horizontal {
request.inner_horizontal = Some(border.clone());
}
if sides.inner_vertical {
request.inner_vertical = Some(border);
}
Ok(BatchUpdateRequestItem::UpdateBorders(request))
}
FormatVerb::MergeCells { merge_type, .. } => {
let ResolvedTarget::Range { grid, .. } = resolved else {
unreachable!("resolve_target always resolves MergeCells to a Range")
};
Ok(BatchUpdateRequestItem::MergeCells(MergeCellsRequest {
range: *grid,
merge_type: merge_type.clone(),
}))
}
FormatVerb::UnmergeCells { .. } => {
let ResolvedTarget::Range { grid, .. } = resolved else {
unreachable!("resolve_target always resolves UnmergeCells to a Range")
};
Ok(BatchUpdateRequestItem::UnmergeCells(UnmergeCellsRequest {
range: *grid,
}))
}
FormatVerb::AutoResizeDimension { .. } => {
let ResolvedTarget::Dimension { range } = resolved else {
unreachable!("resolve_target always resolves AutoResizeDimension to a Dimension")
};
Ok(BatchUpdateRequestItem::AutoResizeDimensions(
AutoResizeDimensionsRequest {
dimensions: range.clone(),
},
))
}
FormatVerb::UpdateDimensionProperties { pixel_size, .. } => {
let ResolvedTarget::Dimension { range } = resolved else {
unreachable!(
"resolve_target always resolves UpdateDimensionProperties to a Dimension"
)
};
Ok(BatchUpdateRequestItem::UpdateDimensionProperties(
UpdateDimensionPropertiesRequest {
range: range.clone(),
properties: DimensionProperties {
pixel_size: *pixel_size,
},
fields: "pixelSize".to_string(),
},
))
}
}
}
fn record_attempt(outcome: &FormatOutcome, opts: &FormatOptions, duration: Duration) {
request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
}
fn mutation_record(
outcome: &FormatOutcome,
opts: &FormatOptions,
duration: Duration,
) -> DriveMutationOutcome {
let error = match &outcome.result {
FormatResult::Failed { detail } | FormatResult::AppliedReplyUnreadable { detail, .. } => {
Some(detail.clone())
}
_ => None,
};
let decided_by = match &outcome.result {
FormatResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let (fields_changed, discarded_cells) = match &outcome.result {
FormatResult::Changed {
summary,
discarded_cells,
}
| FormatResult::AppliedReplyUnreadable {
summary,
discarded_cells,
..
}
| FormatResult::WouldChange {
summary,
discarded_cells,
} => (Some(summary.clone()), discarded_cells.clone()),
_ => (None, Vec::new()),
};
DriveMutationOutcome {
operation: opts.verb.log_operation(),
file_id: outcome.spreadsheet_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
fields_changed,
discarded_cells,
error,
duration,
..Default::default()
}
}
#[must_use]
pub fn describe(outcome: &FormatOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &FormatOutcome) -> 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 {
FormatResult::WouldChange { summary, .. } => {
vec![format!("Would {summary} in {book}")]
}
FormatResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets {}` only works on spreadsheets",
verb.label()
)],
FormatResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
verb.label()
)],
FormatResult::RefusedNoVisibleParents => vec![format!(
"Refused: {book} has no parent folder visible to this account, so no folder \
rule can apply to it. Grant it by id instead: add {{\"file_id\": \"<spreadsheet \
id>\", \"allow\": [\"sheets-structure\"]}} to write_permissions.rules."
)],
FormatResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
FormatResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
FormatResult::Blocked { decided_by } => vec![match decided_by {
Some(rule) => format!(
"Blocked: {} on {book} refused by rule on {} {}{}",
verb.label(),
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: {} on {book} refused by default policy (no matching rule for \
sheets-structure)",
verb.label()
),
}],
FormatResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
FormatResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
FormatResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
FormatResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
FormatResult::Changed { summary, .. } => {
vec![format!("{} in {book}", past_tense_summary(verb, summary))]
}
FormatResult::AppliedReplyUnreadable {
summary, detail, ..
} => {
vec![applied_reply_unreadable_line(
summary,
&book,
RetryHint::Idempotent,
detail,
)]
}
FormatResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
#[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;
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 mount_workbook() -> 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(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {
"sheetId": 0, "title": "Q1", "index": 0,
"gridProperties": {"rowCount": 1000, "columnCount": 26}}},
],
})),
)
}
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 format_cells_opts(dry_run: bool) -> FormatOptions {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "1");
FormatOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: FormatVerb::FormatCells {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
format: Box::new(CellFormatFlags {
bold: Some(true),
..Default::default()
}),
},
dry_run,
lease_token: Some(token),
ledger_path,
}
}
#[tokio::test]
async fn dry_run_reports_would_change_and_makes_no_batch_update_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(true), &rules).await;
match outcome.result {
FormatResult::WouldChange { summary, .. } => {
assert!(summary.contains("bold"), "{summary}");
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn a_denied_gate_blocks_before_any_batch_update_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
match outcome.result {
FormatResult::Blocked { decided_by } => assert!(decided_by.is_none()),
other => panic!("expected Blocked, got {other:?}"),
}
}
#[tokio::test]
async fn format_cells_sends_a_repeat_cell_request_with_no_value_field() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.expect("no batchUpdate request was sent");
let body: serde_json::Value = serde_json::from_slice(&batch.body).unwrap();
let repeat_cell = &body["requests"][0]["repeatCell"];
assert_eq!(
repeat_cell["cell"]["userEnteredFormat"]["textFormat"]["bold"],
true
);
assert!(
repeat_cell["cell"].get("userEnteredValue").is_none(),
"repeatCell must never carry a value: {repeat_cell}"
);
}
#[tokio::test]
async fn merge_cells_dry_run_lists_discarded_cells() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep", "gone"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = FormatOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: FormatVerb::MergeCells {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
merge_type: "MERGE_ALL".to_string(),
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
match outcome.result {
FormatResult::WouldChange {
discarded_cells, ..
} => {
assert_eq!(discarded_cells, vec!["B1: gone"]);
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn merge_cells_dry_run_keeps_the_full_discard_list_past_the_render_limit() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let values: Vec<Vec<String>> = (1..=202).map(|row| vec![format!("v{row}")]).collect();
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:A202",
))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "values": values })),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = FormatOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: FormatVerb::MergeCells {
sheet: Some("Q1".to_string()),
range: Some("A1:A202".to_string()),
merge_type: "MERGE_ALL".to_string(),
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
let summary = match outcome.result {
FormatResult::WouldChange {
summary,
discarded_cells,
} => {
assert_eq!(discarded_cells.len(), 201);
assert_eq!(discarded_cells[0], "A2: v2");
assert_eq!(discarded_cells[200], "A202: v202");
summary
}
other => panic!("expected WouldChange, got {other:?}"),
};
assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
assert!(summary.contains("A201: v201"), "{summary}");
assert!(!summary.contains("A202: v202"), "{summary}");
assert!(
summary.ends_with(
"… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
),
"{summary}"
);
}
#[test]
fn parse_hex_color_accepts_with_and_without_hash() {
let a = parse_hex_color("#FF8800").unwrap();
let b = parse_hex_color("ff8800").unwrap();
assert!((a.red - b.red).abs() < f32::EPSILON);
assert!((a.red - 1.0).abs() < 0.01);
assert!((a.green - 0x88 as f32 / 255.0).abs() < 0.01);
assert!((a.blue - 0.0).abs() < 0.01);
}
#[test]
fn parse_hex_color_rejects_wrong_length_and_non_hex() {
assert!(parse_hex_color("#FFF").is_err());
assert!(parse_hex_color("#GGGGGG").is_err());
assert!(parse_hex_color("").is_err());
}
#[test]
fn normalize_hex_for_display_always_carries_exactly_one_hash() {
assert_eq!(normalize_hex_for_display("#FF8800"), "#FF8800");
assert_eq!(normalize_hex_for_display("FF8800"), "#FF8800");
assert_eq!(normalize_hex_for_display("##FF8800"), "##FF8800");
assert_eq!(normalize_hex_for_display(""), "#");
}
#[test]
fn format_hex_color_round_trips_parse_hex_color() {
for hex in ["#FF8800", "#000000", "#FFFFFF", "#4A86E8"] {
let color = parse_hex_color(hex).unwrap();
assert_eq!(format_hex_color(color), hex);
}
}
#[test]
fn build_cell_format_refuses_an_empty_flag_set() {
let err = build_cell_format(&CellFormatFlags::default()).unwrap_err();
assert!(err.contains("at least one property"), "{err}");
}
#[test]
fn build_cell_format_masks_exactly_the_flags_given() {
let flags = CellFormatFlags {
bold: Some(true),
background: Some("#FF0000".to_string()),
..Default::default()
};
let (format, fields) = build_cell_format(&flags).unwrap();
assert_eq!(format.text_format.unwrap().bold, Some(true));
assert!(format.background_color_style.is_some());
assert_eq!(
fields,
"userEnteredFormat.textFormat.bold,userEnteredFormat.backgroundColorStyle"
);
}
#[test]
fn build_cell_format_requires_number_format_pattern_and_type_together() {
let flags = CellFormatFlags {
number_format_pattern: Some("0.00".to_string()),
..Default::default()
};
let err = build_cell_format(&flags).unwrap_err();
assert!(err.contains("together"), "{err}");
}
#[test]
fn discarded_from_values_skips_the_top_left_and_blanks() {
let values: ValueRange = serde_json::from_value(serde_json::json!({
"values": [["keep", "b1", ""], ["a2", null, "c2"]]
}))
.unwrap();
let discarded = discarded_from_values(&values, 0, 0);
assert_eq!(discarded, vec!["B1: b1", "A2: a2", "C2: c2"]);
}
#[test]
fn discarded_from_values_offsets_addresses_by_the_ranges_own_start() {
let values: ValueRange = serde_json::from_value(serde_json::json!({
"values": [["keep", "d5"]]
}))
.unwrap();
let discarded = discarded_from_values(&values, 4, 2);
assert_eq!(discarded, vec!["D5: d5"]);
}
#[test]
fn merge_cells_summary_lists_every_discard_up_to_the_render_limit() {
let discarded: Vec<String> = (1..=200).map(|row| format!("A{row}: v{row}")).collect();
let summary = describe_effect(&merge_cells_verb("A1:A201"), &discarded).unwrap();
assert!(!summary.contains('…'), "{summary}");
assert!(summary.contains("discarding 200 cell(s)"), "{summary}");
assert!(summary.ends_with(&discarded.join("; ")), "{summary}");
}
#[test]
fn merge_cells_summary_elides_past_the_render_limit_but_keeps_the_exact_count() {
let discarded: Vec<String> = (1..=201).map(|row| format!("A{row}: v{row}")).collect();
let summary = describe_effect(&merge_cells_verb("A1:A202"), &discarded).unwrap();
assert!(summary.contains("discarding 201 cell(s)"), "{summary}");
assert!(summary.contains("A200: v200"), "{summary}");
assert!(!summary.contains("A201: v201"), "{summary}");
assert!(
summary.ends_with(
"… and 1 more (full list in -o json / the drivemutation log's discarded_cells)"
),
"{summary}"
);
}
#[tokio::test]
async fn merge_cells_refuses_an_open_ended_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = FormatOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: FormatVerb::MergeCells {
sheet: Some("Q1".to_string()),
range: Some("A:A".to_string()),
merge_type: "MERGE_ALL".to_string(),
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("open-ended"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn merge_cells_refuses_a_range_over_the_cell_budget_before_reading_it() {
for dry_run in [true, false] {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path_regex(
r"^/v4/spreadsheets/sheet-1/values/",
))
.respond_with(wiremock::ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
mount_batch_update(serde_json::json!({}))
.expect(0)
.mount(&server)
.await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let rules = vec![allow_rule("folder-1")];
let opts = opts(merge_cells_verb("A1:ZZ1000"), dry_run);
let outcome = format(&drive, &sheets, &opts, &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert_eq!(
detail,
"'A1:ZZ1000' covers 702000 cells, over the 50000-cell budget for \
merge-cells; narrow --range",
"dry_run={dry_run}"
);
}
other => panic!("dry_run={dry_run}: expected RefusedInvalidRange, got {other:?}"),
}
assert!(audit.records().is_empty(), "{:?}", audit.records());
server.verify().await;
}
}
#[tokio::test]
async fn merge_cells_accepts_a_range_exactly_at_the_cell_budget() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:J5000",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep", "gone"]]
})),
)
.expect(1)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = opts(merge_cells_verb("A1:J5000"), true);
let outcome = format(&drive, &sheets, &opts, &rules).await;
match outcome.result {
FormatResult::WouldChange {
discarded_cells, ..
} => assert_eq!(discarded_cells, vec!["B1: gone"]),
other => panic!("expected WouldChange, got {other:?}"),
}
}
fn opts(verb: FormatVerb, dry_run: bool) -> FormatOptions {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "1");
FormatOptions {
spreadsheet_id: "sheet-1".to_string(),
verb,
dry_run,
lease_token: Some(token),
ledger_path,
}
}
fn update_borders_verb(sides: BorderSides, style: &str, color: Option<&str>) -> FormatVerb {
FormatVerb::UpdateBorders {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
sides,
style: style.to_string(),
color: color.map(str::to_string),
}
}
fn merge_cells_verb(range: &str) -> FormatVerb {
FormatVerb::MergeCells {
sheet: Some("Q1".to_string()),
range: Some(range.to_string()),
merge_type: "MERGE_ALL".to_string(),
}
}
fn unmerge_cells_verb() -> FormatVerb {
FormatVerb::UnmergeCells {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
}
}
fn auto_resize_verb(sheet: &str, start: i64, end: i64) -> FormatVerb {
FormatVerb::AutoResizeDimension {
sheet: sheet.to_string(),
dimension: Dimension::Rows,
start,
end,
}
}
fn update_dimension_verb(sheet: &str, start: i64, end: i64, pixel_size: i64) -> FormatVerb {
FormatVerb::UpdateDimensionProperties {
sheet: sheet.to_string(),
dimension: Dimension::Columns,
start,
end,
pixel_size,
}
}
fn find_batch_update(requests: &[wiremock::Request]) -> serde_json::Value {
let batch = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.expect("no batchUpdate request was sent");
serde_json::from_slice(&batch.body).unwrap()
}
#[test]
fn border_sides_any_is_true_iff_at_least_one_side_is_set() {
assert!(!BorderSides::default().any());
assert!(BorderSides {
top: true,
..Default::default()
}
.any());
assert!(BorderSides {
bottom: true,
..Default::default()
}
.any());
assert!(BorderSides {
left: true,
..Default::default()
}
.any());
assert!(BorderSides {
right: true,
..Default::default()
}
.any());
assert!(BorderSides {
inner_horizontal: true,
..Default::default()
}
.any());
assert!(BorderSides {
inner_vertical: true,
..Default::default()
}
.any());
}
#[test]
fn log_operation_and_label_are_distinct_for_every_verb() {
let verbs = [
format_cells_opts(false).verb,
update_borders_verb(BorderSides::default(), "SOLID", None),
merge_cells_verb("A1:B2"),
unmerge_cells_verb(),
auto_resize_verb("Q1", 1, 2),
update_dimension_verb("Q1", 1, 2, 10),
];
let ops: HashSet<&str> = verbs.iter().map(FormatVerb::log_operation).collect();
assert_eq!(ops.len(), verbs.len());
let labels: HashSet<&str> = verbs.iter().map(FormatVerb::label).collect();
assert_eq!(labels.len(), verbs.len());
}
#[test]
fn sheet_and_range_is_none_only_for_the_dimension_span_verbs() {
assert_eq!(
merge_cells_verb("A1:B2").sheet_and_range(),
Some((Some("Q1"), Some("A1:B2")))
);
assert!(auto_resize_verb("Q1", 1, 2).sheet_and_range().is_none());
assert!(update_dimension_verb("Q1", 1, 2, 10)
.sheet_and_range()
.is_none());
}
#[test]
fn log_status_is_distinct_for_every_variant() {
let statuses = [
FormatResult::WouldChange {
summary: "x".to_string(),
discarded_cells: Vec::new(),
}
.log_status(),
FormatResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
}
.log_status(),
FormatResult::RefusedShortcut.log_status(),
FormatResult::RefusedNoVisibleParents.log_status(),
FormatResult::RefusedSheetNotFound {
title: "x".to_string(),
available: Vec::new(),
}
.log_status(),
FormatResult::RefusedInvalidRange {
detail: "x".to_string(),
}
.log_status(),
FormatResult::Blocked { decided_by: None }.log_status(),
FormatResult::RefusedNoLease.log_status(),
FormatResult::RefusedLeaseExpired.log_status(),
FormatResult::RefusedLeaseWrongFile.log_status(),
FormatResult::RefusedLeaseStale.log_status(),
FormatResult::Changed {
summary: "x".to_string(),
discarded_cells: Vec::new(),
}
.log_status(),
FormatResult::AppliedReplyUnreadable {
summary: "x".to_string(),
discarded_cells: Vec::new(),
detail: "x".to_string(),
}
.log_status(),
FormatResult::Failed {
detail: "x".to_string(),
}
.log_status(),
];
let unique: HashSet<&str> = statuses.iter().copied().collect();
assert_eq!(unique.len(), statuses.len());
assert!(statuses.iter().all(|s| !s.is_empty()));
}
#[test]
fn record_attempt_handles_a_would_change_outcome_without_panicking() {
let opts = format_cells_opts(false);
let outcome = FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: opts.verb.clone(),
result: FormatResult::WouldChange {
summary: "set bold".to_string(),
discarded_cells: vec!["B1: gone".to_string()],
},
};
record_attempt(&outcome, &opts, Duration::from_millis(1));
}
#[test]
fn write_jsonl_serializes_the_outcome_as_one_json_line() {
let outcome = FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: format_cells_opts(false).verb,
result: FormatResult::Changed {
summary: "set bold".to_string(),
discarded_cells: Vec::new(),
},
};
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.matches('\n').count(), 1);
let value: serde_json::Value = serde_json::from_str(text.trim_end()).unwrap();
assert_eq!(value["spreadsheet_id"], "sheet-1");
assert_eq!(value["result"]["status"], "changed");
}
#[test]
fn build_cell_format_covers_every_remaining_flag() {
let flags = CellFormatFlags {
italic: Some(true),
strikethrough: Some(true),
underline: Some(true),
font_size: Some(14),
text_color: Some("#112233".to_string()),
horizontal_align: Some("CENTER".to_string()),
vertical_align: Some("MIDDLE".to_string()),
number_format_pattern: Some("0.00".to_string()),
number_format_type: Some("NUMBER".to_string()),
wrap: Some("WRAP".to_string()),
font_family: Some("Arial".to_string()),
text_rotation: Some(TextRotationFlag::Angle(45)),
hyperlink_display_type: Some("PLAIN_TEXT".to_string()),
padding_top: Some(1),
padding_right: Some(2),
padding_bottom: Some(3),
padding_left: Some(4),
text_direction: Some("RIGHT_TO_LEFT".to_string()),
..Default::default()
};
let (format, fields) = build_cell_format(&flags).unwrap();
let text_format = format.text_format.unwrap();
assert_eq!(text_format.italic, Some(true));
assert_eq!(text_format.strikethrough, Some(true));
assert_eq!(text_format.underline, Some(true));
assert_eq!(text_format.font_size, Some(14));
assert!(text_format.foreground_color_style.is_some());
assert_eq!(text_format.font_family.as_deref(), Some("Arial"));
assert_eq!(format.horizontal_alignment.as_deref(), Some("CENTER"));
assert_eq!(format.vertical_alignment.as_deref(), Some("MIDDLE"));
assert!(format.number_format.is_some());
assert_eq!(format.wrap_strategy.as_deref(), Some("WRAP"));
let text_rotation = format.text_rotation.unwrap();
assert_eq!(text_rotation.angle, Some(45));
assert_eq!(text_rotation.vertical, None);
assert_eq!(format.hyperlink_display_type.as_deref(), Some("PLAIN_TEXT"));
let padding = format.padding.unwrap();
assert_eq!(padding.top, Some(1));
assert_eq!(padding.right, Some(2));
assert_eq!(padding.bottom, Some(3));
assert_eq!(padding.left, Some(4));
assert_eq!(format.text_direction.as_deref(), Some("RIGHT_TO_LEFT"));
for expected in [
"userEnteredFormat.textFormat.italic",
"userEnteredFormat.textFormat.strikethrough",
"userEnteredFormat.textFormat.underline",
"userEnteredFormat.textFormat.fontSize",
"userEnteredFormat.textFormat.foregroundColorStyle",
"userEnteredFormat.textFormat.fontFamily",
"userEnteredFormat.horizontalAlignment",
"userEnteredFormat.verticalAlignment",
"userEnteredFormat.numberFormat",
"userEnteredFormat.wrapStrategy",
"userEnteredFormat.textRotation",
"userEnteredFormat.hyperlinkDisplayType",
"userEnteredFormat.padding",
"userEnteredFormat.textDirection",
] {
assert!(fields.contains(expected), "{fields} missing {expected}");
}
}
#[test]
fn build_cell_format_sets_text_rotation_vertical() {
let flags = CellFormatFlags {
text_rotation: Some(TextRotationFlag::Vertical),
..Default::default()
};
let (format, fields) = build_cell_format(&flags).unwrap();
let text_rotation = format.text_rotation.unwrap();
assert_eq!(text_rotation.angle, None);
assert_eq!(text_rotation.vertical, Some(true));
assert_eq!(fields, "userEnteredFormat.textRotation");
}
#[test]
fn build_cell_format_accepts_negative_text_rotation_angle() {
let flags = CellFormatFlags {
text_rotation: Some(TextRotationFlag::Angle(-45)),
..Default::default()
};
let (format, _fields) = build_cell_format(&flags).unwrap();
let text_rotation = format.text_rotation.unwrap();
assert_eq!(text_rotation.angle, Some(-45));
}
#[test]
fn build_cell_format_rejects_out_of_range_text_rotation_angle() {
for angle in [91, -91, i64::MAX, i64::MIN] {
let flags = CellFormatFlags {
text_rotation: Some(TextRotationFlag::Angle(angle)),
..Default::default()
};
let err = build_cell_format(&flags).unwrap_err();
assert!(err.contains("-90 and 90"), "{angle}: {err}");
}
}
#[test]
fn build_cell_format_rejects_out_of_range_padding() {
for flags in [
CellFormatFlags {
padding_top: Some(-1),
..Default::default()
},
CellFormatFlags {
padding_top: Some(i64::from(i32::MAX) + 1),
..Default::default()
},
] {
let err = build_cell_format(&flags).unwrap_err();
assert!(err.contains("--padding-top"), "{err}");
}
}
#[test]
#[should_panic(expected = "FormatCells to a Range")]
fn build_request_panics_if_format_cells_resolves_to_a_dimension() {
let verb = format_cells_opts(false).verb;
let resolved = ResolvedTarget::Dimension {
range: DimensionRange {
sheet_id: 0,
dimension: Dimension::Rows,
start_index: 0,
end_index: 1,
},
};
let _ = build_request(&verb, &resolved);
}
#[test]
#[should_panic(expected = "UpdateBorders to a Range")]
fn build_request_panics_if_update_borders_resolves_to_a_dimension() {
let verb = update_borders_verb(
BorderSides {
top: true,
..Default::default()
},
"SOLID",
None,
);
let resolved = ResolvedTarget::Dimension {
range: DimensionRange {
sheet_id: 0,
dimension: Dimension::Rows,
start_index: 0,
end_index: 1,
},
};
let _ = build_request(&verb, &resolved);
}
#[test]
#[should_panic(expected = "MergeCells to a Range")]
fn build_request_panics_if_merge_cells_resolves_to_a_dimension() {
let resolved = ResolvedTarget::Dimension {
range: DimensionRange {
sheet_id: 0,
dimension: Dimension::Rows,
start_index: 0,
end_index: 1,
},
};
let _ = build_request(&merge_cells_verb("A1:B2"), &resolved);
}
#[test]
#[should_panic(expected = "UnmergeCells to a Range")]
fn build_request_panics_if_unmerge_cells_resolves_to_a_dimension() {
let resolved = ResolvedTarget::Dimension {
range: DimensionRange {
sheet_id: 0,
dimension: Dimension::Rows,
start_index: 0,
end_index: 1,
},
};
let _ = build_request(&unmerge_cells_verb(), &resolved);
}
#[test]
#[should_panic(expected = "AutoResizeDimension to a Dimension")]
fn build_request_panics_if_auto_resize_resolves_to_a_range() {
let resolved = ResolvedTarget::Range {
sheet_title: "Q1".to_string(),
grid: GridRange::default(),
};
let _ = build_request(&auto_resize_verb("Q1", 1, 2), &resolved);
}
#[test]
#[should_panic(expected = "UpdateDimensionProperties to a Dimension")]
fn build_request_panics_if_update_dimension_properties_resolves_to_a_range() {
let resolved = ResolvedTarget::Range {
sheet_title: "Q1".to_string(),
grid: GridRange::default(),
};
let _ = build_request(&update_dimension_verb("Q1", 1, 2, 10), &resolved);
}
#[tokio::test]
async fn read_discarded_cells_returns_empty_for_a_non_range_target() {
let server = wiremock::MockServer::start().await;
let (_drive, sheets) = clients(&server).await;
let api = SheetsApi::new(&sheets);
let resolved = ResolvedTarget::Dimension {
range: DimensionRange {
sheet_id: 0,
dimension: Dimension::Rows,
start_index: 0,
end_index: 1,
},
};
let cells = read_discarded_cells(&api, "sheet-1", &resolved)
.await
.unwrap();
assert!(cells.is_empty());
}
#[tokio::test]
async fn read_discarded_cells_skips_the_read_for_a_single_cell_range() {
let server = wiremock::MockServer::start().await;
let (_drive, sheets) = clients(&server).await;
let api = SheetsApi::new(&sheets);
let resolved = ResolvedTarget::Range {
sheet_title: "Q1".to_string(),
grid: GridRange {
sheet_id: 0,
start_row_index: Some(0),
end_row_index: Some(1),
start_column_index: Some(0),
end_column_index: Some(1),
},
};
let cells = read_discarded_cells(&api, "sheet-1", &resolved)
.await
.unwrap();
assert!(cells.is_empty());
}
#[tokio::test]
async fn a_conflicting_sheet_and_range_is_refused_before_any_drive_call() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let verb = FormatVerb::FormatCells {
sheet: Some("Other".to_string()),
range: Some("Sheet1!A1:B2".to_string()),
format: Box::new(CellFormatFlags {
bold: Some(true),
..Default::default()
}),
};
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("already names a sheet"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn a_metadata_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn a_non_spreadsheet_target_is_refused_by_mime_type() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", "application/pdf", &["folder-1"])
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
match outcome.result {
FormatResult::RefusedNotASpreadsheet { mime_type } => {
assert_eq!(mime_type, "application/pdf");
}
other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
}
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
}
#[tokio::test]
async fn a_shortcut_target_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["folder-1"],
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::RefusedShortcut));
}
#[tokio::test]
async fn a_target_with_no_visible_parents_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(
outcome.result,
FormatResult::RefusedNoVisibleParents
));
}
#[tokio::test]
async fn a_gate_ancestor_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
}
#[tokio::test]
async fn a_workbook_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
}
#[tokio::test]
async fn format_cells_reports_sheet_not_found_for_a_range_target() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let verb = FormatVerb::FormatCells {
sheet: Some("Nope".to_string()),
range: Some("A1:B2".to_string()),
format: Box::new(CellFormatFlags {
bold: Some(true),
..Default::default()
}),
};
let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
match outcome.result {
FormatResult::RefusedSheetNotFound { title, available } => {
assert_eq!(title, "Nope");
assert_eq!(available, vec!["Q1".to_string()]);
}
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn format_cells_reports_an_invalid_a1_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let verb = FormatVerb::FormatCells {
sheet: Some("Q1".to_string()),
range: Some("definitely bogus".to_string()),
format: Box::new(CellFormatFlags {
bold: Some(true),
..Default::default()
}),
};
let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("not a recognised A1 range"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn dimension_verb_reports_sheet_not_found() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(auto_resize_verb("Nope", 1, 2), true),
&rules,
)
.await;
match outcome.result {
FormatResult::RefusedSheetNotFound { title, available } => {
assert_eq!(title, "Nope");
assert_eq!(available, vec!["Q1".to_string()]);
}
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn dimension_verbs_reject_an_invalid_start_end_span() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
for verb in [
auto_resize_verb("Q1", 0, 5),
update_dimension_verb("Q1", 5, 3, 10),
] {
let outcome = format(&drive, &sheets, &opts(verb, true), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--start"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
}
#[tokio::test]
async fn merge_cells_reports_a_values_read_failure_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(merge_cells_verb("A1:B2"), true),
&rules,
)
.await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
}
#[tokio::test]
async fn merge_cells_single_cell_range_has_no_discards_and_skips_the_values_read() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(merge_cells_verb("A1:A1"), true),
&rules,
)
.await;
match outcome.result {
FormatResult::WouldChange {
summary,
discarded_cells,
} => {
assert!(discarded_cells.is_empty());
assert_eq!(summary, "merge (MERGE_ALL)");
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn update_borders_refuses_when_no_side_is_selected() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let verb = update_borders_verb(BorderSides::default(), "SOLID", None);
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("at least one side"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn update_borders_refuses_inner_horizontal_on_a_single_row() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let verb = FormatVerb::UpdateBorders {
sheet: Some("Q1".to_string()),
range: Some("A1:B1".to_string()),
sides: BorderSides {
inner_horizontal: true,
..Default::default()
},
style: "SOLID".to_string(),
color: None,
};
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--inner-horizontal"), "{detail}");
assert!(detail.contains("more than one row"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn update_borders_refuses_inner_vertical_on_a_single_column() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let verb = FormatVerb::UpdateBorders {
sheet: Some("Q1".to_string()),
range: Some("A1:A2".to_string()),
sides: BorderSides {
inner_vertical: true,
..Default::default()
},
style: "SOLID".to_string(),
color: None,
};
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--inner-vertical"), "{detail}");
assert!(detail.contains("more than one column"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn update_borders_rejects_a_malformed_color_at_request_build_time() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let dir = tempfile::tempdir().unwrap();
let audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let rules = vec![allow_rule("folder-1")];
let sides = BorderSides {
top: true,
..Default::default()
};
let verb = update_borders_verb(sides, "SOLID", Some("ZZZZZZ"));
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match outcome.result {
FormatResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("not a color"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
assert!(audit.records().is_empty(), "{:?}", audit.records());
}
#[tokio::test]
async fn update_borders_sets_every_selected_side() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let sides = BorderSides {
top: true,
bottom: true,
left: true,
right: true,
..Default::default()
};
let verb = update_borders_verb(sides, "DASHED", Some("00FF00"));
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match &outcome.result {
FormatResult::Changed { summary, .. } => {
assert!(summary.contains("top, bottom, left, right"), "{summary}");
}
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
let update_borders = &body["requests"][0]["updateBorders"];
for side in ["top", "bottom", "left", "right"] {
assert_eq!(update_borders[side]["style"], "DASHED", "{update_borders}");
}
}
#[tokio::test]
async fn update_borders_sets_inner_horizontal_and_inner_vertical() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let sides = BorderSides {
inner_horizontal: true,
inner_vertical: true,
..Default::default()
};
let verb = update_borders_verb(sides, "DOTTED", Some("00FF00"));
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
match &outcome.result {
FormatResult::Changed { summary, .. } => {
assert!(
summary.contains("inner-horizontal, inner-vertical"),
"{summary}"
);
}
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
let update_borders = &body["requests"][0]["updateBorders"];
for side in ["innerHorizontal", "innerVertical"] {
assert_eq!(update_borders[side]["style"], "DOTTED", "{update_borders}");
}
assert!(update_borders.get("top").is_none());
}
#[tokio::test]
async fn update_borders_defaults_to_black_when_no_color_is_given() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let sides = BorderSides {
top: true,
..Default::default()
};
let verb = update_borders_verb(sides, "SOLID", None);
let outcome = format(&drive, &sheets, &opts(verb, false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Changed { .. }));
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
let color = &body["requests"][0]["updateBorders"]["top"]["colorStyle"]["rgbColor"];
assert_eq!(color["red"], 0.0);
assert_eq!(color["green"], 0.0);
assert_eq!(color["blue"], 0.0);
}
#[tokio::test]
async fn merge_cells_real_run_sends_merge_type_and_discards_are_still_reported() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A1:B2",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep", "gone"]]
})),
)
.mount(&server)
.await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(merge_cells_verb("A1:B2"), false),
&rules,
)
.await;
match &outcome.result {
FormatResult::Changed {
summary,
discarded_cells,
} => {
assert_eq!(discarded_cells, &vec!["B1: gone".to_string()]);
assert!(summary.contains("discarding 1 cell"), "{summary}");
}
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
assert_eq!(body["requests"][0]["mergeCells"]["mergeType"], "MERGE_ALL");
}
#[tokio::test]
async fn unmerge_cells_sends_the_composed_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &opts(unmerge_cells_verb(), false), &rules).await;
match &outcome.result {
FormatResult::Changed { summary, .. } => assert_eq!(summary, "unmerge"),
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
assert!(body["requests"][0].get("unmergeCells").is_some());
}
#[tokio::test]
async fn auto_resize_dimension_sends_the_resolved_sheet_id_and_span() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(auto_resize_verb("Q1", 2, 5), false),
&rules,
)
.await;
match &outcome.result {
FormatResult::Changed { summary, .. } => {
assert!(summary.contains("row(s)"), "{summary}");
}
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
let dims = &body["requests"][0]["autoResizeDimensions"]["dimensions"];
assert_eq!(dims["sheetId"], 0);
assert_eq!(dims["startIndex"], 1);
assert_eq!(dims["endIndex"], 5);
}
#[tokio::test]
async fn update_dimension_properties_sends_the_pixel_size() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(
&drive,
&sheets,
&opts(update_dimension_verb("Q1", 1, 3, 120), false),
&rules,
)
.await;
match &outcome.result {
FormatResult::Changed { summary, .. } => assert!(summary.contains("120"), "{summary}"),
other => panic!("expected Changed, got {other:?}"),
}
let requests = server.received_requests().await.unwrap();
let body = find_batch_update(&requests);
let request = &body["requests"][0]["updateDimensionProperties"];
assert_eq!(request["properties"]["pixelSize"], 120);
assert_eq!(request["fields"], "pixelSize");
}
#[tokio::test]
async fn format_cells_with_an_unreadable_reply_reports_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_string("not json"))
.expect(1)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(
matches!(outcome.result, FormatResult::AppliedReplyUnreadable { .. }),
"{:?}",
outcome.result
);
assert_eq!(outcome.result.log_status(), "applied-reply-unreadable");
assert!(
describe(&outcome).starts_with("Applied, but the reply could not be read"),
"{}",
describe(&outcome)
);
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_as_an_idempotent_single_line() {
let outcome = FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: format_cells_opts(false).verb,
result: FormatResult::AppliedReplyUnreadable {
summary: "set bold on 'Q1'!A1:B2".to_string(),
discarded_cells: Vec::new(),
detail: "bad reply".to_string(),
},
};
assert_eq!(
describe_lines(&outcome),
vec![
"Applied, but the reply could not be read: set bold on 'Q1'!A1:B2 in 'Budget' — \
check the spreadsheet to confirm (bad reply)"
.to_string()
]
);
}
#[test]
fn an_unreadable_reply_logs_the_summary_and_discarded_cells_like_changed() {
let opts = format_cells_opts(false);
let record = |result| {
mutation_record(
&FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb: opts.verb.clone(),
result,
},
&opts,
Duration::from_millis(1),
)
};
let changed = record(FormatResult::Changed {
summary: "merge cells".to_string(),
discarded_cells: vec!["B1: gone".to_string()],
});
let unreadable = record(FormatResult::AppliedReplyUnreadable {
summary: "merge cells".to_string(),
discarded_cells: vec!["B1: gone".to_string()],
detail: "bad reply".to_string(),
});
assert_eq!(unreadable.fields_changed.as_deref(), Some("merge cells"));
assert_eq!(unreadable.fields_changed, changed.fields_changed);
assert_eq!(unreadable.discarded_cells, vec!["B1: gone".to_string()]);
assert_eq!(unreadable.discarded_cells, changed.discarded_cells);
assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
assert_eq!(unreadable.status, "applied-reply-unreadable");
}
#[tokio::test]
async fn a_batch_update_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
}
fn every_format_result() -> Vec<FormatResult> {
vec![
FormatResult::WouldChange {
summary: "set bold".to_string(),
discarded_cells: vec!["B1: gone".to_string()],
},
FormatResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
FormatResult::RefusedShortcut,
FormatResult::RefusedNoVisibleParents,
FormatResult::RefusedSheetNotFound {
title: "Q9".to_string(),
available: vec!["Q1".to_string()],
},
FormatResult::RefusedSheetNotFound {
title: "Q9".to_string(),
available: Vec::new(),
},
FormatResult::RefusedInvalidRange {
detail: "bad range".to_string(),
},
FormatResult::Blocked {
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".to_string(),
depth: 0,
}),
},
FormatResult::Blocked { decided_by: None },
FormatResult::RefusedNoLease,
FormatResult::RefusedLeaseExpired,
FormatResult::RefusedLeaseWrongFile,
FormatResult::RefusedLeaseStale,
FormatResult::Changed {
summary: "set bold".to_string(),
discarded_cells: Vec::new(),
},
FormatResult::AppliedReplyUnreadable {
summary: "set bold".to_string(),
discarded_cells: vec!["B1: gone".to_string()],
detail: "bad reply".to_string(),
},
FormatResult::Failed {
detail: "boom".to_string(),
},
]
}
#[test]
fn describe_lines_covers_every_result_with_no_control_characters() {
for file_name in [Some("Budget".to_string()), None] {
for result in every_format_result() {
match &result {
FormatResult::WouldChange { .. }
| FormatResult::RefusedNotASpreadsheet { .. }
| FormatResult::RefusedShortcut
| FormatResult::RefusedNoVisibleParents
| FormatResult::RefusedSheetNotFound { .. }
| FormatResult::RefusedInvalidRange { .. }
| FormatResult::Blocked { .. }
| FormatResult::RefusedNoLease
| FormatResult::RefusedLeaseExpired
| FormatResult::RefusedLeaseWrongFile
| FormatResult::RefusedLeaseStale
| FormatResult::Changed { .. }
| FormatResult::AppliedReplyUnreadable { .. }
| FormatResult::Failed { .. } => {}
}
let outcome = FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: file_name.clone(),
resolved_folder_id: None,
verb: format_cells_opts(false).verb,
result,
};
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 1, "{:?}", outcome.result);
for line in &lines {
assert!(
!line.chars().any(char::is_control),
"{line:?} for {:?}",
outcome.result
);
}
assert_eq!(describe(&outcome), lines.join("\n"));
}
}
}
#[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",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = FormatOptions {
lease_token: None,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, FormatResult::RefusedNoLease));
assert_eq!(outcome.result.log_status(), "refused-no-lease");
}
#[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",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let opts = format_cells_opts(false);
let mut lock_path = opts.ledger_path.clone().into_os_string();
lock_path.push(".lock");
std::fs::create_dir(std::path::PathBuf::from(lock_path)).unwrap();
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, FormatResult::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",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-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("folder-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 rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Failed { .. }));
}
#[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",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let opts = FormatOptions {
lease_token: Some("bogus-token".to_string()),
ledger_path,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, FormatResult::RefusedLeaseExpired));
}
#[tokio::test]
async fn refuses_an_expired_lease() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = "expired-token".to_string();
let mut ledger = crate::drive::lease::ledger::LeaseLedger::default();
ledger.insert(crate::drive::lease::ledger::LeaseRecord {
token: token.clone(),
file_id: "sheet-1".to_string(),
version: "1".to_string(),
modified_time: None,
backup: crate::drive::lease::ledger::LeaseBackup::Bytes {
path: std::path::PathBuf::from("/tmp/test-backup"),
sha256: "deadbeef".to_string(),
size: 0,
},
acquired_at: chrono::Utc::now() - chrono::Duration::hours(2),
expires_at: chrono::Utc::now() - chrono::Duration::hours(1),
released_at: None,
superseded_by: None,
restored_at: None,
restored_sheet_id: None,
});
ledger.save(&ledger_path).unwrap();
let opts = FormatOptions {
lease_token: Some(token),
ledger_path,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, FormatResult::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",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "some-other-sheet", "1");
let opts = FormatOptions {
lease_token: Some(token),
ledger_path,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
FormatResult::RefusedLeaseWrongFile
));
}
#[tokio::test]
async fn refuses_a_stale_lease_when_the_file_has_moved() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let opts = FormatOptions {
lease_token: Some(token),
ledger_path,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
}
#[tokio::test]
async fn require_lease_false_writes_without_a_lease() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
mount_batch_update(serde_json::json!({"spreadsheetId": "sheet-1", "replies": [{}]}))
.mount(&server)
.await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: false,
};
let opts = FormatOptions {
lease_token: None,
ledger_path: std::path::PathBuf::from("/nonexistent/lease-ledger.jsonl"),
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &[rule]).await;
assert!(matches!(outcome.result, FormatResult::Changed { .. }));
}
#[tokio::test]
async fn require_lease_false_still_refuses_a_stale_lease_if_one_is_presented() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rule = FolderPermissionRule {
folder_id: Some("folder-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsStructure).collect(),
deny: HashSet::default(),
require_lease: false,
};
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, "sheet-1", "0");
let opts = FormatOptions {
lease_token: Some(token),
ledger_path,
..format_cells_opts(false)
};
let outcome = format(&drive, &sheets, &opts, &[rule]).await;
assert!(matches!(outcome.result, FormatResult::RefusedLeaseStale));
}
#[tokio::test]
async fn a_leased_format_concludes_its_audit_pair_with_allowed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().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 rules = vec![allow_rule("folder-1")];
let outcome = format(&drive, &sheets, &format_cells_opts(false), &rules).await;
assert!(matches!(outcome.result, FormatResult::Changed { .. }));
let records = audit.records();
assert_eq!(audit.verdicts(), ["pending", "allowed"], "{records:?}");
assert_eq!(records[0].command, ["drive", "sheets-format-cells"]);
}
fn borders_verb() -> FormatVerb {
FormatVerb::UpdateBorders {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
sides: BorderSides {
top: true,
left: true,
..BorderSides::default()
},
style: "SOLID_MEDIUM".to_string(),
color: None,
}
}
fn lines_for(verb: FormatVerb, result: FormatResult) -> Vec<String> {
describe_lines(&FormatOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
verb,
result,
})
}
#[test]
fn update_borders_summary_starts_with_a_verb() {
let summary = describe_effect(&borders_verb(), &[]).unwrap();
assert_eq!(summary, "set top, left border(s), style SOLID_MEDIUM");
let lines = lines_for(
borders_verb(),
FormatResult::WouldChange {
summary,
discarded_cells: Vec::new(),
},
);
assert_eq!(
lines,
["Would set top, left border(s), style SOLID_MEDIUM in 'Budget'"]
);
}
#[test]
fn dimension_summaries_name_the_sheet_and_the_span() {
let auto_resize = FormatVerb::AutoResizeDimension {
sheet: "Q1".to_string(),
dimension: Dimension::Columns,
start: 2,
end: 4,
};
assert_eq!(
describe_effect(&auto_resize, &[]).unwrap(),
"auto-resize column(s) B:D of 'Q1'"
);
let apostrophe = FormatVerb::AutoResizeDimension {
sheet: "Bob's".to_string(),
dimension: Dimension::Rows,
start: 1,
end: 1,
};
assert_eq!(
describe_effect(&apostrophe, &[]).unwrap(),
"auto-resize row(s) 1:1 of 'Bob''s'"
);
let pixel_size = FormatVerb::UpdateDimensionProperties {
sheet: "Q1".to_string(),
dimension: Dimension::Rows,
start: 2,
end: 5,
pixel_size: 40,
};
assert_eq!(
describe_effect(&pixel_size, &[]).unwrap(),
"set the pixelSize of row(s) 2:5 of 'Q1' to 40"
);
}
#[test]
fn a_real_run_reads_as_past_tense_prose_not_the_hyphenated_verb() {
let auto_resize = FormatVerb::AutoResizeDimension {
sheet: "Q1".to_string(),
dimension: Dimension::Rows,
start: 1,
end: 3,
};
let cases = [
(
borders_verb(),
"set top, left border(s), style SOLID_MEDIUM",
"Set top, left border(s), style SOLID_MEDIUM in 'Budget'",
),
(
merge_cells_verb("A1:B2"),
"merge (MERGE_ALL)",
"Merged cells (MERGE_ALL) in 'Budget'",
),
(
unmerge_cells_verb(),
"unmerge",
"Unmerged cells in 'Budget'",
),
(
auto_resize,
"auto-resize row(s) 1:3 of 'Q1'",
"Auto-resized row(s) 1:3 of 'Q1' in 'Budget'",
),
];
for verb in [
format_cells_opts(false).verb,
FormatVerb::UpdateDimensionProperties {
sheet: "Q1".to_string(),
dimension: Dimension::Columns,
start: 1,
end: 1,
pixel_size: 80,
},
] {
let summary = describe_effect(&verb, &[]).unwrap();
let lines = lines_for(
verb,
FormatResult::Changed {
summary,
discarded_cells: Vec::new(),
},
);
assert!(lines[0].starts_with("Set "), "{lines:?}");
}
for (verb, summary, expected) in cases {
let lines = lines_for(
verb,
FormatResult::Changed {
summary: summary.to_string(),
discarded_cells: Vec::new(),
},
);
assert_eq!(lines, [expected]);
}
}
#[test]
fn a_summary_without_its_verbs_imperative_falls_back_to_applied() {
let lines = lines_for(
borders_verb(),
FormatResult::Changed {
summary: "x".to_string(),
discarded_cells: Vec::new(),
},
);
assert_eq!(lines, ["Applied: x in 'Budget'"]);
}
}