sheeter-engine 1.2.0

Convert, inspect, and transform data files across ten formats with streaming where it matters and auto-detection where it can.
Documentation
use assert_cmd::Command;
use std::fs;
use tempfile::tempdir;

#[test]
fn duplicate_csv_headers_error() {
    let dir = tempdir().unwrap();
    let input = dir.path().join("input.csv");
    let output = dir.path().join("output.json");
    fs::write(&input, "name,name\nAlice,Bob").unwrap();
    Command::cargo_bin("sheeter")
        .unwrap()
        .args([
            "convert",
            "--from",
            "csv",
            "--to",
            "json",
            input.to_str().unwrap(),
            output.to_str().unwrap(),
        ])
        .assert()
        .failure();
}

#[test]
fn extra_json_keys_preserved() {
    let dir = tempdir().unwrap();
    let input = dir.path().join("input.json");
    let output = dir.path().join("output.csv");
    fs::write(&input, r#"[{"a":1},{"a":2,"b":3}]"#).unwrap();
    Command::cargo_bin("sheeter")
        .unwrap()
        .args([
            "convert",
            "--from",
            "json",
            "--to",
            "csv",
            input.to_str().unwrap(),
            output.to_str().unwrap(),
        ])
        .assert()
        .success();
    let csv = fs::read_to_string(output).unwrap();
    assert_eq!(csv, "a,b\n1,\n2,3\n");
}

#[test]
fn nested_json_value_error() {
    let dir = tempdir().unwrap();
    let input = dir.path().join("input.json");
    let output = dir.path().join("output.csv");
    fs::write(&input, r#"[{"a":{"b":1}}]"#).unwrap();
    Command::cargo_bin("sheeter")
        .unwrap()
        .args([
            "convert",
            "--from",
            "json",
            "--to",
            "csv",
            input.to_str().unwrap(),
            output.to_str().unwrap(),
        ])
        .assert()
        .failure();
}

#[test]
fn csv_bom_is_stripped() {
    let csv = "\u{feff}name,age\nAlice,30";
    let json = sheeter_engine::csv_to_json(csv).unwrap();
    assert_eq!(json, r#"[{"name":"Alice","age":"30"}]"#);
}

#[test]
fn yaml_norway_problem_avoided() {
    let yaml = "- country: NO\n- country: yes\n";
    let json = sheeter_engine::yaml_to_json(yaml).unwrap();
    assert!(json.contains("\"NO\""));
    assert!(json.contains("\"yes\""));
}

#[test]
fn yaml_null_preserved() {
    let yaml = "- a: null\n";
    let json = sheeter_engine::yaml_to_json(yaml).unwrap();
    assert!(json.contains("null"));
}

#[test]
fn json_key_order_union_is_stable() {
    let json = r#"[{"b":2,"a":1},{"a":3,"b":4}]"#;
    let csv = sheeter_engine::json_to_csv(json).unwrap();
    let headers = csv.lines().next().unwrap();
    assert_eq!(headers, "b,a");
}

#[test]
fn tsv_roundtrips_tabs_in_cells() {
    let json = r#"[{"a":"1\t2","b":"3"}]"#;
    let tsv = sheeter_engine::json_to_tsv(json).unwrap();
    assert!(
        tsv.contains("1\t2"),
        "tab-containing cell must appear: {tsv:?}"
    );
    let back = sheeter_engine::tsv_to_json(&tsv).unwrap();
    assert_eq!(back, json);
}

#[test]
fn csv_roundtrips_commas_and_quotes() {
    let json = r#"[{"a":"hello, world","b":"say \"hi\""}]"#;
    let csv = sheeter_engine::json_to_csv(json).unwrap();
    let back = sheeter_engine::csv_to_json(&csv).unwrap();
    assert_eq!(back, json);
}

#[test]
fn yaml_binary_tag_treated_as_string() {
    let yaml = "- data: !!binary aGVsbG8=\n";
    let json = sheeter_engine::yaml_to_json(yaml).unwrap();
    assert!(
        json.contains("aGVsbG8="),
        "binary tag must not be base64-decoded: {json}"
    );
}

#[test]
fn csv_ragged_rows_error() {
    let csv = "a,b\n1,2,3";
    assert!(sheeter_engine::csv_to_json(csv).is_err());
}

#[test]
fn yaml_non_string_keys_stringified() {
    let yaml = "- 1: value\n";
    let json = sheeter_engine::yaml_to_json(yaml).unwrap();
    assert_eq!(json, r#"[{"1":"value"}]"#);
}

#[test]
fn toml_dot_notation_becomes_nested() {
    let json = sheeter_engine::toml_to_json("a.b = 1\n").unwrap();
    assert_eq!(json, r#"{"a":{"b":1}}"#);
}

#[test]
fn toml_array_of_tables_at_depth() {
    let toml = "[config]\n[[config.items]]\nx = 1\n[[config.items]]\nx = 2\n";
    let csv = sheeter_engine::toml_to_csv(toml).unwrap();
    assert_eq!(csv, "x\n1\n2\n");
}

#[test]
fn xml_markers_survive_toml_roundtrip() {
    let xml = r#"<r><item id="1">Alice</item></r>"#;
    let toml = sheeter_engine::xml_to_toml(xml).unwrap();
    let xml_back = sheeter_engine::toml_to_xml(&toml).unwrap();
    assert!(xml_back.contains(r#"id="1""#), "attribute lost: {xml_back}");
    assert!(xml_back.contains("Alice"), "text lost: {xml_back}");
}

#[test]
fn toml_preserves_insertion_order() {
    let json = sheeter_engine::toml_to_json("name = \"Alice\"\nage = 30\n").unwrap();
    assert_eq!(json, r#"{"name":"Alice","age":30}"#);
}

#[test]
fn convert_str_rejects_xlsx() {
    use sheeter_engine::{convert, Format};
    let err = convert(Format::Xlsx, Format::Json, "not binary").unwrap_err();
    assert!(err.to_string().contains("convert_workbook"));
}