use anyhow::Result;
pub(crate) fn quote_sheet_title(title: &str) -> String {
format!("'{}'", title.replace('\'', "''"))
}
pub(crate) fn split_sheet_prefix(range: &str) -> Option<(String, &str)> {
if let Some(rest) = range.strip_prefix('\'') {
let mut title = String::new();
let mut chars = rest.char_indices();
while let Some((idx, ch)) = chars.next() {
if ch != '\'' {
title.push(ch);
continue;
}
if rest[idx + 1..].starts_with('\'') {
title.push('\'');
chars.next();
continue;
}
let after = &rest[idx + 1..];
return after.strip_prefix('!').map(|tail| (title, tail));
}
None
} else {
let (title, tail) = range.split_once('!')?;
if title.contains([':', '[', ']', '\\', '?', '/', '*']) {
return None;
}
Some((title.to_string(), tail))
}
}
pub(crate) fn sheet_title_of(range: &str) -> Option<String> {
split_sheet_prefix(range).map(|(title, _)| title)
}
pub(crate) fn is_whole_sheet_reference(range: &str) -> bool {
let Some(rest) = range.strip_prefix('\'') else {
return false;
};
let mut chars = rest.char_indices();
while let Some((idx, ch)) = chars.next() {
if ch != '\'' {
continue;
}
if rest[idx + 1..].starts_with('\'') {
chars.next();
continue;
}
return rest[idx + 1..].is_empty();
}
false
}
pub(crate) fn compose(sheet: Option<&str>, range: Option<&str>) -> Result<String> {
let range = range.map(str::trim).filter(|r| !r.is_empty());
let sheet = sheet.filter(|s| !s.is_empty());
match (sheet, range) {
(Some(sheet), Some(range)) => {
anyhow::ensure!(
split_sheet_prefix(range).is_none() && !is_whole_sheet_reference(range),
"--range {range:?} already names a sheet, so --sheet {sheet:?} would be \
ambiguous; drop one of them"
);
validate_range(range)?;
Ok(format!("{}!{}", quote_sheet_title(sheet), range))
}
(Some(sheet), None) => Ok(quote_sheet_title(sheet)),
(None, Some(range)) => {
validate_range(range)?;
Ok(range.to_string())
}
(None, None) => anyhow::bail!("a range is required: pass --range, --sheet, or both"),
}
}
pub(crate) fn validate_range(range: &str) -> Result<()> {
anyhow::ensure!(!range.trim().is_empty(), "range must not be empty");
anyhow::ensure!(
!range.contains(['\r', '\n']),
"refusing range {range:?}: must not contain a CR or LF byte"
);
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn quote_sheet_title_always_quotes_even_a_simple_title() {
assert_eq!(quote_sheet_title("Sheet1"), "'Sheet1'");
}
#[test]
fn quote_sheet_title_doubles_inner_apostrophes() {
assert_eq!(quote_sheet_title("Bob's Sheet"), "'Bob''s Sheet'");
assert_eq!(quote_sheet_title("''"), "''''''");
}
#[test]
fn quote_sheet_title_handles_a_title_containing_a_bang() {
assert_eq!(quote_sheet_title("Q1!"), "'Q1!'");
}
#[test]
fn quote_sheet_title_disambiguates_a_title_that_looks_like_a_reference() {
assert_eq!(quote_sheet_title("A1"), "'A1'");
}
#[test]
fn split_sheet_prefix_none_for_a_bare_range() {
assert_eq!(split_sheet_prefix("A1:B2"), None);
assert_eq!(split_sheet_prefix("MyNamedRange"), None);
}
#[test]
fn split_sheet_prefix_splits_an_unquoted_prefix() {
let (title, rest) = split_sheet_prefix("Sheet1!A1:B2").unwrap();
assert_eq!(title, "Sheet1");
assert_eq!(rest, "A1:B2");
}
#[test]
fn split_sheet_prefix_splits_a_quoted_prefix_and_unquotes_it() {
let (title, rest) = split_sheet_prefix("'My Sheet'!A1:B2").unwrap();
assert_eq!(title, "My Sheet");
assert_eq!(rest, "A1:B2");
}
#[test]
fn split_sheet_prefix_unescapes_doubled_apostrophes() {
let (title, rest) = split_sheet_prefix("'Bob''s Sheet'!A1").unwrap();
assert_eq!(title, "Bob's Sheet");
assert_eq!(rest, "A1");
}
#[test]
fn split_sheet_prefix_finds_the_closing_quote_not_the_first_bang() {
let (title, rest) = split_sheet_prefix("'Q1!'!A1:B2").unwrap();
assert_eq!(title, "Q1!");
assert_eq!(rest, "A1:B2");
}
#[test]
fn split_sheet_prefix_none_for_a_quoted_title_with_no_range() {
assert_eq!(split_sheet_prefix("'My Sheet'"), None);
}
#[test]
fn split_sheet_prefix_none_for_an_unterminated_quote() {
assert_eq!(split_sheet_prefix("'unterminated"), None);
}
#[test]
fn split_sheet_prefix_none_for_an_unquoted_prefix_containing_a_colon() {
assert_eq!(split_sheet_prefix("A1:!!"), None);
}
#[test]
fn split_sheet_prefix_round_trips_through_quote_sheet_title() {
for title in ["Sheet1", "My Sheet", "Bob's Sheet", "Q1!", "A1", "''"] {
let composed = format!("{}!A1", quote_sheet_title(title));
let (parsed, rest) = split_sheet_prefix(&composed).unwrap();
assert_eq!(parsed, title, "round-trip failed for {title:?}");
assert_eq!(rest, "A1");
}
}
#[test]
fn sheet_title_of_reads_a_server_normalised_range() {
assert_eq!(
sheet_title_of("'My Sheet'!A1:Z1000").as_deref(),
Some("My Sheet")
);
assert_eq!(sheet_title_of("A1:B2"), None);
}
#[test]
fn compose_joins_sheet_and_bare_range() {
assert_eq!(
compose(Some("My Sheet"), Some("A1:B2")).unwrap(),
"'My Sheet'!A1:B2"
);
}
#[test]
fn compose_sheet_only_is_the_whole_sheet() {
assert_eq!(compose(Some("My Sheet"), None).unwrap(), "'My Sheet'");
}
#[test]
fn compose_range_only_passes_through_untouched() {
assert_eq!(compose(None, Some("A1:B2")).unwrap(), "A1:B2");
assert_eq!(
compose(None, Some("'My Sheet'!A1:B2")).unwrap(),
"'My Sheet'!A1:B2"
);
}
#[test]
fn compose_preserves_unbounded_forms() {
for range in ["A:A", "1:2", "A5:A", "Sheet1"] {
assert_eq!(compose(None, Some(range)).unwrap(), range);
}
}
#[test]
fn compose_rejects_sheet_alongside_an_already_prefixed_range() {
let err = compose(Some("Other"), Some("Sheet1!A1:B2")).unwrap_err();
assert!(err.to_string().contains("already names a sheet"), "{err}");
}
#[test]
fn compose_rejects_sheet_alongside_a_quoted_whole_sheet_range() {
let err = compose(Some("Other"), Some("'Q1'")).unwrap_err();
assert!(err.to_string().contains("already names a sheet"), "{err}");
}
#[test]
fn compose_does_not_misreport_a_malformed_range_as_naming_a_sheet() {
assert_eq!(compose(Some("W"), Some("A1:!!")).unwrap(), "'W'!A1:!!");
}
#[test]
fn compose_rejects_neither_argument() {
let err = compose(None, None).unwrap_err();
assert!(err.to_string().contains("a range is required"), "{err}");
}
#[test]
fn compose_treats_an_empty_or_whitespace_range_as_absent() {
assert_eq!(compose(Some("S"), Some(" ")).unwrap(), "'S'");
assert!(compose(None, Some("")).is_err());
}
#[test]
fn compose_trims_surrounding_whitespace_from_a_range() {
assert_eq!(compose(Some("S"), Some(" A1:B2 ")).unwrap(), "'S'!A1:B2");
}
#[test]
fn validate_range_rejects_line_breaks() {
for bad in ["A1\nB2", "A1\r\nB2", "A1\r"] {
let err = validate_range(bad).unwrap_err();
assert!(err.to_string().contains("CR or LF"), "{err}");
}
assert!(compose(None, Some("A1\nB2")).is_err());
}
#[test]
fn validate_range_accepts_anything_else_the_server_will_judge() {
for ok in [
"A1",
"A1:B2",
"A:A",
"MyNamedRange",
"!!!",
"definitely bogus",
] {
validate_range(ok).unwrap();
}
}
}