Skip to main content

knf/
value.rs

1//! Conversion between the merge IR and the native JSON/TOML value types.
2//!
3//! Merge runs on [`knf_core::Value`], so these fire exactly twice per run: once
4//! per layer on the way in, once on the whole document on the way out. Free
5//! functions rather than `From`/`TryFrom` impls because both sides are foreign
6//! types — `impl From<toml::Value> for knf_core::Value` names nothing local and
7//! does not compile.
8
9use std::fmt;
10
11use knf_core::{Number, Value};
12use knf_dotted::{Seg, render_path};
13
14/// JSON → IR. Total: every JSON value has an IR counterpart.
15pub fn from_json(value: serde_json::Value) -> Value {
16    match value {
17        serde_json::Value::Null => Value::Null,
18        serde_json::Value::Bool(b) => Value::Bool(b),
19        serde_json::Value::Number(n) => Value::Number(number_from_json(&n)),
20        serde_json::Value::String(s) => Value::String(s),
21        serde_json::Value::Array(items) => Value::Array(items.into_iter().map(from_json).collect()),
22        serde_json::Value::Object(map) => {
23            Value::Object(map.into_iter().map(|(k, v)| (k, from_json(v))).collect())
24        }
25    }
26}
27
28/// IR → JSON. Total; a datetime renders as the string JSON would have to use.
29pub fn to_json(value: Value) -> serde_json::Value {
30    match value {
31        Value::Null => serde_json::Value::Null,
32        Value::Bool(b) => serde_json::Value::Bool(b),
33        Value::Number(n) => serde_json::Value::Number(number_to_json(n)),
34        Value::String(s) | Value::Datetime(s) => serde_json::Value::String(s),
35        Value::Array(items) => serde_json::Value::Array(items.into_iter().map(to_json).collect()),
36        Value::Object(map) => {
37            serde_json::Value::Object(map.into_iter().map(|(k, v)| (k, to_json(v))).collect())
38        }
39    }
40}
41
42/// TOML → IR. Total: datetimes keep their source spelling rather than becoming
43/// strings, which is what lets a TOML datetime survive a mixed-format merge.
44pub fn from_toml(value: toml::Value) -> Value {
45    match value {
46        toml::Value::String(s) => Value::String(s),
47        toml::Value::Integer(i) => Value::Number(Number::I64(i)),
48        toml::Value::Float(f) => Value::Number(Number::F64(f)),
49        toml::Value::Boolean(b) => Value::Bool(b),
50        toml::Value::Datetime(dt) => Value::Datetime(dt.to_string()),
51        toml::Value::Array(items) => Value::Array(items.into_iter().map(from_toml).collect()),
52        toml::Value::Table(table) => {
53            Value::Object(table.into_iter().map(|(k, v)| (k, from_toml(v))).collect())
54        }
55    }
56}
57
58/// IR → TOML, rejecting nulls first.
59///
60/// The pre-check is separate because serde's own message ("unsupported None
61/// value") carries no key path, and `toml`'s map serializer *skips* `None`
62/// entries rather than failing — so walking for nulls up front is the only way
63/// to surface the impossibility at all, let alone with paths.
64pub fn to_toml(value: Value) -> Result<toml::Value, NullInToml> {
65    let mut paths = Vec::new();
66    collect_nulls(&value, &mut Vec::new(), &mut paths);
67    if !paths.is_empty() {
68        return Err(NullInToml { entries: paths });
69    }
70    Ok(to_toml_unchecked(value))
71}
72
73fn to_toml_unchecked(value: Value) -> toml::Value {
74    match value {
75        Value::Null => {
76            unreachable!("nulls are rejected by to_toml before conversion");
77        }
78        Value::Bool(b) => toml::Value::Boolean(b),
79        Value::Number(n) => number_to_toml(n),
80        Value::String(s) => toml::Value::String(s),
81        // Infallible by construction: every `Datetime` *originates* in the TOML
82        // parser, from a string `toml` itself printed. Interpolation may copy
83        // one (`d2 = "${d}"` takes the referent's type), but nothing anywhere
84        // synthesizes one from text — `${env:...}` types through JSON, which has
85        // no datetime and so structurally cannot.
86        Value::Datetime(s) => toml::Value::Datetime(
87            s.parse()
88                .expect("a Datetime always originates in the TOML parser, so it re-parses"),
89        ),
90        Value::Array(items) => {
91            toml::Value::Array(items.into_iter().map(to_toml_unchecked).collect())
92        }
93        Value::Object(map) => {
94            let mut table = toml::Table::new();
95            for (k, v) in map {
96                table.insert(k, to_toml_unchecked(v));
97            }
98            toml::Value::Table(table)
99        }
100    }
101}
102
103fn number_from_json(n: &serde_json::Number) -> Number {
104    if let Some(i) = n.as_i64() {
105        Number::I64(i)
106    } else if let Some(u) = n.as_u64() {
107        Number::from_u64(u)
108    } else if let Some(f) = n.as_f64() {
109        Number::F64(f)
110    } else {
111        Number::F64(0.0)
112    }
113}
114
115fn number_to_json(n: Number) -> serde_json::Number {
116    match n {
117        Number::I64(i) => i.into(),
118        Number::U64(u) => u.into(),
119        Number::F64(f) => serde_json::Number::from_f64(f).unwrap_or_else(|| 0.into()),
120    }
121}
122
123fn number_to_toml(n: Number) -> toml::Value {
124    match n {
125        Number::I64(i) => toml::Value::Integer(i),
126        // TOML integers are signed, so anything past i64::MAX has to become a
127        // float. Lossy, but the alternative is refusing to emit at all.
128        Number::U64(u) => match i64::try_from(u) {
129            Ok(i) => toml::Value::Integer(i),
130            Err(_) => toml::Value::Float(u as f64),
131        },
132        Number::F64(f) => toml::Value::Float(f),
133    }
134}
135
136// --- nulls in TOML --------------------------------------------------------
137
138fn collect_nulls(value: &Value, cur: &mut Vec<Seg>, out: &mut Vec<Vec<Seg>>) {
139    match value {
140        Value::Null => out.push(cur.clone()),
141        Value::Object(obj) => {
142            for (k, v) in obj {
143                cur.push(Seg::Key(k.clone()));
144                collect_nulls(v, cur, out);
145                cur.pop();
146            }
147        }
148        Value::Array(items) => {
149            for (i, v) in items.iter().enumerate() {
150                cur.push(Seg::Index(i));
151                collect_nulls(v, cur, out);
152                cur.pop();
153            }
154        }
155        _ => {}
156    }
157}
158
159/// Substitutes `placeholder` for every null in the document.
160///
161/// The alternative to [`to_toml`]'s rejection, and so only ever called on the
162/// way to TOML — JSON holds a null fine and has nothing to be rescued from. A
163/// null cannot be encoded as TOML, leaving only two honest options: fail, or
164/// write a value that was in none of the inputs. *Which* value that is has to
165/// be the user's choice rather than the tool's — `yq` and `tomlq` both drop
166/// null keys silently, and both invent a string inside arrays (`""` and
167/// `"None"` respectively, for the same input), which is the behaviour this
168/// exists to avoid.
169pub fn replace_nulls(value: &mut Value, placeholder: &str) {
170    match value {
171        Value::Null => *value = Value::String(placeholder.to_string()),
172        Value::Object(obj) => {
173            for v in obj.values_mut() {
174                replace_nulls(v, placeholder);
175            }
176        }
177        Value::Array(items) => {
178            for v in items.iter_mut() {
179                replace_nulls(v, placeholder);
180            }
181        }
182        _ => {}
183    }
184}
185
186/// Nulls survived into a document being converted to TOML.
187///
188/// A genuine impossibility in user data, so it is an error rather than a silent
189/// drop: the `toml` crate's map serializer *skips* a `None` entry, so emitting
190/// without this check would quietly lose keys.
191///
192/// Carries paths and nothing else. Naming the layer each null came from would
193/// mean retaining every parsed layer past the merge purely for an error path;
194/// the paths alone locate the value in the merged document, and the usual fix
195/// (`-f json`, or `--null-as`) does not depend on knowing the file.
196#[derive(Debug)]
197pub struct NullInToml {
198    entries: Vec<Vec<Seg>>,
199}
200
201impl fmt::Display for NullInToml {
202    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
203        writeln!(f, "cannot serialize null to TOML")?;
204        for path in &self.entries {
205            writeln!(f, "  --> {}", render_path(path))?;
206        }
207        write!(
208            f,
209            "help: emit JSON with -f json, substitute with --null-as, or remove the null"
210        )
211    }
212}
213
214impl std::error::Error for NullInToml {}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use serde_json::json;
220
221    fn ir(v: serde_json::Value) -> Value {
222        from_json(v)
223    }
224
225    #[test]
226    fn a_toml_datetime_stays_a_datetime_in_the_ir() {
227        let parsed: toml::Value =
228            toml::from_str("date = 1979-05-27T07:32:00Z\n").expect("valid toml");
229        let v = from_toml(parsed);
230        let Value::Object(map) = &v else {
231            panic!("expected an object, got {v:?}");
232        };
233        assert_eq!(
234            map["date"],
235            Value::Datetime("1979-05-27T07:32:00Z".to_string())
236        );
237        // Only the sentinel-free `Display` spelling, never toml's internal map.
238        assert_eq!(to_json(v), json!({"date": "1979-05-27T07:32:00Z"}));
239    }
240
241    /// All four TOML datetime forms round-trip through `Display`/`FromStr`,
242    /// which is the whole basis for storing a datetime as a `String`.
243    #[test]
244    fn every_toml_datetime_form_round_trips() {
245        let src = "\
246offset = 1979-05-27T07:32:00Z
247offset_frac = 1979-05-27T00:32:00.999999-07:00
248local = 1979-05-27T07:32:00
249date = 1979-05-27
250time = 07:32:00.5
251";
252        let parsed: toml::Value = toml::from_str(src).expect("valid toml");
253        let back = to_toml(from_toml(parsed.clone())).expect("no nulls");
254        assert_eq!(back, parsed);
255    }
256
257    #[test]
258    fn to_toml_rejects_nulls_with_array_indices() {
259        let err = to_toml(ir(json!({"a": {"b": null}, "c": [1, null], "d": 2}))).unwrap_err();
260        let rendered: Vec<_> = err.entries.iter().map(|p| render_path(p)).collect();
261        assert_eq!(rendered, vec!["a.b", "c[1]"]);
262    }
263
264    #[test]
265    fn numbers_bools_and_tables_round_trip() {
266        let src = json!({
267            "n": 1,
268            "f": 1.5,
269            "ok": true,
270            "name": "svc",
271            "xs": [1, 2],
272            "nested": {"k": 3}
273        });
274        let toml = to_toml(ir(src.clone())).expect("no nulls");
275        assert_eq!(to_json(from_toml(toml)), src);
276    }
277
278    /// A JSON integer above `i64::MAX` must not be rounded through `f64`.
279    #[test]
280    fn large_unsigned_integers_survive_json_round_trip() {
281        let src = json!({"id": 10_000_000_000_000_000_001_u64});
282        assert_eq!(to_json(ir(src.clone())), src);
283    }
284
285    /// The array case is the one both `yq` and `tomlq` fabricate a value for,
286    /// since a null cannot be dropped from an array without shifting every
287    /// index after it. Substituting preserves the length and lets the user name
288    /// the value that lands there.
289    #[test]
290    fn replace_nulls_substitutes_everywhere_and_unblocks_toml() {
291        let mut v = ir(json!({"a": {"b": null}, "c": [1, null, 3], "d": 2}));
292        replace_nulls(&mut v, "none");
293        assert_eq!(
294            to_json(v.clone()),
295            json!({"a": {"b": "none"}, "c": [1, "none", 3], "d": 2})
296        );
297        to_toml(v).expect("the substitution left no nulls");
298    }
299
300    /// A document without nulls is untouched, so the flag cannot perturb a
301    /// merge that never needed it.
302    #[test]
303    fn replace_nulls_is_the_identity_without_nulls() {
304        let src = json!({"a": 1, "xs": [1, 2], "nested": {"k": "v"}});
305        let mut v = ir(src.clone());
306        replace_nulls(&mut v, "none");
307        assert_eq!(to_json(v), src);
308    }
309}