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 {
68            entries: paths.into_iter().map(|path| (path, None)).collect(),
69        });
70    }
71    Ok(to_toml_unchecked(value))
72}
73
74fn to_toml_unchecked(value: Value) -> toml::Value {
75    match value {
76        Value::Null => {
77            unreachable!("nulls are rejected by to_toml before conversion");
78        }
79        Value::Bool(b) => toml::Value::Boolean(b),
80        Value::Number(n) => number_to_toml(n),
81        Value::String(s) => toml::Value::String(s),
82        // Infallible by construction: the only producers of IR values are the
83        // TOML parser, the JSON parser and `--set` (a JSON-parsed RHS), and only
84        // the first ever emits `Datetime` — from a string `toml` itself printed.
85        Value::Datetime(s) => toml::Value::Datetime(
86            s.parse()
87                .expect("a Datetime is only ever produced by the TOML parser, so it re-parses"),
88        ),
89        Value::Array(items) => {
90            toml::Value::Array(items.into_iter().map(to_toml_unchecked).collect())
91        }
92        Value::Object(map) => {
93            let mut table = toml::Table::new();
94            for (k, v) in map {
95                table.insert(k, to_toml_unchecked(v));
96            }
97            toml::Value::Table(table)
98        }
99    }
100}
101
102fn number_from_json(n: &serde_json::Number) -> Number {
103    if let Some(i) = n.as_i64() {
104        Number::I64(i)
105    } else if let Some(u) = n.as_u64() {
106        Number::from_u64(u)
107    } else if let Some(f) = n.as_f64() {
108        Number::F64(f)
109    } else {
110        Number::F64(0.0)
111    }
112}
113
114fn number_to_json(n: Number) -> serde_json::Number {
115    match n {
116        Number::I64(i) => i.into(),
117        Number::U64(u) => u.into(),
118        Number::F64(f) => serde_json::Number::from_f64(f).unwrap_or_else(|| 0.into()),
119    }
120}
121
122fn number_to_toml(n: Number) -> toml::Value {
123    match n {
124        Number::I64(i) => toml::Value::Integer(i),
125        // TOML integers are signed, so anything past i64::MAX has to become a
126        // float. Lossy, but the alternative is refusing to emit at all.
127        Number::U64(u) => match i64::try_from(u) {
128            Ok(i) => toml::Value::Integer(i),
129            Err(_) => toml::Value::Float(u as f64),
130        },
131        Number::F64(f) => toml::Value::Float(f),
132    }
133}
134
135// --- nulls in TOML --------------------------------------------------------
136
137/// One step of a path into a value.
138#[derive(Debug, Clone, PartialEq, Eq)]
139enum Seg {
140    Key(String),
141    Index(usize),
142}
143
144fn render_path(path: &[Seg]) -> String {
145    let mut out = String::new();
146    for seg in path {
147        match seg {
148            Seg::Key(k) => {
149                if !out.is_empty() {
150                    out.push('.');
151                }
152                out.push_str(k);
153            }
154            Seg::Index(i) => out.push_str(&format!("[{i}]")),
155        }
156    }
157    out
158}
159
160fn collect_nulls(value: &Value, cur: &mut Vec<Seg>, out: &mut Vec<Vec<Seg>>) {
161    match value {
162        Value::Null => out.push(cur.clone()),
163        Value::Object(obj) => {
164            for (k, v) in obj {
165                cur.push(Seg::Key(k.clone()));
166                collect_nulls(v, cur, out);
167                cur.pop();
168            }
169        }
170        Value::Array(items) => {
171            for (i, v) in items.iter().enumerate() {
172                cur.push(Seg::Index(i));
173                collect_nulls(v, cur, out);
174                cur.pop();
175            }
176        }
177        _ => {}
178    }
179}
180
181fn resolve<'a>(value: &'a Value, path: &[Seg]) -> Option<&'a Value> {
182    let mut cur = value;
183    for seg in path {
184        cur = match (seg, cur) {
185            (Seg::Key(k), Value::Object(obj)) => obj.get(k)?,
186            (Seg::Index(i), Value::Array(items)) => items.get(*i)?,
187            _ => return None,
188        };
189    }
190    Some(cur)
191}
192
193/// Nulls survived into a document being converted to TOML.
194///
195/// A genuine impossibility in user data, so it is an error rather than a silent
196/// drop. Provenance is filled in after the fact by looking each path up in the
197/// layers that went into the merge — not threaded through the merge.
198#[derive(Debug)]
199pub struct NullInToml {
200    /// Path segments, and the last source that wrote null there (once attached).
201    entries: Vec<(Vec<Seg>, Option<String>)>,
202}
203
204impl NullInToml {
205    /// Look each null path up in `sources` and name the last layer that wrote it.
206    pub fn with_origins<S: fmt::Display>(mut self, sources: &[(S, Value)]) -> Self {
207        for (path, origin) in &mut self.entries {
208            *origin = sources
209                .iter()
210                .rev()
211                .find(|(_, v)| resolve(v, path) == Some(&Value::Null))
212                .map(|(name, _)| name.to_string());
213        }
214        self
215    }
216}
217
218impl fmt::Display for NullInToml {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        writeln!(f, "cannot serialize null to TOML")?;
221        let rendered: Vec<(String, &Option<String>)> = self
222            .entries
223            .iter()
224            .map(|(path, origin)| (render_path(path), origin))
225            .collect();
226        let width = rendered
227            .iter()
228            .map(|(path, _)| path.len())
229            .max()
230            .unwrap_or(0);
231        for (path, origin) in &rendered {
232            match origin {
233                Some(origin) => writeln!(f, "  --> {path:<width$}   (from {origin})")?,
234                None => writeln!(f, "  --> {path}")?,
235            }
236        }
237        write!(f, "help: emit JSON with -f json, or remove the null")
238    }
239}
240
241impl std::error::Error for NullInToml {}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use serde_json::json;
247
248    fn ir(v: serde_json::Value) -> Value {
249        from_json(v)
250    }
251
252    #[test]
253    fn a_toml_datetime_stays_a_datetime_in_the_ir() {
254        let parsed: toml::Value =
255            toml::from_str("date = 1979-05-27T07:32:00Z\n").expect("valid toml");
256        let v = from_toml(parsed);
257        let Value::Object(map) = &v else {
258            panic!("expected an object, got {v:?}");
259        };
260        assert_eq!(
261            map["date"],
262            Value::Datetime("1979-05-27T07:32:00Z".to_string())
263        );
264        // Only the sentinel-free `Display` spelling, never toml's internal map.
265        assert_eq!(to_json(v), json!({"date": "1979-05-27T07:32:00Z"}));
266    }
267
268    /// All four TOML datetime forms round-trip through `Display`/`FromStr`,
269    /// which is the whole basis for storing a datetime as a `String`.
270    #[test]
271    fn every_toml_datetime_form_round_trips() {
272        let src = "\
273offset = 1979-05-27T07:32:00Z
274offset_frac = 1979-05-27T00:32:00.999999-07:00
275local = 1979-05-27T07:32:00
276date = 1979-05-27
277time = 07:32:00.5
278";
279        let parsed: toml::Value = toml::from_str(src).expect("valid toml");
280        let back = to_toml(from_toml(parsed.clone())).expect("no nulls");
281        assert_eq!(back, parsed);
282    }
283
284    #[test]
285    fn to_toml_rejects_nulls_with_array_indices() {
286        let err = to_toml(ir(json!({"a": {"b": null}, "c": [1, null], "d": 2}))).unwrap_err();
287        let rendered: Vec<_> = err.entries.iter().map(|(p, _)| render_path(p)).collect();
288        assert_eq!(rendered, vec!["a.b", "c[1]"]);
289    }
290
291    #[test]
292    fn numbers_bools_and_tables_round_trip() {
293        let src = json!({
294            "n": 1,
295            "f": 1.5,
296            "ok": true,
297            "name": "svc",
298            "xs": [1, 2],
299            "nested": {"k": 3}
300        });
301        let toml = to_toml(ir(src.clone())).expect("no nulls");
302        assert_eq!(to_json(from_toml(toml)), src);
303    }
304
305    /// A JSON integer above `i64::MAX` must not be rounded through `f64`.
306    #[test]
307    fn large_unsigned_integers_survive_json_round_trip() {
308        let src = json!({"id": 10_000_000_000_000_000_001_u64});
309        assert_eq!(to_json(ir(src.clone())), src);
310    }
311
312    #[test]
313    fn with_origins_names_the_last_writer() {
314        let err = to_toml(ir(json!({"proxy": null}))).unwrap_err();
315        let sources = [
316            ("base.json", ir(json!({"proxy": null}))),
317            ("over.json", ir(json!({"proxy": null}))),
318        ];
319        let err = err.with_origins(&sources);
320        let msg = err.to_string();
321        assert!(msg.contains("from over.json"), "{msg}");
322        assert!(!msg.contains("from base.json"), "{msg}");
323    }
324}