use clap::{Parser, Subcommand, ValueEnum};
use sheeter_engine::{convert_workbook, Format};
use std::fs;
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)]
enum Commands {
Convert {
#[arg(long, ignore_case = true)]
from: FormatArg,
#[arg(long, ignore_case = true)]
to: FormatArg,
#[arg(long)]
sheet: Option<String>,
input: PathBuf,
output: PathBuf,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum FormatArg {
Csv,
Tsv,
Json,
Yaml,
Xml,
Toml,
Xlsx,
}
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,
}
}
}
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 main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let Commands::Convert {
from,
to,
sheet,
input,
output,
} = cli.command;
let bytes = fs::read(&input)?;
let mut results = convert_workbook(from.into(), to.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(());
}
}
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)
};
let path = parent.join(filename);
fs::write(&path, content)?;
println!("{}", path.display());
}
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");
}
}