sheeter-engine 0.10.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, 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 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>,
        /// Sort by column(s), e.g. "age desc,name"
        #[arg(long)]
        sort: Option<String>,
        /// Drop duplicate rows (whole row)
        #[arg(long)]
        dedup: bool,
        /// Drop duplicates by column(s), e.g. "email"
        #[arg(long, value_name = "COLS")]
        dedup_by: Option<String>,
        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>,
    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");
    }
}