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