use clap::{Parser, Subcommand, ValueEnum};
use sheeter_engine::{
convert_workbook_with_options, external_sort_delimited, inspect, stream_delimited,
CanonicalValue, Filter, Format, SortSpec, SqlDialect, Transforms,
};
use std::fs;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "sheeter",
version,
about = "Convert data files between formats"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Commands {
Convert {
#[arg(long, ignore_case = true)]
from: FormatArg,
#[arg(long, ignore_case = true)]
to: FormatArg,
#[arg(long)]
sheet: Option<String>,
#[arg(long, default_value = "generic")]
dialect: String,
#[arg(long)]
filter: Option<String>,
#[arg(long)]
select: Option<String>,
#[arg(long)]
rename: Option<String>,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
sort: Option<String>,
#[arg(long)]
dedup: bool,
#[arg(long, value_name = "COLS")]
dedup_by: Option<String>,
input: PathBuf,
output: PathBuf,
},
Inspect {
#[arg(long, ignore_case = true)]
format: FormatArg,
input: PathBuf,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum FormatArg {
Csv,
Tsv,
Json,
Yaml,
Xml,
Toml,
Xlsx,
Sql,
}
impl From<FormatArg> for Format {
fn from(f: FormatArg) -> Self {
match f {
FormatArg::Csv => Self::Csv,
FormatArg::Tsv => Self::Tsv,
FormatArg::Json => Self::Json,
FormatArg::Yaml => Self::Yaml,
FormatArg::Xml => Self::Xml,
FormatArg::Toml => Self::Toml,
FormatArg::Xlsx => Self::Xlsx,
FormatArg::Sql => Self::Sql,
}
}
}
fn sanitize(name: &str) -> String {
let cleaned: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
if cleaned.is_empty() {
"Sheet".to_string()
} else {
cleaned
}
}
fn build_transforms(
filter: Option<String>,
select: Option<String>,
rename: Option<String>,
limit: Option<usize>,
dedup: bool,
dedup_by: Option<String>,
) -> Result<Transforms, Box<dyn std::error::Error>> {
let filter = match filter {
Some(s) => Some(Filter::parse(&s)?),
None => None,
};
let select = select.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
});
let rename = rename
.map(|s| {
s.split(',')
.filter_map(|pair| {
let mut parts = pair.split('=');
let from = parts.next()?.trim().to_string();
let to = parts.next()?.trim().to_string();
if from.is_empty() || to.is_empty() {
None
} else {
Some((from, to))
}
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let dedup_spec = if dedup {
Some(Vec::new())
} else {
dedup_by.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
})
};
Ok(Transforms {
filter,
select,
rename,
limit,
dedup: dedup_spec,
})
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
match cli.command {
Commands::Convert {
from,
to,
sheet,
dialect,
filter,
select,
rename,
limit,
sort,
dedup,
dedup_by,
input,
output,
} => {
let from_fmt: Format = from.into();
let to_fmt: Format = to.into();
let transforms = build_transforms(filter, select, rename, limit, dedup, dedup_by)?;
let delimited_from = matches!(from_fmt, Format::Csv | Format::Tsv);
let delimited_to = matches!(to_fmt, Format::Csv | Format::Tsv);
if delimited_from && delimited_to {
let from_delim = if matches!(from_fmt, Format::Tsv) {
b'\t'
} else {
b','
};
let to_delim = if matches!(to_fmt, Format::Tsv) {
b'\t'
} else {
b','
};
let infile = fs::File::open(&input)?;
let outfile = fs::File::create(&output)?;
let mut buffered = BufWriter::new(outfile);
if let Some(spec) = sort.as_deref() {
let sort_spec = SortSpec::parse(spec)?;
external_sort_delimited(
std::io::BufReader::new(infile),
from_delim,
&mut buffered,
to_delim,
&transforms,
&sort_spec,
50_000,
)?;
} else {
stream_delimited(
std::io::BufReader::new(infile),
from_delim,
&mut buffered,
to_delim,
&transforms,
)?;
}
return Ok(());
}
let bytes = fs::read(&input)?;
let sql_dialect: SqlDialect = dialect
.parse()
.map_err(|e: String| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let table_name = output.file_stem().and_then(|s| s.to_str());
let mut results =
convert_workbook_with_options(from_fmt, to_fmt, &bytes, sql_dialect, table_name)?;
if !transforms.is_empty() && matches!(to_fmt, Format::Xlsx | Format::Sql) {
return Err(format!(
"--filter/--select/--rename/--limit/--sort/--dedup cannot be applied \
when the output format is {} because it cannot be parsed back. \
Convert to a text format first (json, csv, tsv, yaml, xml, toml), \
then convert that file to {} with transforms.",
format!("{to_fmt:?}").to_lowercase(),
format!("{to_fmt:?}").to_lowercase()
)
.into());
}
if !transforms.is_empty() {
for (_, content) in results.iter_mut() {
let text = std::str::from_utf8(content)?;
let hub = match to_fmt {
Format::Json => CanonicalValue::from_json(text)?,
Format::Yaml => CanonicalValue::from_yaml(text)?,
Format::Xml => CanonicalValue::from_xml(text)?,
Format::Toml => CanonicalValue::from_toml(text)?,
Format::Csv => CanonicalValue::from_csv(text)?,
Format::Tsv => CanonicalValue::from_tsv(text)?,
Format::Xlsx | Format::Sql => continue,
};
let filtered = transforms.apply_to_array(&hub);
let reserialized = match to_fmt {
Format::Json => filtered.to_json()?,
Format::Yaml => filtered.to_yaml()?,
Format::Xml => filtered.to_xml()?,
Format::Toml => filtered.to_toml()?,
Format::Csv => filtered.to_csv()?,
Format::Tsv => filtered.to_tsv()?,
Format::Xlsx | Format::Sql => continue,
};
*content = reserialized.into_bytes();
}
}
if let Some(want) = sheet {
results.retain(|(name, _)| name == &want);
if results.is_empty() {
return Err(format!("sheet {want:?} not found").into());
}
}
if results.len() == 1 {
fs::write(&output, &results[0].1)?;
return Ok(());
}
let stem = output
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("output");
let ext = output.extension().and_then(|s| s.to_str()).unwrap_or("");
let parent = output.parent().unwrap_or_else(|| Path::new("."));
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
for (raw_name, content) in &results {
let mut base = sanitize(raw_name);
let mut n = 2;
while !used.insert(base.clone()) {
base = format!("{}_{}", sanitize(raw_name), n);
n += 1;
}
let filename = if ext.is_empty() {
format!("{}.{}", stem, base)
} else {
format!("{}.{}.{}", stem, base, ext)
};
fs::write(parent.join(filename), content)?;
}
Ok(())
}
Commands::Inspect { format, input } => {
let bytes = fs::read(&input)?;
let report = inspect(&bytes, format.into())?;
print!("{report}");
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::sanitize;
#[test]
fn sanitize_replaces_forbidden_chars() {
assert_eq!(sanitize("A/B"), "A_B");
assert_eq!(sanitize("A:B*C?"), "A_B_C_");
assert_eq!(sanitize("a\\b"), "a_b");
}
#[test]
fn sanitize_preserves_valid_names() {
assert_eq!(sanitize("Sheet1"), "Sheet1");
assert_eq!(sanitize("Vendas 2024"), "Vendas 2024");
assert_eq!(sanitize("A.B"), "A.B");
}
#[test]
fn sanitize_falls_back_on_empty() {
assert_eq!(sanitize(""), "Sheet");
}
}