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::{
AutoFillRequest, BatchUpdateRequestItem, Dimension, GridRange, Sheet, SourceAndDestination,
};
use crate::drive::types::SheetTargetRefusal;
use crate::drive::write_gate::{self, DecidingRule, DriveOperation, FolderPermissionRule};
use crate::request_log::{self, DriveMutationOutcome};
const LOG_OPERATION: &str = "sheets-auto-fill";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AutoFillForm {
Range {
sheet: Option<String>,
range: Option<String>,
},
SourceAndDestination {
sheet: Option<String>,
source: Option<String>,
dimension: Dimension,
fill_length: i64,
},
}
impl AutoFillForm {
fn sheet_and_range(&self) -> (Option<&str>, Option<&str>) {
match self {
Self::Range { sheet, range } => (sheet.as_deref(), range.as_deref()),
Self::SourceAndDestination { sheet, source, .. } => {
(sheet.as_deref(), source.as_deref())
}
}
}
}
#[derive(Debug, Clone)]
pub struct AutoFillOptions {
pub spreadsheet_id: String,
pub form: AutoFillForm,
pub use_alternate_series: bool,
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 AutoFillResult {
WouldChange {
summary: String,
destination: String,
#[serde(skip_serializing_if = "Option::is_none")]
requested_destination: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
destination_is_upper_bound: bool,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
RefusedDestinationPastGrid {
requested_destination: String,
row_count: Option<i64>,
column_count: Option<i64>,
},
Blocked {
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed {
summary: String,
destination: String,
#[serde(skip_serializing_if = "Option::is_none")]
requested_destination: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
destination_is_upper_bound: bool,
},
AppliedReplyUnreadable {
summary: String,
destination: String,
#[serde(skip_serializing_if = "Option::is_none")]
requested_destination: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
destination_is_upper_bound: bool,
detail: String,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for AutoFillResult {
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 AutoFillResult {
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::RefusedDestinationPastGrid { .. } => "refused-destination-past-grid",
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 AutoFillOutcome {
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 form: AutoFillForm,
pub result: AutoFillResult,
}
impl JsonlSerialize for AutoFillOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn auto_fill(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &AutoFillOptions,
rules: &[FolderPermissionRule],
) -> AutoFillOutcome {
let started = Instant::now();
let outcome = auto_fill_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, started.elapsed());
}
outcome
}
async fn auto_fill_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &AutoFillOptions,
rules: &[FolderPermissionRule],
) -> AutoFillOutcome {
let bare = |result| AutoFillOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
form: opts.form.clone(),
result,
};
let (sheet, range) = opts.form.sheet_and_range();
let composed = match a1::compose(sheet, range) {
Ok(composed) => composed,
Err(err) => {
return bare(AutoFillResult::RefusedInvalidRange {
detail: err.to_string(),
})
}
};
if let AutoFillForm::SourceAndDestination { fill_length, .. } = &opts.form {
if *fill_length == 0 {
return bare(AutoFillResult::RefusedInvalidRange {
detail: "--fill-length must not be 0".to_string(),
});
}
}
let (target, decision, resolved_folder_id, requires_lease) = match target_gate::resolve(
drive,
&opts.spreadsheet_id,
DriveOperation::SheetsWrite,
rules,
)
.await
{
target_gate::TargetGateOutcome::MetadataFetchFailed { detail } => {
return bare(AutoFillResult::Failed { detail })
}
target_gate::TargetGateOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => AutoFillResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
AutoFillResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => AutoFillResult::RefusedNoVisibleParents,
};
return AutoFillOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
form: opts.form.clone(),
result,
};
}
target_gate::TargetGateOutcome::GateFetchFailed { target, detail } => {
return AutoFillOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
form: opts.form.clone(),
result: AutoFillResult::Failed { detail },
};
}
target_gate::TargetGateOutcome::Gated {
target,
decision,
resolved_folder_id,
requires_lease,
} => (target, decision, resolved_folder_id, requires_lease),
};
let pre_gated = |result| AutoFillOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
sheet_id: None,
form: opts.form.clone(),
result,
};
if decision.verdict == write_gate::Verdict::Deny {
return pre_gated(AutoFillResult::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 pre_gated(AutoFillResult::Failed {
detail: format!("{err:#}"),
})
}
};
let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
&workbook,
&composed,
|detail| AutoFillResult::RefusedInvalidRange { detail },
|title, available| AutoFillResult::RefusedSheetNotFound { title, available },
) {
Ok(resolved) => resolved,
Err(result) => return pre_gated(result),
};
let gated = |result| AutoFillOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
sheet_id: Some(source_grid.sheet_id),
form: opts.form.clone(),
result,
};
if !grid_range::is_bounded(&source_grid) {
return gated(AutoFillResult::RefusedInvalidRange {
detail: format!(
"{composed} is open-ended; auto-fill needs a fully bounded range (e.g. A1:D10)"
),
});
}
let (destination, is_upper_bound) = match &opts.form {
AutoFillForm::Range { .. } => (source_grid, true),
AutoFillForm::SourceAndDestination {
dimension,
fill_length,
..
} => match compute_destination(source_grid, *dimension, *fill_length) {
Ok(destination) => (destination, false),
Err(detail) => return gated(AutoFillResult::RefusedInvalidRange { detail }),
},
};
let requested_destination_a1 = grid_range_to_a1(&sheet_title, &destination);
let source_a1 = grid_range_to_a1(&sheet_title, &source_grid);
let sheet = grid_range::find_sheet_by_id(&workbook, source_grid.sheet_id);
let applied_destination = if let Some(sheet) = sheet {
if let Some(applied) = clamp_to_grid(sheet, &destination) {
applied
} else {
let grid = sheet
.properties
.as_ref()
.and_then(|props| props.grid_properties.as_ref());
return gated(AutoFillResult::RefusedDestinationPastGrid {
requested_destination: requested_destination_a1,
row_count: grid.and_then(|g| g.row_count),
column_count: grid.and_then(|g| g.column_count),
});
}
} else {
destination };
let destination_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
let requested_destination =
(applied_destination != destination).then_some(requested_destination_a1);
let overwritten_cells = {
let read_a1 = grid_range_to_a1(&sheet_title, &applied_destination);
match api
.values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
.await
{
Ok(values) => grid_range::non_blank_locations(
&values,
applied_destination.start_row_index.unwrap_or(0),
applied_destination.start_column_index.unwrap_or(0),
),
Err(err) => {
return gated(AutoFillResult::Failed {
detail: format!("{err:#}"),
})
}
}
};
let summary = describe_effect(
&opts.form,
&source_a1,
&destination_a1,
applied_fill_length(&opts.form, &source_grid, &applied_destination),
opts.use_alternate_series,
);
if opts.dry_run {
return gated(AutoFillResult::WouldChange {
summary,
destination: destination_a1,
requested_destination,
overwritten_cells,
destination_is_upper_bound: is_upper_bound,
});
}
let request = build_request(&opts.form, source_grid, opts.use_alternate_series);
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets auto-fill",
operation: 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)) => AutoFillResult::Changed {
summary,
destination: destination_a1,
requested_destination,
overwritten_cells,
destination_is_upper_bound: is_upper_bound,
},
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
AutoFillResult::AppliedReplyUnreadable {
summary,
destination: destination_a1,
requested_destination,
overwritten_cells,
destination_is_upper_bound: is_upper_bound,
detail,
}
}
Err(err) => AutoFillResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn compute_destination(
source: GridRange,
dimension: Dimension,
fill_length: i64,
) -> Result<GridRange, String> {
let (start, end) = match dimension {
Dimension::Rows => (source.start_row_index, source.end_row_index),
Dimension::Columns => (source.start_column_index, source.end_column_index),
};
let (Some(start), Some(end)) = (start, end) else {
return Err(format!(
"--source needs a fully bounded range on the {} axis to compute a destination from",
dimension.noun()
));
};
let (new_start, new_end) = if fill_length > 0 {
let Some(new_end) = end.checked_add(fill_length) else {
return Err(format!("--fill-length {fill_length} is out of range"));
};
(end, new_end)
} else {
let Some(new_start) = start.checked_add(fill_length) else {
return Err(format!("--fill-length {fill_length} is out of range"));
};
if new_start < 0 {
return Err(format!(
"--fill-length {fill_length} would fill {} {}(s) before the start of the sheet",
new_start.unsigned_abs(),
dimension.noun()
));
}
(new_start, start)
};
let mut destination = source;
match dimension {
Dimension::Rows => {
destination.start_row_index = Some(new_start);
destination.end_row_index = Some(new_end);
}
Dimension::Columns => {
destination.start_column_index = Some(new_start);
destination.end_column_index = Some(new_end);
}
}
Ok(destination)
}
fn clamp_to_grid(sheet: &Sheet, destination: &GridRange) -> Option<GridRange> {
let grid = sheet
.properties
.as_ref()
.and_then(|props| props.grid_properties.as_ref());
let mut clamped = *destination;
if let (Some(end), Some(count)) = (destination.end_row_index, grid.and_then(|g| g.row_count)) {
clamped.end_row_index = Some(end.min(count));
}
if let (Some(end), Some(count)) = (
destination.end_column_index,
grid.and_then(|g| g.column_count),
) {
clamped.end_column_index = Some(end.min(count));
}
let empty = |start: Option<i64>, end: Option<i64>| match (start, end) {
(Some(start), Some(end)) => start >= end,
_ => false,
};
if empty(clamped.start_row_index, clamped.end_row_index)
|| empty(clamped.start_column_index, clamped.end_column_index)
{
return None;
}
Some(clamped)
}
fn grid_range_to_a1(sheet_title: &str, grid: &GridRange) -> String {
let (Some(r0), Some(r1), Some(c0), Some(c1)) = (
grid.start_row_index,
grid.end_row_index,
grid.start_column_index,
grid.end_column_index,
) else {
unreachable!("auto-fill ranges are refused earlier unless fully bounded")
};
let start = format!("{}{}", grid_range::column_index_to_letters(c0), r0 + 1);
let end = format!("{}{}", grid_range::column_index_to_letters(c1 - 1), r1);
match a1::compose(Some(sheet_title), Some(&format!("{start}:{end}"))) {
Ok(composed) => composed,
Err(err) => unreachable!("auto-fill composes only bounded numeric ranges: {err}"), }
}
const UNPREVIEWABLE_VALUES_CAVEAT: &str =
" the filled values are computed by Sheets' own series detection and are never reported, \
before or after the request";
fn past_grid_extent_caveat_dry_run(applied: &str, requested: &str) -> String {
format!(
" the destination runs past the sheet's current extent; auto-fill clips to it, so only \
{applied} would be filled — {requested} was requested"
)
}
fn past_grid_extent_caveat(applied: &str, requested: &str) -> String {
format!(
" the destination ran past the sheet's current extent; auto-fill clipped to it, so \
only {applied} was filled — {requested} was requested"
)
}
fn applied_fill_length(
form: &AutoFillForm,
source_grid: &GridRange,
applied_destination: &GridRange,
) -> i64 {
let AutoFillForm::SourceAndDestination {
dimension,
fill_length,
..
} = form
else {
return 0;
};
let (source_start, source_end, applied_start, applied_end) = match dimension {
Dimension::Rows => (
source_grid.start_row_index,
source_grid.end_row_index,
applied_destination.start_row_index,
applied_destination.end_row_index,
),
Dimension::Columns => (
source_grid.start_column_index,
source_grid.end_column_index,
applied_destination.start_column_index,
applied_destination.end_column_index,
),
};
if *fill_length >= 0 {
applied_end.unwrap_or(0) - source_end.unwrap_or(0)
} else {
applied_start.unwrap_or(0) - source_start.unwrap_or(0)
}
}
fn describe_effect(
form: &AutoFillForm,
source_a1: &str,
destination_a1: &str,
applied_fill_length: i64,
use_alternate_series: bool,
) -> String {
let mut summary = match form {
AutoFillForm::Range { .. } => format!(
"auto-fill within {destination_a1} (Sheets decides which cells are the source and \
which are filled)"
),
AutoFillForm::SourceAndDestination { dimension, .. } => {
let direction = match (dimension, applied_fill_length >= 0) {
(Dimension::Rows, true) => "down",
(Dimension::Rows, false) => "up",
(Dimension::Columns, true) => "right",
(Dimension::Columns, false) => "left",
};
format!(
"auto-fill {destination_a1} from source {source_a1}, extending {} {}(s) \
{direction}",
applied_fill_length.abs(),
dimension.noun(),
)
}
};
if use_alternate_series {
summary.push_str(", using the alternate series");
}
summary
}
fn overwritten_line(
overwritten_cells: &[String],
destination_is_upper_bound: bool,
dry_run: bool,
) -> String {
if overwritten_cells.is_empty() {
return " no non-blank cells in the destination".to_string();
}
let bound = if destination_is_upper_bound {
"up to "
} else {
""
};
let tense = if dry_run {
"would be overwritten"
} else {
"were overwritten"
};
format!(
" {bound}{} non-blank cell(s) {tense}: {}",
overwritten_cells.len(),
grid_range::render_locations(overwritten_cells)
)
}
fn build_request(
form: &AutoFillForm,
source_grid: GridRange,
use_alternate_series: bool,
) -> BatchUpdateRequestItem {
let request = match form {
AutoFillForm::Range { .. } => AutoFillRequest {
range: Some(source_grid),
source_and_destination: None,
use_alternate_series,
},
AutoFillForm::SourceAndDestination {
dimension,
fill_length,
..
} => AutoFillRequest {
range: None,
source_and_destination: Some(SourceAndDestination {
source: source_grid,
dimension: *dimension,
fill_length: *fill_length,
}),
use_alternate_series,
},
};
BatchUpdateRequestItem::AutoFill(request)
}
fn record_attempt(outcome: &AutoFillOutcome, duration: Duration) {
request_log::record_drive_mutation(mutation_record(outcome, duration));
}
fn mutation_record(outcome: &AutoFillOutcome, duration: Duration) -> DriveMutationOutcome {
let error = match &outcome.result {
AutoFillResult::Failed { detail }
| AutoFillResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
_ => None,
};
let decided_by = match &outcome.result {
AutoFillResult::Blocked { decided_by } => decided_by.as_ref(),
_ => None,
};
let decided_by = write_gate::decided_by_log_fields(decided_by);
let (range, fields_changed, overwritten_cells, overwritten_cells_upper_bound) =
match &outcome.result {
AutoFillResult::Changed {
summary,
destination,
overwritten_cells,
destination_is_upper_bound,
..
}
| AutoFillResult::AppliedReplyUnreadable {
summary,
destination,
overwritten_cells,
destination_is_upper_bound,
..
} => (
Some(destination.clone()),
Some(summary.clone()),
overwritten_cells.clone(),
*destination_is_upper_bound,
),
_ => (None, None, Vec::new(), false),
};
DriveMutationOutcome {
operation: 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,
range,
fields_changed,
overwritten_cells,
overwritten_cells_upper_bound,
error,
duration,
..Default::default()
}
}
#[must_use]
pub fn describe(outcome: &AutoFillOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &AutoFillOutcome) -> Vec<String> {
let book = outcome.file_name.as_deref().map_or_else(
|| format!("'{}'", outcome.spreadsheet_id),
|n| format!("'{n}'"),
);
match &outcome.result {
AutoFillResult::WouldChange {
summary,
destination,
requested_destination,
overwritten_cells,
destination_is_upper_bound,
} => change_lines(
&format!("Would {summary} in {book}"),
overwritten_cells,
*destination_is_upper_bound,
destination,
requested_destination.as_deref(),
true,
),
AutoFillResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets auto-fill` only works on spreadsheets"
)],
AutoFillResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets auto-fill` doesn't follow shortcuts"
)],
AutoFillResult::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-write\"]}} to write_permissions.rules."
)],
AutoFillResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
AutoFillResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
AutoFillResult::RefusedDestinationPastGrid {
requested_destination,
row_count,
column_count,
} => {
let extent = match (row_count, column_count) {
(Some(rows), Some(cols)) => format!(" ({rows} rows x {cols} columns)"),
(Some(rows), None) => format!(" ({rows} rows)"),
(None, Some(cols)) => format!(" ({cols} columns)"),
(None, None) => String::new(),
};
vec![format!(
"Refused: {requested_destination} lies wholly past {book}'s current grid{extent}; \
auto-fill does not grow the sheet. Grow it first (e.g. `sheets append`, \
`sheets insert-rows`/`insert-columns`), or choose a destination within the grid."
)]
}
AutoFillResult::Blocked { decided_by } => vec![match decided_by {
Some(rule) => format!(
"Blocked: auto-fill on {book} refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: auto-fill on {book} refused by default policy (no matching rule for \
sheets-write)"
),
}],
AutoFillResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
AutoFillResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
AutoFillResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
AutoFillResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
AutoFillResult::Changed {
summary,
destination,
requested_destination,
overwritten_cells,
destination_is_upper_bound,
} => change_lines(
&format!("Applied: {summary} in {book}"),
overwritten_cells,
*destination_is_upper_bound,
destination,
requested_destination.as_deref(),
false,
),
AutoFillResult::AppliedReplyUnreadable {
summary,
destination,
requested_destination,
overwritten_cells,
destination_is_upper_bound,
detail,
} => {
let mut lines = vec![applied_reply_unreadable_line(
summary,
&book,
RetryHint::Idempotent,
detail,
)];
lines.extend(change_detail_lines(
overwritten_cells,
*destination_is_upper_bound,
destination,
requested_destination.as_deref(),
false,
));
lines
}
AutoFillResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
fn change_lines(
head: &str,
overwritten_cells: &[String],
destination_is_upper_bound: bool,
destination: &str,
requested_destination: Option<&str>,
dry_run: bool,
) -> Vec<String> {
let mut lines = vec![head.to_string()];
lines.extend(change_detail_lines(
overwritten_cells,
destination_is_upper_bound,
destination,
requested_destination,
dry_run,
));
lines
}
fn change_detail_lines(
overwritten_cells: &[String],
destination_is_upper_bound: bool,
destination: &str,
requested_destination: Option<&str>,
dry_run: bool,
) -> Vec<String> {
let mut lines = vec![overwritten_line(
overwritten_cells,
destination_is_upper_bound,
dry_run,
)];
if let Some(requested) = requested_destination {
lines.push(if dry_run {
past_grid_extent_caveat_dry_run(destination, requested)
} else {
past_grid_extent_caveat(destination, requested)
});
}
lines.push(UNPREVIEWABLE_VALUES_CAVEAT.to_string());
lines
}
#[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::{GridProperties, SheetProperties};
use crate::drive::test_support::seed_lease;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
use std::collections::HashSet;
fn bounded(sheet_id: i64, r0: i64, r1: i64, c0: i64, c1: i64) -> GridRange {
GridRange {
sheet_id,
start_row_index: Some(r0),
end_row_index: Some(r1),
start_column_index: Some(c0),
end_column_index: Some(c1),
}
}
#[test]
fn compute_destination_extends_rows_forward() {
let source = bounded(0, 0, 3, 0, 2); let dest = compute_destination(source, Dimension::Rows, 4).unwrap();
assert_eq!(dest, bounded(0, 3, 7, 0, 2)); }
#[test]
fn compute_destination_extends_rows_backward() {
let source = bounded(0, 5, 8, 0, 2); let dest = compute_destination(source, Dimension::Rows, -3).unwrap();
assert_eq!(dest, bounded(0, 2, 5, 0, 2)); }
#[test]
fn compute_destination_extends_columns_forward() {
let source = bounded(0, 0, 2, 0, 1); let dest = compute_destination(source, Dimension::Columns, 2).unwrap();
assert_eq!(dest, bounded(0, 0, 2, 1, 3)); }
#[test]
fn compute_destination_extends_columns_backward() {
let source = bounded(0, 0, 2, 5, 6); let dest = compute_destination(source, Dimension::Columns, -5).unwrap();
assert_eq!(dest, bounded(0, 0, 2, 0, 5)); }
#[test]
fn compute_destination_refuses_going_before_the_start_of_the_sheet() {
let source = bounded(0, 2, 5, 0, 2); let err = compute_destination(source, Dimension::Rows, -3).unwrap_err();
assert!(err.contains("before the start"), "{err}");
}
#[test]
fn compute_destination_allows_landing_exactly_at_the_start() {
let source = bounded(0, 2, 5, 0, 2); let dest = compute_destination(source, Dimension::Rows, -2).unwrap();
assert_eq!(dest, bounded(0, 0, 2, 0, 2)); }
#[test]
fn compute_destination_refuses_overflow() {
let source = bounded(0, i64::MAX - 1, i64::MAX, 0, 1);
let err = compute_destination(source, Dimension::Rows, i64::MAX).unwrap_err();
assert!(err.contains("out of range"), "{err}");
}
#[test]
fn compute_destination_refuses_an_unbounded_axis_and_backward_overflow() {
let unbounded = GridRange {
sheet_id: 0,
..Default::default()
};
assert!(compute_destination(unbounded, Dimension::Columns, 1)
.unwrap_err()
.contains("fully bounded"));
assert!(
compute_destination(bounded(0, 0, 1, 0, 1), Dimension::Rows, i64::MIN)
.unwrap_err()
.contains("9223372036854775808 row(s) before the start")
);
assert!(
compute_destination(bounded(0, -1, 1, 0, 1), Dimension::Rows, i64::MIN)
.unwrap_err()
.contains("out of range")
);
}
#[test]
fn lease_refusals_map_to_their_auto_fill_results() {
assert_eq!(
AutoFillResult::from_lease_expired(),
AutoFillResult::RefusedLeaseExpired
);
assert_eq!(
AutoFillResult::from_lease_wrong_file(),
AutoFillResult::RefusedLeaseWrongFile
);
assert_eq!(
AutoFillResult::from_lease_stale(),
AutoFillResult::RefusedLeaseStale
);
assert_eq!(
AutoFillResult::from_lease_failed("ledger unavailable".into()),
AutoFillResult::Failed {
detail: "ledger unavailable".into()
}
);
}
#[test]
fn jsonl_outcome_serializes_one_record_without_the_input_form() {
let outcome = AutoFillOutcome {
spreadsheet_id: "sheet-1".into(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
form: range_form(),
result: AutoFillResult::RefusedShortcut,
};
let mut out = Vec::new();
outcome.write_jsonl(&mut out).unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(value["result"]["status"], "refused-shortcut");
assert!(value.get("form").is_none());
assert_eq!(std::str::from_utf8(&out).unwrap().lines().count(), 1);
}
fn sheet_with_extent(row_count: i64, column_count: i64) -> Sheet {
Sheet {
properties: Some(SheetProperties {
sheet_id: Some(0),
title: "Q1".to_string(),
index: Some(0),
hidden: None,
grid_properties: Some(GridProperties {
row_count: Some(row_count),
column_count: Some(column_count),
frozen_row_count: None,
frozen_column_count: None,
hide_gridlines: None,
}),
right_to_left: None,
}),
..Default::default()
}
}
#[test]
fn clamp_to_grid_trims_a_destination_that_overhangs_the_extent() {
let sheet = sheet_with_extent(10, 5);
let clamped = clamp_to_grid(&sheet, &bounded(0, 8, 14, 0, 1)).unwrap();
assert_eq!(clamped.end_row_index, Some(10));
assert_eq!(clamped.start_row_index, Some(8));
let clamped = clamp_to_grid(&sheet, &bounded(0, 0, 1, 3, 9)).unwrap();
assert_eq!(clamped.end_column_index, Some(5));
}
#[test]
fn clamp_to_grid_leaves_a_destination_within_the_extent_untouched() {
let sheet = sheet_with_extent(10, 5);
let destination = bounded(0, 2, 5, 1, 3);
assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
}
#[test]
fn clamp_to_grid_is_none_when_the_destination_lies_wholly_past_the_extent() {
let sheet = sheet_with_extent(10, 5);
assert_eq!(clamp_to_grid(&sheet, &bounded(0, 10, 14, 0, 1)), None);
assert_eq!(clamp_to_grid(&sheet, &bounded(0, 0, 1, 5, 9)), None);
}
#[test]
fn clamp_to_grid_leaves_an_axis_with_no_known_extent_alone() {
let mut sheet = sheet_with_extent(10, 5);
sheet
.properties
.as_mut()
.expect("fixture always sets properties")
.grid_properties = None;
let destination = bounded(0, 0, 9_999, 0, 1);
assert_eq!(clamp_to_grid(&sheet, &destination), Some(destination));
}
#[test]
fn grid_range_to_a1_round_trips_a_bounded_range() {
let grid = bounded(0, 0, 2, 0, 2); assert_eq!(grid_range_to_a1("Q1", &grid), "'Q1'!A1:B2");
}
#[test]
#[should_panic(expected = "fully bounded")]
fn grid_range_to_a1_panics_on_an_unbounded_range() {
let grid = GridRange {
sheet_id: 0,
..Default::default()
};
let _ = grid_range_to_a1("Q1", &grid);
}
#[test]
fn build_request_range_form_carries_the_range_and_no_source_and_destination() {
let source = bounded(0, 0, 2, 0, 2);
let form = AutoFillForm::Range {
sheet: Some("Q1".to_string()),
range: Some("A1:B2".to_string()),
};
let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, true) else {
panic!("expected AutoFill"); };
assert_eq!(request.range, Some(source));
assert_eq!(request.source_and_destination, None);
assert!(request.use_alternate_series);
}
#[test]
fn build_request_source_and_destination_form_carries_dimension_and_fill_length() {
let source = bounded(0, 0, 2, 0, 2);
let form = AutoFillForm::SourceAndDestination {
sheet: Some("Q1".to_string()),
source: Some("A1:B2".to_string()),
dimension: Dimension::Rows,
fill_length: 5,
};
let BatchUpdateRequestItem::AutoFill(request) = build_request(&form, source, false) else {
panic!("expected AutoFill"); };
assert_eq!(request.range, None);
let sad = request.source_and_destination.unwrap();
assert_eq!(sad.source, source);
assert_eq!(sad.dimension, Dimension::Rows);
assert_eq!(sad.fill_length, 5);
assert!(!request.use_alternate_series);
}
#[test]
fn describe_effect_names_the_direction_for_every_axis_and_sign() {
let form = |dimension, fill_length| AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension,
fill_length,
};
assert!(
describe_effect(&form(Dimension::Rows, 3), "A1:A3", "A4:A6", 3, false)
.contains("3 row(s) down")
);
assert!(
describe_effect(&form(Dimension::Rows, -3), "A4:A6", "A1:A3", -3, false)
.contains("3 row(s) up")
);
assert!(
describe_effect(&form(Dimension::Columns, 2), "A1:A1", "B1:C1", 2, false)
.contains("2 column(s) right")
);
assert!(
describe_effect(&form(Dimension::Columns, -2), "C1:C1", "A1:B1", -2, false)
.contains("2 column(s) left")
);
}
#[test]
fn describe_effect_names_the_source_it_extends() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: 7,
};
let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A10", 7, false);
assert!(summary.contains("from source 'Q1'!A1:A3"), "{summary}");
}
#[test]
fn describe_effect_uses_the_applied_fill_length_not_the_requested_one() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: 1000,
};
let summary = describe_effect(&form, "'Q1'!A1:A3", "'Q1'!A4:A1000", 997, false);
assert!(summary.contains("997 row(s) down"), "{summary}");
assert!(!summary.contains("1000 row(s)"), "{summary}");
}
#[test]
fn describe_effect_names_the_range_form_as_server_decided() {
let form = AutoFillForm::Range {
sheet: None,
range: None,
};
let summary = describe_effect(&form, "A1:A10", "A1:A10", 0, false);
assert!(summary.contains("Sheets decides"), "{summary}");
}
#[test]
fn describe_effect_names_the_alternate_series_flag() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: 1,
};
let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
assert!(summary.contains("alternate series"), "{summary}");
}
#[test]
fn describe_effect_is_tense_neutral() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: 1,
};
let summary = describe_effect(&form, "A1:A1", "A2:A2", 1, true);
for conditional in ["would", "cannot", "may "] {
assert!(!summary.contains(conditional), "{summary}");
}
}
#[test]
fn applied_fill_length_shrinks_to_the_clipped_destination_forward() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: 1000,
};
let source = bounded(0, 0, 3, 0, 1); let applied = bounded(0, 3, 1000, 0, 1); assert_eq!(applied_fill_length(&form, &source, &applied), 997);
}
#[test]
fn applied_fill_length_is_unaffected_by_a_backward_fill() {
let form = AutoFillForm::SourceAndDestination {
sheet: None,
source: None,
dimension: Dimension::Rows,
fill_length: -3,
};
let source = bounded(0, 5, 8, 0, 1); let applied = bounded(0, 2, 5, 0, 1); assert_eq!(applied_fill_length(&form, &source, &applied), -3);
}
#[test]
fn applied_fill_length_is_zero_for_the_range_form() {
let form = AutoFillForm::Range {
sheet: None,
range: None,
};
let grid = bounded(0, 0, 1, 0, 1);
assert_eq!(applied_fill_length(&form, &grid, &grid), 0);
}
#[test]
fn overwritten_line_reports_cells_never_their_values() {
let cells = vec!["A4".to_string(), "A5".to_string()];
let line = overwritten_line(&cells, false, true);
assert_eq!(line, " 2 non-blank cell(s) would be overwritten: A4, A5");
}
#[test]
fn overwritten_line_marks_an_upper_bound_count_as_up_to() {
let cells = vec!["A1".to_string()];
let line = overwritten_line(&cells, true, true);
assert_eq!(line, " up to 1 non-blank cell(s) would be overwritten: A1");
}
#[test]
fn overwritten_line_uses_the_past_tense_after_a_real_run() {
let cells = vec!["A4".to_string()];
assert_eq!(
overwritten_line(&cells, false, false),
" 1 non-blank cell(s) were overwritten: A4"
);
assert_eq!(
overwritten_line(&[], false, false),
" no non-blank cells in the destination"
);
}
#[test]
fn overwritten_line_elides_past_the_render_limit_but_keeps_the_true_count() {
let cells: Vec<String> = (1..=60).map(|row| format!("A{row}")).collect();
let line = overwritten_line(&cells, false, true);
assert!(line.contains("… and 10 more"), "{line}");
assert!(
line.starts_with(" 60 non-blank cell(s) would be overwritten:"),
"{line}"
);
assert_eq!(cells.len(), 60);
}
#[test]
fn change_lines_name_the_grid_extent_caveat_in_the_matching_tense() {
let dry = change_lines(
"Would x in 'B'",
&[],
false,
"'Q1'!A1:A10",
Some("'Q1'!A1:A20"),
true,
);
assert!(
dry.contains(&past_grid_extent_caveat_dry_run(
"'Q1'!A1:A10",
"'Q1'!A1:A20"
)),
"{dry:?}"
);
let real = change_lines(
"Applied: x in 'B'",
&[],
false,
"'Q1'!A1:A10",
Some("'Q1'!A1:A20"),
false,
);
assert!(
real.contains(&past_grid_extent_caveat("'Q1'!A1:A10", "'Q1'!A1:A20")),
"{real:?}"
);
let within = change_lines("Would x in 'B'", &[], false, "'Q1'!A1:A10", None, true);
assert!(
!within.iter().any(|l| l.contains("current extent")),
"{within:?}"
);
}
#[test]
fn change_lines_always_state_the_server_decides_the_values() {
for dry_run in [true, false] {
let lines = change_lines("head", &[], false, "'Q1'!A1:A10", None, dry_run);
assert!(
lines.contains(&UNPREVIEWABLE_VALUES_CAVEAT.to_string()),
"{lines:?}"
);
}
}
#[test]
fn auto_fill_result_log_status_names_every_variant() {
assert_eq!(
AutoFillResult::WouldChange {
summary: String::new(),
destination: String::new(),
requested_destination: None,
overwritten_cells: Vec::new(),
destination_is_upper_bound: false,
}
.log_status(),
"would-change"
);
assert_eq!(
AutoFillResult::RefusedNotASpreadsheet {
mime_type: String::new()
}
.log_status(),
"refused-not-a-spreadsheet"
);
assert_eq!(
AutoFillResult::RefusedShortcut.log_status(),
"refused-shortcut"
);
assert_eq!(
AutoFillResult::RefusedNoVisibleParents.log_status(),
"refused-no-visible-parents"
);
assert_eq!(
AutoFillResult::RefusedSheetNotFound {
title: String::new(),
available: Vec::new(),
}
.log_status(),
"refused-sheet-not-found"
);
assert_eq!(
AutoFillResult::RefusedInvalidRange {
detail: String::new()
}
.log_status(),
"refused-invalid-range"
);
assert_eq!(
AutoFillResult::RefusedDestinationPastGrid {
requested_destination: String::new(),
row_count: None,
column_count: None,
}
.log_status(),
"refused-destination-past-grid"
);
assert_eq!(
AutoFillResult::Blocked { decided_by: None }.log_status(),
"blocked"
);
assert_eq!(
AutoFillResult::RefusedNoLease.log_status(),
LeaseGateRefusal::NoLease.log_status()
);
assert_eq!(
AutoFillResult::RefusedLeaseExpired.log_status(),
LeaseGateRefusal::Expired.log_status()
);
assert_eq!(
AutoFillResult::RefusedLeaseWrongFile.log_status(),
LeaseGateRefusal::WrongFile.log_status()
);
assert_eq!(
AutoFillResult::RefusedLeaseStale.log_status(),
LeaseGateRefusal::Stale.log_status()
);
assert_eq!(
AutoFillResult::Changed {
summary: String::new(),
destination: String::new(),
requested_destination: None,
overwritten_cells: Vec::new(),
destination_is_upper_bound: false,
}
.log_status(),
"changed"
);
assert_eq!(
AutoFillResult::AppliedReplyUnreadable {
summary: String::new(),
destination: String::new(),
requested_destination: None,
overwritten_cells: Vec::new(),
destination_is_upper_bound: false,
detail: String::new(),
}
.log_status(),
"applied-reply-unreadable"
);
assert_eq!(
AutoFillResult::Failed {
detail: String::new()
}
.log_status(),
"failed"
);
}
#[test]
fn sheet_and_range_reads_source_for_the_source_and_destination_form() {
let form = AutoFillForm::SourceAndDestination {
sheet: Some("Q1".to_string()),
source: Some("A1:A3".to_string()),
dimension: Dimension::Rows,
fill_length: 1,
};
assert_eq!(form.sheet_and_range(), (Some("Q1"), Some("A1:A3")));
}
#[test]
fn no_describe_line_contains_an_embedded_newline() {
let base = AutoFillOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
sheet_id: Some(0),
form: range_form(),
result: AutoFillResult::Failed {
detail: String::new(),
},
};
let results = vec![
AutoFillResult::WouldChange {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: Some("'Q1'!A1:A20".to_string()),
overwritten_cells: vec!["A1".to_string()],
destination_is_upper_bound: true,
},
AutoFillResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
AutoFillResult::RefusedShortcut,
AutoFillResult::RefusedNoVisibleParents,
AutoFillResult::RefusedSheetNotFound {
title: "Q9".to_string(),
available: vec!["Q1".to_string()],
},
AutoFillResult::RefusedInvalidRange {
detail: "bad range".to_string(),
},
AutoFillResult::RefusedDestinationPastGrid {
requested_destination: "'Q1'!A2001:A2010".to_string(),
row_count: Some(1000),
column_count: Some(26),
},
AutoFillResult::Blocked { decided_by: None },
AutoFillResult::RefusedNoLease,
AutoFillResult::RefusedLeaseExpired,
AutoFillResult::RefusedLeaseWrongFile,
AutoFillResult::RefusedLeaseStale,
AutoFillResult::Changed {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: None,
overwritten_cells: Vec::new(),
destination_is_upper_bound: true,
},
AutoFillResult::AppliedReplyUnreadable {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: Some("'Q1'!A1:A20".to_string()),
overwritten_cells: vec!["A1".to_string()],
destination_is_upper_bound: true,
detail: "bad reply".to_string(),
},
AutoFillResult::Failed {
detail: "boom".to_string(),
},
];
for result in results {
let outcome = AutoFillOutcome {
result,
..base.clone()
};
let lines = describe_lines(&outcome);
assert!(!lines.is_empty(), "{lines:?}");
for line in &lines {
assert!(!line.contains('\n') && !line.contains('\r'), "{line:?}");
}
}
}
#[test]
fn describe_handles_missing_metadata_and_identifies_a_deciding_rule() {
let base = AutoFillOutcome {
spreadsheet_id: "sheet-1".into(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
form: range_form(),
result: AutoFillResult::RefusedSheetNotFound {
title: "Missing".into(),
available: Vec::new(),
},
};
assert!(describe(&base).contains("Available: none"));
assert!(describe(&base).contains("'sheet-1'"));
let blocked = AutoFillOutcome {
result: AutoFillResult::Blocked {
decided_by: Some(DecidingRule::Folder {
folder_id: "folder-1".into(),
depth: 1,
}),
},
..base
};
assert!(describe(&blocked).contains("folder-1"));
}
#[test]
fn refused_destination_past_grid_message_names_only_the_known_axes() {
let base = AutoFillOutcome {
spreadsheet_id: "sheet-1".into(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
form: range_form(),
result: AutoFillResult::RefusedDestinationPastGrid {
requested_destination: "'Q1'!A2001:A2010".into(),
row_count: Some(1000),
column_count: None,
},
};
assert!(describe(&base).contains("(1000 rows)"));
let column_only = AutoFillOutcome {
result: AutoFillResult::RefusedDestinationPastGrid {
requested_destination: "'Q1'!AA1:AZ10".into(),
row_count: None,
column_count: Some(26),
},
..base.clone()
};
assert!(describe(&column_only).contains("(26 columns)"));
let neither_known = AutoFillOutcome {
result: AutoFillResult::RefusedDestinationPastGrid {
requested_destination: "'Q1'!A2001:A2010".into(),
row_count: None,
column_count: None,
},
..base
};
assert!(describe(&neither_known).contains("current grid;"));
}
fn range_form() -> AutoFillForm {
AutoFillForm::Range {
sheet: Some("Q1".to_string()),
range: Some("A1:A10".to_string()),
}
}
fn source_and_destination_form(dimension: Dimension, fill_length: i64) -> AutoFillForm {
AutoFillForm::SourceAndDestination {
sheet: Some("Q1".to_string()),
source: Some("A1:A3".to_string()),
dimension,
fill_length,
}
}
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 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 allow_rule(folder: &str) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some(folder.to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
deny: HashSet::default(),
require_lease: true,
}
}
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 base_opts(form: AutoFillForm, dry_run: bool) -> AutoFillOptions {
AutoFillOptions {
spreadsheet_id: "sheet-1".to_string(),
form,
use_alternate_series: false,
dry_run,
lease_token: None,
ledger_path: std::path::PathBuf::new(),
}
}
#[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 = base_opts(range_form(), false);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::Blocked { .. }));
}
#[tokio::test]
async fn invalid_composed_range_is_refused_before_fetching_metadata() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let form = AutoFillForm::Range {
sheet: Some("Q1".into()),
range: Some("Other!A1:A2".into()),
};
let outcome = auto_fill_inner(&drive, &sheets, &base_opts(form, true), &[]).await;
assert!(matches!(
outcome.result,
AutoFillResult::RefusedInvalidRange { .. }
));
assert!(server
.received_requests()
.await
.unwrap()
.iter()
.all(|r| r.url.path() != "/drive/v3/files/sheet-1"));
}
#[tokio::test]
async fn target_resolution_refusals_keep_the_target_name() {
for (mime, parents, expected) in [
(
crate::drive::types::GOOGLE_SHORTCUT_MIME_TYPE,
vec!["folder-1"],
"shortcut",
),
("application/pdf", vec!["folder-1"], "not-spreadsheet"),
(
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
vec![],
"no-parents",
),
] {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file("sheet-1", mime, &parents).mount(&server).await;
let outcome =
auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
assert!(outcome.sheet_id.is_none());
assert!(
match expected {
"shortcut" => matches!(outcome.result, AutoFillResult::RefusedShortcut),
"not-spreadsheet" => matches!(
outcome.result,
AutoFillResult::RefusedNotASpreadsheet { .. }
),
_ => matches!(outcome.result, AutoFillResult::RefusedNoVisibleParents),
},
"{outcome:?}"
);
}
}
#[tokio::test]
async fn metadata_and_workbook_failures_are_reported_at_their_respective_stages() {
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(500))
.mount(&server)
.await;
let outcome = auto_fill_inner(&drive, &sheets, &base_opts(range_form(), true), &[]).await;
assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
assert!(outcome.file_name.is_none());
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))
.mount(&server)
.await;
let outcome = auto_fill_inner(
&drive,
&sheets,
&base_opts(range_form(), true),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
}
#[tokio::test]
async fn a_folder_lookup_failure_preserves_the_target_name() {
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))
.mount(&server)
.await;
let outcome = auto_fill_inner(
&drive,
&sheets,
&base_opts(range_form(), true),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
assert_eq!(outcome.file_name.as_deref(), Some("sheet-1"));
assert!(outcome.sheet_id.is_none());
}
#[tokio::test]
async fn backward_fill_before_the_sheet_start_is_refused_before_values_get() {
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 outcome = auto_fill_inner(
&drive,
&sheets,
&base_opts(source_and_destination_form(Dimension::Rows, -1), true),
&[allow_rule("folder-1")],
)
.await;
assert!(matches!(
outcome.result,
AutoFillResult::RefusedInvalidRange { .. }
));
assert_eq!(outcome.sheet_id, Some(0));
assert!(server
.received_requests()
.await
.unwrap()
.iter()
.all(|r| !r.url.path().contains("/values/")));
}
#[tokio::test]
async fn source_and_destination_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;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["existing"], [], ["also here"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
match outcome.result {
AutoFillResult::WouldChange {
destination,
overwritten_cells,
destination_is_upper_bound,
..
} => {
assert_eq!(destination, "'Q1'!A4:A10");
assert_eq!(overwritten_cells, vec!["A4".to_string(), "A6".to_string()]);
assert!(!destination_is_upper_bound);
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn range_form_dry_run_reports_the_destination_as_an_upper_bound() {
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:A10",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["1"], ["2"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(range_form(), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
match outcome.result {
AutoFillResult::WouldChange {
destination_is_upper_bound,
overwritten_cells,
..
} => {
assert!(destination_is_upper_bound);
assert_eq!(overwritten_cells, vec!["A1".to_string(), "A2".to_string()]);
}
other => panic!("expected WouldChange, got {other:?}"),
}
}
#[tokio::test]
async fn an_open_ended_source_is_refused() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
let rules = vec![allow_rule("folder-1")];
let form = AutoFillForm::SourceAndDestination {
sheet: Some("Q1".to_string()),
source: Some("A:A".to_string()),
dimension: Dimension::Rows,
fill_length: 1,
};
let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
match outcome.result {
AutoFillResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("fully bounded"), "{detail}");
assert!(detail.starts_with("'Q1'!A:A is open-ended"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_fill_length_of_zero_is_refused_before_any_request() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
let rules: Vec<FolderPermissionRule> = Vec::new();
let outcome = auto_fill(
&drive,
&sheets,
&base_opts(source_and_destination_form(Dimension::Rows, 0), true),
&rules,
)
.await;
match outcome.result {
AutoFillResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("--fill-length"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_real_run_sends_a_source_and_destination_auto_fill_request_and_reports_changed() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
mount_workbook().mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.and(wiremock::matchers::body_json(serde_json::json!({
"requests": [{
"autoFill": {
"sourceAndDestination": {
"source": {"sheetId": 0, "startRowIndex": 0, "endRowIndex": 3,
"startColumnIndex": 0, "endColumnIndex": 1},
"dimension": "ROWS",
"fillLength": 7
},
"useAlternateSeries": false
}
}]
})))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1", "replies": [{}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
}
#[tokio::test]
async fn a_destination_wholly_past_the_grid_extent_is_refused_before_any_lease_or_mutation() {
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 form = AutoFillForm::Range {
sheet: Some("Q1".to_string()),
range: Some("A2001:A2010".to_string()),
};
for dry_run in [true, false] {
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = AutoFillOptions {
lease_token,
ledger_path,
form: form.clone(),
..base_opts(range_form(), dry_run)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let AutoFillResult::RefusedDestinationPastGrid {
requested_destination,
row_count,
column_count,
} = &outcome.result
else {
panic!("dry_run={dry_run}: {:?}", outcome.result);
};
assert_eq!(requested_destination, "'Q1'!A2001:A2010");
assert_eq!(*row_count, Some(1000));
assert_eq!(*column_count, Some(26));
let lines = describe_lines(&outcome);
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].contains("lies wholly past"), "{lines:?}");
}
assert!(
!server
.received_requests()
.await
.unwrap_or_default()
.iter()
.any(
|r| r.url.path().contains("/values/") || r.url.path().ends_with(":batchUpdate")
),
"neither the read nor the mutation must be attempted"
);
}
#[tokio::test]
async fn a_destination_straddling_the_grid_edge_is_clipped_and_reports_both_ranges() {
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'!A4:A1000",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let AutoFillResult::WouldChange {
destination,
requested_destination,
overwritten_cells,
..
} = &outcome.result
else {
panic!("{:?}", outcome.result);
};
assert_eq!(destination, "'Q1'!A4:A1000");
assert_eq!(requested_destination.as_deref(), Some("'Q1'!A4:A1003"));
assert_eq!(overwritten_cells, &vec!["A4".to_string()]);
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Would auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) down in \
'sheet-1'"
);
assert_eq!(
lines[2],
past_grid_extent_caveat_dry_run("'Q1'!A4:A1000", "'Q1'!A4:A1003")
);
}
#[tokio::test]
async fn a_clipped_real_run_reports_the_applied_count_in_the_head_line() {
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'!A4:A1000",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.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!({
"spreadsheetId": "sheet-1", "replies": [{}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 1000), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::Changed { .. }));
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Applied: auto-fill 'Q1'!A4:A1000 from source 'Q1'!A1:A3, extending 997 row(s) \
down in 'sheet-1'"
);
assert_eq!(
lines[2],
past_grid_extent_caveat("'Q1'!A4:A1000", "'Q1'!A4:A1003")
);
}
#[tokio::test]
async fn a_destination_straddling_the_grid_edge_clips_along_columns_too() {
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'!B1:Z3",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Columns, 30), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let AutoFillResult::WouldChange {
destination,
requested_destination,
overwritten_cells,
..
} = &outcome.result
else {
panic!("{:?}", outcome.result); };
assert_eq!(destination, "'Q1'!B1:Z3");
assert!(requested_destination.is_some(), "{requested_destination:?}");
assert_eq!(overwritten_cells, &vec!["B1".to_string()]);
}
#[tokio::test]
async fn a_range_form_destination_straddling_the_grid_edge_is_clipped_too() {
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'!A990:A1000",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep"]]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let form = AutoFillForm::Range {
sheet: Some("Q1".to_string()),
range: Some("A990:A1010".to_string()),
};
let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
let AutoFillResult::WouldChange {
destination,
requested_destination,
destination_is_upper_bound,
..
} = &outcome.result
else {
panic!("{:?}", outcome.result); };
assert_eq!(destination, "'Q1'!A990:A1000");
assert_eq!(requested_destination.as_deref(), Some("'Q1'!A990:A1010"));
assert!(destination_is_upper_bound);
}
#[tokio::test]
async fn a_destination_inside_the_grid_has_no_requested_destination_key_in_json() {
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'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let AutoFillResult::WouldChange {
requested_destination,
..
} = &outcome.result
else {
panic!("{:?}", outcome.result); };
assert!(requested_destination.is_none());
let mut out = Vec::new();
outcome.write_jsonl(&mut out).unwrap();
let value: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert!(value["result"].get("requested_destination").is_none());
}
#[tokio::test]
async fn unknown_grid_extent_leaves_the_destination_unclamped_and_never_refuses() {
let server = wiremock::MockServer::start().await;
let (drive, sheets) = clients(&server).await;
mount_file(
"sheet-1",
crate::drive::types::GOOGLE_SHEET_MIME_TYPE,
&["folder-1"],
)
.mount(&server)
.await;
mount_folder("folder-1").mount(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/v4/spreadsheets/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1",
"properties": {"title": "Budget"},
"sheets": [
{"properties": {"sheetId": 0, "title": "Q1", "index": 0}},
],
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A1003",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 1000), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let AutoFillResult::WouldChange {
destination,
requested_destination,
..
} = &outcome.result
else {
panic!("{:?}", outcome.result); };
assert_eq!(destination, "'Q1'!A4:A1003");
assert!(requested_destination.is_none());
}
#[tokio::test]
async fn a_values_get_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("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), true);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
}
async fn mount_leasable_fixture(server: &wiremock::MockServer) {
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'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(server)
.await;
}
async fn run_with_lease(
server: &wiremock::MockServer,
lease_token: Option<String>,
ledger_path: std::path::PathBuf,
) -> AutoFillOutcome {
let (drive, sheets) = clients(server).await;
mount_leasable_fixture(server).await;
let rules = vec![allow_rule("folder-1")];
let opts = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(
!server
.received_requests()
.await
.unwrap_or_default()
.iter()
.any(|r| r.url.path().ends_with(":batchUpdate")),
"a refused lease must not reach batchUpdate"
);
outcome
}
fn ledger_in_a_tempdir() -> std::path::PathBuf {
tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl")
}
#[tokio::test]
async fn an_expired_or_unknown_lease_is_refused() {
let server = wiremock::MockServer::start().await;
let outcome = run_with_lease(
&server,
Some("never-issued".to_string()),
ledger_in_a_tempdir(),
)
.await;
assert!(matches!(
outcome.result,
AutoFillResult::RefusedLeaseExpired
));
}
#[tokio::test]
async fn a_lease_bound_to_another_file_is_refused() {
let server = wiremock::MockServer::start().await;
let ledger_path = ledger_in_a_tempdir();
let token = seed_lease(&ledger_path, "some-other-sheet", "1");
let outcome = run_with_lease(&server, Some(token), ledger_path).await;
assert!(matches!(
outcome.result,
AutoFillResult::RefusedLeaseWrongFile
));
}
#[tokio::test]
async fn refuses_a_stale_lease_when_the_file_has_moved() {
let server = wiremock::MockServer::start().await;
let ledger_path = ledger_in_a_tempdir();
let token = seed_lease(&ledger_path, "sheet-1", "0");
let outcome = run_with_lease(&server, Some(token), ledger_path).await;
assert!(matches!(outcome.result, AutoFillResult::RefusedLeaseStale));
}
#[tokio::test]
async fn a_real_run_renders_in_the_past_tense_and_names_the_source() {
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'!A4:A10",
))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"values": [["keep"], [], ["also"]]
})),
)
.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!({
"spreadsheetId": "sheet-1", "replies": [{}]
})),
)
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Applied: auto-fill 'Q1'!A4:A10 from source 'Q1'!A1:A3, extending 7 row(s) down in \
'sheet-1'"
);
assert_eq!(lines[1], " 2 non-blank cell(s) were overwritten: A4, A6");
assert_eq!(lines[2], UNPREVIEWABLE_VALUES_CAVEAT);
let AutoFillResult::Changed { summary, .. } = &outcome.result else {
panic!("{:?}", outcome.result);
};
assert!(!summary.contains("would"), "{summary}");
}
#[tokio::test]
async fn auto_fill_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("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.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 = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert_eq!(
outcome.result.log_status(),
"applied-reply-unreadable",
"{:?}",
outcome.result
);
assert!(
describe(&outcome).starts_with("Applied, but the reply could not be read"),
"{}",
describe(&outcome)
);
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_with_the_same_detail_lines_as_changed() {
let outcome = AutoFillOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
sheet_id: Some(0),
form: range_form(),
result: AutoFillResult::AppliedReplyUnreadable {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: Some("'Q1'!A1:A20".to_string()),
overwritten_cells: vec!["A2".to_string()],
destination_is_upper_bound: true,
detail: "bad reply".to_string(),
},
};
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Applied, but the reply could not be read: auto-fill within 'Q1'!A1:A10 in \
'Budget' — check the spreadsheet to confirm (bad reply)"
);
let changed = describe_lines(&AutoFillOutcome {
result: AutoFillResult::Changed {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: Some("'Q1'!A1:A20".to_string()),
overwritten_cells: vec!["A2".to_string()],
destination_is_upper_bound: true,
},
..outcome
});
assert!(lines.len() > 1, "{lines:?}");
assert_eq!(lines[1..], changed[1..]);
assert!(lines[1].contains("A2"), "{lines:?}");
}
#[test]
fn an_unreadable_reply_logs_the_same_fields_as_changed() {
let outcome = |result| AutoFillOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: None,
sheet_id: Some(7),
form: range_form(),
result,
};
let changed = mutation_record(
&outcome(AutoFillResult::Changed {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: None,
overwritten_cells: vec!["A2".to_string()],
destination_is_upper_bound: true,
}),
Duration::from_millis(1),
);
let unreadable = mutation_record(
&outcome(AutoFillResult::AppliedReplyUnreadable {
summary: "auto-fill within 'Q1'!A1:A10".to_string(),
destination: "'Q1'!A1:A10".to_string(),
requested_destination: None,
overwritten_cells: vec!["A2".to_string()],
destination_is_upper_bound: true,
detail: "bad reply".to_string(),
}),
Duration::from_millis(1),
);
assert_eq!(unreadable.range.as_deref(), Some("'Q1'!A1:A10"));
assert_eq!(unreadable.range, changed.range);
assert_eq!(
unreadable.fields_changed.as_deref(),
Some("auto-fill within 'Q1'!A1:A10")
);
assert_eq!(unreadable.overwritten_cells, vec!["A2".to_string()]);
assert!(unreadable.overwritten_cells_upper_bound);
assert_eq!(unreadable.sheet_id, Some(7));
assert_eq!(unreadable.error.as_deref(), Some("bad reply"));
}
#[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("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.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))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let (lease_token, ledger_path) = leased_opts_for("sheet-1");
let opts = AutoFillOptions {
lease_token,
ledger_path,
..base_opts(source_and_destination_form(Dimension::Rows, 7), false)
};
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::Failed { .. }));
}
#[tokio::test]
async fn a_missing_lease_is_refused_when_the_deciding_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;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1/values/'Q1'!A4:A10",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
.mount(&server)
.await;
let rules = vec![allow_rule("folder-1")];
let opts = base_opts(source_and_destination_form(Dimension::Rows, 7), false);
let outcome = auto_fill(&drive, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, AutoFillResult::RefusedNoLease));
}
#[tokio::test]
async fn the_sheet_not_found_refusal_names_the_available_sheets() {
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 form = AutoFillForm::Range {
sheet: Some("Missing".to_string()),
range: Some("A1:A2".to_string()),
};
let outcome = auto_fill(&drive, &sheets, &base_opts(form, true), &rules).await;
match outcome.result {
AutoFillResult::RefusedSheetNotFound { title, available } => {
assert_eq!(title, "Missing");
assert_eq!(available, vec!["Q1".to_string()]);
}
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
}