use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};
use crate::drive::client::DriveClient;
use crate::drive::files_api::FilesApi;
use crate::drive::lease::check::{
conclude_native_leased_write, gate_optional_leased_write, FromLeaseRefusal, LeaseGateRefusal,
LeasedWrite,
};
use crate::drive::sheets::a1;
use crate::drive::sheets::api::{
applied_reply_unreadable_line, BatchUpdateOutcome, RetryHint, SheetsApi, ValueRenderOption,
};
use crate::drive::sheets::client::SheetsClient;
use crate::drive::sheets::grid_range;
use crate::drive::sheets::target_gate;
use crate::drive::sheets::types::{
BatchUpdateRequestItem, DelimiterType, GridRange, Spreadsheet, TextToColumnsRequest, ValueRange,
};
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-text-to-columns";
const GATE_OPERATIONS: &[DriveOperation] =
&[DriveOperation::SheetsWrite, DriveOperation::SheetsStructure];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Delimiter {
Comma,
Semicolon,
Period,
Space,
Auto,
Custom(String),
}
impl Delimiter {
fn wire_type(&self) -> DelimiterType {
match self {
Self::Comma => DelimiterType::Comma,
Self::Semicolon => DelimiterType::Semicolon,
Self::Period => DelimiterType::Period,
Self::Space => DelimiterType::Space,
Self::Auto => DelimiterType::Autodetect,
Self::Custom(_) => DelimiterType::Custom,
}
}
fn local_split_candidates(&self) -> Vec<&str> {
match self {
Self::Comma => vec![","],
Self::Semicolon => vec![";"],
Self::Period => vec!["."],
Self::Space => vec![" "],
Self::Custom(text) => vec![text.as_str()],
Self::Auto => vec![",", ";", ".", " "],
}
}
fn describe(&self) -> String {
match self {
Self::Comma => "comma".to_string(),
Self::Semicolon => "semicolon".to_string(),
Self::Period => "period".to_string(),
Self::Space => "space".to_string(),
Self::Auto => "an auto-detected separator".to_string(),
Self::Custom(text) => format!("custom ({text:?})"),
}
}
}
#[derive(Debug, Clone)]
pub struct TextToColumnsOptions {
pub spreadsheet_id: String,
pub sheet: Option<String>,
pub source: Option<String>,
pub delimiter: Delimiter,
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 TextToColumnsResult {
WouldChange {
summary: String,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
spill: Option<String>,
width_upper_bound: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
past_grid_extent: bool,
},
RefusedNotASpreadsheet {
mime_type: String,
},
RefusedShortcut,
RefusedNoVisibleParents,
RefusedSheetNotFound {
title: String,
available: Vec<String>,
},
RefusedInvalidRange {
detail: String,
},
RefusedInvalidDelimiter {
detail: String,
},
Blocked {
operation: DriveOperation,
decided_by: Option<DecidingRule>,
},
RefusedNoLease,
RefusedLeaseExpired,
RefusedLeaseWrongFile,
RefusedLeaseStale,
Changed {
summary: String,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
spill: Option<String>,
width_upper_bound: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
past_grid_extent: bool,
},
AppliedReplyUnreadable {
summary: String,
detail: String,
source: String,
#[serde(skip_serializing_if = "Option::is_none")]
spill: Option<String>,
width_upper_bound: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
overwritten_cells: Vec<String>,
past_grid_extent: bool,
},
Failed {
detail: String,
},
}
impl FromLeaseRefusal for TextToColumnsResult {
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 TextToColumnsResult {
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::RefusedInvalidDelimiter { .. } => "refused-invalid-delimiter",
Self::Blocked { .. } => "blocked",
Self::RefusedNoLease => LeaseGateRefusal::NoLease.log_status(),
Self::RefusedLeaseExpired => LeaseGateRefusal::Expired.log_status(),
Self::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile.log_status(),
Self::RefusedLeaseStale => LeaseGateRefusal::Stale.log_status(),
Self::Changed { .. } => "changed",
Self::AppliedReplyUnreadable { .. } => "applied-reply-unreadable",
Self::Failed { .. } => "failed",
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TextToColumnsOutcome {
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 delimiter: Delimiter,
pub result: TextToColumnsResult,
}
impl JsonlSerialize for TextToColumnsOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> anyhow::Result<()> {
write_scalar_jsonl(self, out)
}
}
pub async fn text_to_columns(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &TextToColumnsOptions,
rules: &[FolderPermissionRule],
) -> TextToColumnsOutcome {
let started = Instant::now();
let outcome = text_to_columns_inner(drive, sheets, opts, rules).await;
if !opts.dry_run {
record_attempt(&outcome, started.elapsed());
}
outcome
}
async fn text_to_columns_inner(
drive: &DriveClient,
sheets: &SheetsClient,
opts: &TextToColumnsOptions,
rules: &[FolderPermissionRule],
) -> TextToColumnsOutcome {
let bare = |result| TextToColumnsOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: None,
resolved_folder_id: None,
sheet_id: None,
delimiter: opts.delimiter.clone(),
result,
};
let composed = match a1::compose(opts.sheet.as_deref(), opts.source.as_deref()) {
Ok(composed) => composed,
Err(err) => {
return bare(TextToColumnsResult::RefusedInvalidRange {
detail: err.to_string(),
})
}
};
if let Delimiter::Custom(text) = &opts.delimiter {
if text.is_empty() {
return bare(TextToColumnsResult::RefusedInvalidDelimiter {
detail: "--custom-delimiter must not be empty".to_string(),
});
}
}
let (target, verdict, denied, resolved_folder_id, requires_lease) =
match target_gate::resolve_all(drive, &opts.spreadsheet_id, GATE_OPERATIONS, rules).await {
target_gate::TargetGateUnionOutcome::MetadataFetchFailed { detail } => {
return bare(TextToColumnsResult::Failed { detail })
}
target_gate::TargetGateUnionOutcome::Refused { target, refusal } => {
let result = match refusal {
SheetTargetRefusal::Shortcut => TextToColumnsResult::RefusedShortcut,
SheetTargetRefusal::NotASpreadsheet { mime_type } => {
TextToColumnsResult::RefusedNotASpreadsheet { mime_type }
}
SheetTargetRefusal::NoVisibleParents => {
TextToColumnsResult::RefusedNoVisibleParents
}
};
return TextToColumnsOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
delimiter: opts.delimiter.clone(),
result,
};
}
target_gate::TargetGateUnionOutcome::GateFetchFailed { target, detail } => {
return TextToColumnsOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name),
resolved_folder_id: None,
sheet_id: None,
delimiter: opts.delimiter.clone(),
result: TextToColumnsResult::Failed { detail },
};
}
target_gate::TargetGateUnionOutcome::Gated {
target,
verdict,
denied,
resolved_folder_id,
requires_lease,
} => (target, verdict, denied, resolved_folder_id, requires_lease),
};
let pre_gated = |result| TextToColumnsOutcome {
spreadsheet_id: opts.spreadsheet_id.clone(),
file_name: Some(target.name.clone()),
resolved_folder_id: resolved_folder_id.clone(),
sheet_id: None,
delimiter: opts.delimiter.clone(),
result,
};
if verdict == write_gate::Verdict::Deny {
let (operation, decided_by) = denied.unwrap_or((GATE_OPERATIONS[0], None));
return pre_gated(TextToColumnsResult::Blocked {
operation,
decided_by,
});
}
let api = SheetsApi::new(sheets);
let workbook = match api.get_spreadsheet(&opts.spreadsheet_id).await {
Ok(workbook) => workbook,
Err(err) => {
return pre_gated(TextToColumnsResult::Failed {
detail: format!("{err:#}"),
})
}
};
let (sheet_title, source_grid) = match grid_range::resolve_grid_range(
&workbook,
&composed,
|detail| TextToColumnsResult::RefusedInvalidRange { detail },
|title, available| TextToColumnsResult::RefusedSheetNotFound { title, available },
) {
Ok(resolved) => resolved,
Err(result) => return pre_gated(result),
};
let gated = |result| TextToColumnsOutcome {
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),
delimiter: opts.delimiter.clone(),
result,
};
if !grid_range::is_bounded(&source_grid) {
return gated(TextToColumnsResult::RefusedInvalidRange {
detail: format!(
"{composed} is open-ended; text-to-columns needs a fully bounded single \
column (e.g. A2:A100)"
),
});
}
if !is_single_column(&source_grid) {
return gated(TextToColumnsResult::RefusedInvalidRange {
detail: format!(
"{composed} spans more than one column; text-to-columns' source must span \
exactly one column"
),
});
}
let source_a1 = grid_range::bounded_range_to_a1(&sheet_title, &source_grid).unwrap_or(composed);
let source_values = match api
.values_get(
&opts.spreadsheet_id,
&source_a1,
ValueRenderOption::Formatted,
)
.await
{
Ok(values) => values,
Err(err) => {
return gated(TextToColumnsResult::Failed {
detail: format!("{err:#}"),
})
}
};
let width_upper_bound = split_width(&source_values, &opts.delimiter);
let spill_grid = spill_span(source_grid, width_upper_bound);
let past_grid_extent = spill_grid.is_some_and(|spill| extends_past_grid(&workbook, &spill));
let (spill_a1, overwritten_cells) = match spill_grid {
None => (None, Vec::new()),
Some(spill_grid) => {
let overwritten_cells = match grid_range::clamp_to_sheet(&workbook, &spill_grid) {
None => Vec::new(),
Some(read_grid) => {
let read_a1 = grid_range::bounded_range_to_a1(&sheet_title, &read_grid)
.unwrap_or_else(|| sheet_title.clone());
match api
.values_get(&opts.spreadsheet_id, &read_a1, ValueRenderOption::Formatted)
.await
{
Ok(values) => grid_range::non_blank_locations(
&values,
read_grid.start_row_index.unwrap_or(0),
read_grid.start_column_index.unwrap_or(0),
),
Err(err) => {
return gated(TextToColumnsResult::Failed {
detail: format!("{err:#}"),
})
}
}
}
};
(
grid_range::bounded_range_to_a1(&sheet_title, &spill_grid),
overwritten_cells,
)
}
};
let summary = describe_effect(
&source_a1,
spill_a1.as_deref(),
width_upper_bound,
&opts.delimiter,
);
if opts.dry_run {
return gated(TextToColumnsResult::WouldChange {
summary,
source: source_a1,
spill: spill_a1,
width_upper_bound,
overwritten_cells,
past_grid_extent,
});
}
let request = build_request(source_grid, &opts.delimiter);
let files_api = FilesApi::new(drive);
let leased = LeasedWrite {
log_prefix: "drive sheets text-to-columns",
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(_)) => TextToColumnsResult::Changed {
summary,
source: source_a1,
spill: spill_a1,
width_upper_bound,
overwritten_cells,
past_grid_extent,
},
Ok(BatchUpdateOutcome::AppliedReplyUnreadable { detail }) => {
TextToColumnsResult::AppliedReplyUnreadable {
summary,
detail,
source: source_a1,
spill: spill_a1,
width_upper_bound,
overwritten_cells,
past_grid_extent,
}
}
Err(err) => TextToColumnsResult::Failed {
detail: format!("{err:#}"),
},
};
drop(lease_grant);
gated(result)
}
fn is_single_column(grid: &GridRange) -> bool {
grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0) == 1
}
fn cell_text(cell: &serde_json::Value) -> String {
match cell.as_str() {
Some(text) => text.to_string(),
None if cell.is_null() => String::new(),
None => cell.to_string(),
}
}
fn split_width(values: &ValueRange, delimiter: &Delimiter) -> usize {
let candidates = delimiter.local_split_candidates();
values
.values
.iter()
.filter_map(|row| row.first())
.map(cell_text)
.filter(|text| !text.is_empty())
.map(|text| {
candidates
.iter()
.map(|sep| text.split(sep).count())
.max()
.unwrap_or(1)
})
.max()
.unwrap_or(0)
}
fn spill_span(source: GridRange, width: usize) -> Option<GridRange> {
if width <= 1 {
return None;
}
let start_column = source.start_column_index?;
let width = i64::try_from(width).ok()?;
Some(GridRange {
sheet_id: source.sheet_id,
start_row_index: source.start_row_index,
end_row_index: source.end_row_index,
start_column_index: Some(start_column + 1),
end_column_index: Some(start_column + width),
})
}
fn extends_past_grid(workbook: &Spreadsheet, extent: &GridRange) -> bool {
let Some(sheet) = grid_range::find_sheet_by_id(workbook, extent.sheet_id) else {
return false;
};
let grid = sheet
.properties
.as_ref()
.and_then(|props| props.grid_properties.as_ref());
let row_count = grid.and_then(|g| g.row_count);
let column_count = grid.and_then(|g| g.column_count);
let rows_exceed = matches!(
(extent.end_row_index, row_count),
(Some(end), Some(count)) if end > count
);
let cols_exceed = matches!(
(extent.end_column_index, column_count),
(Some(end), Some(count)) if end > count
);
rows_exceed || cols_exceed
}
const UNPREVIEWABLE_SPLIT_CAVEAT: &str =
" the number of columns the split needs, and the values it writes, are computed by \
Sheets' own splitting and are never reported, before or after the request; the count above \
is a local upper-bound estimate only";
const PAST_GRID_EXTENT_CAVEAT_DRY_RUN: &str =
" the spill extends past the sheet's current extent — Sheets may grow the sheet or refuse \
the request";
const PAST_GRID_EXTENT_CAVEAT: &str =
" the spill extended past the sheet's current extent, so Sheets may have grown it";
const AUTO_DELIMITER_CAVEAT: &str =
" --delimiter auto lets Sheets detect the separator itself, and it detects separators this \
preview does not try (a tab-separated column splits under auto, though none of comma, \
semicolon, period or space appears in it) — so for auto the width above and the cells \
listed are a guess in both directions, not a bound";
fn describe_effect(
source_a1: &str,
spill_a1: Option<&str>,
width_upper_bound: usize,
delimiter: &Delimiter,
) -> String {
match spill_a1 {
Some(spill_a1) => format!(
"split {source_a1} on {} into up to {width_upper_bound} column(s), spill {spill_a1}",
delimiter.describe(),
),
None if matches!(delimiter, Delimiter::Auto) => format!(
"split {source_a1} on {} (none detected in the preview, so the spill span is \
unknown)",
delimiter.describe(),
),
None => format!(
"split {source_a1} on {} into a single column each row; no row spills beyond \
the source under this delimiter",
delimiter.describe(),
),
}
}
fn overwritten_line(
overwritten_cells: &[String],
delimiter: &Delimiter,
dry_run: bool,
) -> Option<String> {
if overwritten_cells.is_empty() {
return match delimiter {
Delimiter::Auto => None,
_ => Some(" no non-blank cells in the spill columns".to_string()),
};
}
let tense = if dry_run {
"would be overwritten"
} else {
"were overwritten"
};
Some(format!(
" up to {} non-blank cell(s) {tense}: {}",
overwritten_cells.len(),
grid_range::render_locations(overwritten_cells)
))
}
fn build_request(source: GridRange, delimiter: &Delimiter) -> BatchUpdateRequestItem {
let delimiter_text = match delimiter {
Delimiter::Custom(text) => Some(text.clone()),
_ => None,
};
BatchUpdateRequestItem::TextToColumns(TextToColumnsRequest {
source,
delimiter: delimiter_text,
delimiter_type: delimiter.wire_type(),
})
}
fn record_attempt(outcome: &TextToColumnsOutcome, duration: Duration) {
request_log::record_drive_mutation(mutation_outcome(outcome, duration));
}
fn mutation_outcome(outcome: &TextToColumnsOutcome, duration: Duration) -> DriveMutationOutcome {
let error = match &outcome.result {
TextToColumnsResult::Failed { detail }
| TextToColumnsResult::AppliedReplyUnreadable { detail, .. } => Some(detail.clone()),
_ => None,
};
let decided_by = match &outcome.result {
TextToColumnsResult::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) = match &outcome.result {
TextToColumnsResult::Changed {
summary,
source,
overwritten_cells,
..
}
| TextToColumnsResult::AppliedReplyUnreadable {
summary,
source,
overwritten_cells,
..
} => (
Some(source.clone()),
Some(summary.clone()),
overwritten_cells.clone(),
),
_ => (None, None, Vec::new()),
};
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: true,
error,
duration,
..Default::default()
}
}
#[must_use]
pub fn describe(outcome: &TextToColumnsOutcome) -> String {
describe_lines(outcome).join("\n")
}
#[must_use]
pub fn describe_lines(outcome: &TextToColumnsOutcome) -> Vec<String> {
let book = outcome.file_name.as_deref().map_or_else(
|| format!("'{}'", outcome.spreadsheet_id),
|n| format!("'{n}'"),
);
match &outcome.result {
TextToColumnsResult::WouldChange {
summary,
overwritten_cells,
past_grid_extent,
..
} => change_lines(
&format!("Would {summary} in {book}"),
overwritten_cells,
*past_grid_extent,
&outcome.delimiter,
true,
),
TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => vec![format!(
"Refused: {book} is not a Google Sheet (mimeType: {mime_type}); \
`drive sheets text-to-columns` only works on spreadsheets"
)],
TextToColumnsResult::RefusedShortcut => vec![format!(
"Refused: {book} is a shortcut; `drive sheets text-to-columns` doesn't follow \
shortcuts"
)],
TextToColumnsResult::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."
)],
TextToColumnsResult::RefusedSheetNotFound { title, available } => {
vec![grid_range::no_sheet_refusal(&book, title, available)]
}
TextToColumnsResult::RefusedInvalidRange { detail } => vec![format!("Refused: {detail}")],
TextToColumnsResult::RefusedInvalidDelimiter { detail } => {
vec![format!("Refused: {detail}")]
}
TextToColumnsResult::Blocked {
operation,
decided_by,
} => vec![match decided_by {
Some(rule) => format!(
"Blocked: text-to-columns on {book} refused by rule on {} {}{}",
rule.kind_label(),
rule.id(),
rule.depth_suffix()
),
None => format!(
"Blocked: text-to-columns on {book} refused by default policy (no matching rule \
for {operation})"
),
}],
TextToColumnsResult::RefusedNoLease => LeaseGateRefusal::NoLease
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
TextToColumnsResult::RefusedLeaseExpired => LeaseGateRefusal::Expired
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
TextToColumnsResult::RefusedLeaseWrongFile => LeaseGateRefusal::WrongFile
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
TextToColumnsResult::RefusedLeaseStale => LeaseGateRefusal::Stale
.describe_line(&outcome.spreadsheet_id, &book)
.into_iter()
.collect(),
TextToColumnsResult::Changed {
summary,
overwritten_cells,
past_grid_extent,
..
} => change_lines(
&format!("Applied: {summary} in {book}"),
overwritten_cells,
*past_grid_extent,
&outcome.delimiter,
false,
),
TextToColumnsResult::AppliedReplyUnreadable {
summary,
detail,
overwritten_cells,
past_grid_extent,
..
} => change_lines(
&applied_reply_unreadable_line(summary, &book, RetryHint::NotIdempotent, detail),
overwritten_cells,
*past_grid_extent,
&outcome.delimiter,
false,
),
TextToColumnsResult::Failed { detail } => vec![format!("Failed: {detail}")],
}
}
fn change_lines(
head: &str,
overwritten_cells: &[String],
past_grid_extent: bool,
delimiter: &Delimiter,
dry_run: bool,
) -> Vec<String> {
let mut lines = vec![head.to_string()];
lines.extend(overwritten_line(overwritten_cells, delimiter, dry_run));
if past_grid_extent {
lines.push(
if dry_run {
PAST_GRID_EXTENT_CAVEAT_DRY_RUN
} else {
PAST_GRID_EXTENT_CAVEAT
}
.to_string(),
);
}
if matches!(delimiter, Delimiter::Auto) {
lines.push(AUTO_DELIMITER_CAVEAT.to_string());
}
lines.push(UNPREVIEWABLE_SPLIT_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::test_support::seed_lease;
use crate::test_support::env::MapEnv;
use crate::utils::secret::Secret;
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),
}
}
fn values(rows: &[&str]) -> ValueRange {
ValueRange {
range: None,
values: rows
.iter()
.map(|cell| {
if cell.is_empty() {
vec![]
} else {
vec![serde_json::json!(cell)]
}
})
.collect(),
}
}
#[test]
fn every_delimiter_maps_to_a_wire_type_its_candidates_and_a_name() {
let cases = [
(Delimiter::Comma, DelimiterType::Comma, vec![","], "comma"),
(
Delimiter::Semicolon,
DelimiterType::Semicolon,
vec![";"],
"semicolon",
),
(
Delimiter::Period,
DelimiterType::Period,
vec!["."],
"period",
),
(Delimiter::Space, DelimiterType::Space, vec![" "], "space"),
(
Delimiter::Auto,
DelimiterType::Autodetect,
vec![",", ";", ".", " "],
"an auto-detected separator",
),
(
Delimiter::Custom("||".to_string()),
DelimiterType::Custom,
vec!["||"],
"custom (\"||\")",
),
];
for (delimiter, wire, candidates, name) in cases {
assert_eq!(delimiter.wire_type(), wire, "{delimiter:?}");
assert_eq!(
delimiter.local_split_candidates(),
candidates,
"{delimiter:?}"
);
assert_eq!(delimiter.describe(), name, "{delimiter:?}");
}
}
#[test]
fn every_result_variant_has_its_own_log_status() {
let statuses = [
TextToColumnsResult::RefusedNotASpreadsheet {
mime_type: String::new(),
},
TextToColumnsResult::RefusedShortcut,
TextToColumnsResult::RefusedNoVisibleParents,
TextToColumnsResult::RefusedSheetNotFound {
title: String::new(),
available: Vec::new(),
},
TextToColumnsResult::RefusedInvalidRange {
detail: String::new(),
},
TextToColumnsResult::RefusedInvalidDelimiter {
detail: String::new(),
},
TextToColumnsResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: None,
},
TextToColumnsResult::RefusedNoLease,
TextToColumnsResult::RefusedLeaseExpired,
TextToColumnsResult::RefusedLeaseWrongFile,
TextToColumnsResult::RefusedLeaseStale,
TextToColumnsResult::AppliedReplyUnreadable {
summary: String::new(),
detail: String::new(),
source: String::new(),
spill: None,
width_upper_bound: 0,
overwritten_cells: Vec::new(),
past_grid_extent: false,
},
TextToColumnsResult::Failed {
detail: String::new(),
},
];
let mut seen: Vec<&str> = statuses
.iter()
.map(TextToColumnsResult::log_status)
.collect();
seen.push(would_change_outcome(Vec::new(), false).result.log_status());
let unique: std::collections::HashSet<&str> = seen.iter().copied().collect();
assert_eq!(unique.len(), seen.len(), "duplicate log status in {seen:?}");
assert_eq!(
TextToColumnsResult::RefusedLeaseStale.log_status(),
LeaseGateRefusal::Stale.log_status()
);
}
#[test]
fn jsonl_writes_one_record_and_omits_the_delimiter() {
let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
outcome.delimiter = Delimiter::Custom("not-in-the-record".to_string());
let mut out = Vec::new();
outcome.write_jsonl(&mut out).unwrap();
let text = String::from_utf8(out).unwrap();
assert_eq!(text.lines().count(), 1);
assert!(!text.contains("not-in-the-record"));
let parsed: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(parsed["result"]["status"], "would-change");
}
#[test]
fn is_single_column_accepts_a_one_column_span() {
assert!(is_single_column(&bounded(0, 0, 10, 2, 3)));
}
#[test]
fn is_single_column_rejects_a_wider_span() {
assert!(!is_single_column(&bounded(0, 0, 10, 2, 4)));
}
#[test]
fn cell_text_renders_a_string_verbatim() {
assert_eq!(cell_text(&serde_json::json!("a,b")), "a,b");
}
#[test]
fn cell_text_is_empty_for_null() {
assert_eq!(cell_text(&serde_json::Value::Null), "");
}
#[test]
fn cell_text_falls_back_to_json_rendering_for_a_non_string_non_null_cell() {
assert_eq!(cell_text(&serde_json::json!(42)), "42");
}
#[test]
fn split_width_is_zero_for_no_rows() {
assert_eq!(split_width(&values(&[]), &Delimiter::Comma), 0);
}
#[test]
fn split_width_ignores_a_blank_row() {
assert_eq!(split_width(&values(&[""]), &Delimiter::Comma), 0);
}
#[test]
fn split_width_counts_comma_pieces() {
assert_eq!(split_width(&values(&["a,b,c"]), &Delimiter::Comma), 3);
}
#[test]
fn split_width_is_the_widest_row() {
assert_eq!(
split_width(&values(&["a,b", "x,y,z", "solo"]), &Delimiter::Comma),
3
);
}
#[test]
fn split_width_with_a_custom_delimiter() {
assert_eq!(
split_width(&values(&["a|b|c|d"]), &Delimiter::Custom("|".to_string())),
4
);
}
#[test]
fn split_width_auto_takes_the_widest_of_the_four_fixed_delimiters() {
assert_eq!(split_width(&values(&["a.b.c"]), &Delimiter::Auto), 3);
}
#[test]
fn split_width_with_no_delimiter_present_is_one() {
assert_eq!(split_width(&values(&["solo"]), &Delimiter::Comma), 1);
}
#[test]
fn spill_span_is_none_for_a_width_of_one_or_less() {
assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 1), None);
assert_eq!(spill_span(bounded(0, 0, 10, 0, 1), 0), None);
}
#[test]
fn spill_span_covers_the_columns_to_the_right_of_the_source() {
let spill = spill_span(bounded(0, 1, 10, 0, 1), 3).unwrap();
assert_eq!(spill.start_column_index, Some(1));
assert_eq!(spill.end_column_index, Some(3));
assert_eq!(spill.start_row_index, Some(1));
assert_eq!(spill.end_row_index, Some(10));
}
#[test]
fn extends_past_grid_flags_a_spill_past_the_column_count() {
let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
"spreadsheetId": "sheet-1",
"sheets": [{"properties": {"sheetId": 0, "title": "Q1",
"gridProperties": {"rowCount": 100, "columnCount": 3}}}],
}))
.unwrap();
assert!(extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 3)));
}
#[test]
fn extends_past_grid_is_false_when_the_sheet_is_unknown() {
let workbook: Spreadsheet = serde_json::from_value(serde_json::json!({
"spreadsheetId": "sheet-1",
"sheets": [],
}))
.unwrap();
assert!(!extends_past_grid(&workbook, &bounded(0, 0, 10, 0, 5)));
}
#[test]
fn describe_effect_with_a_spill() {
let summary = describe_effect("'Q1'!A2:A10", Some("'Q1'!B2:D10"), 3, &Delimiter::Comma);
assert_eq!(
summary,
"split 'Q1'!A2:A10 on comma into up to 3 column(s), spill 'Q1'!B2:D10"
);
}
#[test]
fn describe_effect_with_no_spill() {
let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Space);
assert_eq!(
summary,
"split 'Q1'!A2:A10 on space into a single column each row; no row spills beyond \
the source under this delimiter"
);
}
#[test]
fn describe_effect_with_no_spill_under_auto() {
let summary = describe_effect("'Q1'!A2:A10", None, 1, &Delimiter::Auto);
assert_eq!(
summary,
"split 'Q1'!A2:A10 on an auto-detected separator (none detected in the preview, so \
the spill span is unknown)"
);
}
#[test]
fn every_summary_branch_is_tense_neutral() {
for (spill, width) in [(Some("'Q1'!B2:B10"), 2), (None, 1)] {
let summary = describe_effect("'Q1'!A2:A10", spill, width, &Delimiter::Comma);
assert!(
!summary.contains("would"),
"summary is reused in the past tense: {summary}"
);
}
}
#[test]
fn overwritten_line_is_always_prefixed_up_to() {
let line = overwritten_line(&["B2".to_string()], &Delimiter::Comma, true).unwrap();
assert!(line.starts_with(" up to 1 non-blank cell(s) would be overwritten"));
}
#[test]
fn overwritten_line_empty_case() {
assert_eq!(
overwritten_line(&[], &Delimiter::Comma, false),
Some(" no non-blank cells in the spill columns".to_string())
);
}
#[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, &Delimiter::Comma, true).unwrap();
assert!(line.contains("… and 10 more"), "{line}");
assert!(
line.starts_with(" up to 60 non-blank cell(s) would be overwritten:"),
"{line}"
);
assert_eq!(cells.len(), 60);
}
#[test]
fn auto_never_prints_an_all_clear() {
assert_eq!(overwritten_line(&[], &Delimiter::Auto, true), None);
assert!(overwritten_line(&["B2".to_string()], &Delimiter::Auto, true).is_some());
}
#[test]
fn build_request_omits_delimiter_for_a_fixed_type() {
let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Comma);
match request {
BatchUpdateRequestItem::TextToColumns(req) => {
assert_eq!(req.delimiter, None);
assert_eq!(req.delimiter_type, DelimiterType::Comma);
}
other => panic!("unexpected request: {other:?}"), }
}
#[test]
fn build_request_carries_the_custom_delimiter_text() {
let request = build_request(bounded(0, 0, 10, 0, 1), &Delimiter::Custom("|".to_string()));
match request {
BatchUpdateRequestItem::TextToColumns(req) => {
assert_eq!(req.delimiter, Some("|".to_string()));
assert_eq!(req.delimiter_type, DelimiterType::Custom);
}
other => panic!("unexpected request: {other:?}"), }
}
fn would_change_outcome(
overwritten_cells: Vec<String>,
past_grid_extent: bool,
) -> TextToColumnsOutcome {
TextToColumnsOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: Some("folder-1".to_string()),
sheet_id: Some(0),
delimiter: Delimiter::Comma,
result: TextToColumnsResult::WouldChange {
summary: "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4"
.to_string(),
source: "'Q1'!A2:A4".to_string(),
spill: Some("'Q1'!B2:B4".to_string()),
width_upper_bound: 2,
overwritten_cells,
past_grid_extent,
},
}
}
#[test]
fn would_change_lines_carry_the_upper_bound_and_the_unpreviewable_caveat() {
let outcome = would_change_outcome(vec!["B2".to_string()], false);
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Would split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4 in 'Budget'"
);
assert_eq!(
lines[1],
" up to 1 non-blank cell(s) would be overwritten: B2"
);
assert_eq!(lines[2], UNPREVIEWABLE_SPLIT_CAVEAT);
}
#[test]
fn past_grid_extent_adds_the_dry_run_caveat_line() {
let outcome = would_change_outcome(vec![], true);
let lines = describe_lines(&outcome);
assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT_DRY_RUN);
assert_eq!(lines[3], UNPREVIEWABLE_SPLIT_CAVEAT);
}
#[test]
fn changed_lines_use_the_past_tense() {
let mut outcome = would_change_outcome(vec!["B2".to_string()], true);
outcome.result = match outcome.result {
TextToColumnsResult::WouldChange {
summary,
source,
spill,
width_upper_bound,
overwritten_cells,
past_grid_extent,
} => TextToColumnsResult::Changed {
summary,
source,
spill,
width_upper_bound,
overwritten_cells,
past_grid_extent,
},
other => other, };
let lines = describe_lines(&outcome);
assert!(lines[0].starts_with("Applied: "));
assert!(lines[1].contains("were overwritten"));
assert_eq!(lines[2], PAST_GRID_EXTENT_CAVEAT);
}
#[test]
fn every_result_variant_renders_as_non_empty_newline_free_lines() {
let results = [
TextToColumnsResult::RefusedNotASpreadsheet {
mime_type: "application/pdf".to_string(),
},
TextToColumnsResult::RefusedShortcut,
TextToColumnsResult::RefusedNoVisibleParents,
TextToColumnsResult::RefusedSheetNotFound {
title: "Q9".to_string(),
available: vec!["Q1".to_string()],
},
TextToColumnsResult::RefusedSheetNotFound {
title: "Q9".to_string(),
available: Vec::new(),
},
TextToColumnsResult::RefusedInvalidRange {
detail: "'A1:B2' spans more than one column".to_string(),
},
TextToColumnsResult::RefusedInvalidDelimiter {
detail: "--custom-delimiter must not be empty".to_string(),
},
TextToColumnsResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: None,
},
TextToColumnsResult::RefusedNoLease,
TextToColumnsResult::RefusedLeaseExpired,
TextToColumnsResult::RefusedLeaseWrongFile,
TextToColumnsResult::RefusedLeaseStale,
TextToColumnsResult::AppliedReplyUnreadable {
summary: "split 'Q1'!A2:A4 on comma".to_string(),
detail: "bad reply".to_string(),
source: "'Q1'!A2:A4".to_string(),
spill: Some("'Q1'!B2:B4".to_string()),
width_upper_bound: 2,
overwritten_cells: Vec::new(),
past_grid_extent: false,
},
TextToColumnsResult::Failed {
detail: "HTTP 500".to_string(),
},
];
for result in results {
let mut outcome = would_change_outcome(Vec::new(), false);
let status = result.log_status();
outcome.result = result;
let lines = describe_lines(&outcome);
assert!(!lines.is_empty(), "{status} rendered nothing");
for line in &lines {
assert!(!line.is_empty(), "{status} rendered an empty line");
assert!(
!line.contains('\n'),
"{status} rendered an embedded newline"
);
}
assert_eq!(describe(&outcome), lines.join("\n"), "{status}");
}
}
#[test]
fn a_failed_lease_check_maps_to_failed() {
match TextToColumnsResult::from_lease_failed("ledger unreadable".to_string()) {
TextToColumnsResult::Failed { detail } => assert_eq!(detail, "ledger unreadable"),
other => panic!("expected Failed, got {other:?}"), }
}
#[test]
fn describe_falls_back_to_the_spreadsheet_id_with_no_file_name() {
let mut outcome = would_change_outcome(Vec::new(), false);
outcome.file_name = None;
assert!(describe(&outcome).contains("'sheet-1'"));
}
#[test]
fn auto_earns_its_own_caveat_line_and_no_other_delimiter_does() {
let mut outcome = would_change_outcome(vec!["B2".to_string()], false);
assert!(!describe_lines(&outcome).contains(&AUTO_DELIMITER_CAVEAT.to_string()));
outcome.delimiter = Delimiter::Auto;
let lines = describe_lines(&outcome);
assert_eq!(lines[lines.len() - 2], AUTO_DELIMITER_CAVEAT);
assert_eq!(lines[lines.len() - 1], UNPREVIEWABLE_SPLIT_CAVEAT);
}
#[test]
fn refused_sheet_not_found_lists_available_titles() {
let outcome = TextToColumnsOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: Some("folder-1".to_string()),
sheet_id: None,
delimiter: Delimiter::Comma,
result: TextToColumnsResult::RefusedSheetNotFound {
title: "Q2".to_string(),
available: vec!["Q1".to_string()],
},
};
assert_eq!(
describe_lines(&outcome),
vec!["Refused: 'Budget' has no sheet titled 'Q2'. Available: 'Q1'".to_string()]
);
}
#[test]
fn blocked_with_no_deciding_rule_names_default_policy() {
let outcome = TextToColumnsOutcome {
spreadsheet_id: "sheet-1".to_string(),
file_name: Some("Budget".to_string()),
resolved_folder_id: Some("folder-1".to_string()),
sheet_id: None,
delimiter: Delimiter::Comma,
result: TextToColumnsResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: None,
},
};
assert_eq!(
describe_lines(&outcome),
vec![
"Blocked: text-to-columns on 'Budget' refused by default policy (no matching \
rule for sheets-write)"
.to_string()
]
);
}
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 client(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 rule_allowing(ops: &[DriveOperation], require_lease: bool) -> FolderPermissionRule {
FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: ops.iter().copied().collect(),
deny: std::collections::HashSet::default(),
require_lease,
}
}
fn rule(_op: DriveOperation, require_lease: bool) -> FolderPermissionRule {
rule_allowing(GATE_OPERATIONS, require_lease)
}
fn base_opts(dry_run: bool) -> TextToColumnsOptions {
TextToColumnsOptions {
spreadsheet_id: "sheet-1".to_string(),
sheet: None,
source: Some("Q1!A2:A4".to_string()),
delimiter: Delimiter::Comma,
dry_run,
lease_token: None,
ledger_path: PathBuf::default(),
}
}
async fn mount_file_metadata(server: &wiremock::MockServer, body: serde_json::Value) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(body))
.mount(server)
.await;
}
fn spreadsheet_metadata() -> serde_json::Value {
serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet",
"parents": ["parent-1"], "version": "1",
})
}
async fn mount_parent(server: &wiremock::MockServer) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "parent-1", "name": "parent-1",
"mimeType": "application/vnd.google-apps.folder", "parents": [],
})),
)
.mount(server)
.await;
}
async fn mount_workbook(server: &wiremock::MockServer, row_count: i64, column_count: i64) {
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",
"sheets": [{"properties": {"sheetId": 0, "title": "Q1",
"gridProperties": {
"rowCount": row_count, "columnCount": column_count,
}}}],
})),
)
.mount(server)
.await;
}
async fn mount_metadata(server: &wiremock::MockServer) {
mount_file_metadata(server, spreadsheet_metadata()).await;
mount_parent(server).await;
mount_workbook(server, 1000, 26).await;
}
async fn mount_values_failure(server: &wiremock::MockServer, range: &str) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/sheet-1/values/{range}"
)))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(server)
.await;
}
async fn mount_batch_update(server: &wiremock::MockServer, status: u16) {
let template = if status == 200 {
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"spreadsheetId": "sheet-1", "replies": [{}],
}))
} else {
wiremock::ResponseTemplate::new(status)
};
wiremock::Mock::given(wiremock::matchers::method("POST"))
.and(wiremock::matchers::path(
"/v4/spreadsheets/sheet-1:batchUpdate",
))
.respond_with(template)
.mount(server)
.await;
}
fn batch_update_was_called(requests: &[wiremock::Request]) -> bool {
requests
.iter()
.any(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
}
async fn mount_values(server: &wiremock::MockServer, range: &str, rows: serde_json::Value) {
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path(format!(
"/v4/spreadsheets/sheet-1/values/{range}"
)))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(rows))
.mount(server)
.await;
}
#[tokio::test]
async fn dry_run_reads_source_and_spill_and_never_calls_batch_update() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"], ["x,y,z"], [""]]}),
)
.await;
mount_values(
&server,
"'Q1'!B2:C4",
serde_json::json!({"values": [["", ""], ["old", ""]]}),
)
.await;
let opts = base_opts(true);
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::WouldChange {
width_upper_bound,
spill,
overwritten_cells,
..
} => {
assert_eq!(*width_upper_bound, 3);
assert_eq!(spill.as_deref(), Some("'Q1'!B2:C4"));
assert_eq!(overwritten_cells, &vec!["B3".to_string()]);
}
other => panic!("expected WouldChange, got {other:?}"),
}
assert!(server
.received_requests()
.await
.unwrap()
.iter()
.all(|r| r.method != wiremock::http::Method::POST
|| r.url.path() != "/v4/spreadsheets/sheet-1:batchUpdate"));
}
#[tokio::test]
async fn real_run_posts_exactly_one_text_to_columns_request() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).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 opts = base_opts(false);
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::Changed { .. }
));
let batch_requests: Vec<_> = server
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|r| r.url.path() == "/v4/spreadsheets/sheet-1:batchUpdate")
.collect();
assert_eq!(batch_requests.len(), 1);
let body: serde_json::Value = serde_json::from_slice(&batch_requests[0].body).unwrap();
assert_eq!(body["requests"].as_array().unwrap().len(), 1);
assert!(body["requests"][0]["textToColumns"].is_object());
}
#[tokio::test]
async fn default_policy_blocks_with_no_configured_rules() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet",
"parents": ["parent-1"], "version": "1",
})),
)
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "parent-1", "name": "parent-1",
"mimeType": "application/vnd.google-apps.folder", "parents": [],
})),
)
.mount(&server)
.await;
let opts = base_opts(true);
let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::Blocked {
operation: DriveOperation::SheetsWrite,
decided_by: None,
}
));
}
#[tokio::test]
async fn neither_half_of_the_gate_opens_it_alone() {
for (granted, missing) in [
(DriveOperation::SheetsWrite, DriveOperation::SheetsStructure),
(DriveOperation::SheetsStructure, DriveOperation::SheetsWrite),
] {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let rules = vec![rule_allowing(&[granted], false)];
let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
match &outcome.result {
TextToColumnsResult::Blocked { operation, .. } => {
assert_eq!(*operation, missing, "granted {granted}");
}
other => panic!("expected Blocked with {granted} granted, got {other:?}"),
}
assert!(describe_lines(&outcome)[0].contains(&missing.to_string()));
}
}
#[tokio::test]
async fn both_operations_together_open_the_gate() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
let rules = vec![rule_allowing(GATE_OPERATIONS, false)];
let outcome = text_to_columns(&client, &sheets, &base_opts(true), &rules).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::WouldChange { .. }
));
}
#[tokio::test]
async fn a_blocked_by_rule_names_the_deciding_folder_in_the_message() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let deny_by_rule = FolderPermissionRule {
folder_id: Some("parent-1".to_string()),
file_id: None,
recursive: true,
allow: std::iter::once(DriveOperation::SheetsWrite).collect(),
deny: std::iter::once(DriveOperation::SheetsStructure).collect(),
require_lease: false,
};
let outcome = text_to_columns(&client, &sheets, &base_opts(false), &[deny_by_rule]).await;
assert!(
matches!(
outcome.result,
TextToColumnsResult::Blocked {
decided_by: Some(_),
..
}
),
"{:?}",
outcome.result
);
let text = describe(&outcome);
assert!(
text.contains("refused by rule on folder parent-1"),
"{text}"
);
}
#[tokio::test]
async fn open_ended_source_is_refused() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let mut opts = base_opts(true);
opts.source = Some("Q1!A:A".to_string());
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match outcome.result {
TextToColumnsResult::RefusedInvalidRange { detail } => {
assert!(detail.starts_with("Q1!A:A is open-ended"), "{detail}");
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn multi_column_source_is_refused() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let mut opts = base_opts(true);
opts.source = Some("Q1!A2:B4".to_string());
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match outcome.result {
TextToColumnsResult::RefusedInvalidRange { detail } => {
assert!(detail.contains("exactly one column"));
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn empty_custom_delimiter_is_refused_before_any_network_call() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
let mut opts = base_opts(true);
opts.delimiter = Delimiter::Custom(String::new());
let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::RefusedInvalidDelimiter { .. }
));
assert!(server.received_requests().await.unwrap().is_empty());
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn lease_required_and_absent_is_refused() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
let opts = base_opts(false);
let rules = vec![rule(DriveOperation::SheetsWrite, true)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::RefusedNoLease
));
}
#[tokio::test]
async fn a_valid_lease_lets_the_real_run_succeed() {
let ledger_dir = tempfile::tempdir().unwrap();
let ledger_path = ledger_dir.path().join("leases.json");
let token = seed_lease(&ledger_path, "sheet-1", "1");
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).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;
wiremock::Mock::given(wiremock::matchers::method("PATCH"))
.and(wiremock::matchers::path("/drive/v3/files/sheet-1"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"id": "sheet-1", "name": "Budget", "version": "2",
})),
)
.mount(&server)
.await;
let mut opts = base_opts(false);
opts.lease_token = Some(token);
opts.ledger_path = ledger_path;
let rules = vec![rule(DriveOperation::SheetsWrite, true)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::Changed { .. }
));
}
#[tokio::test]
async fn a_valid_lease_records_the_failure_when_batch_update_fails() {
let ledger_dir = tempfile::tempdir().unwrap();
let ledger_path = ledger_dir.path().join("leases.json");
let token = seed_lease(&ledger_path, "sheet-1", "1");
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
mount_batch_update(&server, 400).await;
let mut opts = base_opts(false);
opts.lease_token = Some(token);
opts.ledger_path = ledger_path;
let rules = vec![rule(DriveOperation::SheetsWrite, true)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
}
#[tokio::test]
async fn no_cell_value_from_either_read_reaches_the_output_or_the_outcome() {
const SECRET: &str = "super-secret-cell-value";
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [[format!("{SECRET},{SECRET}")]]}),
)
.await;
mount_values(
&server,
"'Q1'!B2:B4",
serde_json::json!({"values": [[SECRET]]}),
)
.await;
let opts = base_opts(true);
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::WouldChange {
overwritten_cells, ..
} => assert_eq!(overwritten_cells, &vec!["B2".to_string()]),
other => panic!("expected WouldChange, got {other:?}"), }
let rendered = describe_lines(&outcome).join("\n");
assert!(
!rendered.contains(SECRET),
"leaked into the text: {rendered}"
);
let serialized = serde_json::to_string(&outcome).unwrap();
assert!(
!serialized.contains(SECRET),
"leaked into the outcome: {serialized}"
);
}
#[tokio::test]
async fn a_spill_straddling_the_grid_edge_reads_only_the_in_grid_part() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(&server, spreadsheet_metadata()).await;
mount_parent(&server).await;
mount_workbook(&server, 1000, 3).await; mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b,c,d,e"]]}),
)
.await;
mount_values(
&server,
"'Q1'!B2:C4",
serde_json::json!({"values": [["", "old"]]}),
)
.await;
let opts = base_opts(true);
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::WouldChange {
spill,
width_upper_bound,
overwritten_cells,
past_grid_extent,
..
} => {
assert_eq!(spill.as_deref(), Some("'Q1'!B2:E4"));
assert_eq!(*width_upper_bound, 5);
assert_eq!(overwritten_cells, &vec!["C2".to_string()]);
assert!(past_grid_extent);
}
other => panic!("expected WouldChange, got {other:?}"),
}
assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT_DRY_RUN.to_string()));
}
#[tokio::test]
async fn a_spill_wholly_past_the_grid_extent_skips_the_read_and_still_splits() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(&server, spreadsheet_metadata()).await;
mount_parent(&server).await;
mount_workbook(&server, 1000, 3).await; mount_values(
&server,
"'Q1'!C2:C4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_batch_update(&server, 200).await;
let mut opts = base_opts(false);
opts.source = Some("Q1!C2:C4".to_string());
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::Changed {
spill,
overwritten_cells,
past_grid_extent,
..
} => {
assert_eq!(spill.as_deref(), Some("'Q1'!D2:D4"));
assert!(overwritten_cells.is_empty());
assert!(past_grid_extent);
}
other => panic!("expected Changed, got {other:?}"), }
let reads: Vec<_> = server
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|r| r.url.path().contains("/values/"))
.collect();
assert_eq!(reads.len(), 1);
assert!(describe_lines(&outcome).contains(&PAST_GRID_EXTENT_CAVEAT.to_string()));
}
#[tokio::test]
async fn a_source_read_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values_failure(&server, "'Q1'!A2:A4").await;
let outcome = text_to_columns(
&client,
&sheets,
&base_opts(true),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
}
#[tokio::test]
async fn a_spill_read_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values_failure(&server, "'Q1'!B2:B4").await;
let outcome = text_to_columns(
&client,
&sheets,
&base_opts(true),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
}
#[tokio::test]
async fn text_to_columns_with_an_unreadable_reply_reports_applied_reply_unreadable() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).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 outcome = text_to_columns(
&client,
&sheets,
&base_opts(false),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert_eq!(
outcome.result.log_status(),
"applied-reply-unreadable",
"{outcome:?}"
);
assert!(
describe(&outcome).starts_with("Applied, but the reply could not be read"),
"{}",
describe(&outcome)
);
}
fn unreadable_outcome(
overwritten_cells: Vec<String>,
past_grid_extent: bool,
) -> TextToColumnsOutcome {
let mut outcome = would_change_outcome(Vec::new(), false);
outcome.result = TextToColumnsResult::AppliedReplyUnreadable {
summary: "split 'Q1'!A2:A4 on comma".to_string(),
detail: "bad reply".to_string(),
source: "'Q1'!A2:A4".to_string(),
spill: Some("'Q1'!B2:B4".to_string()),
width_upper_bound: 2,
overwritten_cells,
past_grid_extent,
};
outcome
}
#[test]
fn describe_lines_renders_applied_reply_unreadable_with_the_plan_time_lines() {
let outcome = unreadable_outcome(vec!["B2".to_string()], true);
let lines = describe_lines(&outcome);
assert_eq!(
lines[0],
"Applied, but the reply could not be read: split 'Q1'!A2:A4 on comma in \
'Budget' — do not retry; check the spreadsheet first (bad reply)"
);
assert_eq!(
lines[1..],
[
" up to 1 non-blank cell(s) were overwritten: B2".to_string(),
PAST_GRID_EXTENT_CAVEAT.to_string(),
UNPREVIEWABLE_SPLIT_CAVEAT.to_string(),
]
);
let mut changed = outcome;
changed.result = TextToColumnsResult::Changed {
summary: "split 'Q1'!A2:A4 on comma".to_string(),
source: "'Q1'!A2:A4".to_string(),
spill: Some("'Q1'!B2:B4".to_string()),
width_upper_bound: 2,
overwritten_cells: vec!["B2".to_string()],
past_grid_extent: true,
};
assert_eq!(lines[1..], describe_lines(&changed)[1..]);
}
#[test]
fn the_unreadable_reply_logs_everything_changed_logs() {
let outcome = unreadable_outcome(vec!["B2".to_string(), "B3".to_string()], false);
let logged = mutation_outcome(&outcome, Duration::ZERO);
assert_eq!(logged.status, "applied-reply-unreadable");
assert_eq!(logged.sheet_id, Some(0));
assert_eq!(logged.range.as_deref(), Some("'Q1'!A2:A4"));
assert_eq!(
logged.fields_changed.as_deref(),
Some("split 'Q1'!A2:A4 on comma")
);
assert_eq!(logged.overwritten_cells, vec!["B2", "B3"]);
assert!(logged.overwritten_cells_upper_bound);
assert_eq!(logged.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 (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(&server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
mount_batch_update(&server, 400).await;
let outcome = text_to_columns(
&client,
&sheets,
&base_opts(false),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
}
#[tokio::test]
async fn a_workbook_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(&server, spreadsheet_metadata()).await;
mount_parent(&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 = text_to_columns(
&client,
&sheets,
&base_opts(true),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
}
#[tokio::test]
async fn a_metadata_fetch_failure_is_reported_as_failed() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&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 = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
assert!(outcome.file_name.is_none());
}
#[tokio::test]
async fn a_gate_fetch_failure_is_reported_as_failed_and_still_names_the_file() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(&server, spreadsheet_metadata()).await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/drive/v3/files/parent-1"))
.respond_with(wiremock::ResponseTemplate::new(500))
.mount(&server)
.await;
let outcome = text_to_columns(
&client,
&sheets,
&base_opts(true),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
assert!(matches!(outcome.result, TextToColumnsResult::Failed { .. }));
assert_eq!(outcome.file_name.as_deref(), Some("Budget"));
assert!(outcome.resolved_folder_id.is_none());
}
#[tokio::test]
async fn a_target_that_is_not_a_spreadsheet_is_refused() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(
&server,
serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.document",
"parents": ["parent-1"], "version": "1",
}),
)
.await;
let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
match &outcome.result {
TextToColumnsResult::RefusedNotASpreadsheet { mime_type } => {
assert_eq!(mime_type, "application/vnd.google-apps.document");
}
other => panic!("expected RefusedNotASpreadsheet, got {other:?}"),
}
assert!(describe_lines(&outcome)[0].contains("is not a Google Sheet"));
}
#[tokio::test]
async fn a_shortcut_target_is_refused_rather_than_followed() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(
&server,
serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.shortcut",
"parents": ["parent-1"], "version": "1",
"shortcutDetails": {
"targetId": "real-sheet",
"targetMimeType": "application/vnd.google-apps.spreadsheet",
},
}),
)
.await;
let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::RefusedShortcut
));
assert!(describe_lines(&outcome)[0].contains("doesn't follow shortcuts"));
}
#[tokio::test]
async fn a_target_with_no_visible_parents_is_refused_with_the_by_id_hint() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_file_metadata(
&server,
serde_json::json!({
"id": "sheet-1", "name": "Budget",
"mimeType": "application/vnd.google-apps.spreadsheet",
"parents": [], "version": "1",
}),
)
.await;
let outcome = text_to_columns(&client, &sheets, &base_opts(true), &[]).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::RefusedNoVisibleParents
));
assert!(describe_lines(&outcome)[0].contains("write_permissions.rules"));
}
#[tokio::test]
async fn a_source_on_a_missing_sheet_is_refused_and_names_the_available_titles() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let mut opts = base_opts(true);
opts.source = Some("Q9!A2:A4".to_string());
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::RefusedSheetNotFound { title, available } => {
assert_eq!(title, "Q9");
assert_eq!(available, &vec!["Q1".to_string()]);
}
other => panic!("expected RefusedSheetNotFound, got {other:?}"),
}
}
fn ledger_in_a_tempdir() -> PathBuf {
tempfile::tempdir()
.unwrap()
.keep()
.join("lease-ledger.jsonl")
}
async fn run_with_lease(
server: &wiremock::MockServer,
lease_token: Option<String>,
ledger_path: PathBuf,
) -> TextToColumnsOutcome {
let (client, sheets) = client(server).await;
mount_metadata(server).await;
mount_values(
server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["a,b"]]}),
)
.await;
mount_values(server, "'Q1'!B2:B4", serde_json::json!({"values": []})).await;
mount_batch_update(server, 200).await;
let opts = TextToColumnsOptions {
lease_token,
ledger_path,
..base_opts(false)
};
let outcome = text_to_columns(
&client,
&sheets,
&opts,
&[rule(DriveOperation::SheetsWrite, true)],
)
.await;
assert!(
!batch_update_was_called(&server.received_requests().await.unwrap()),
"a refused lease must not reach batchUpdate"
);
outcome
}
#[tokio::test]
async fn a_sheet_that_conflicts_with_a_prefixed_source_is_refused_before_any_request() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
let mut opts = base_opts(true);
opts.sheet = Some("Q1".to_string());
opts.source = Some("'Q2'!A2:A4".to_string());
let outcome = text_to_columns(&client, &sheets, &opts, &[]).await;
assert!(matches!(
outcome.result,
TextToColumnsResult::RefusedInvalidRange { .. }
));
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn an_unparseable_source_is_refused_with_the_resolvers_own_detail() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
let mut opts = base_opts(true);
opts.source = Some("Q1!not-a-range".to_string());
let rules = vec![rule(DriveOperation::SheetsWrite, false)];
let outcome = text_to_columns(&client, &sheets, &opts, &rules).await;
match &outcome.result {
TextToColumnsResult::RefusedInvalidRange { detail } => {
assert!(
!detail.is_empty(),
"the resolver's detail is passed through"
);
}
other => panic!("expected RefusedInvalidRange, got {other:?}"),
}
}
#[tokio::test]
async fn a_source_that_never_splits_reads_no_spill_and_still_sends_the_request() {
let server = wiremock::MockServer::start().await;
let (client, sheets) = client(&server).await;
mount_metadata(&server).await;
mount_values(
&server,
"'Q1'!A2:A4",
serde_json::json!({"values": [["no separator here"]]}),
)
.await;
mount_batch_update(&server, 200).await;
let outcome = text_to_columns(
&client,
&sheets,
&base_opts(false),
&[rule(DriveOperation::SheetsWrite, false)],
)
.await;
match &outcome.result {
TextToColumnsResult::Changed {
summary,
spill,
width_upper_bound,
overwritten_cells,
past_grid_extent,
..
} => {
assert_eq!(*width_upper_bound, 1);
assert!(spill.is_none());
assert!(overwritten_cells.is_empty());
assert!(!past_grid_extent);
assert!(summary.contains("no row spills beyond the source"));
}
other => panic!("expected Changed, got {other:?}"), }
let reads: Vec<_> = server
.received_requests()
.await
.unwrap()
.into_iter()
.filter(|r| r.url.path().contains("/values/"))
.collect();
assert_eq!(reads.len(), 1, "no spill span means no second read");
}
#[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,
TextToColumnsResult::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,
TextToColumnsResult::RefusedLeaseWrongFile
));
}
#[tokio::test]
async fn a_stale_lease_is_refused_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,
TextToColumnsResult::RefusedLeaseStale
));
}
}