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 [`Value`], so these fire exactly twice per run: once per layer
4//! on the way in, once on the whole document on the way out. Free functions
5//! rather than `From`/`TryFrom` impls so the only way into or out of a format is
6//! by name, from [`crate::format`].
7
8use std::fmt;
9
10use crate::{Map, Number, Seg, Value, render_path};
11
12/// JSON → IR. Total: every JSON value has an IR counterpart.
13pub fn from_json(value: serde_json::Value) -> Value {
14    match value {
15        serde_json::Value::Null => Value::Null,
16        serde_json::Value::Bool(b) => Value::Bool(b),
17        serde_json::Value::Number(n) => Value::Number(number_from_json(&n)),
18        serde_json::Value::String(s) => Value::String(s),
19        serde_json::Value::Array(items) => Value::Array(items.into_iter().map(from_json).collect()),
20        serde_json::Value::Object(map) => {
21            Value::Object(map.into_iter().map(|(k, v)| (k, from_json(v))).collect())
22        }
23    }
24}
25
26/// JSON object → IR object.
27///
28/// An overlay layer is a map, not a value — a scalar layer would replace the
29/// whole document rather than shadow a key — so a caller holding a `serde_json`
30/// object needs this rather than [`from_json`] to build one.
31pub fn object_from_json(map: serde_json::Map<String, serde_json::Value>) -> Map {
32    map.into_iter().map(|(k, v)| (k, from_json(v))).collect()
33}
34
35/// IR → JSON, rejecting up front the one thing JSON cannot hold.
36///
37/// One impossibility against [`to_toml`]'s three, and the same pre-walk shape for
38/// the same reasons: a non-finite float. TOML's number grammar has `inf`, `-inf`
39/// and `nan` literals, so an ordinary `.toml` input hands this function an
40/// infinity, and `serde_json::Number::from_f64` refuses it. That refusal used to
41/// be swallowed — `timeout = inf` emitted `{"timeout":0}` — which is the same
42/// silent substitution the null walk exists to prevent, one format over.
43///
44/// A datetime is not an impossibility here: JSON has no such type, so it renders
45/// as the string JSON would have to use anyway.
46pub fn to_json(value: Value) -> Result<serde_json::Value, NonFiniteFloat> {
47    let mut nonfinite = Vec::new();
48    collect_unjsonable(&value, &mut Vec::new(), &mut nonfinite);
49    if !nonfinite.is_empty() {
50        return Err(NonFiniteFloat { entries: nonfinite });
51    }
52    Ok(to_json_unchecked(value))
53}
54
55fn to_json_unchecked(value: Value) -> serde_json::Value {
56    match value {
57        Value::Null => serde_json::Value::Null,
58        Value::Bool(b) => serde_json::Value::Bool(b),
59        Value::Number(n) => serde_json::Value::Number(number_to_json(n)),
60        Value::String(s) | Value::Datetime(s) => serde_json::Value::String(s),
61        Value::Array(items) => {
62            serde_json::Value::Array(items.into_iter().map(to_json_unchecked).collect())
63        }
64        Value::Object(map) => serde_json::Value::Object(
65            map.into_iter()
66                .map(|(k, v)| (k, to_json_unchecked(v)))
67                .collect(),
68        ),
69    }
70}
71
72/// TOML → IR. Total: datetimes keep their source spelling rather than becoming
73/// strings, which is what lets a TOML datetime survive a mixed-format merge.
74pub fn from_toml(value: toml::Value) -> Value {
75    match value {
76        toml::Value::String(s) => Value::String(s),
77        toml::Value::Integer(i) => Value::Number(Number::I64(i)),
78        toml::Value::Float(f) => Value::Number(Number::F64(f)),
79        toml::Value::Boolean(b) => Value::Bool(b),
80        toml::Value::Datetime(dt) => Value::Datetime(dt.to_string()),
81        toml::Value::Array(items) => Value::Array(items.into_iter().map(from_toml).collect()),
82        toml::Value::Table(table) => {
83            Value::Object(table.into_iter().map(|(k, v)| (k, from_toml(v))).collect())
84        }
85    }
86}
87
88/// IR → TOML, rejecting up front what TOML cannot hold.
89///
90/// Three impossibilities, one walk. A null is the one users meet most, and its
91/// check is separate because serde's own message ("unsupported None value")
92/// carries no key path, and `toml`'s map serializer *skips* a `None` entry rather
93/// than failing — so walking up front is the only way to surface it at all, let
94/// alone with paths.
95///
96/// An integer above `i64::MAX` is the second. TOML integers are signed 64-bit, so
97/// a JSON snowflake ID has no TOML spelling at all; it used to be rounded through
98/// `f64`, which is the very loss [`Number::U64`] exists to prevent.
99///
100/// A [`Value::Datetime`] whose spelling does not re-parse is the third. All three
101/// ride along here rather than failing inside the conversion for the same two
102/// reasons: the messages want the key path this walk already carries, and checking
103/// here keeps `to_toml_unchecked` infallible, so no `Result` has to be threaded
104/// through its array and table arms.
105///
106/// Reported in the order a *document* can reach them. Nulls and out-of-range
107/// integers both arrive from a real input file; only a caller that hand-built a
108/// `Value` can produce a malformed datetime, so it goes last.
109pub fn to_toml(value: Value) -> Result<toml::Value, TomlError> {
110    let mut nulls = Vec::new();
111    let mut integers = Vec::new();
112    let mut datetimes = Vec::new();
113    collect_untomlable(
114        &value,
115        &mut Vec::new(),
116        &mut nulls,
117        &mut integers,
118        &mut datetimes,
119    );
120    if !nulls.is_empty() {
121        return Err(TomlError::Null(NullInToml { entries: nulls }));
122    }
123    if !integers.is_empty() {
124        return Err(TomlError::Integer(IntegerOutOfRange { entries: integers }));
125    }
126    if !datetimes.is_empty() {
127        return Err(TomlError::Datetime(BadDatetime { entries: datetimes }));
128    }
129    Ok(to_toml_unchecked(value))
130}
131
132fn to_toml_unchecked(value: Value) -> toml::Value {
133    match value {
134        Value::Null => {
135            unreachable!("nulls are rejected by to_toml before conversion");
136        }
137        Value::Bool(b) => toml::Value::Boolean(b),
138        Value::Number(n) => number_to_toml(n),
139        Value::String(s) => toml::Value::String(s),
140        // Guarded by `to_toml`, exactly as the `Null` arm above is. In a document
141        // that came from a parser it could not fail at all: every `Datetime`
142        // *originates* in the TOML parser, from a string `toml` itself printed,
143        // and while interpolation may copy one (`d2 = "${d}"` takes the referent's
144        // type), nothing anywhere synthesizes one from text — `${env:...}` types
145        // through JSON, which has no datetime and so structurally cannot. A caller
146        // hand-building a `Value` can still spell one wrongly, and that is what the
147        // pre-walk catches.
148        Value::Datetime(s) => toml::Value::Datetime(
149            s.parse()
150                .expect("unparseable datetimes are rejected by to_toml before conversion"),
151        ),
152        Value::Array(items) => {
153            toml::Value::Array(items.into_iter().map(to_toml_unchecked).collect())
154        }
155        Value::Object(map) => {
156            let mut table = toml::Table::new();
157            for (k, v) in map {
158                table.insert(k, to_toml_unchecked(v));
159            }
160            toml::Value::Table(table)
161        }
162    }
163}
164
165fn number_from_json(n: &serde_json::Number) -> Number {
166    if let Some(i) = n.as_i64() {
167        Number::I64(i)
168    } else if let Some(u) = n.as_u64() {
169        Number::from_u64(u)
170    } else if let Some(f) = n.as_f64() {
171        Number::F64(f)
172    } else {
173        // Unreachable as this workspace is built: without serde_json's
174        // `arbitrary_precision` feature a `Number` is exactly one of i64/u64/f64,
175        // so one of the three arms above always takes it. Nothing here enables
176        // that feature, but cargo unifies features across a whole graph, so a
177        // downstream crate could switch it on from outside — hence a fallback
178        // rather than a panic in a library.
179        Number::F64(0.0)
180    }
181}
182
183fn number_to_json(n: Number) -> serde_json::Number {
184    match n {
185        Number::I64(i) => i.into(),
186        Number::U64(u) => u.into(),
187        // Guarded by `to_json`, exactly as the arms in `to_toml_unchecked` are.
188        // `from_f64` returns `None` only for inf and NaN, and both are collected
189        // by the pre-walk before this runs.
190        Number::F64(f) => serde_json::Number::from_f64(f)
191            .expect("non-finite floats are rejected by to_json before conversion"),
192    }
193}
194
195fn number_to_toml(n: Number) -> toml::Value {
196    match n {
197        Number::I64(i) => toml::Value::Integer(i),
198        // TOML integers are signed, so anything past i64::MAX has no TOML
199        // spelling. It used to become a float, which silently discarded the exact
200        // digits `Number::U64` exists to preserve; `to_toml`'s pre-walk rejects it
201        // instead, leaving this conversion for the values that do fit. Only a
202        // hand-built `Number::U64` gets here at all — `Number::from_u64` demotes
203        // anything representable to `I64` — and the pre-walk lets exactly those
204        // through.
205        Number::U64(u) => toml::Value::Integer(
206            i64::try_from(u)
207                .expect("out-of-range integers are rejected by to_toml before conversion"),
208        ),
209        Number::F64(f) => toml::Value::Float(f),
210    }
211}
212
213// --- what TOML cannot hold ------------------------------------------------
214
215/// One walk for all three impossibilities, each collected with the path it sits at.
216fn collect_untomlable(
217    value: &Value,
218    cur: &mut Vec<Seg>,
219    nulls: &mut Vec<Vec<Seg>>,
220    integers: &mut Vec<(Vec<Seg>, u64)>,
221    datetimes: &mut Vec<(Vec<Seg>, String)>,
222) {
223    match value {
224        Value::Null => nulls.push(cur.clone()),
225        // A guard rather than an arm, for the same reason as the datetime below:
226        // a `U64` small enough for an `i64` converts perfectly well, and only a
227        // hand-built value is ever spelled that way, since `Number::from_u64`
228        // demotes. Every `U64` a *parser* produces fails this `try_from`.
229        Value::Number(Number::U64(u)) if i64::try_from(*u).is_err() => {
230            integers.push((cur.clone(), *u));
231        }
232        // A guard rather than an arm, so a datetime that parses — every datetime
233        // a parsed document can contain — falls through to the `_` below.
234        Value::Datetime(s) if s.parse::<toml::value::Datetime>().is_err() => {
235            datetimes.push((cur.clone(), s.clone()));
236        }
237        Value::Object(obj) => {
238            for (k, v) in obj {
239                cur.push(Seg::Key(k.clone()));
240                collect_untomlable(v, cur, nulls, integers, datetimes);
241                cur.pop();
242            }
243        }
244        Value::Array(items) => {
245            for (i, v) in items.iter().enumerate() {
246                cur.push(Seg::Index(i));
247                collect_untomlable(v, cur, nulls, integers, datetimes);
248                cur.pop();
249            }
250        }
251        _ => {}
252    }
253}
254
255// --- what JSON cannot hold ------------------------------------------------
256
257/// The mirror of [`collect_untomlable`], with one kind to find rather than three.
258fn collect_unjsonable(value: &Value, cur: &mut Vec<Seg>, nonfinite: &mut Vec<(Vec<Seg>, f64)>) {
259    match value {
260        Value::Number(Number::F64(f)) if !f.is_finite() => nonfinite.push((cur.clone(), *f)),
261        Value::Object(obj) => {
262            for (k, v) in obj {
263                cur.push(Seg::Key(k.clone()));
264                collect_unjsonable(v, cur, nonfinite);
265                cur.pop();
266            }
267        }
268        Value::Array(items) => {
269            for (i, v) in items.iter().enumerate() {
270                cur.push(Seg::Index(i));
271                collect_unjsonable(v, cur, nonfinite);
272                cur.pop();
273            }
274        }
275        _ => {}
276    }
277}
278
279/// The TOML spellings, which are the only text a non-finite float has anywhere in
280/// this workspace — and the spelling such a value arrived as, since TOML is the
281/// only format that can carry one in.
282///
283/// Also what interpolation splices for a non-finite float embedded in a string.
284///
285/// Only non-finite values reach here, so the sign test after the NaN test is
286/// exhaustive.
287pub(crate) fn nonfinite_spelling(f: f64) -> &'static str {
288    if f.is_nan() {
289        "nan"
290    } else if f.is_sign_positive() {
291        "inf"
292    } else {
293        "-inf"
294    }
295}
296
297/// Substitutes `placeholder` for every null in the document.
298///
299/// The alternative to [`to_toml`]'s rejection, and so only ever called on the
300/// way to TOML — JSON holds a null fine and has nothing to be rescued from. A
301/// null cannot be encoded as TOML, leaving only two honest options: fail, or
302/// write a value that was in none of the inputs. *Which* value that is has to
303/// be the user's choice rather than the tool's — `yq` and `tomlq` both drop
304/// null keys silently, and both invent a string inside arrays (`""` and
305/// `"None"` respectively, for the same input), which is the behaviour this
306/// exists to avoid.
307pub fn replace_nulls(value: &mut Value, placeholder: &str) {
308    match value {
309        Value::Null => *value = Value::String(placeholder.to_string()),
310        Value::Object(obj) => {
311            for v in obj.values_mut() {
312                replace_nulls(v, placeholder);
313            }
314        }
315        Value::Array(items) => {
316            for v in items.iter_mut() {
317                replace_nulls(v, placeholder);
318            }
319        }
320        _ => {}
321    }
322}
323
324/// Nulls survived into a document being converted to TOML.
325///
326/// A genuine impossibility in user data, so it is an error rather than a silent
327/// drop: the `toml` crate's map serializer *skips* a `None` entry, so emitting
328/// without this check would quietly lose keys.
329///
330/// Carries paths and nothing else — no filenames and no flag names. Naming the
331/// layer each null came from would mean retaining every parsed layer past the
332/// merge purely for an error path, and the paths alone locate the value in the
333/// merged document. The remedies are all interface-shaped — emit JSON instead,
334/// substitute a string, or drop the null — so which of them a caller can offer
335/// is the caller's to say, the same division of labour [`crate::LoadError`]
336/// keeps.
337#[derive(Debug)]
338pub struct NullInToml {
339    entries: Vec<Vec<Seg>>,
340}
341
342impl fmt::Display for NullInToml {
343    /// Ends without a trailing newline, so a caller can append a `help:` line
344    /// of its own — which is what `knf-cli` does, and why the line is not here.
345    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
346        write!(f, "cannot serialize null to TOML")?;
347        for path in &self.entries {
348            write!(f, "\n  --> {}", render_path(path))?;
349        }
350        Ok(())
351    }
352}
353
354impl std::error::Error for NullInToml {}
355
356/// A [`Value::Datetime`] carrying text that is not a TOML datetime.
357///
358/// Unreachable from a parsed document, and unreachable from this crate's own
359/// pipeline: a datetime only ever *originates* in the TOML parser, and neither
360/// `${env:...}` nor the CLI's `--set` can make one, since both type their values
361/// through JSON, which has no datetime. It is reachable from a caller that
362/// builds a [`Value`] by hand, though — the variant is an ordinary public one
363/// holding an ordinary `String` — and this is what that caller gets instead of a
364/// panic. The rule it reports on is unchanged: nothing may synthesize a datetime
365/// from text.
366///
367/// Carries paths and the offending spelling, and like [`NullInToml`] stops short
368/// of a trailing newline so an interface can append a line of its own. There is no
369/// flag that would help here, so no interface has one to append.
370#[derive(Debug)]
371pub struct BadDatetime {
372    entries: Vec<(Vec<Seg>, String)>,
373}
374
375impl fmt::Display for BadDatetime {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        write!(f, "cannot serialize datetime to TOML")?;
378        for (path, text) in &self.entries {
379            write!(f, "\n  --> {}: `{text}`", render_path(path))?;
380        }
381        Ok(())
382    }
383}
384
385impl std::error::Error for BadDatetime {}
386
387/// An integer too large for TOML's signed 64-bit integers.
388///
389/// Reachable from an ordinary JSON input — a snowflake ID or a hash above
390/// `i64::MAX` is exactly what [`Number::U64`] exists to carry losslessly — so
391/// unlike [`BadDatetime`] this is a failure a user meets rather than one only a
392/// caller can build. It used to be rounded through `f64` on the way out, which
393/// discarded the very digits the variant preserves.
394///
395/// Carries paths and the offending value, and like its siblings stops short of a
396/// trailing newline so an interface can append a line of its own. There is one
397/// worth appending here: `-f json` emits the integer exactly.
398#[derive(Debug)]
399pub struct IntegerOutOfRange {
400    entries: Vec<(Vec<Seg>, u64)>,
401}
402
403impl fmt::Display for IntegerOutOfRange {
404    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
405        write!(f, "cannot serialize integer to TOML")?;
406        for (path, n) in &self.entries {
407            write!(f, "\n  --> {}: `{n}`", render_path(path))?;
408        }
409        Ok(())
410    }
411}
412
413impl std::error::Error for IntegerOutOfRange {}
414
415/// A float JSON cannot represent: an infinity or a NaN.
416///
417/// JSON's grammar has no spelling for either, and `serde_json` refuses to build a
418/// `Number` from one. TOML's grammar *does* — `inf`, `-inf`, `nan` are literals —
419/// so a `.toml` input can carry one straight into a `-f json` emission, where it
420/// used to be silently replaced by `0`.
421///
422/// The lone JSON impossibility, which is why [`to_json`] returns this type
423/// directly where [`to_toml`] returns the [`TomlError`] enum. A second one would
424/// be the moment to introduce the wrapper, not before: an enum with one variant
425/// buys a consumer nothing and costs it a `match`.
426///
427/// Carries paths and the value's TOML spelling, and stops short of a trailing
428/// newline like its TOML-side siblings.
429#[derive(Debug)]
430pub struct NonFiniteFloat {
431    entries: Vec<(Vec<Seg>, f64)>,
432}
433
434impl fmt::Display for NonFiniteFloat {
435    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
436        write!(f, "cannot serialize non-finite number to JSON")?;
437        for (path, value) in &self.entries {
438            write!(
439                f,
440                "\n  --> {}: `{}`",
441                render_path(path),
442                nonfinite_spelling(*value)
443            )?;
444        }
445        Ok(())
446    }
447}
448
449impl std::error::Error for NonFiniteFloat {}
450
451/// Why a document could not be converted to TOML.
452///
453/// Deliberately no `#[from]` and no `#[source]` on either variant: a generated
454/// `source()` would be a second copy of a message the variant's own `Display`
455/// already prints in full, and `knf-cli` walks the cause chain onto stderr.
456#[derive(Debug, thiserror::Error)]
457pub enum TomlError {
458    /// The document contains a null, which TOML cannot represent.
459    #[error("{0}")]
460    Null(NullInToml),
461    /// The document contains an integer above `i64::MAX`.
462    #[error("{0}")]
463    Integer(IntegerOutOfRange),
464    /// The document contains a datetime whose spelling does not parse.
465    #[error("{0}")]
466    Datetime(BadDatetime),
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use serde_json::json;
473
474    fn ir(v: serde_json::Value) -> Value {
475        from_json(v)
476    }
477
478    fn json(v: Value) -> serde_json::Value {
479        to_json(v).expect("no non-finite floats")
480    }
481
482    #[test]
483    fn a_toml_datetime_stays_a_datetime_in_the_ir() {
484        let parsed: toml::Value =
485            toml::from_str("date = 1979-05-27T07:32:00Z\n").expect("valid toml");
486        let v = from_toml(parsed);
487        let Value::Object(map) = &v else {
488            panic!("expected an object, got {v:?}");
489        };
490        assert_eq!(
491            map["date"],
492            Value::Datetime("1979-05-27T07:32:00Z".to_string())
493        );
494        // Only the sentinel-free `Display` spelling, never toml's internal map.
495        assert_eq!(json(v), json!({"date": "1979-05-27T07:32:00Z"}));
496    }
497
498    /// All four TOML datetime forms round-trip through `Display`/`FromStr`,
499    /// which is the whole basis for storing a datetime as a `String`.
500    #[test]
501    fn every_toml_datetime_form_round_trips() {
502        let src = "\
503offset = 1979-05-27T07:32:00Z
504offset_frac = 1979-05-27T00:32:00.999999-07:00
505local = 1979-05-27T07:32:00
506date = 1979-05-27
507time = 07:32:00.5
508";
509        let parsed: toml::Value = toml::from_str(src).expect("valid toml");
510        let back = to_toml(from_toml(parsed.clone())).expect("every spelling re-parses");
511        assert_eq!(back, parsed);
512    }
513
514    #[test]
515    fn to_toml_rejects_nulls_with_array_indices() {
516        let err = to_toml(ir(json!({"a": {"b": null}, "c": [1, null], "d": 2}))).unwrap_err();
517        let TomlError::Null(report) = err else {
518            panic!("expected a null report, got {err}");
519        };
520        let rendered: Vec<_> = report.entries.iter().map(|p| render_path(p)).collect();
521        assert_eq!(rendered, vec!["a.b", "c[1]"]);
522    }
523
524    /// Reachable only from a hand-built `Value` — the variant is public and holds
525    /// a plain `String` — and it used to abort the process instead. Every
526    /// offender is named, so a caller does not learn of them one run at a time,
527    /// and the report ends without a newline like its null-shaped sibling.
528    #[test]
529    fn to_toml_reports_every_datetime_that_does_not_reparse() {
530        let value = Value::Object(Map::from_iter([
531            ("created".to_string(), Value::Datetime("nope".to_string())),
532            (
533                "events".to_string(),
534                Value::Array(vec![
535                    // The valid one is untouched, so only the second is reported.
536                    Value::Datetime("1979-05-27T07:32:00Z".to_string()),
537                    Value::Datetime("yesterday".to_string()),
538                ]),
539            ),
540        ]));
541
542        let err = to_toml(value).unwrap_err();
543
544        assert!(matches!(err, TomlError::Datetime(_)), "{err}");
545        assert_eq!(
546            err.to_string(),
547            "cannot serialize datetime to TOML\n  --> created: `nope`\n  --> events[1]: `yesterday`"
548        );
549    }
550
551    /// A document with all three reports the nulls, then the integers. The order
552    /// is how close each is to something a real input file can contain: a null and
553    /// an oversized integer both arrive from a document, a malformed datetime only
554    /// from a caller that built one by hand.
555    #[test]
556    fn a_null_is_reported_before_a_malformed_datetime() {
557        let all_three = || {
558            Value::Object(Map::from_iter([
559                ("a".to_string(), Value::Null),
560                ("n".to_string(), Value::Number(Number::U64(u64::MAX))),
561                ("d".to_string(), Value::Datetime("nope".to_string())),
562            ]))
563        };
564        assert!(matches!(
565            to_toml(all_three()).unwrap_err(),
566            TomlError::Null(_)
567        ));
568
569        // Drop the null and the integer surfaces; drop that too and the datetime does.
570        let Value::Object(mut map) = all_three() else {
571            unreachable!()
572        };
573        map.shift_remove("a");
574        assert!(matches!(
575            to_toml(Value::Object(map.clone())).unwrap_err(),
576            TomlError::Integer(_)
577        ));
578        map.shift_remove("n");
579        assert!(matches!(
580            to_toml(Value::Object(map)).unwrap_err(),
581            TomlError::Datetime(_)
582        ));
583    }
584
585    /// TOML integers are signed 64-bit, so a snowflake ID has no spelling there at
586    /// all. It used to round through `f64` — discarding the exact digits
587    /// `Number::U64` exists to keep — and now names every offender instead.
588    #[test]
589    fn to_toml_reports_every_integer_above_i64_max() {
590        let value = ir(json!({
591            "id": 10_000_000_000_000_000_001_u64,
592            "ok": 42,
593            "ids": [1, 18_446_744_073_709_551_615_u64],
594        }));
595
596        let err = to_toml(value).unwrap_err();
597
598        assert!(matches!(err, TomlError::Integer(_)), "{err}");
599        assert_eq!(
600            err.to_string(),
601            "cannot serialize integer to TOML\n  --> id: `10000000000000000001`\n  \
602             --> ids[1]: `18446744073709551615`"
603        );
604    }
605
606    /// The pre-walk guards on the range, not on the variant, so a `U64` small
607    /// enough for an `i64` still converts. Only a hand-built value is spelled that
608    /// way — `Number::from_u64` demotes — but nothing should panic when one is.
609    #[test]
610    fn a_u64_small_enough_for_an_i64_still_converts() {
611        let value = Value::Object(Map::from_iter([(
612            "n".to_string(),
613            Value::Number(Number::U64(1)),
614        )]));
615        let toml = to_toml(value).expect("1 fits an i64");
616        assert_eq!(toml["n"], toml::Value::Integer(1));
617    }
618
619    /// TOML's grammar has `inf`, `-inf` and `nan`; JSON's has none of them, and
620    /// `serde_json` refuses to build a number from one. This used to be swallowed,
621    /// emitting `0` for a value the user wrote as `inf`.
622    #[test]
623    fn to_json_reports_every_non_finite_float() {
624        let value = Value::Object(Map::from_iter([
625            (
626                "timeout".to_string(),
627                Value::Number(Number::F64(f64::INFINITY)),
628            ),
629            ("ok".to_string(), Value::Number(Number::F64(1.5))),
630            (
631                "backoff".to_string(),
632                Value::Array(vec![
633                    Value::Number(Number::F64(f64::NEG_INFINITY)),
634                    Value::Number(Number::F64(f64::NAN)),
635                ]),
636            ),
637        ]));
638
639        let err = to_json(value).unwrap_err();
640
641        assert_eq!(
642            err.to_string(),
643            "cannot serialize non-finite number to JSON\n  --> timeout: `inf`\n  \
644             --> backoff[0]: `-inf`\n  --> backoff[1]: `nan`"
645        );
646    }
647
648    /// Both reports end mid-line so `knf-cli` can append a `help:` line flush
649    /// against the last `-->`, the same seam the null report keeps.
650    #[test]
651    fn the_new_reports_end_without_a_newline() {
652        let big = ir(json!({"id": 10_000_000_000_000_000_001_u64}));
653        assert!(!to_toml(big).unwrap_err().to_string().ends_with('\n'));
654
655        let inf = Value::Object(Map::from_iter([(
656            "t".to_string(),
657            Value::Number(Number::F64(f64::INFINITY)),
658        )]));
659        assert!(!to_json(inf).unwrap_err().to_string().ends_with('\n'));
660    }
661
662    /// A non-finite float is a JSON problem alone: TOML spells all three, so the
663    /// same document emits fine that way and `-f toml` is a real escape.
664    #[test]
665    fn non_finite_floats_are_fine_in_toml() {
666        let value = Value::Object(Map::from_iter([(
667            "timeout".to_string(),
668            Value::Number(Number::F64(f64::INFINITY)),
669        )]));
670        assert_eq!(
671            toml::to_string(&to_toml(value).expect("toml has inf")).expect("serializes"),
672            "timeout = inf\n"
673        );
674    }
675
676    #[test]
677    fn numbers_bools_and_tables_round_trip() {
678        let src = json!({
679            "n": 1,
680            "f": 1.5,
681            "ok": true,
682            "name": "svc",
683            "xs": [1, 2],
684            "nested": {"k": 3}
685        });
686        let toml = to_toml(ir(src.clone())).expect("no nulls");
687        assert_eq!(json(from_toml(toml)), src);
688    }
689
690    /// A JSON integer above `i64::MAX` must not be rounded through `f64`.
691    #[test]
692    fn large_unsigned_integers_survive_json_round_trip() {
693        let src = json!({"id": 10_000_000_000_000_000_001_u64});
694        assert_eq!(json(ir(src.clone())), src);
695    }
696
697    /// The array case is the one both `yq` and `tomlq` fabricate a value for,
698    /// since a null cannot be dropped from an array without shifting every
699    /// index after it. Substituting preserves the length and lets the user name
700    /// the value that lands there.
701    #[test]
702    fn replace_nulls_substitutes_everywhere_and_unblocks_toml() {
703        let mut v = ir(json!({"a": {"b": null}, "c": [1, null, 3], "d": 2}));
704        replace_nulls(&mut v, "none");
705        assert_eq!(
706            json(v.clone()),
707            json!({"a": {"b": "none"}, "c": [1, "none", 3], "d": 2})
708        );
709        to_toml(v).expect("the substitution left no nulls");
710    }
711
712    /// A document without nulls is untouched, so the flag cannot perturb a
713    /// merge that never needed it.
714    #[test]
715    fn replace_nulls_is_the_identity_without_nulls() {
716        let src = json!({"a": 1, "xs": [1, 2], "nested": {"k": "v"}});
717        let mut v = ir(src.clone());
718        replace_nulls(&mut v, "none");
719        assert_eq!(json(v), src);
720    }
721}