sheeter-engine 0.2.0

A CLI tool for converting data files between formats. v0.1.0 supports CSV <-> JSON.
Documentation
//! Sheeter is a data file conversion library and CLI tool.
//!
//! This crate provides functions to convert between CSV and JSON formats.
//! It is designed to be small, fast, and portable, suitable for use on
//! low-end devices such as Android phones. The core idea is simple: take
//! data in one format, understand its structure, and emit it in another
//! format without unnecessary overhead.
//!
//! # Examples
//!
//! Convert CSV to JSON:
//!
//! ```
//! use sheeter_engine::csv_to_json;
//!
//! let csv = "name,age\nAlice,30\nBob,25";
//! let json = csv_to_json(csv).unwrap();
//! assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
//! ```
//!
//! Convert JSON to CSV:
//!
//! ```
//! use sheeter_engine::json_to_csv;
//!
//! let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
//! let csv = json_to_csv(json).unwrap();
//! assert_eq!(csv, "name,age\nAlice,30\nBob,25\n");
//! ```

use std::io::Cursor;

/// Converts a delimited string into a JSON array of objects.
///
/// This is the shared engine behind `csv_to_json` and `tsv_to_json`. The first
/// line is read as the header row; every subsequent line becomes a JSON object
/// mapping header names to string cell values. Duplicate header names are
/// rejected because they would silently overwrite data.
///
/// # Errors
///
/// Returns an error on malformed input, duplicate headers, or JSON
/// serialization failure.
///
/// # Examples
///
/// ```
/// use sheeter_engine::csv_to_json;
///
/// let csv = "name,age\nAlice,30";
/// assert_eq!(
///     csv_to_json(csv).unwrap(),
///     r#"[{"name":"Alice","age":"30"}]"#
/// );
/// ```
fn delimited_to_json(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
    let mut rdr = csv::ReaderBuilder::new()
        .delimiter(delim)
        .from_reader(Cursor::new(input));
    let headers = rdr.headers()?.clone();
    let mut seen = std::collections::HashSet::new();
    for header in headers.iter() {
        if !seen.insert(header) {
            return Err(format!("duplicate header: {header}").into());
        }
    }
    let mut records = Vec::new();
    for result in rdr.records() {
        let record = result?;
        let mut map = serde_json::Map::new();
        for (header, value) in headers.iter().zip(record.iter()) {
            map.insert(
                header.to_string(),
                serde_json::Value::String(value.to_string()),
            );
        }
        records.push(serde_json::Value::Object(map));
    }
    Ok(serde_json::to_string(&records)?)
}

/// Converts a JSON array of objects into a delimited string.
///
/// This is the shared engine behind `json_to_csv` and `json_to_tsv`. The header
/// row is the union of all keys across all objects, in first-appearance order.
/// Missing keys become empty cells. Strings are written as-is; numbers and
/// booleans use their JSON literal forms; null becomes an empty cell. Nested
/// objects and arrays cannot be represented losslessly in flat output and cause
/// an error.
///
/// # Errors
///
/// Returns an error if the JSON is not an array of objects, contains nested
/// values, or if writing fails.
///
/// # Examples
///
/// ```
/// use sheeter_engine::json_to_csv;
///
/// let json = r#"[{"a":1},{"a":2,"b":3}]"#;
/// assert_eq!(json_to_csv(json).unwrap(), "a,b\n1,\n2,3\n");
/// ```
fn json_to_delimited(input: &str, delim: u8) -> Result<String, Box<dyn std::error::Error>> {
    let values: Vec<serde_json::Value> = serde_json::from_str(input)?;
    if values.is_empty() {
        return Ok(String::new());
    }
    let mut headers: Vec<String> = Vec::new();
    for value in &values {
        let obj = value.as_object().ok_or("JSON array must contain objects")?;
        for key in obj.keys() {
            if !headers.contains(key) {
                headers.push(key.clone());
            }
        }
    }
    let mut wtr = csv::WriterBuilder::new()
        .delimiter(delim)
        .from_writer(vec![]);
    wtr.write_record(&headers)?;
    for value in values {
        let obj = value.as_object().ok_or("JSON array must contain objects")?;
        let mut record: Vec<String> = Vec::with_capacity(headers.len());
        for h in &headers {
            let cell = match obj.get(h) {
                Some(serde_json::Value::String(s)) => s.clone(),
                Some(serde_json::Value::Number(n)) => n.to_string(),
                Some(serde_json::Value::Bool(b)) => b.to_string(),
                Some(serde_json::Value::Null) => String::new(),
                Some(serde_json::Value::Object(_)) | Some(serde_json::Value::Array(_)) => {
                    return Err(format!(
                        "nested value at key {h} cannot be represented in flat output"
                    )
                    .into());
                }
                None => String::new(),
            };
            record.push(cell);
        }
        wtr.write_record(&record)?;
    }
    let data = wtr.into_inner()?;
    Ok(String::from_utf8(data)?)
}

/// Converts a CSV string into a JSON array of objects.
///
/// CSV (Comma-Separated Values) is a simple tabular format. This function reads
/// the first row as the header and each subsequent row as a record, producing a
/// JSON array of objects. All values are emitted as JSON strings.
///
/// # Errors
///
/// Returns an error on malformed CSV, duplicate headers, or JSON failure.
///
/// # Examples
///
/// ```
/// use sheeter_engine::csv_to_json;
///
/// let csv = "name,age\nAlice,30\nBob,25";
/// let json = csv_to_json(csv).unwrap();
/// assert_eq!(json, r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#);
/// ```
pub fn csv_to_json(csv: &str) -> Result<String, Box<dyn std::error::Error>> {
    delimited_to_json(csv, b',')
}

/// Converts a TSV string into a JSON array of objects.
///
/// TSV (Tab-Separated Values) is the same tabular model as CSV but uses a tab
/// character as the field separator. This is useful for data exported from
/// spreadsheet tools that prefer tabs over commas.
///
/// # Errors
///
/// Returns an error on malformed TSV, duplicate headers, or JSON failure.
///
/// # Examples
///
/// ```
/// use sheeter_engine::tsv_to_json;
///
/// let tsv = "name\tage\nAlice\t30";
/// let json = tsv_to_json(tsv).unwrap();
/// assert_eq!(json, r#"[{"name":"Alice","age":"30"}]"#);
/// ```
pub fn tsv_to_json(tsv: &str) -> Result<String, Box<dyn std::error::Error>> {
    delimited_to_json(tsv, b'\t')
}

/// Converts a JSON array of objects into a CSV string.
///
/// The header row is the union of all keys across all objects. Missing keys
/// become empty cells. Nested objects or arrays cause an error to prevent
/// silent corruption of round-trips.
///
/// # Errors
///
/// Returns an error if the input is not an array of objects, contains nested
/// values, or if writing fails.
///
/// # Examples
///
/// ```
/// use sheeter_engine::json_to_csv;
///
/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
/// assert_eq!(json_to_csv(json).unwrap(), "name,age\nAlice,30\nBob,25\n");
/// ```
pub fn json_to_csv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
    json_to_delimited(json, b',')
}

/// Converts a JSON array of objects into a TSV string.
///
/// Same semantics as `json_to_csv`, but fields are separated by a tab
/// character. The output uses `\n` line endings.
///
/// # Errors
///
/// Returns an error if the input is not an array of objects, contains nested
/// values, or if writing fails.
///
/// # Examples
///
/// ```
/// use sheeter_engine::json_to_tsv;
///
/// let json = r#"[{"name":"Alice","age":"30"},{"name":"Bob","age":"25"}]"#;
/// assert_eq!(json_to_tsv(json).unwrap(), "name\tage\nAlice\t30\nBob\t25\n");
/// ```
pub fn json_to_tsv(json: &str) -> Result<String, Box<dyn std::error::Error>> {
    json_to_delimited(json, b'\t')
}