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}