diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | Cargo.lock | 197 | ||||
-rw-r--r-- | Cargo.toml | 19 | ||||
-rw-r--r-- | README.md | 26 | ||||
-rw-r--r-- | src/main.rs | 182 |
5 files changed, 425 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c3d3aa6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,197 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "argh" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ff18325c8a36b82f992e533ece1ec9f9a9db446bd1c14d4f936bac88fcd240" +dependencies = [ + "argh_derive", + "argh_shared", + "rust-fuzzy-search", +] + +[[package]] +name = "argh_derive" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b2b83a50d329d5d8ccc620f5c7064028828538bdf5646acd60dc1f767803" +dependencies = [ + "argh_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "argh_shared" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a464143cc82dedcdc3928737445362466b7674b5db4e2eb8e869846d6d84f4f6" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "indexmap" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rust-fuzzy-search" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.216" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.134" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d00f4175c42ee48b15416f6193a959ba3a0d67fc699a0db9ad12df9f83991c7d" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "syn" +version = "2.0.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml-patch" +version = "0.1.0" +dependencies = [ + "anyhow", + "argh", + "indoc", + "serde_json", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5eaf9f4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "toml-patch" +version = "0.1.0" +description = "A command-line tool to apply updates from a JSON file to a TOML file, while preserving comments." +repository = "https://git.push-f.com/toml-patch/" +authors = ["Martin Fischer <martin@push-f.com>"] +license = "MIT" +categories = ["command-line-utilities"] +keywords = ["cli", "toml", "json"] +edition = "2021" + +[dependencies] +anyhow = "1.0.93" +argh = "0.1.12" +serde_json = "1.0.132" +toml_edit = "0.22.22" + +[dev-dependencies] +indoc = "2.0.5" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2954d8 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# toml-patch + +A command-line tool to apply updates from a JSON file to a TOML file, +while preserving comments in the latter (thanks to [toml_edit]). + +``` +$ toml-patch <(echo '# test\na=true') <(echo '{"x": {"y": 42}}') +# test +a=true + +[x] +y = 42 +``` + +Note that the JSON document must be a JSON object and that `null` values +will result in existing key/value pairs being removed. + +By default the updated TOML document is printed to stdout, you can pass `--write` to instead overwrite the specified TOML file. + +## Limitations + +* No partial updates for arrays. +* No support for setting any of TOMLs date/time types. + + +[toml_edit]: https://crates.io/crates/toml_edit 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\"]"); +} |