summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--Cargo.lock197
-rw-r--r--Cargo.toml19
-rw-r--r--README.md26
-rw-r--r--src/main.rs182
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\"]");
+}