diff options
author | Martin Fischer <martin@push-f.com> | 2024-11-10 10:53:38 +0100 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2024-12-25 14:07:16 +0100 |
commit | 9f38fa9e1aac1b19d086fbdc31c25d89c4981362 (patch) | |
tree | b4c08b06f5abeb5b2899bb81c720fb7d51fabc0f /src/main.rs |
Diffstat (limited to 'src/main.rs')
-rw-r--r-- | src/main.rs | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f40604e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,182 @@ +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<String, serde_json::Value> = + 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<String, serde_json::Value>, 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<Option<toml_edit::Value>> { + 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<String> { + let mut doc = toml.parse::<DocumentMut>().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\"]"); +} |