univert 0.2.0

Universal file converter (library and CLI)
Documentation
use std::fs;

use crate::{Error, Job, Result};

pub fn json_to_yaml(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let value: serde_json::Value = serde_json::from_str(&raw)
    .map_err(|e| Error::other(format!("invalid JSON: {e}")))?;
  let yaml = serde_yaml_ng::to_string(&value)
    .map_err(|e| Error::other(format!("yaml serialize: {e}")))?;
  fs::write(job.output, yaml)?;
  Ok(())
}

pub fn yaml_to_json(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let mut value: serde_json::Value = serde_yaml_ng::from_str(&raw)
    .map_err(|e| Error::other(format!("invalid YAML: {e}")))?;
  normalize_yaml_timestamps(&mut value);
  let json = serde_json::to_string(&value)
    .map_err(|e| Error::other(format!("json serialize: {e}")))?;
  fs::write(job.output, json + "\n")?;
  Ok(())
}

/// YAML timestamps (unquoted `YYYY-MM-DD[ ... ]`) are parsed as Date objects
/// by js-yaml; when serialized to JSON they come out as ISO 8601 strings with
/// millisecond precision. `serde_yaml_ng` leaves them as plain strings, so
/// we walk the tree and normalize anything that looks like a YAML timestamp
/// to match JS output.
fn normalize_yaml_timestamps(value: &mut serde_json::Value) {
  match value {
    serde_json::Value::String(s) => {
      if let Some(iso) = yaml_ts_to_iso(s) {
        *s = iso;
      }
    }
    serde_json::Value::Array(arr) => {
      for item in arr {
        normalize_yaml_timestamps(item);
      }
    }
    serde_json::Value::Object(map) => {
      for (_, v) in map.iter_mut() {
        normalize_yaml_timestamps(v);
      }
    }
    _ => {}
  }
}

fn yaml_ts_to_iso(s: &str) -> Option<String> {
  // YAML 1.1 timestamp grammar, simplified. Only unambiguous forms match.
  let bytes = s.as_bytes();

  // Plain date: YYYY-MM-DD
  if s.len() == 10
    && bytes[4] == b'-'
    && bytes[7] == b'-'
    && s[..4].chars().all(|c| c.is_ascii_digit())
    && s[5..7].chars().all(|c| c.is_ascii_digit())
    && s[8..10].chars().all(|c| c.is_ascii_digit())
  {
    return Some(format!("{s}T00:00:00.000Z"));
  }
  None
}

pub fn json_to_json5(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let value: serde_json::Value = serde_json::from_str(&raw)
    .map_err(|e| Error::other(format!("invalid JSON: {e}")))?;
  let out = json5_writer::write(&value);
  fs::write(job.output, out)?;
  Ok(())
}

pub fn json5_to_json(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let value: serde_json::Value = json5::from_str(&raw)
    .map_err(|e| Error::other(format!("invalid JSON5: {e}")))?;
  let json = serde_json::to_string(&value)
    .map_err(|e| Error::other(format!("json serialize: {e}")))?;
  fs::write(job.output, json + "\n")?;
  Ok(())
}

/// Writer that produces JSON5 matching the fixture style:
/// - 2-space indent
/// - single-quoted strings
/// - unquoted bare keys when the key is a valid identifier
/// - trailing commas inside objects and arrays
/// - one trailing newline at EOF
mod json5_writer {
  use serde_json::Value;
  use std::fmt::Write;

  pub fn write(value: &Value) -> String {
    let mut out = String::new();
    write_value(&mut out, value, 0);
    out.push('\n');
    out
  }

  fn write_value(out: &mut String, value: &Value, indent: usize) {
    match value {
      Value::Null => out.push_str("null"),
      Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
      Value::Number(n) => {
        let _ = write!(out, "{n}");
      }
      Value::String(s) => write_string(out, s),
      Value::Array(arr) => write_array(out, arr, indent),
      Value::Object(map) => write_object(out, map, indent),
    }
  }

  fn write_array(out: &mut String, arr: &[Value], indent: usize) {
    if arr.is_empty() {
      out.push_str("[]");
      return;
    }
    out.push('[');
    out.push('\n');
    for item in arr {
      pad(out, indent + 1);
      write_value(out, item, indent + 1);
      out.push(',');
      out.push('\n');
    }
    pad(out, indent);
    out.push(']');
  }

  fn write_object(
    out: &mut String,
    map: &serde_json::Map<String, Value>,
    indent: usize,
  ) {
    if map.is_empty() {
      out.push_str("{}");
      return;
    }
    out.push('{');
    out.push('\n');
    for (k, v) in map {
      pad(out, indent + 1);
      write_key(out, k);
      out.push_str(": ");
      write_value(out, v, indent + 1);
      out.push(',');
      out.push('\n');
    }
    pad(out, indent);
    out.push('}');
  }

  fn pad(out: &mut String, indent: usize) {
    for _ in 0..indent {
      out.push_str("  ");
    }
  }

  fn write_key(out: &mut String, key: &str) {
    if is_bare_identifier(key) {
      out.push_str(key);
    } else {
      write_string(out, key);
    }
  }

  fn is_bare_identifier(s: &str) -> bool {
    let mut chars = s.chars();
    match chars.next() {
      None => return false,
      Some(c) if !(c.is_ascii_alphabetic() || c == '_' || c == '$') => {
        return false;
      }
      _ => {}
    }
    for c in chars {
      if !(c.is_ascii_alphanumeric() || c == '_' || c == '$') {
        return false;
      }
    }
    true
  }

  fn write_string(out: &mut String, s: &str) {
    out.push('\'');
    for c in s.chars() {
      match c {
        '\\' => out.push_str("\\\\"),
        '\'' => out.push_str("\\'"),
        '\n' => out.push_str("\\n"),
        '\r' => out.push_str("\\r"),
        '\t' => out.push_str("\\t"),
        '\x08' => out.push_str("\\b"),
        '\x0C' => out.push_str("\\f"),
        c if (c as u32) < 0x20 => {
          let _ = write!(out, "\\u{:04x}", c as u32);
        }
        c => out.push(c),
      }
    }
    out.push('\'');
  }
}