use crate::drive::sheets::a1;
use crate::drive::sheets::types::{GridRange, Sheet, Spreadsheet, ValueRange};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CellRef {
Cell(i64, i64),
Column(i64),
Row(i64),
}
fn column_letters_to_index(letters: &str) -> Option<i64> {
if letters.is_empty() || !letters.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
let mut index: i64 = 0;
for b in letters.bytes() {
let digit = i64::from(b.to_ascii_uppercase() - b'A' + 1);
index = index.checked_mul(26)?.checked_add(digit)?;
}
Some(index - 1)
}
pub(crate) fn column_index_to_letters(mut index: i64) -> String {
let mut letters = Vec::new();
loop {
letters.push((b'A' + (index % 26) as u8) as char);
index = index / 26 - 1;
if index < 0 {
break;
}
}
letters.iter().rev().collect()
}
pub(crate) fn non_blank_locations(
values: &ValueRange,
row_offset: i64,
col_offset: i64,
) -> Vec<String> {
let mut locations = Vec::new();
for (row_idx, row) in values.values.iter().enumerate() {
for (col_idx, cell) in row.iter().enumerate() {
let is_blank = cell.is_null() || cell.as_str().is_some_and(str::is_empty);
if is_blank {
continue;
}
locations.push(format!(
"{}{}",
column_index_to_letters(col_idx as i64 + col_offset),
row_idx as i64 + row_offset + 1
));
}
}
locations
}
pub(crate) fn width_warning(
verb: &str,
start: i64,
end: i64,
allocated: Option<i64>,
) -> Option<String> {
match allocated {
Some(columns) if start == 0 && end >= columns => None,
Some(columns) => Some(format!(
"selected columns {start}..{end} of the sheet's {columns} allocated columns; if data exists outside the selection, {verb} can separate records from their other columns"
)),
None => Some(format!(
"the selected range may be narrower than its rows; {verb} can separate records from columns outside the selection"
)),
}
}
pub(crate) fn reorder_caveat_lines(
verb: &str,
summary: String,
width_warning: Option<&str>,
changed: bool,
order_caveat: Option<&str>,
) -> Vec<String> {
let mut lines = Vec::new();
if let Some(warning) = width_warning {
lines.push(format!("Warning: {warning}"));
}
lines.push(summary);
if let Some(caveat) = order_caveat {
lines.push(format!(" {caveat}"));
}
lines.push(if changed {
" references outside the range may now observe values from a different row".to_string()
} else {
format!(
" references outside the range may observe values from a different row after {verb}"
)
});
lines
}
pub(crate) const RENDERED_LOCATION_LIMIT: usize = 50;
pub(crate) fn truncate_joined(items: &[String], limit: usize, separator: &str) -> String {
if items.len() <= limit {
return items.join(separator);
}
format!(
"{}{separator}… and {} more",
items[..limit].join(separator),
items.len() - limit
)
}
pub(crate) fn truncate_locations(locations: &[String], limit: usize) -> String {
truncate_joined(locations, limit, ", ")
}
pub(crate) fn render_locations(locations: &[String]) -> String {
truncate_locations(locations, RENDERED_LOCATION_LIMIT)
}
pub(crate) fn is_bounded(grid: &GridRange) -> bool {
grid.start_row_index.is_some()
&& grid.end_row_index.is_some()
&& grid.start_column_index.is_some()
&& grid.end_column_index.is_some()
}
pub(crate) fn cell_count(grid: &GridRange) -> Option<i64> {
match (
grid.start_row_index,
grid.end_row_index,
grid.start_column_index,
grid.end_column_index,
) {
(Some(start_row), Some(end_row), Some(start_col), Some(end_col)) => {
Some((end_row - start_row).saturating_mul(end_col - start_col))
}
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ColumnOutOfRange {
pub(crate) column: i64,
pub(crate) start: i64,
pub(crate) end: Option<i64>,
}
pub(crate) fn check_column_in_range(column: i64, grid: &GridRange) -> Result<(), ColumnOutOfRange> {
let start = grid.start_column_index.unwrap_or(0);
let end = grid.end_column_index;
let inside = column >= start && end.is_none_or(|end| column < end);
if inside {
Ok(())
} else {
Err(ColumnOutOfRange { column, start, end })
}
}
pub(crate) fn find_sheet_id<E>(
workbook: &Spreadsheet,
title: &str,
not_found: impl FnOnce(String, Vec<String>) -> E,
) -> Result<i64, E> {
workbook
.sheets
.iter()
.filter_map(|sheet| sheet.properties.as_ref())
.find(|props| props.title == title)
.and_then(|props| props.sheet_id)
.ok_or_else(|| not_found(title.to_string(), workbook.sheet_titles()))
}
pub(crate) fn default_sheet_prefix(
workbook: &Spreadsheet,
composed: String,
current_sheet_id: Option<i64>,
) -> String {
if a1::split_sheet_prefix(&composed).is_some() || a1::is_whole_sheet_reference(&composed) {
return composed;
}
current_sheet_id
.and_then(|id| sheet_title_by_id(workbook, id))
.and_then(|title| a1::compose(Some(&title), Some(&composed)).ok())
.unwrap_or(composed)
}
pub(crate) fn no_sheet_refusal(book: &str, title: &str, available: &[String]) -> String {
format!(
"Refused: {book} has no sheet titled '{title}'. Available: {}",
available_sheets(available)
)
}
pub(crate) fn available_sheets(available: &[String]) -> String {
if available.is_empty() {
"none".to_string()
} else {
available
.iter()
.map(|title| format!("'{title}'"))
.collect::<Vec<_>>()
.join(", ")
}
}
pub(crate) fn sheet_title_by_id(workbook: &Spreadsheet, sheet_id: i64) -> Option<String> {
find_sheet_by_id(workbook, sheet_id).map(|sheet| sheet.title().to_string())
}
pub(crate) fn find_sheet_by_id(workbook: &Spreadsheet, sheet_id: i64) -> Option<&Sheet> {
workbook
.sheets
.iter()
.find(|sheet| sheet.sheet_id() == Some(sheet_id))
}
pub(crate) fn is_single_cell(grid: &GridRange) -> bool {
is_bounded(grid)
&& grid.end_row_index.unwrap_or(0) - grid.start_row_index.unwrap_or(0) == 1
&& grid.end_column_index.unwrap_or(0) - grid.start_column_index.unwrap_or(0) == 1
}
pub(crate) fn bounded_range_to_a1(sheet_title: &str, grid: &GridRange) -> Option<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 {
return None;
};
if r1 <= r0 || c1 <= c0 {
return None;
}
let start = format!("{}{}", column_index_to_letters(c0), r0 + 1);
let end = format!("{}{}", column_index_to_letters(c1 - 1), r1);
a1::compose(Some(sheet_title), Some(&format!("{start}:{end}"))).ok()
}
pub(crate) fn clamp_to_sheet(workbook: &Spreadsheet, grid: &GridRange) -> Option<GridRange> {
let sheet = find_sheet_by_id(workbook, grid.sheet_id)?;
let props = sheet
.properties
.as_ref()
.and_then(|p| p.grid_properties.as_ref());
let clip = |end: Option<i64>, count: Option<i64>| match (end, count) {
(Some(end), Some(count)) => Some(end.min(count)),
(end, _) => end,
};
let clamped = GridRange {
sheet_id: grid.sheet_id,
start_row_index: grid.start_row_index,
end_row_index: clip(grid.end_row_index, props.and_then(|g| g.row_count)),
start_column_index: grid.start_column_index,
end_column_index: clip(grid.end_column_index, props.and_then(|g| g.column_count)),
};
let empty = |start: Option<i64>, end: Option<i64>| matches!((start, end), (Some(start), Some(end)) if end <= start);
if empty(clamped.start_row_index, clamped.end_row_index)
|| empty(clamped.start_column_index, clamped.end_column_index)
{
return None;
}
Some(clamped)
}
pub(crate) fn starts_past_grid(workbook: &Spreadsheet, grid: &GridRange) -> bool {
let Some(props) = find_sheet_by_id(workbook, grid.sheet_id)
.and_then(|sheet| sheet.properties.as_ref())
.and_then(|p| p.grid_properties.as_ref())
else {
return false;
};
let past = |start: Option<i64>, count: Option<i64>| matches!((start, count), (Some(start), Some(count)) if start >= count);
past(grid.start_row_index, props.row_count) || past(grid.start_column_index, props.column_count)
}
pub(crate) fn render_grid_range(range: &GridRange) -> String {
let rows = describe_bound(range.start_row_index, range.end_row_index, "rows");
let cols = describe_bound(range.start_column_index, range.end_column_index, "cols");
if rows.is_empty() && cols.is_empty() {
format!("sheetId {} (whole sheet)", range.sheet_id)
} else {
format!("sheetId {}{rows}{cols}", range.sheet_id)
}
}
fn describe_bound(start: Option<i64>, end: Option<i64>, label: &str) -> String {
match (start, end) {
(Some(start), Some(end)) => format!(", {label} {}-{end}", start + 1),
(Some(start), None) => format!(", {label} {}+", start + 1),
(None, Some(end)) => format!(", {label} 1-{end}"),
(None, None) => String::new(),
}
}
pub(crate) fn resolve_grid_range<E>(
workbook: &Spreadsheet,
composed: &str,
invalid_range: impl Fn(String) -> E,
not_found: impl FnOnce(String, Vec<String>) -> E,
) -> Result<(String, GridRange), E> {
let Some((title, bare_range)) = a1::split_sheet_prefix(composed) else {
let detail = if a1::is_whole_sheet_reference(composed) {
format!(
"{composed} names a sheet but not a range within it; pass --whole-sheet to \
target the whole sheet, or add --range to name a range inside it"
)
} else {
format!(
"{composed} does not name a sheet; pass --sheet, or a --range carrying its \
own 'Sheet!' prefix"
)
};
return Err(invalid_range(detail));
};
let sheet_id = find_sheet_id(workbook, &title, not_found)?;
let grid = parse_grid_range(sheet_id, bare_range).map_err(invalid_range)?;
Ok((title, grid))
}
fn parse_cell_ref(token: &str) -> Option<CellRef> {
let split_at = token.find(|c: char| c.is_ascii_digit());
match split_at {
None => column_letters_to_index(token).map(CellRef::Column),
Some(0) => {
let row: i64 = token.parse().ok()?;
(row >= 1).then_some(CellRef::Row(row - 1))
}
Some(at) => {
let (letters, digits) = token.split_at(at);
let column = column_letters_to_index(letters)?;
let row: i64 = digits.parse().ok()?;
(row >= 1).then_some(CellRef::Cell(column, row - 1))
}
}
}
const SUPPORTED_FORMS: &str = "a cell (A1), a bounded range (A1:D20), a whole column or \
column span (A:A, A:D), a whole row or row span (5:5, 5:20), or a column open-ended \
downward from a cell (A5:A)";
pub(crate) fn parse_grid_range(sheet_id: i64, range: &str) -> Result<GridRange, String> {
let range = range.trim();
let bare = || GridRange {
sheet_id,
..Default::default()
};
let mut parts = range.splitn(2, ':');
let first = parts.next().unwrap_or_default();
match parts.next() {
None => match parse_cell_ref(first) {
Some(CellRef::Cell(col, row)) => Ok(GridRange {
start_row_index: Some(row),
end_row_index: Some(row + 1),
start_column_index: Some(col),
end_column_index: Some(col + 1),
..bare()
}),
Some(CellRef::Column(col)) => Ok(GridRange {
start_column_index: Some(col),
end_column_index: Some(col + 1),
..bare()
}),
Some(CellRef::Row(row)) => Ok(GridRange {
start_row_index: Some(row),
end_row_index: Some(row + 1),
..bare()
}),
None => Err(format!(
"'{range}' is not a recognised A1 range; expected {SUPPORTED_FORMS}"
)),
},
Some(second) => {
let (Some(start), Some(end)) = (parse_cell_ref(first), parse_cell_ref(second)) else {
return Err(format!(
"'{range}' is not a recognised A1 range; expected {SUPPORTED_FORMS}"
));
};
match (start, end) {
(CellRef::Cell(c1, r1), CellRef::Cell(c2, r2)) => Ok(GridRange {
start_row_index: Some(r1.min(r2)),
end_row_index: Some(r1.max(r2) + 1),
start_column_index: Some(c1.min(c2)),
end_column_index: Some(c1.max(c2) + 1),
..bare()
}),
(CellRef::Column(c1), CellRef::Column(c2)) => Ok(GridRange {
start_column_index: Some(c1.min(c2)),
end_column_index: Some(c1.max(c2) + 1),
..bare()
}),
(CellRef::Row(r1), CellRef::Row(r2)) => Ok(GridRange {
start_row_index: Some(r1.min(r2)),
end_row_index: Some(r1.max(r2) + 1),
..bare()
}),
(CellRef::Cell(c1, r1), CellRef::Column(c2)) if c1 == c2 => Ok(GridRange {
start_row_index: Some(r1),
end_row_index: None,
start_column_index: Some(c1),
end_column_index: Some(c1 + 1),
..bare()
}),
_ => Err(format!(
"'{range}' is not a recognised A1 range; expected {SUPPORTED_FORMS}"
)),
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn no_sheet_refusal_quotes_every_available_title() {
assert_eq!(
no_sheet_refusal("'Budget'", "Q9", &["Q1".to_string(), "Q2".to_string()]),
"Refused: 'Budget' has no sheet titled 'Q9'. Available: 'Q1', 'Q2'"
);
}
#[test]
fn no_sheet_refusal_says_none_for_a_sheetless_workbook() {
assert_eq!(
no_sheet_refusal("'Budget'", "Q9", &[]),
"Refused: 'Budget' has no sheet titled 'Q9'. Available: none"
);
}
use crate::drive::sheets::types::{GridProperties, Sheet, SheetProperties};
#[test]
fn non_blank_locations_skips_blanks_and_offsets_addresses() {
let values: ValueRange = serde_json::from_value(serde_json::json!({
"values": [["a", "", "c"], [null, "d2"]]
}))
.unwrap();
let locations = non_blank_locations(&values, 4, 2);
assert_eq!(locations, vec!["C5", "E5", "D6"]);
}
#[test]
fn non_blank_locations_includes_the_top_left_cell() {
let values: ValueRange = serde_json::from_value(serde_json::json!({
"values": [["kept"]]
}))
.unwrap();
assert_eq!(non_blank_locations(&values, 0, 0), vec!["A1"]);
}
#[test]
fn truncate_locations_joins_everything_under_the_limit() {
let cells = vec!["A1".to_string(), "B2".to_string()];
assert_eq!(truncate_locations(&cells, 50), "A1, B2");
assert_eq!(truncate_locations(&cells, 2), "A1, B2");
assert_eq!(truncate_locations(&[], 50), "");
}
#[test]
fn truncate_locations_elides_past_the_limit_and_counts_the_remainder() {
let cells: Vec<String> = (1..=5).map(|row| format!("A{row}")).collect();
assert_eq!(truncate_locations(&cells, 2), "A1, A2, … and 3 more");
}
#[test]
fn truncate_joined_uses_the_given_separator_in_the_body_and_the_suffix() {
let items: Vec<String> = (1..=5).map(|row| format!("A{row}")).collect();
assert_eq!(truncate_joined(&items, 2, "; "), "A1; A2; … and 3 more");
}
#[test]
fn render_locations_elides_at_fifty_one_but_not_fifty() {
let fifty: Vec<String> = (1..=50).map(|row| format!("A{row}")).collect();
let rendered_fifty = render_locations(&fifty);
assert!(!rendered_fifty.contains('…'));
assert_eq!(rendered_fifty, fifty.join(", "));
let fifty_one: Vec<String> = (1..=51).map(|row| format!("A{row}")).collect();
let rendered_fifty_one = render_locations(&fifty_one);
assert!(rendered_fifty_one.ends_with("… and 1 more"));
}
#[test]
fn non_blank_locations_never_carries_a_value() {
let values: ValueRange = serde_json::from_value(serde_json::json!({
"values": [["a-secret-value"]]
}))
.unwrap();
let locations = non_blank_locations(&values, 0, 0);
assert_eq!(locations, vec!["A1"]);
assert!(!locations.iter().any(|loc| loc.contains("secret")));
}
#[test]
fn column_letters_to_index_single_and_double_letters() {
assert_eq!(column_letters_to_index("A"), Some(0));
assert_eq!(column_letters_to_index("Z"), Some(25));
assert_eq!(column_letters_to_index("AA"), Some(26));
assert_eq!(column_letters_to_index("AZ"), Some(51));
assert_eq!(column_letters_to_index("BA"), Some(52));
}
#[test]
fn column_letters_to_index_rejects_non_letters() {
assert_eq!(column_letters_to_index(""), None);
assert_eq!(column_letters_to_index("1"), None);
assert_eq!(column_letters_to_index("A1"), None);
}
#[test]
fn a_single_cell() {
let g = parse_grid_range(7, "B3").unwrap();
assert_eq!(g.sheet_id, 7);
assert_eq!(g.start_row_index, Some(2));
assert_eq!(g.end_row_index, Some(3));
assert_eq!(g.start_column_index, Some(1));
assert_eq!(g.end_column_index, Some(2));
}
#[test]
fn a_bounded_range() {
let g = parse_grid_range(1, "A1:D20").unwrap();
assert_eq!(g.start_row_index, Some(0));
assert_eq!(g.end_row_index, Some(20));
assert_eq!(g.start_column_index, Some(0));
assert_eq!(g.end_column_index, Some(4));
}
#[test]
fn a_bounded_range_normalises_a_reversed_pair() {
let g = parse_grid_range(1, "D20:A1").unwrap();
assert_eq!(g.start_row_index, Some(0));
assert_eq!(g.end_row_index, Some(20));
assert_eq!(g.start_column_index, Some(0));
assert_eq!(g.end_column_index, Some(4));
}
#[test]
fn a_whole_column() {
let g = parse_grid_range(1, "C:C").unwrap();
assert_eq!(g.start_row_index, None);
assert_eq!(g.end_row_index, None);
assert_eq!(g.start_column_index, Some(2));
assert_eq!(g.end_column_index, Some(3));
}
#[test]
fn a_column_span() {
let g = parse_grid_range(1, "A:C").unwrap();
assert_eq!(g.start_column_index, Some(0));
assert_eq!(g.end_column_index, Some(3));
assert_eq!(g.start_row_index, None);
}
#[test]
fn a_whole_row() {
let g = parse_grid_range(1, "5:5").unwrap();
assert_eq!(g.start_row_index, Some(4));
assert_eq!(g.end_row_index, Some(5));
assert_eq!(g.start_column_index, None);
assert_eq!(g.end_column_index, None);
}
#[test]
fn a_row_span() {
let g = parse_grid_range(1, "5:20").unwrap();
assert_eq!(g.start_row_index, Some(4));
assert_eq!(g.end_row_index, Some(20));
}
#[test]
fn a_column_open_ended_downward_from_a_cell() {
let g = parse_grid_range(1, "A5:A").unwrap();
assert_eq!(g.start_row_index, Some(4));
assert_eq!(g.end_row_index, None);
assert_eq!(g.start_column_index, Some(0));
assert_eq!(g.end_column_index, Some(1));
}
#[test]
fn mismatched_column_open_end_is_rejected() {
let err = parse_grid_range(1, "A5:B").unwrap_err();
assert!(err.contains("not a recognised A1 range"), "{err}");
}
#[test]
fn a_row_open_ended_rightward_is_out_of_the_supported_grammar() {
let err = parse_grid_range(1, "A5:5").unwrap_err();
assert!(err.contains("expected"), "{err}");
}
#[test]
fn garbage_is_rejected_with_a_message_naming_supported_forms() {
let err = parse_grid_range(1, "!!!").unwrap_err();
assert!(err.contains("column span"), "{err}");
}
#[test]
fn empty_string_is_rejected() {
assert!(parse_grid_range(1, "").is_err());
}
#[test]
fn whitespace_is_trimmed() {
let g = parse_grid_range(1, " A1:B2 ").unwrap();
assert_eq!(g.start_row_index, Some(0));
}
#[test]
fn column_index_to_letters_round_trips_column_letters_to_index() {
for (index, letters) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA")] {
assert_eq!(column_index_to_letters(index), letters);
assert_eq!(column_letters_to_index(letters), Some(index));
}
}
#[test]
fn check_column_in_range_accepts_start_and_last_column_of_a_bounded_range() {
let grid = parse_grid_range(1, "C1:F2").unwrap();
assert!(check_column_in_range(2, &grid).is_ok());
assert!(check_column_in_range(5, &grid).is_ok());
}
#[test]
fn check_column_in_range_rejects_below_start_and_at_exclusive_end() {
let grid = parse_grid_range(1, "C1:F2").unwrap();
assert_eq!(
check_column_in_range(1, &grid),
Err(ColumnOutOfRange {
column: 1,
start: 2,
end: Some(6),
})
);
assert_eq!(
check_column_in_range(6, &grid),
Err(ColumnOutOfRange {
column: 6,
start: 2,
end: Some(6),
})
);
}
#[test]
fn check_column_in_range_treats_an_unset_end_as_unbounded() {
let whole_rows = parse_grid_range(1, "2:5").unwrap();
assert!(check_column_in_range(0, &whole_rows).is_ok());
assert!(check_column_in_range(700, &whole_rows).is_ok());
}
#[test]
fn check_column_in_range_treats_an_unset_start_as_zero() {
let whole_column = parse_grid_range(1, "A:A").unwrap();
assert!(check_column_in_range(0, &whole_column).is_ok());
assert!(check_column_in_range(-1, &whole_column).is_err());
}
#[test]
fn check_column_in_range_rejects_everything_in_an_empty_range() {
let empty = GridRange {
sheet_id: 1,
start_row_index: Some(0),
end_row_index: Some(1),
start_column_index: Some(2),
end_column_index: Some(2),
};
assert!(check_column_in_range(2, &empty).is_err());
}
#[test]
fn check_column_in_range_rejects_a_negative_column() {
let grid = parse_grid_range(1, "A1:F2").unwrap();
assert!(check_column_in_range(-1, &grid).is_err());
}
#[test]
fn is_bounded_requires_all_four_indices() {
let bounded = parse_grid_range(1, "A1:B2").unwrap();
assert!(is_bounded(&bounded));
let open_column = parse_grid_range(1, "A:A").unwrap();
assert!(!is_bounded(&open_column));
}
#[test]
fn cell_count_multiplies_rows_by_columns_for_a_bounded_range() {
assert_eq!(
cell_count(&parse_grid_range(1, "A1:D20").unwrap()),
Some(80)
);
assert_eq!(cell_count(&parse_grid_range(1, "C3").unwrap()), Some(1));
}
#[test]
fn cell_count_is_none_for_an_open_ended_range() {
assert_eq!(cell_count(&parse_grid_range(1, "A:A").unwrap()), None);
assert_eq!(cell_count(&parse_grid_range(1, "5:5").unwrap()), None);
assert_eq!(cell_count(&parse_grid_range(1, "A1:A").unwrap()), None);
}
#[test]
fn cell_count_handles_a_huge_range_and_saturates_instead_of_overflowing() {
assert_eq!(
cell_count(&parse_grid_range(1, "A1:ZZZ10000000").unwrap()),
Some(18_278 * 10_000_000)
);
let extreme = GridRange {
sheet_id: 1,
start_row_index: Some(0),
end_row_index: Some(i64::MAX),
start_column_index: Some(0),
end_column_index: Some(i64::MAX),
};
assert_eq!(cell_count(&extreme), Some(i64::MAX));
}
#[test]
fn a_bare_column_letter_with_no_colon() {
let g = parse_grid_range(1, "C").unwrap();
assert_eq!(g.start_column_index, Some(2));
assert_eq!(g.end_column_index, Some(3));
assert_eq!(g.start_row_index, None);
assert_eq!(g.end_row_index, None);
}
#[test]
fn a_bare_row_number_with_no_colon() {
let g = parse_grid_range(1, "5").unwrap();
assert_eq!(g.start_row_index, Some(4));
assert_eq!(g.end_row_index, Some(5));
assert_eq!(g.start_column_index, None);
assert_eq!(g.end_column_index, None);
}
#[test]
fn a_range_with_one_unparseable_side_is_rejected() {
let err = parse_grid_range(1, "A1:!!!").unwrap_err();
assert!(err.contains("not a recognised A1 range"), "{err}");
}
#[test]
fn is_single_cell_accepts_one_cell_and_refuses_a_range_or_an_open_end() {
assert!(is_single_cell(&parse_grid_range(1, "A1").unwrap()));
assert!(!is_single_cell(&parse_grid_range(1, "A1:A2").unwrap()));
assert!(!is_single_cell(&parse_grid_range(1, "A1:B1").unwrap()));
assert!(!is_single_cell(&parse_grid_range(1, "A:A").unwrap()));
}
#[test]
fn bounded_range_to_a1_round_trips_a_rectangle() {
let grid = parse_grid_range(7, "B2:D5").unwrap();
assert_eq!(
bounded_range_to_a1("Q1", &grid).as_deref(),
Some("'Q1'!B2:D5")
);
}
#[test]
fn bounded_range_to_a1_refuses_an_open_ended_or_empty_range() {
assert_eq!(
bounded_range_to_a1("Q1", &parse_grid_range(1, "A:A").unwrap()),
None
);
let empty = GridRange {
sheet_id: 1,
start_row_index: Some(0),
end_row_index: Some(0),
start_column_index: Some(0),
end_column_index: Some(0),
};
assert_eq!(bounded_range_to_a1("Q1", &empty), None);
}
fn workbook_with_grid(sheet_id: i64, rows: i64, columns: i64) -> Spreadsheet {
Spreadsheet {
sheets: vec![Sheet {
properties: Some(SheetProperties {
sheet_id: Some(sheet_id),
title: "Q1".to_string(),
grid_properties: Some(GridProperties {
row_count: Some(rows),
column_count: Some(columns),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn clamp_to_sheet_clips_an_extent_past_the_grids_edge() {
let workbook = workbook_with_grid(1, 10, 5);
let clamped = clamp_to_sheet(&workbook, &parse_grid_range(1, "A1:Z100").unwrap()).unwrap();
assert_eq!(clamped.end_row_index, Some(10));
assert_eq!(clamped.end_column_index, Some(5));
}
#[test]
fn clamp_to_sheet_leaves_an_extent_inside_the_grid_alone() {
let workbook = workbook_with_grid(1, 10, 5);
let grid = parse_grid_range(1, "A1:B2").unwrap();
assert_eq!(clamp_to_sheet(&workbook, &grid), Some(grid));
}
#[test]
fn clamp_to_sheet_is_none_when_nothing_of_the_range_exists() {
let workbook = workbook_with_grid(1, 10, 5);
assert_eq!(
clamp_to_sheet(&workbook, &parse_grid_range(1, "A20:B30").unwrap()),
None
);
}
#[test]
fn clamp_to_sheet_leaves_an_axis_with_no_reported_count_alone() {
let workbook = workbook_with_sheet(1, "Q1");
let grid = parse_grid_range(1, "A1:Z100").unwrap();
assert_eq!(clamp_to_sheet(&workbook, &grid), Some(grid));
}
#[test]
fn clamp_to_sheet_is_none_for_a_sheet_the_workbook_does_not_carry() {
let workbook = workbook_with_grid(1, 10, 5);
assert_eq!(
clamp_to_sheet(&workbook, &parse_grid_range(99, "A1:B2").unwrap()),
None
);
}
fn workbook_with_sheet(sheet_id: i64, title: &str) -> Spreadsheet {
Spreadsheet {
sheets: vec![Sheet {
properties: Some(SheetProperties {
sheet_id: Some(sheet_id),
title: title.to_string(),
..Default::default()
}),
..Default::default()
}],
..Default::default()
}
}
#[test]
fn default_sheet_prefix_supplies_the_current_sheet_only_when_none_is_named() {
let workbook = workbook_with_sheet(5, "Q 2");
assert_eq!(
default_sheet_prefix(&workbook, "A1:B2".to_string(), Some(5)),
"'Q 2'!A1:B2"
);
assert_eq!(
default_sheet_prefix(&workbook, "Q1!A1:B2".to_string(), Some(5)),
"Q1!A1:B2"
);
assert_eq!(
default_sheet_prefix(&workbook, "'Q1'".to_string(), Some(5)),
"'Q1'"
);
assert_eq!(
default_sheet_prefix(&workbook, "A1:B2".to_string(), None),
"A1:B2"
);
assert_eq!(
default_sheet_prefix(&workbook, "A1:B2".to_string(), Some(9)),
"A1:B2"
);
}
#[test]
fn resolve_grid_range_rejects_a_range_with_no_sheet_prefix() {
let workbook = workbook_with_sheet(0, "Q1");
let err = resolve_grid_range(
&workbook,
"A1:B2",
|detail| detail,
|t, a| format!("{t} {a:?}"),
)
.unwrap_err();
assert!(err.contains("does not name a sheet"), "{err}");
}
#[test]
fn resolve_grid_range_points_a_sheet_only_composition_at_whole_sheet() {
let workbook = workbook_with_sheet(0, "Q1");
let err = resolve_grid_range(
&workbook,
"'Q1'",
|detail| detail,
|t, a| format!("{t} {a:?}"), )
.unwrap_err();
assert!(err.contains("--whole-sheet"), "{err}");
}
#[test]
fn sheet_title_by_id_finds_the_matching_sheet() {
let workbook = workbook_with_sheet(3, "Q1");
assert_eq!(sheet_title_by_id(&workbook, 3), Some("Q1".to_string()));
assert_eq!(sheet_title_by_id(&workbook, 99), None);
}
#[test]
fn find_sheet_by_id_finds_the_matching_sheet() {
let workbook = workbook_with_sheet(3, "Q1");
assert_eq!(find_sheet_by_id(&workbook, 3).map(Sheet::title), Some("Q1"));
assert!(find_sheet_by_id(&workbook, 99).is_none());
}
#[test]
fn render_grid_range_whole_sheet_when_all_bounds_none() {
let range = GridRange {
sheet_id: 0,
..Default::default()
};
assert_eq!(render_grid_range(&range), "sheetId 0 (whole sheet)");
}
#[test]
fn render_grid_range_rows_and_cols() {
let range = GridRange {
sheet_id: 3,
start_row_index: Some(0),
end_row_index: Some(5),
start_column_index: Some(0),
end_column_index: Some(2),
};
assert_eq!(render_grid_range(&range), "sheetId 3, rows 1-5, cols 1-2");
}
#[test]
fn render_grid_range_shows_an_open_ended_bound_rather_than_dropping_it() {
let range = GridRange {
sheet_id: 0,
start_row_index: Some(4),
end_row_index: None,
start_column_index: Some(0),
end_column_index: Some(1),
};
assert_eq!(render_grid_range(&range), "sheetId 0, rows 5+, cols 1-1");
}
#[test]
fn render_grid_range_shows_an_open_start_bound() {
let range = GridRange {
sheet_id: 0,
start_row_index: None,
end_row_index: Some(5),
..Default::default()
};
assert_eq!(render_grid_range(&range), "sheetId 0, rows 1-5");
}
#[test]
fn reorder_caveat_lines_would_change_has_no_warning_and_names_the_verb() {
let lines =
reorder_caveat_lines("sorting", "Would sort A1:B2".to_string(), None, false, None);
assert_eq!(
lines,
vec![
"Would sort A1:B2".to_string(),
" references outside the range may observe values from a different row after sorting"
.to_string(),
]
);
}
#[test]
fn reorder_caveat_lines_changed_drops_the_verb_and_switches_to_now() {
let lines = reorder_caveat_lines(
"randomizing",
"Randomized A1:B2".to_string(),
None,
true,
None,
);
assert_eq!(
lines,
vec![
"Randomized A1:B2".to_string(),
" references outside the range may now observe values from a different row"
.to_string(),
]
);
}
#[test]
fn reorder_caveat_lines_prepends_the_width_warning_when_present() {
let lines = reorder_caveat_lines(
"sorting",
"Applied sort of A1:B2".to_string(),
Some("selected columns 0..2 of the sheet's 5 allocated columns"),
true,
None,
);
assert_eq!(
lines,
vec![
"Warning: selected columns 0..2 of the sheet's 5 allocated columns".to_string(),
"Applied sort of A1:B2".to_string(),
" references outside the range may now observe values from a different row"
.to_string(),
]
);
}
#[test]
fn reorder_caveat_lines_inserts_the_order_caveat_between_summary_and_reference_line() {
let lines = reorder_caveat_lines(
"randomizing",
"Would randomize the row order of A1:B2".to_string(),
None,
false,
Some("the resulting order is chosen by the server and cannot be previewed"),
);
assert_eq!(
lines,
vec![
"Would randomize the row order of A1:B2".to_string(),
" the resulting order is chosen by the server and cannot be previewed"
.to_string(),
" references outside the range may observe values from a different row after randomizing"
.to_string(),
]
);
}
}