use clap::{Parser, Subcommand, ValueEnum};
use sheeter_engine::{
convert_workbook_with_options, inspect, stream_delimited, Filter, Format, 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)]
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>,
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>,
) -> 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();
Ok(Transforms {
filter,
select,
rename,
limit,
})
}
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,
input,
output,
} => {
let from_fmt: Format = from.into();
let to_fmt: Format = to.into();
let transforms = build_transforms(filter, select, rename, limit)?;
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);
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)?;
let _ = &transforms;
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(())
}
}
}