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,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::format::parse_hex_color;
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
AddBandingRequest, BandedRange, BandingProperties, BatchUpdateRequestItem, BatchUpdateResponse,
ColorStyle, DeleteBandingRequest, GridRange, Spreadsheet, UpdateBandingRequest,
};
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 BandingAxis {
Rows,
Columns,
}
impl BandingAxis {
const fn label(self) -> &'static str {
match self {
Self::Rows => "row",
Self::Columns => "column",
}
}
const fn field_name(self) -> &'static str {
match self {
Self::Rows => "rowProperties",
Self::Columns => "columnProperties",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BandingVerb {
AddBanding {
sheet: String,
range: String,
axis: BandingAxis,
header_color: Option<String>,
first_band_color: String,
second_band_color: String,
footer_color: Option<String>,
},
UpdateBanding {
banded_range_id: i64,
sheet: Option<String>,
range: Option<String>,
axis: BandingAxis,
header_color: Option<String>,
first_band_color: Option<String>,
second_band_color: Option<String>,
footer_color: Option<String>,
},
DeleteBanding {
banded_range_id: i64,
},
}
impl BandingVerb {
const fn log_operation(&self) -> &'static str {
match self {
Self::AddBanding { .. } => "sheets-add-banding",
Self::UpdateBanding { .. } => "sheets-update-banding",
Self::DeleteBanding { .. } => "sheets-delete-banding",
}
}
const fn label(&self) -> &'static str {
match self {
Self::AddBanding { .. } => "add-banding",
Self::UpdateBanding { .. } => "update-banding",
Self::DeleteBanding { .. } => "delete-banding",
}
}
}
#[derive(Debug, Clone)]
pub struct BandingOptions {
pub spreadsheet_id: String,
pub verb: BandingVerb,
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 BandingResult {
WouldChange {
summary: String,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
RefusedBandedRangeNotFound {
banded_range_id: i64,
},
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed {
summary: String,
banded_range_id: Option<i64>,
},
AppliedReplyUnreadable {
summary: String,
banded_range_id: Option<i64>,
detail: String,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for BandingResult {
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 BandingResult {
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::RefusedBandedRangeNotFound { .. } => "refused-banded-range-not-found",
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 BandingOutcome {
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_serializing_if = "Option::is_none")]
pub sheet_id: Option<i64>,
#[serde(skip)]
pub verb: BandingVerb,
pub result: BandingResult,
}
impl JsonlSerialize for BandingOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn banding(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &BandingOptions,
rules: &[FolderPermissionRule],
) -> BandingOutcome {
let started = Instant::now();
let outcome = banding_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, opts, started.elapsed());
}
outcome
}
async fn banding_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &BandingOptions,
rules: &[FolderPermissionRule],
) -> BandingOutcome {
let bare = |result| BandingOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
verb: opts.verb.clone(),
result,
};
if let Err(detail) = validate_verb(&opts.verb) {
return bare(BandingResult::RefusedInvalidRange { detail });
}
let composed_range = match compose_target(&opts.verb) {
Ok(composed) => composed,
Err(detail) => return bare(BandingResult::RefusedInvalidRange { detail }),
};
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(BandingResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => BandingResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
BandingResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => BandingResult::RefusedNoVisibleParents,
};
return BandingOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
verb: opts.verb.clone(),
result,
};
}
target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
return BandingOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
verb: opts.verb.clone(),
result: BandingResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let pre_gated = |result| BandingOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
sheet_id: None,
verb: opts.verb.clone(),
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return pre_gated(BandingResult::Blocked {
decided_by: decision.decided_by,
});
}
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet_with_banding(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return pre_gated(BandingResult::Failed {
detail: format!("{err:#}"),
})
}
};
let composed_range = match (&opts.verb, composed_range) {
(
BandingVerb::UpdateBanding {
banded_range_id,
sheet: None,
..
},
Some(composed),
) => {
let current_sheet = match find_existing_banded_range(&workbook, *banded_range_id) {
Ok(banded) => banded.range.as_ref().map(|range| range.sheet_id),
Err(result) => return pre_gated(result),
};
Some(grid_range::default_sheet_prefix(
&workbook,
composed,
current_sheet,
))
}
(_, composed) => composed,
};
let resolved_target =
match resolve_sheet_target(&workbook, &opts.verb, composed_range.as_deref()) {
Ok(resolved) => resolved,
Err(result) => return pre_gated(result),
};
let existing = match &opts.verb {
BandingVerb::UpdateBanding {
banded_range_id, ..
}
| BandingVerb::DeleteBanding { banded_range_id } => {
match find_existing_banded_range(&workbook, *banded_range_id) {
Ok(existing) => Some(existing),
Err(result) => return pre_gated(result),
}
}
BandingVerb::AddBanding { .. } => None,
};
let sheet_id = resolved_target.map(|grid| grid.sheet_id).or_else(|| {
existing
.and_then(|existing| existing.range.as_ref())
.map(|range| range.sheet_id)
});
let gated = |result| BandingOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
sheet_id,
verb: opts.verb.clone(),
result,
};
if let Err(detail) = validate_new_axis_colors(&opts.verb, existing) {
return gated(BandingResult::RefusedInvalidRange { detail });
}
let summary = describe_effect(&opts.verb);
if opts.dry_run {
return gated(BandingResult::WouldChange { summary });
}
let (request, existing_id) = match build_request(&opts.verb, resolved_target, existing) {
Ok(built) => built,
Err(detail) => return gated(BandingResult::RefusedInvalidRange { detail }),
};
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets banding",
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)) => {
let banded_range_id = added_banded_range_id(&response).or(existing_id);
BandingResult::Changed {
summary,
banded_range_id,
}
}
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
BandingResult::AppliedReplyUnreadable {
summary,
banded_range_id: existing_id,
detail,
}
}
Err(err) => BandingResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn compose_target(verb: &BandingVerb) -> Result<Option<String>, String> {
match verb {
BandingVerb::AddBanding { sheet, range, .. } => a1::compose(Some(sheet), Some(range))
.map(Some)
.map_err(|err| err.to_string()),
BandingVerb::UpdateBanding { sheet, range, .. } => {
match (sheet.as_deref(), range.as_deref()) {
(None, None) => Ok(None),
(sheet, range) => a1::compose(sheet, range)
.map(Some)
.map_err(|err| err.to_string()),
}
}
BandingVerb::DeleteBanding { .. } => Ok(None),
}
}
fn validate_verb(verb: &BandingVerb) -> Result<(), String> {
if let BandingVerb::UpdateBanding {
sheet,
range,
header_color,
first_band_color,
second_band_color,
footer_color,
..
} = verb
{
let nothing_to_change = sheet.is_none()
&& range.is_none()
&& header_color.is_none()
&& first_band_color.is_none()
&& second_band_color.is_none()
&& footer_color.is_none();
if nothing_to_change {
return Err(
"nothing to change: pass --sheet/--range, --header-color, --first-band-color, \
--second-band-color, or --footer-color"
.to_string(),
);
}
}
Ok(())
}
fn resolve_sheet_target(
workbook: &Spreadsheet,
verb: &BandingVerb,
composed: Option<&str>,
) -> Result<Option<GridRange>, BandingResult> {
match verb {
BandingVerb::AddBanding { .. } => {
let composed = composed.unwrap_or_default();
let (_, grid) = grid_range::resolve_grid_range(
workbook,
composed,
|detail| BandingResult::RefusedInvalidRange { detail },
|title, available| BandingResult::RefusedSheetNotFound { title, available },
)?;
Ok(Some(grid))
}
BandingVerb::UpdateBanding { .. } => match composed {
Some(composed) => {
let (_, grid) = grid_range::resolve_grid_range(
workbook,
composed,
|detail| BandingResult::RefusedInvalidRange { detail },
|title, available| BandingResult::RefusedSheetNotFound { title, available },
)?;
Ok(Some(grid))
}
None => Ok(None),
},
BandingVerb::DeleteBanding { .. } => Ok(None),
}
}
fn find_existing_banded_range(
workbook: &Spreadsheet,
banded_range_id: i64,
) -> Result<&BandedRange, BandingResult> {
workbook
.sheets
.iter()
.flat_map(|sheet| sheet.banded_ranges.iter())
.find(|banded| banded.banded_range_id == Some(banded_range_id))
.ok_or(BandingResult::RefusedBandedRangeNotFound { banded_range_id })
}
fn validate_new_axis_colors(
verb: &BandingVerb,
existing: Option<&BandedRange>,
) -> Result<(), String> {
let BandingVerb::UpdateBanding {
axis,
header_color,
first_band_color,
second_band_color,
footer_color,
..
} = verb
else {
return Ok(());
};
let Some(existing) = existing else {
return Ok(());
};
let axis_exists = match axis {
BandingAxis::Rows => existing.row_properties.is_some(),
BandingAxis::Columns => existing.column_properties.is_some(),
};
let colors_changed = header_color.is_some()
|| first_band_color.is_some()
|| second_band_color.is_some()
|| footer_color.is_some();
if axis_exists || !colors_changed {
return Ok(());
}
let missing: Vec<&str> = [
("--first-band-color", first_band_color.is_none()),
("--second-band-color", second_band_color.is_none()),
]
.into_iter()
.filter_map(|(flag, is_missing)| is_missing.then_some(flag))
.collect();
if missing.is_empty() {
return Ok(());
}
let label = axis.label();
Err(format!(
"banded range {} has no {label} banding, so setting {label} colors adds it from \
scratch, which requires both --first-band-color and --second-band-color; missing: {}",
existing
.banded_range_id
.map_or_else(|| "?".to_string(), |id| id.to_string()),
missing.join(", ")
))
}
fn parse_color_style(hex: &str) -> Result<ColorStyle, String> {
Ok(ColorStyle::rgb(parse_hex_color(hex)?))
}
fn build_request(
verb: &BandingVerb,
resolved: Option<GridRange>,
existing: Option<&BandedRange>,
) -> Result<(BatchUpdateRequestItem, Option<i64>), String> {
match verb {
BandingVerb::AddBanding {
axis,
header_color,
first_band_color,
second_band_color,
footer_color,
..
} => {
let Some(range) = resolved else {
unreachable!("resolved is resolved for AddBanding above") };
let properties = BandingProperties {
header_color_style: header_color.as_deref().map(parse_color_style).transpose()?,
first_band_color_style: Some(parse_color_style(first_band_color)?),
second_band_color_style: Some(parse_color_style(second_band_color)?),
footer_color_style: footer_color.as_deref().map(parse_color_style).transpose()?,
};
let mut banded_range = BandedRange {
banded_range_id: None,
range: Some(range),
row_properties: None,
column_properties: None,
};
match axis {
BandingAxis::Rows => banded_range.row_properties = Some(properties),
BandingAxis::Columns => banded_range.column_properties = Some(properties),
}
Ok((
BatchUpdateRequestItem::AddBanding(AddBandingRequest { banded_range }),
None,
))
}
BandingVerb::UpdateBanding {
banded_range_id,
axis,
header_color,
first_band_color,
second_band_color,
footer_color,
..
} => {
let Some(existing) = existing else {
unreachable!("existing is resolved for UpdateBanding above") };
let update = build_update(
existing,
*banded_range_id,
*axis,
resolved,
header_color.as_deref(),
first_band_color.as_deref(),
second_band_color.as_deref(),
footer_color.as_deref(),
)?;
Ok((
BatchUpdateRequestItem::UpdateBanding(update),
Some(*banded_range_id),
))
}
BandingVerb::DeleteBanding { banded_range_id } => Ok((
BatchUpdateRequestItem::DeleteBanding(DeleteBandingRequest {
banded_range_id: *banded_range_id,
}),
Some(*banded_range_id),
)),
}
}
#[allow(clippy::too_many_arguments)]
fn build_update(
existing: &BandedRange,
banded_range_id: i64,
axis: BandingAxis,
range: Option<GridRange>,
header_color: Option<&str>,
first_band_color: Option<&str>,
second_band_color: Option<&str>,
footer_color: Option<&str>,
) -> Result<UpdateBandingRequest, String> {
let mut fields = Vec::new();
let mut banded_range = BandedRange {
banded_range_id: Some(banded_range_id),
range: None,
row_properties: None,
column_properties: None,
};
if let Some(range) = range {
banded_range.range = Some(range);
fields.push("range");
}
let properties_changed = header_color.is_some()
|| first_band_color.is_some()
|| second_band_color.is_some()
|| footer_color.is_some();
let mut properties = match axis {
BandingAxis::Rows => existing.row_properties.clone().unwrap_or_default(),
BandingAxis::Columns => existing.column_properties.clone().unwrap_or_default(),
};
if let Some(hex) = header_color {
properties.header_color_style = Some(parse_color_style(hex)?);
}
if let Some(hex) = first_band_color {
properties.first_band_color_style = Some(parse_color_style(hex)?);
}
if let Some(hex) = second_band_color {
properties.second_band_color_style = Some(parse_color_style(hex)?);
}
if let Some(hex) = footer_color {
properties.footer_color_style = Some(parse_color_style(hex)?);
}
if properties_changed {
match axis {
BandingAxis::Rows => banded_range.row_properties = Some(properties),
BandingAxis::Columns => banded_range.column_properties = Some(properties),
}
fields.push(axis.field_name());
}
Ok(UpdateBandingRequest {
banded_range,
fields: fields.join(","),
})
}
fn added_banded_range_id(response: &BatchUpdateResponse) -> Option<i64> {
response
.replies
.iter()
.find_map(|reply| reply.add_banding.as_ref())
.and_then(|added| added.banded_range.as_ref())
.and_then(|banded| banded.banded_range_id)
}
fn describe_effect(verb: &BandingVerb) -> String {
match verb {
BandingVerb::AddBanding { axis, .. } => format!("add {} banding", axis.label()),
BandingVerb::UpdateBanding {
banded_range_id,
axis,
..
} => format!("update {} banding id {banded_range_id}", axis.label()),
BandingVerb::DeleteBanding { banded_range_id } => {
format!("delete banding id {banded_range_id}")
}
}
}
fn record_attempt(outcome: &BandingOutcome, opts: &BandingOptions, duration: Duration) {
request_log::record_drive_mutation(mutation_record(outcome, opts, duration));
}
fn mutation_record(
outcome: &BandingOutcome,
opts: &BandingOptions,
duration: Duration,
) -> DriveMutationOutcome {
let error = match &outcome.result {
BandingResult::Failed { detail } | BandingResult::AppliedReplyUnreadable { detail, .. } => {
Some(detail.clone())
}
_ => None,
};
let decided_by = match &outcome.result {
BandingResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let banded_range_id = match &outcome.result {
BandingResult::Changed {
banded_range_id, ..
}
| BandingResult::AppliedReplyUnreadable {
banded_range_id, ..
} => *banded_range_id,
_ => None,
};
let fields_changed = match &outcome.result {
BandingResult::AppliedReplyUnreadable { summary, .. } => Some(summary.clone()),
_ => None,
};
DriveMutationOutcome {
operation: opts.verb.log_operation(),
file_id: outcome.spreadsheet_id.clone(),
file_name: outcome.file_name.clone().unwrap_or_default(),
status: outcome.result.log_status().to_string(),
resolved_folder_id: outcome.resolved_folder_id.clone(),
decided_by_folder_id: decided_by.folder_id,
decided_by_depth: decided_by.depth,
decided_by_file_id: decided_by.file_id,
sheet_id: outcome.sheet_id,
banded_range_id,
fields_changed,
error,
duration,
..Default::default()
}
}
#[must_use]
pub fn describe(outcome: &BandingOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &BandingOutcome) -> 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 {
BandingResult::WouldChange { summary } => vec![format!("Would {summary} in {book}")],
BandingResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets {}` only works on spreadsheets",
verb.label()
)],
BandingResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets {}` doesn't follow shortcuts",
verb.label()
)],
BandingResult::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."
)],
BandingResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
BandingResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
BandingResult::RefusedBandedRangeNotFound { banded_range_id } => vec![format!(
"Refused: {book} has no banded range with id {banded_range_id}; run \
`drive sheets list-bandings` to see what exists"
)],
BandingResult::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()
),
}],
BandingResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
BandingResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
BandingResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
BandingResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
BandingResult::Changed {
summary,
banded_range_id,
} => {
let id = banded_range_id
.filter(|_| matches!(outcome.verb, BandingVerb::AddBanding { .. }))
.map_or_else(String::new, |id| format!(" (id {id})"));
vec![format!("Applied: {summary}{id} in {book}")]
}
BandingResult::AppliedReplyUnreadable {
summary, detail, ..
} => {
let mut lines = vec![applied_reply_unreadable_line(
summary,
&book,
RetryHint::NotIdempotent,
detail,
)];
if matches!(verb, BandingVerb::AddBanding { .. }) {
lines.push(
" the new banded range's id was in the unreadable reply; run \
`list-bandings` to find it"
.to_string(),
);
}
lines
}
BandingResult::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::sheets::types::Sheet;
use crate::drive::test_support::seed_lease;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use std::collections::HashSet;
#[test]
fn log_operation_and_label_are_distinct_for_every_verb() {
let verbs = [
BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
BandingVerb::UpdateBanding {
banded_range_id: 1,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#000000".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
},
BandingVerb::DeleteBanding { banded_range_id: 1 },
];
let mut operations: Vec<&str> = verbs.iter().map(BandingVerb::log_operation).collect();
let mut labels: Vec<&str> = verbs.iter().map(BandingVerb::label).collect();
operations.sort_unstable();
operations.dedup();
labels.sort_unstable();
labels.dedup();
assert_eq!(operations.len(), verbs.len());
assert_eq!(labels.len(), verbs.len());
}
#[test]
fn validate_verb_rejects_update_banding_with_nothing_to_change() {
let verb = BandingVerb::UpdateBanding {
banded_range_id: 1,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
};
let err = validate_verb(&verb).unwrap_err();
assert!(err.contains("nothing to change"), "{err}");
}
#[test]
fn validate_verb_accepts_update_banding_with_only_a_color_change() {
let verb = BandingVerb::UpdateBanding {
banded_range_id: 1,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#000000".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
};
validate_verb(&verb).unwrap();
}
#[test]
fn banding_axis_label_and_field_name_cover_both_axes() {
assert_eq!(BandingAxis::Rows.label(), "row");
assert_eq!(BandingAxis::Columns.label(), "column");
assert_eq!(BandingAxis::Rows.field_name(), "rowProperties");
assert_eq!(BandingAxis::Columns.field_name(), "columnProperties");
}
fn sheet_with_banding(sheet_id: i64, banded_range_id: i64) -> Sheet {
Sheet {
banded_ranges: vec![BandedRange {
banded_range_id: Some(banded_range_id),
range: Some(GridRange {
sheet_id,
start_row_index: Some(0),
end_row_index: Some(10),
start_column_index: Some(0),
end_column_index: Some(4),
}),
row_properties: Some(BandingProperties {
header_color_style: None,
first_band_color_style: Some(ColorStyle::rgb(
crate::drive::sheets::types::Color {
red: 1.0,
green: 1.0,
blue: 1.0,
},
)),
second_band_color_style: Some(ColorStyle::rgb(
crate::drive::sheets::types::Color {
red: 0.0,
green: 0.0,
blue: 0.0,
},
)),
footer_color_style: None,
}),
column_properties: None,
}],
..Default::default()
}
}
#[test]
fn find_existing_banded_range_returns_not_found_for_unknown_id() {
let workbook = Spreadsheet {
sheets: vec![sheet_with_banding(0, 7)],
..Default::default()
};
let err = find_existing_banded_range(&workbook, 99).unwrap_err();
assert_eq!(
err,
BandingResult::RefusedBandedRangeNotFound {
banded_range_id: 99
}
);
}
#[test]
fn find_existing_banded_range_finds_the_matching_id() {
let workbook = Spreadsheet {
sheets: vec![sheet_with_banding(0, 7)],
..Default::default()
};
let found = find_existing_banded_range(&workbook, 7).unwrap();
assert_eq!(found.banded_range_id, Some(7));
}
#[test]
fn build_request_add_banding_sets_row_properties_for_the_rows_axis() {
let range = GridRange {
sheet_id: 0,
start_row_index: Some(0),
end_row_index: Some(10),
start_column_index: Some(0),
end_column_index: Some(4),
};
let verb = BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: Some("#111111".to_string()),
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
};
let (request, existing_id) = build_request(&verb, Some(range), None).unwrap();
assert_eq!(existing_id, None);
let BatchUpdateRequestItem::AddBanding(add) = request else {
panic!("expected AddBanding"); };
assert_eq!(add.banded_range.banded_range_id, None);
assert_eq!(add.banded_range.range, Some(range));
assert!(add.banded_range.row_properties.is_some());
assert!(add.banded_range.column_properties.is_none());
let properties = add.banded_range.row_properties.unwrap();
assert!(properties.header_color_style.is_some());
assert!(properties.footer_color_style.is_none());
}
#[test]
fn build_request_add_banding_sets_column_properties_for_the_columns_axis() {
let range = GridRange {
sheet_id: 0,
..Default::default()
};
let verb = BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A:D".to_string(),
axis: BandingAxis::Columns,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
};
let (request, _) = build_request(&verb, Some(range), None).unwrap();
let BatchUpdateRequestItem::AddBanding(add) = request else {
panic!("expected AddBanding"); };
assert!(add.banded_range.row_properties.is_none());
assert!(add.banded_range.column_properties.is_some());
}
#[test]
fn build_request_add_banding_rejects_a_malformed_color() {
let range = GridRange {
sheet_id: 0,
..Default::default()
};
let verb = BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "not-a-color".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
};
let err = build_request(&verb, Some(range), None).unwrap_err();
assert!(err.contains("not a color"), "{err}");
}
#[test]
fn build_request_update_banding_merges_onto_the_existing_axis_without_touching_unset_colors() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#123456".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
};
let (request, existing_id) = build_request(&verb, None, Some(&existing)).unwrap();
assert_eq!(existing_id, Some(7));
let BatchUpdateRequestItem::UpdateBanding(update) = request else {
panic!("expected UpdateBanding"); };
assert_eq!(update.fields, "rowProperties");
assert!(update.banded_range.range.is_none());
let properties = update.banded_range.row_properties.unwrap();
assert!(properties.header_color_style.is_some());
assert_eq!(
properties.first_band_color_style,
existing.row_properties.unwrap().first_band_color_style
);
}
#[test]
fn build_request_update_banding_preserves_an_existing_theme_colored_band() {
let mut sheet = sheet_with_banding(0, 7);
sheet.banded_ranges[0]
.row_properties
.as_mut()
.unwrap()
.first_band_color_style = Some(ColorStyle::theme("ACCENT1"));
let existing = sheet.banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#123456".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
};
let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
let BatchUpdateRequestItem::UpdateBanding(update) = request else {
panic!("expected UpdateBanding"); };
let properties = update.banded_range.row_properties.unwrap();
let serialized = serde_json::to_value(&properties).unwrap();
let first_band = properties.first_band_color_style.unwrap();
assert_eq!(first_band.theme_color, Some("ACCENT1".to_string()));
assert_eq!(first_band.rgb_color, None);
let first_band_json = &serialized["firstBandColorStyle"];
assert_eq!(first_band_json["themeColor"], "ACCENT1");
assert!(
first_band_json.get("rgbColor").is_none(),
"a preserved theme-colored band must not gain a rgbColor field: {serialized}"
);
}
#[test]
fn build_request_update_banding_changes_every_color_field() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#123456".to_string()),
first_band_color: Some("#111111".to_string()),
second_band_color: Some("#222222".to_string()),
footer_color: Some("#333333".to_string()),
};
let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
let BatchUpdateRequestItem::UpdateBanding(update) = request else {
panic!("expected UpdateBanding"); };
assert_eq!(update.fields, "rowProperties");
let properties = update.banded_range.row_properties.unwrap();
assert!(properties.header_color_style.is_some());
assert!(properties.first_band_color_style.is_some());
assert!(properties.second_band_color_style.is_some());
assert!(properties.footer_color_style.is_some());
}
#[test]
fn build_request_update_banding_sets_column_properties_for_the_columns_axis() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Columns,
header_color: Some("#123456".to_string()),
first_band_color: Some("#111111".to_string()),
second_band_color: Some("#222222".to_string()),
footer_color: None,
};
let (request, _) = build_request(&verb, None, Some(&existing)).unwrap();
let BatchUpdateRequestItem::UpdateBanding(update) = request else {
panic!("expected UpdateBanding"); };
assert_eq!(update.fields, "columnProperties");
assert!(update.banded_range.row_properties.is_none());
let properties = update.banded_range.column_properties.unwrap();
assert!(properties.first_band_color_style.is_some());
assert!(properties.second_band_color_style.is_some());
}
fn band_color_update_verb(
axis: BandingAxis,
first_band_color: Option<&str>,
second_band_color: Option<&str>,
) -> BandingVerb {
BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis,
header_color: None,
first_band_color: first_band_color.map(str::to_string),
second_band_color: second_band_color.map(str::to_string),
footer_color: None,
}
}
#[test]
fn validate_new_axis_colors_refuses_a_new_axis_with_only_one_band_color() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), None);
let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
assert!(detail.contains("no column banding"), "{detail}");
assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
}
#[test]
fn validate_new_axis_colors_names_both_missing_flags_for_a_header_only_change() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Columns,
header_color: Some("#123456".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
};
let detail = validate_new_axis_colors(&verb, Some(&existing)).unwrap_err();
assert!(
detail.ends_with("missing: --first-band-color, --second-band-color"),
"{detail}"
);
}
#[test]
fn validate_new_axis_colors_accepts_a_new_axis_with_both_band_colors() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = band_color_update_verb(BandingAxis::Columns, Some("#ABCDEF"), Some("#FEDCBA"));
assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
}
#[test]
fn validate_new_axis_colors_accepts_one_color_on_an_existing_axis() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = band_color_update_verb(BandingAxis::Rows, Some("#ABCDEF"), None);
assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
}
#[test]
fn validate_new_axis_colors_ignores_a_range_only_change_on_a_new_axis() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: Some("Q1".to_string()),
range: Some("A1:D20".to_string()),
axis: BandingAxis::Columns,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
};
assert!(validate_new_axis_colors(&verb, Some(&existing)).is_ok());
}
#[test]
fn validate_new_axis_colors_ignores_other_verbs() {
let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
assert!(validate_new_axis_colors(&verb, None).is_ok());
}
#[test]
fn build_request_update_banding_with_only_a_range_change_carries_no_properties() {
let existing = sheet_with_banding(0, 7).banded_ranges.remove(0);
let range = GridRange {
sheet_id: 0,
start_row_index: Some(5),
end_row_index: Some(15),
start_column_index: Some(0),
end_column_index: Some(4),
};
let verb = BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: Some("Q1".to_string()),
range: Some("A6:D15".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
};
let (request, _) = build_request(&verb, Some(range), Some(&existing)).unwrap();
let BatchUpdateRequestItem::UpdateBanding(update) = request else {
panic!("expected UpdateBanding"); };
assert_eq!(update.fields, "range");
assert_eq!(update.banded_range.range, Some(range));
assert!(update.banded_range.row_properties.is_none());
}
#[test]
fn build_request_delete_banding_carries_the_id() {
let verb = BandingVerb::DeleteBanding { banded_range_id: 7 };
let (request, existing_id) = build_request(&verb, None, None).unwrap();
assert_eq!(existing_id, Some(7));
let BatchUpdateRequestItem::DeleteBanding(delete) = request else {
panic!("expected DeleteBanding"); };
assert_eq!(delete.banded_range_id, 7);
}
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 leased_opts_for(spreadsheet_id: &str) -> (Option<String>, std::path::PathBuf) {
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let token = seed_lease(&ledger_path, spreadsheet_id, "1");
(Some(token), ledger_path)
}
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(sheets: serde_json::Value) -> wiremock::Mock {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": sheets,
})),
)
}
#[tokio::test]
async fn a_denied_gate_blocks_before_any_read_or_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 opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::Blocked { .. }));
}
#[tokio::test]
async fn add_banding_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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
}
#[tokio::test]
async fn add_banding_sends_an_add_banding_request_and_reports_the_assigned_id() {
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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"replies": [{"addBanding": {"bandedRange": {"bandedRangeId": 42}}}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result,
BandingResult::Changed {
summary: "add row banding".to_string(),
banded_range_id: Some(42),
}
);
assert_eq!(outcome.sheet_id, Some(0));
}
#[tokio::test]
async fn add_banding_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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.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 (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: add_verb(),
dry_run: false,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(
matches!(outcome.result, BandingResult::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)
);
}
#[tokio::test]
async fn update_banding_refuses_an_unknown_id() {
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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}, "bandedRanges": []},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 99,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#000000".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result,
BandingResult::RefusedBandedRangeNotFound {
banded_range_id: 99
}
);
}
#[tokio::test]
async fn update_banding_sends_an_update_banding_request_and_reports_the_id() {
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(serde_json::json!([
{
"properties": {"sheetId": 0, "title": "Q1", "index": 0},
"bandedRanges": [{
"bandedRangeId": 7,
"range": {"sheetId": 0},
"rowProperties": {
"firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
"secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
},
}],
},
]))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"replies": [{}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#000000".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result,
BandingResult::Changed {
summary: "update row banding id 7".to_string(),
banded_range_id: Some(7),
}
);
assert_eq!(outcome.sheet_id, Some(0));
}
async fn mount_row_only_banding(server: &wiremock::MockServer, batch_updates: u64) {
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(serde_json::json!([
{
"properties": {"sheetId": 0, "title": "Q1", "index": 0},
"bandedRanges": [{
"bandedRangeId": 7,
"range": {"sheetId": 0},
"rowProperties": {
"firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
"secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
},
}],
},
]))
.mount(server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"replies": [{}]
})),
)
.expect(batch_updates)
.mount(server)
.await;
}
async fn run_update_on_row_only_banding(
axis: BandingAxis,
first_band_color: Option<&str>,
second_band_color: Option<&str>,
dry_run: bool,
batch_updates: u64,
) -> (BandingOutcome, wiremock::MockServer) {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_row_only_banding(&server, batch_updates).await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: band_color_update_verb(axis, first_band_color, second_band_color),
dry_run,
lease_token,
ledger_path,
};
(banding(&drive, &sheets, &opts, &rules).await, server)
}
#[tokio::test]
async fn update_banding_range_without_sheet_defaults_to_the_banded_ranges_sheet() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_row_only_banding(&server, 0).await;
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: Some("A1:D20".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: true,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
assert!(
matches!(outcome.result, BandingResult::WouldChange { .. }),
"{:?}",
outcome.result
);
assert_eq!(outcome.sheet_id, Some(0));
}
#[tokio::test]
async fn update_banding_range_without_sheet_reports_an_unknown_id_as_not_found() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_row_only_banding(&server, 0).await;
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 99,
sheet: None,
range: Some("A1:D20".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: true,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &[allow_rule("folder-1")]).await;
assert!(
matches!(
outcome.result,
BandingResult::RefusedBandedRangeNotFound { .. }
),
"{:?}",
outcome.result
);
}
fn assert_refused_missing_second_band_color(outcome: &BandingOutcome) {
let BandingResult::RefusedInvalidRange { detail } = &outcome.result else {
panic!("expected RefusedInvalidRange, got {:?}", outcome.result); };
assert!(detail.contains("no column banding"), "{detail}");
assert!(detail.ends_with("missing: --second-band-color"), "{detail}");
}
#[tokio::test]
async fn update_banding_dry_run_refuses_a_new_axis_with_only_one_band_color() {
let (outcome, server) =
run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, true, 0)
.await;
assert_refused_missing_second_band_color(&outcome);
assert_eq!(outcome.sheet_id, Some(0));
server.verify().await;
}
#[tokio::test]
async fn update_banding_refuses_a_new_axis_with_only_one_band_color_without_calling_the_api() {
let (outcome, server) =
run_update_on_row_only_banding(BandingAxis::Columns, Some("#ABCDEF"), None, false, 0)
.await;
assert_refused_missing_second_band_color(&outcome);
server.verify().await;
}
#[tokio::test]
async fn update_banding_adds_a_new_axis_when_both_band_colors_are_given() {
let (outcome, server) = run_update_on_row_only_banding(
BandingAxis::Columns,
Some("#ABCDEF"),
Some("#FEDCBA"),
false,
1,
)
.await;
assert_eq!(
outcome.result,
BandingResult::Changed {
summary: "update column banding id 7".to_string(),
banded_range_id: Some(7),
}
);
let requests = server.received_requests().await.unwrap();
let body: serde_json::Value = requests
.iter()
.find(|r| r.url.path().ends_with(":batchUpdate"))
.unwrap()
.body_json()
.unwrap();
let update = &body["requests"][0]["updateBanding"];
assert_eq!(update["fields"], "columnProperties");
let columns = &update["bandedRange"]["columnProperties"];
assert!(columns["firstBandColorStyle"].is_object(), "{update}");
assert!(columns["secondBandColorStyle"].is_object(), "{update}");
server.verify().await;
}
#[tokio::test]
async fn update_banding_changes_one_color_on_an_existing_axis() {
let (outcome, server) =
run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, false, 1)
.await;
assert_eq!(
outcome.result,
BandingResult::Changed {
summary: "update row banding id 7".to_string(),
banded_range_id: Some(7),
}
);
server.verify().await;
}
#[tokio::test]
async fn update_banding_dry_run_accepts_one_color_on_an_existing_axis() {
let (outcome, server) =
run_update_on_row_only_banding(BandingAxis::Rows, Some("#ABCDEF"), None, true, 0).await;
assert_eq!(
outcome.result,
BandingResult::WouldChange {
summary: "update row banding id 7".to_string(),
}
);
server.verify().await;
}
#[tokio::test]
async fn delete_banding_sends_a_delete_banding_request() {
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(serde_json::json!([
{
"properties": {"sheetId": 0, "title": "Q1", "index": 0},
"bandedRanges": [{
"bandedRangeId": 7,
"range": {"sheetId": 0},
"rowProperties": {
"firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
"secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
},
}],
},
]))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"replies": [{}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::DeleteBanding { banded_range_id: 7 },
dry_run: false,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result,
BandingResult::Changed {
summary: "delete banding id 7".to_string(),
banded_range_id: Some(7),
}
);
assert_eq!(outcome.sheet_id, Some(0));
}
#[tokio::test]
async fn update_banding_end_to_end_refuses_nothing_to_change() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 1,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn add_banding_end_to_end_rejects_conflicting_sheet_and_range() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Other".to_string(),
range: "Sheet1!A1:B2".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn a_metadata_fetch_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::Failed { .. }));
}
#[tokio::test]
async fn a_shortcut_target_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.shortcut",
&["folder-1"],
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::RefusedShortcut));
}
#[tokio::test]
async fn a_non_spreadsheet_target_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
"application/vnd.google-apps.document",
&["folder-1"],
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedNotASpreadsheet { .. }
));
}
#[tokio::test]
async fn a_target_with_no_visible_parents_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", crate::drive::types::GOOGLE_SHEET_MIME_TYPE, &[])
.mount(&server)
.await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedNoVisibleParents
));
}
#[tokio::test]
async fn a_gate_ancestor_fetch_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/folder-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::Failed { .. }));
}
#[tokio::test]
async fn a_workbook_fetch_failure_after_a_granted_gate_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::Failed { .. }));
}
#[tokio::test]
async fn add_banding_reports_sheet_not_found_for_an_unknown_sheet() {
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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Missing".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedSheetNotFound { .. }
));
}
#[tokio::test]
async fn add_banding_reports_invalid_range_for_a_malformed_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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "not-a-range".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn update_banding_reports_sheet_not_found_when_re_pointing() {
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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: Some("Missing".to_string()),
range: Some("A1:B2".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedSheetNotFound { .. }
));
}
#[tokio::test]
async fn update_banding_reports_invalid_range_for_a_malformed_range_when_re_pointing() {
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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: Some("Q1".to_string()),
range: Some("not-a-range".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn update_banding_with_a_new_range_succeeds() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(serde_json::json!([
{
"properties": {"sheetId": 0, "title": "Q1", "index": 0},
"bandedRanges": [{
"bandedRangeId": 7,
"range": {"sheetId": 0},
"rowProperties": {
"firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
"secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
},
}],
},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: None,
second_band_color: None,
footer_color: None,
},
dry_run: true,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::WouldChange { .. }));
}
#[tokio::test]
async fn update_banding_end_to_end_rejects_a_malformed_color() {
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(serde_json::json!([
{
"properties": {"sheetId": 0, "title": "Q1", "index": 0},
"bandedRanges": [{
"bandedRangeId": 7,
"range": {"sheetId": 0},
"rowProperties": {
"firstBandColorStyle": {"rgbColor": {"red": 1, "green": 1, "blue": 1}},
"secondBandColorStyle": {"rgbColor": {"red": 0, "green": 0, "blue": 0}},
},
}],
},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: None,
first_band_color: Some("not-a-color".to_string()),
second_band_color: None,
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::RefusedInvalidRange { .. }
));
}
#[tokio::test]
async fn a_batch_update_failure_surfaces_as_failed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(wiremock::ResponseTemplate::new(400).set_body_string("bad request"))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token,
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::Failed { .. }));
}
#[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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::RefusedNoLease));
}
#[tokio::test]
async fn refuses_an_unknown_lease_token() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let ledger_path = tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl");
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: Some("bogus-token".to_string()),
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.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 = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
BandingResult::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(serde_json::json!([
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
]))
.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 = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
},
dry_run: false,
lease_token: Some(token),
ledger_path,
};
let outcome = banding(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, BandingResult::RefusedLeaseStale));
}
fn outcome_with(
verb: BandingVerb,
file_name: Option<&str>,
result: BandingResult,
) -> BandingOutcome {
BandingOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: file_name.map(str::to_string),
resolved_folder_id: None,
sheet_id: None,
verb,
result,
}
}
fn add_verb() -> BandingVerb {
BandingVerb::AddBanding {
sheet: "Q1".to_string(),
range: "A1:D10".to_string(),
axis: BandingAxis::Rows,
header_color: None,
first_band_color: "#FFFFFF".to_string(),
second_band_color: "#EEEEEE".to_string(),
footer_color: None,
}
}
fn update_verb() -> BandingVerb {
BandingVerb::UpdateBanding {
banded_range_id: 7,
sheet: None,
range: None,
axis: BandingAxis::Rows,
header_color: Some("#000000".to_string()),
first_band_color: None,
second_band_color: None,
footer_color: None,
}
}
fn delete_verb() -> BandingVerb {
BandingVerb::DeleteBanding { banded_range_id: 7 }
}
#[test]
fn describe_lines_renders_would_change() {
let out = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::WouldChange {
summary: "add row banding".to_string(),
},
);
assert_eq!(describe(&out), "Would add row banding in 'Budget'");
}
#[test]
fn describe_lines_renders_not_a_spreadsheet_with_no_file_name() {
let out = outcome_with(
add_verb(),
None,
BandingResult::RefusedNotASpreadsheet {
mime_type: "text/plain".to_string(),
},
);
let text = describe(&out);
assert!(text.contains("'sheet-1'"), "{text}");
assert!(text.contains("add-banding"), "{text}");
assert!(text.contains("text/plain"), "{text}");
}
#[test]
fn describe_lines_renders_shortcut() {
let out = outcome_with(
delete_verb(),
Some("Budget"),
BandingResult::RefusedShortcut,
);
let text = describe(&out);
assert!(text.contains("shortcut"), "{text}");
assert!(text.contains("delete-banding"), "{text}");
}
#[test]
fn describe_lines_renders_no_visible_parents() {
let out = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::RefusedNoVisibleParents,
);
assert!(describe(&out).contains("sheets-structure"));
}
#[test]
fn describe_lines_renders_sheet_not_found_with_and_without_available_titles() {
let none = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::RefusedSheetNotFound {
title: "Q2".to_string(),
available: Vec::new(),
},
);
assert!(describe(&none).contains("Available: none"));
let some = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::RefusedSheetNotFound {
title: "Q2".to_string(),
available: vec!["Q1".to_string(), "Q3".to_string()],
},
);
assert!(describe(&some).contains("'Q1', 'Q3'"));
}
#[test]
fn describe_lines_renders_invalid_range_and_banded_range_not_found() {
let invalid = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::RefusedInvalidRange {
detail: "bad range".to_string(),
},
);
assert_eq!(describe(&invalid), "Refused: bad range");
let not_found = outcome_with(
update_verb(),
Some("Budget"),
BandingResult::RefusedBandedRangeNotFound {
banded_range_id: 99,
},
);
let text = describe(¬_found);
assert!(text.contains("no banded range with id 99"), "{text}");
assert!(text.contains("list-bandings"), "{text}");
}
#[test]
fn describe_lines_renders_blocked_with_and_without_a_deciding_rule() {
let folder_rule = outcome_with(
update_verb(),
Some("Budget"),
BandingResult::Blocked {
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".to_string(),
depth: 2,
}),
},
);
let text = describe(&folder_rule);
assert!(text.contains("update-banding"), "{text}");
assert!(text.contains("folder folder-1 (depth 2)"), "{text}");
let default_policy = outcome_with(
delete_verb(),
Some("Budget"),
BandingResult::Blocked { decided_by: None },
);
assert!(describe(&default_policy).contains("default policy"));
}
#[test]
fn describe_lines_renders_every_lease_refusal() {
for (result, needle) in [
(
BandingResult::RefusedNoLease,
"requires a Drive write lease",
),
(
BandingResult::RefusedLeaseExpired,
"expired, released, or unknown",
),
(
BandingResult::RefusedLeaseWrongFile,
"acquired for a different file",
),
(
BandingResult::RefusedLeaseStale,
"changed since the lease was acquired",
),
] {
let out = outcome_with(add_verb(), Some("Budget"), result);
let text = describe(&out);
assert!(text.contains(needle), "{text}");
assert!(text.contains("drive lease acquire sheet-1"), "{text}");
}
}
#[test]
fn describe_lines_renders_changed_with_and_without_an_id() {
let with_id = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::Changed {
summary: "add row banding".to_string(),
banded_range_id: Some(42),
},
);
assert!(describe(&with_id).contains("(id 42)"));
let without_id = outcome_with(
update_verb(),
Some("Budget"),
BandingResult::Changed {
summary: "update row banding id 7".to_string(),
banded_range_id: None,
},
);
let text = describe(&without_id);
assert!(!text.contains("(id"), "{text}");
}
#[test]
fn describe_lines_names_an_update_or_delete_id_once() {
for (verb, summary) in [
(update_verb(), "update row banding id 7"),
(delete_verb(), "delete banding id 7"),
] {
let out = outcome_with(
verb,
Some("Budget"),
BandingResult::Changed {
summary: summary.to_string(),
banded_range_id: Some(7),
},
);
assert_eq!(describe(&out), format!("Applied: {summary} in 'Budget'"));
}
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_with_an_id_hint_only_for_adds() {
let added = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::AppliedReplyUnreadable {
summary: "add row banding".to_string(),
banded_range_id: None,
detail: "bad reply".to_string(),
},
);
assert_eq!(
describe_lines(&added),
vec![
"Applied, but the reply could not be read: add row banding in 'Budget' — \
do not retry; check the spreadsheet first (bad reply)"
.to_string(),
" the new banded range's id was in the unreadable reply; run \
`list-bandings` to find it"
.to_string(),
]
);
let deleted = outcome_with(
delete_verb(),
Some("Budget"),
BandingResult::AppliedReplyUnreadable {
summary: "delete banding id 7".to_string(),
banded_range_id: Some(7),
detail: "bad reply".to_string(),
},
);
assert_eq!(
describe_lines(&deleted),
vec![
"Applied, but the reply could not be read: delete banding id 7 in 'Budget' — \
do not retry; check the spreadsheet first (bad reply)"
.to_string()
]
);
}
#[test]
fn mutation_record_logs_the_same_fields_for_an_unreadable_reply_as_for_changed() {
let opts = BandingOptions {
spreadsheet_id: "sheet-1".to_string(),
verb: delete_verb(),
dry_run: false,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
};
let record = |result| {
let mut out = outcome_with(delete_verb(), Some("Budget"), result);
out.sheet_id = Some(3);
mutation_record(&out, &opts, Duration::ZERO)
};
let changed = record(BandingResult::Changed {
summary: "delete banding id 7".to_string(),
banded_range_id: Some(7),
});
let unreadable = record(BandingResult::AppliedReplyUnreadable {
summary: "delete banding id 7".to_string(),
banded_range_id: Some(7),
detail: "bad reply".to_string(),
});
assert_eq!(unreadable.sheet_id, Some(3));
assert_eq!(unreadable.banded_range_id, changed.banded_range_id);
assert_eq!(unreadable.status, "applied-reply-unreadable");
assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
assert_eq!(
unreadable.fields_changed.as_deref(),
Some("delete banding id 7")
);
}
#[test]
fn describe_lines_renders_failed() {
let out = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::Failed {
detail: "boom".to_string(),
},
);
assert_eq!(describe(&out), "Failed: boom");
}
#[test]
fn write_jsonl_emits_one_line_of_json() {
let outcome = outcome_with(
add_verb(),
Some("Budget"),
BandingResult::Changed {
summary: "add row banding".to_string(),
banded_range_id: Some(1),
},
);
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 parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(parsed["result"]["status"], "changed");
}
#[test]
fn banding_result_log_status_names_every_variant() {
assert_eq!(
BandingResult::WouldChange {
summary: String::new(),
}
.log_status(),
"would-change"
);
assert_eq!(
BandingResult::RefusedNotASpreadsheet {
mime_type: String::new(),
}
.log_status(),
"refused-not-a-spreadsheet"
);
assert_eq!(
BandingResult::RefusedShortcut.log_status(),
"refused-shortcut"
);
assert_eq!(
BandingResult::RefusedNoVisibleParents.log_status(),
"refused-no-visible-parents"
);
assert_eq!(
BandingResult::RefusedSheetNotFound {
title: String::new(),
available: Vec::new(),
}
.log_status(),
"refused-sheet-not-found"
);
assert_eq!(
BandingResult::RefusedInvalidRange {
detail: String::new(),
}
.log_status(),
"refused-invalid-range"
);
assert_eq!(
BandingResult::RefusedBandedRangeNotFound { banded_range_id: 1 }.log_status(),
"refused-banded-range-not-found"
);
assert_eq!(
BandingResult::Blocked { decided_by: None }.log_status(),
"blocked"
);
assert_eq!(
BandingResult::RefusedNoLease.log_status(),
LeaseGateRefusal::NoLease.log_status()
);
assert_eq!(
BandingResult::RefusedLeaseExpired.log_status(),
LeaseGateRefusal::Expired.log_status()
);
assert_eq!(
BandingResult::RefusedLeaseWrongFile.log_status(),
LeaseGateRefusal::WrongFile.log_status()
);
assert_eq!(
BandingResult::RefusedLeaseStale.log_status(),
LeaseGateRefusal::Stale.log_status()
);
assert_eq!(
BandingResult::Changed {
summary: String::new(),
banded_range_id: None,
}
.log_status(),
"changed"
);
assert_eq!(
BandingResult::AppliedReplyUnreadable {
summary: String::new(),
banded_range_id: None,
detail: String::new(),
}
.log_status(),
"applied-reply-unreadable"
);
assert_eq!(
BandingResult::Failed {
detail: String::new(),
}
.log_status(),
"failed"
);
}
}