jsonrepair-rs 0.2.0

Repair broken JSON — fix quotes, commas, comments, trailing content, and 30+ other issues
Documentation
use std::{
    env, fs,
    io::Write,
    path::PathBuf,
    process::{Command, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

fn bin() -> &'static str {
    env!("CARGO_BIN_EXE_jsonrepair")
}

fn temp_path(name: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    env::temp_dir().join(format!(
        "jsonrepair-rs-{name}-{}-{nanos}",
        std::process::id()
    ))
}

#[test]
fn repairs_stdin_to_stdout() {
    let mut child = Command::new(bin())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();

    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(b"{name: 'Ada', active: True}")
        .unwrap();

    let output = child.wait_with_output().unwrap();

    assert!(output.status.success(), "{output:?}");
    assert_eq!(
        String::from_utf8_lossy(&output.stdout),
        r#"{"name": "Ada", "active": true}"#
    );
    assert!(output.stderr.is_empty(), "{output:?}");
}

#[test]
fn repairs_file_to_output_file() {
    let input_path = temp_path("input");
    let output_path = temp_path("output");
    fs::write(&input_path, "{skills: ['Rust',], ok: False}").unwrap();

    let output = Command::new(bin())
        .arg(&input_path)
        .arg("--output")
        .arg(&output_path)
        .output()
        .unwrap();

    assert!(output.status.success(), "{output:?}");
    assert_eq!(
        fs::read_to_string(&output_path).unwrap(),
        r#"{"skills": ["Rust"], "ok": false}"#
    );
    assert!(output.stdout.is_empty(), "{output:?}");

    let _ = fs::remove_file(input_path);
    let _ = fs::remove_file(output_path);
}

#[test]
fn reports_repair_errors() {
    let mut child = Command::new(bin())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();

    child
        .stdin
        .as_mut()
        .unwrap()
        .write_all(br#""\u00""#)
        .unwrap();

    let output = child.wait_with_output().unwrap();

    assert!(!output.status.success(), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert!(String::from_utf8_lossy(&output.stderr).contains("JSON repair error"));
}

#[test]
fn repair_error_does_not_truncate_existing_output_file() {
    let input_path = temp_path("invalid-input");
    let output_path = temp_path("existing-output");
    fs::write(&input_path, br#""\u00""#).unwrap();
    fs::write(&output_path, "keep me").unwrap();

    let output = Command::new(bin())
        .arg(&input_path)
        .arg("--output")
        .arg(&output_path)
        .output()
        .unwrap();

    assert!(!output.status.success(), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert!(String::from_utf8_lossy(&output.stderr).contains("JSON repair error"));
    assert_eq!(fs::read_to_string(&output_path).unwrap(), "keep me");

    let _ = fs::remove_file(input_path);
    let _ = fs::remove_file(output_path);
}

#[test]
fn help_documents_exit_codes() -> Result<(), Box<dyn std::error::Error>> {
    let output = Command::new(bin()).arg("--help").output()?;

    assert!(output.status.success(), "{output:?}");
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Exit Codes:"), "{stdout}");
    assert!(
        stdout.contains("  2                   Command-line usage error."),
        "{stdout}"
    );
    Ok(())
}

#[test]
fn usage_error_exits_2() -> Result<(), Box<dyn std::error::Error>> {
    let output = Command::new(bin()).arg("--unknown").output()?;

    assert_eq!(output.status.code(), Some(2), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert!(String::from_utf8_lossy(&output.stderr).contains("unknown option"));
    Ok(())
}

#[test]
fn output_requires_path_exits_2() -> Result<(), Box<dyn std::error::Error>> {
    let output = Command::new(bin()).arg("--output").output()?;

    assert_eq!(output.status.code(), Some(2), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert!(String::from_utf8_lossy(&output.stderr).contains("requires a path"));
    Ok(())
}

#[test]
fn dash_reads_stdin_to_output_file() -> Result<(), Box<dyn std::error::Error>> {
    let output_path = temp_path("dash-output");
    let mut child = Command::new(bin())
        .arg("-")
        .arg("--output")
        .arg(&output_path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(b"{name: 'Ada', active: True}")?;
    } else {
        return Err(
            std::io::Error::new(std::io::ErrorKind::Other, "stdin pipe was unavailable").into(),
        );
    }

    let output = child.wait_with_output()?;

    assert!(output.status.success(), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert_eq!(
        fs::read_to_string(&output_path)?,
        r#"{"name": "Ada", "active": true}"#
    );

    let _ = fs::remove_file(output_path);
    Ok(())
}

#[test]
fn output_equals_writes_file() -> Result<(), Box<dyn std::error::Error>> {
    let output_path = temp_path("equals-output");
    let output_arg = format!("--output={}", output_path.display());
    let mut child = Command::new(bin())
        .arg(output_arg)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()?;

    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(b"{skills: ['Rust',], ok: False}")?;
    } else {
        return Err(
            std::io::Error::new(std::io::ErrorKind::Other, "stdin pipe was unavailable").into(),
        );
    }

    let output = child.wait_with_output()?;

    assert!(output.status.success(), "{output:?}");
    assert!(output.stdout.is_empty(), "{output:?}");
    assert_eq!(
        fs::read_to_string(&output_path)?,
        r#"{"skills": ["Rust"], "ok": false}"#
    );

    let _ = fs::remove_file(output_path);
    Ok(())
}