sheeter-engine 0.7.0

A CLI tool for converting data files between formats. v0.1.0 supports CSV <-> JSON.
Documentation
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 a data file from one format to another
    Convert {
        #[arg(long, ignore_case = true)]
        from: FormatArg,
        #[arg(long, ignore_case = true)]
        to: FormatArg,
        /// Restrict to a single sheet (XLSX input only)
        #[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");
    }
}