use anyhow::{anyhow, Context}; use serde_json::Map; use toml_edit::{DocumentMut, Table}; /// Apply updates from a JSON file to a TOML file while preserving comments in the latter. #[derive(argh::FromArgs)] struct Args { #[argh(positional)] toml_file: String, #[argh(positional)] json_file: String, /// overwrites the given TOML file rather than printing to stdout #[argh(switch)] write: bool, } fn main() -> anyhow::Result<()> { let args: Args = argh::from_env(); let toml = std::fs::read_to_string(&args.toml_file)?; let json = std::fs::read_to_string(&args.json_file)?; let mut doc: DocumentMut = toml.parse()?; let patch: Map = serde_json::from_str(&json).context("failed to parse JSON")?; apply(&patch, doc.as_table_mut())?; if args.write { std::fs::write(args.toml_file, doc.to_string())?; } else { print!("{doc}"); } Ok(()) } fn apply(patch: &Map, table: &mut Table) -> anyhow::Result<()> { for (key, val) in patch { match val { serde_json::Value::Object(map) => { if !table.contains_key(key) || !matches!(table[key], toml_edit::Item::Table(_)) { table.insert(key, toml_edit::table()); } apply(map, table[key].as_table_mut().unwrap())?; } other => match json_value_to_toml_value(other)? { None => { table.remove(key); } Some(new_value) => { if let Some((_, value)) = table.get_key_value_mut(key) { *value = new_value.into(); } else { table.insert(key, new_value.into()); } } }, } } Ok(()) } fn json_value_to_toml_value(value: &serde_json::Value) -> anyhow::Result> { Ok(match value { serde_json::Value::Null => None, serde_json::Value::Bool(bool) => Some((*bool).into()), serde_json::Value::Number(number) => { if number.is_u64() { Some(i64::try_from(number.as_u64().unwrap()).unwrap().into()) } else if number.is_i64() { Some(number.as_i64().unwrap().into()) } else if number.is_f64() { Some(number.as_f64().unwrap().into()) } else { unreachable!(); } } serde_json::Value::String(str) => Some(str.into()), serde_json::Value::Array(vec) => { let mut new = toml_edit::Array::new(); for value in vec { match json_value_to_toml_value(value)? { None => return Err(anyhow!("arrays must not contain null")), Some(value) => new.push(value), } } Some(new.into()) } serde_json::Value::Object(_) => unreachable!(), }) } #[cfg(test)] mod tests { use indoc::indoc; use serde_json::json; use toml_edit::DocumentMut; fn apply(new_value: serde_json::Value, toml: &str) -> anyhow::Result { let mut doc = toml.parse::().unwrap(); super::apply(new_value.as_object().unwrap(), doc.as_table_mut())?; Ok(doc.to_string()) } #[test] fn patch_object() { assert_eq!( apply( json!({"x": {"y": {"foo": true}}}), indoc! {r#" [x.y] # This is x.y.z. z = 3 # End of x.y section. "#} ) .unwrap(), indoc! {r#" [x.y] # This is x.y.z. z = 3 foo = true # End of x.y section. "#} ); } #[test] fn null() { assert_eq!(apply(json!({"x": null}), "x = 0").unwrap(), ""); } #[test] fn object() { assert_eq!( apply(json!({"x": {"y": {"z": true}}}), "x = 0").unwrap(), indoc! {r#" [x] [x.y] z = true "#} ); } #[test] fn array_may_not_contain_null() { assert_eq!( apply(json!({"x": [null]}), "x = 0") .unwrap_err() .to_string(), "arrays must not contain null" ); } macro_rules! test_apply { ($name:ident, $json_value:expr, $toml_value:expr) => { #[test] fn $name() { assert_eq!(apply(json!({"x": $json_value}), "x = 0").unwrap(), format!("x = {}\n", $toml_value)); // FUTURE: move these assertions to separate functions once https://github.com/rust-lang/rust/issues/124225 is stable assert_eq!( apply(json!({"x": $json_value}), "# before\nx = 0").unwrap(), format!("# before\nx = {}\n", $toml_value) ); assert_eq!( apply(json!({"x": $json_value}), "x = 0\n# after").unwrap(), format!("x = {}\n# after", $toml_value) ); } }; } test_apply!(bool, true, "true"); test_apply!(u64, 1, "1"); test_apply!(i64, -1, "-1"); test_apply!(f64, 1.0, "1.0"); test_apply!(str, "foo", "\"foo\""); test_apply!(array, ["foo", "bar"], "[\"foo\", \"bar\"]"); }