univert 0.4.0

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

use ini::Ini;
use serde_json::{Map, Value};

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

pub fn ini_to_json(job: &Job) -> Result<()> {
  let raw = fs::read_to_string(job.input)?;
  let ini = Ini::load_from_str(&raw)
    .map_err(|e| Error::other(format!("invalid INI: {e}")))?;

  let mut out = Map::new();
  for (section, props) in &ini {
    let mut section_map = Map::new();
    for (k, v) in props.iter() {
      section_map.insert(k.to_string(), Value::String(v.to_string()));
    }
    let key = section.map(|s| s.to_string()).unwrap_or_default();
    out.insert(key, Value::Object(section_map));
  }

  let json = serde_json::to_string_pretty(&Value::Object(out))
    .map_err(|e| Error::other(format!("json serialize: {e}")))?;
  fs::write(job.output, json + "\n")?;
  Ok(())
}

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

  let Value::Object(map) = value else {
    return Err(Error::other(
      "json → ini: root must be an object of sections".to_string(),
    ));
  };

  let mut ini = Ini::new();
  // Root-level scalars collect into the default (unnamed) section.
  let mut defaults: Vec<(String, String)> = Vec::new();
  for (section, body) in map {
    match body {
      Value::Object(props) => {
        let section_name = if section.is_empty() {
          None
        } else {
          Some(section.as_str())
        };
        let mut setter = ini.with_section(section_name);
        for (k, v) in &props {
          setter.set(k, scalar_to_ini(v));
        }
      }
      other => {
        defaults.push((section, scalar_to_ini(&other)));
      }
    }
  }
  if !defaults.is_empty() {
    let mut setter = ini.with_general_section();
    for (k, v) in &defaults {
      setter.set(k, v);
    }
  }

  let mut buf: Vec<u8> = Vec::new();
  ini
    .write_to(&mut buf)
    .map_err(|e| Error::other(format!("ini write: {e}")))?;
  fs::write(job.output, buf)?;
  Ok(())
}

fn scalar_to_ini(v: &Value) -> String {
  match v {
    Value::String(s) => s.clone(),
    Value::Null => String::new(),
    Value::Bool(b) => b.to_string(),
    Value::Number(n) => n.to_string(),
    // Arrays / objects flatten to JSON — INI has no standard nested form.
    other => other.to_string(),
  }
}