use std::path::PathBuf;
use anyhow::{Result, bail};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InputSpec {
pub(crate) path: PathBuf,
pub(crate) sheet_name: Option<String>,
pub(crate) table_name: Option<String>,
}
pub(crate) fn parse_input_specs(
entries: &[String],
default_sheet: Option<&str>,
) -> Result<Vec<InputSpec>> {
entries
.iter()
.map(|entry| parse_input_entry(entry, default_sheet))
.collect()
}
fn parse_input_entry(entry: &str, default_sheet: Option<&str>) -> Result<InputSpec> {
let (table_name, path_and_sheet) = split_table_name(entry);
let (path, sheet_name) = split_input_entry(path_and_sheet)?;
if path.trim().is_empty() {
bail!("invalid --input '{entry}', file path cannot be empty");
}
Ok(InputSpec {
path: PathBuf::from(path),
sheet_name: sheet_name
.map(str::to_owned)
.or_else(|| default_sheet.map(str::to_owned)),
table_name: table_name.map(str::to_owned),
})
}
fn split_table_name(entry: &str) -> (Option<&str>, &str) {
if let Some(eq_pos) = entry.find('=') {
let candidate = &entry[..eq_pos];
if is_valid_table_name(candidate) {
return (Some(candidate), &entry[eq_pos + 1..]);
}
}
(None, entry)
}
fn is_valid_table_name(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
pub(crate) fn split_input_entry(entry: &str) -> Result<(&str, Option<&str>)> {
if let Some((path, sheet_name)) = entry.rsplit_once('#') {
let sheet_name = sheet_name.trim();
if sheet_name.is_empty() {
bail!("invalid --input '{entry}', sheet name cannot be empty");
}
return Ok((path, Some(sheet_name)));
}
let Some((path, sheet_name)) = entry.rsplit_once(':') else {
return Ok((entry, None));
};
if path.len() == 1
&& path.as_bytes()[0].is_ascii_alphabetic()
&& (sheet_name.starts_with('\\') || sheet_name.starts_with('/'))
{
return Ok((entry, None));
}
let sheet_name = sheet_name.trim();
if sheet_name.is_empty() {
bail!("invalid --input '{entry}', sheet name cannot be empty");
}
Ok((path, Some(sheet_name)))
}
#[cfg(test)]
mod tests {
use super::{parse_input_specs, split_input_entry, split_table_name};
#[test]
fn splits_input_path_and_sheet() {
let (path, sheet) = split_input_entry("./data.xlsx:Consuntivo").expect("valid input");
assert_eq!(path, "./data.xlsx");
assert_eq!(sheet, Some("Consuntivo"));
}
#[test]
fn splits_input_path_and_sheet_with_hash_separator() {
let (path, sheet) = split_input_entry("./data.xlsx#Consuntivo").expect("valid input");
assert_eq!(path, "./data.xlsx");
assert_eq!(sheet, Some("Consuntivo"));
}
#[test]
fn keeps_windows_drive_path_without_sheet() {
let (path, sheet) =
split_input_entry(r"C:\data\report.xlsx").expect("valid windows path input");
assert_eq!(path, r"C:\data\report.xlsx");
assert_eq!(sheet, None);
}
#[test]
fn parses_windows_drive_path_with_hash_sheet() {
let (path, sheet) =
split_input_entry(r"C:\data\report.xlsx#WKL").expect("valid windows path input");
assert_eq!(path, r"C:\data\report.xlsx");
assert_eq!(sheet, Some("WKL"));
}
#[test]
fn applies_default_sheet_when_not_specified_in_input() {
let entries = vec!["./consuntivo.xlsx".to_owned(), "./wkl.xlsx:WKL".to_owned()];
let parsed = parse_input_specs(&entries, Some("Sheet1")).expect("parsed inputs");
assert_eq!(parsed[0].sheet_name.as_deref(), Some("Sheet1"));
assert_eq!(parsed[1].sheet_name.as_deref(), Some("WKL"));
}
#[test]
fn extracts_table_name_prefix() {
let (name, rest) = split_table_name("sales=./sales.xlsx:Q1");
assert_eq!(name, Some("sales"));
assert_eq!(rest, "./sales.xlsx:Q1");
}
#[test]
fn extracts_table_name_prefix_no_sheet() {
let (name, rest) = split_table_name("costs=./costs.csv");
assert_eq!(name, Some("costs"));
assert_eq!(rest, "./costs.csv");
}
#[test]
fn no_table_name_when_no_equals() {
let (name, rest) = split_table_name("./data.xlsx:Sheet1");
assert_eq!(name, None);
assert_eq!(rest, "./data.xlsx:Sheet1");
}
#[test]
fn no_table_name_when_invalid_identifier() {
let (name, rest) = split_table_name("123bad=./data.csv");
assert_eq!(name, None);
assert_eq!(rest, "123bad=./data.csv");
}
#[test]
fn parses_explicit_table_name_with_sheet() {
let entries = vec!["sales=./sales.xlsx:Q1".to_owned(), "costs=./costs.csv".to_owned()];
let parsed = parse_input_specs(&entries, None).expect("parsed inputs");
assert_eq!(parsed[0].table_name.as_deref(), Some("sales"));
assert_eq!(parsed[0].sheet_name.as_deref(), Some("Q1"));
assert_eq!(parsed[1].table_name.as_deref(), Some("costs"));
assert_eq!(parsed[1].sheet_name, None);
}
#[test]
fn parses_no_table_name_when_not_specified() {
let entries = vec!["./data.xlsx:Sheet1".to_owned()];
let parsed = parse_input_specs(&entries, None).expect("parsed inputs");
assert_eq!(parsed[0].table_name, None);
assert_eq!(parsed[0].sheet_name.as_deref(), Some("Sheet1"));
}
#[test]
fn table_name_with_underscore_is_valid() {
let (name, rest) = split_table_name("my_data=./data.csv");
assert_eq!(name, Some("my_data"));
assert_eq!(rest, "./data.csv");
}
#[test]
fn table_name_starting_with_underscore_is_valid() {
let (name, rest) = split_table_name("_data=./data.csv");
assert_eq!(name, Some("_data"));
assert_eq!(rest, "./data.csv");
}
}