diff options
Diffstat (limited to 'src')
| -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\"]"); +} | 
