sheeter-engine 0.9.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_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 a data file from one format to another
    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,
        /// Filter expression (e.g. "age > 30 and name == 'Bob'")
        #[arg(long)]
        filter: Option<String>,
        /// Columns to keep, comma-separated
        #[arg(long)]
        select: Option<String>,
        /// Rename columns (e.g. "a=x,b=y")
        #[arg(long)]
        rename: Option<String>,
        /// Maximum number of rows
        #[arg(long)]
        limit: Option<usize>,
        input: PathBuf,
        output: PathBuf,
    },
    /// Print a structural summary of a data file
    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)?;

            // Note: transforms on non-delimited paths buffer through the hub.
            // Wired in v0.9.1 for correctness; current release applies them
            // only on the streaming CSV/TSV path.
            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(())
        }
    }
}