Skip to main content

nx_value/
lib.rs

1//! NX value model for JSON-like object hierarchies.
2//!
3//! `NxValue` is intended to be a stable, serde-compatible data IR that can be used as input/output
4//! across the NX API surface.
5
6use std::collections::BTreeMap;
7use std::fs::File;
8use std::io::{Read, Write};
9use std::path::Path;
10
11use serde::de::Error as _;
12use serde::de::{MapAccess, SeqAccess, Visitor};
13use serde::ser::{SerializeMap, SerializeSeq};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16/// A JSON-like tree value used as the stable NX API value type.
17///
18/// It is the Rust form of a canonical value, which `docs/nx-ir-format.md` (*Host values*) defines:
19/// a host builds one and passes it as it is, whether it read the value from JSON or computed it,
20/// and JSON and MessagePack are encodings of it for a wire or a store. It can hold nothing that is
21/// not a canonical value, so a runtime given one has nothing to refuse for its kind.
22///
23/// Constant union cases are represented as [`NxValue::String`] carrying the bare authored case
24/// name. The declaring union is recovered from the target schema (declared NX type, typed DTO
25/// property, or other type annotation) at the point where the value is consumed; the IR itself
26/// carries no in-payload union type wrapper.
27///
28/// NX has no null: its absent value is the empty sequence, [`NxValue::empty`]. [`NxValue::Null`]
29/// is the host's spelling of absence, which NX reads as the empty value. NX writes it in two
30/// places: an entry call's result whose type is a standalone `T?` and holds nothing, and a
31/// present empty field of an update record — a cleared field — which is how key presence carries
32/// "cleared" through JSON. An absent optional record field is an omitted key.
33#[derive(Debug, Clone, PartialEq)]
34pub enum NxValue {
35    Bool(bool),
36    Int32(i32),
37    Int(i64),
38    Float32(f32),
39    Float(f64),
40    /// String value.
41    ///
42    /// A string value may represent either a plain string or a constant union case. The two are
43    /// distinguishable only against the target schema — a schema-less consumer cannot tell
44    /// them apart from the payload alone.
45    String(String),
46    /// The host's `null`: JSON `null`, MessagePack `nil`. NX reads it as the empty value.
47    Null,
48    Array(Vec<NxValue>),
49    /// Record value (ordered properties).
50    ///
51    /// When serialized to JSON, `type_name` is encoded as a `"$type"` string property if present.
52    Record {
53        type_name: Option<String>,
54        properties: BTreeMap<String, NxValue>,
55    },
56}
57
58impl NxValue {
59    /// The empty value: the empty sequence, which is also the absent value.
60    pub fn empty() -> Self {
61        NxValue::Array(Vec::new())
62    }
63
64    /// True for the empty value, or for a host `null`, which NX reads as the empty value.
65    pub fn is_empty_value(&self) -> bool {
66        match self {
67            NxValue::Null => true,
68            NxValue::Array(elements) => elements.is_empty(),
69            _ => false,
70        }
71    }
72
73    /// Deserialize a value from a JSON string.
74    pub fn from_json_str(source: &str) -> Result<Self, serde_json::Error> {
75        serde_json::from_str(source)
76    }
77
78    /// Deserialize a value from a JSON reader.
79    pub fn from_json_reader<R: Read>(reader: R) -> Result<Self, serde_json::Error> {
80        serde_json::from_reader(reader)
81    }
82
83    /// Deserialize a value from a JSON file.
84    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, NxValueIoError> {
85        let file = File::open(path.as_ref())?;
86        Ok(Self::from_json_reader(file)?)
87    }
88
89    /// Serialize a value to a compact JSON string.
90    pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
91        serde_json::to_string(self)
92    }
93
94    /// Serialize a value to a pretty JSON string.
95    pub fn to_json_string_pretty(&self) -> Result<String, serde_json::Error> {
96        serde_json::to_string_pretty(self)
97    }
98
99    /// Serialize a value to JSON using the provided writer.
100    pub fn to_json_writer<W: Write>(&self, writer: W) -> Result<(), serde_json::Error> {
101        serde_json::to_writer(writer, self)
102    }
103
104    /// Serialize a value to a JSON file (compact form).
105    pub fn to_json_file(&self, path: impl AsRef<Path>) -> Result<(), NxValueIoError> {
106        let mut file = File::create(path.as_ref())?;
107        self.to_json_writer(&mut file)?;
108        file.flush()?;
109        Ok(())
110    }
111
112    /// Serialize a value to a JSON file (pretty form).
113    pub fn to_json_file_pretty(&self, path: impl AsRef<Path>) -> Result<(), NxValueIoError> {
114        let mut file = File::create(path.as_ref())?;
115        let json = self.to_json_string_pretty()?;
116        file.write_all(json.as_bytes())?;
117        file.flush()?;
118        Ok(())
119    }
120
121    /// Serialize a value to MessagePack bytes.
122    pub fn to_msgpack_vec(&self) -> Result<Vec<u8>, rmp_serde::encode::Error> {
123        rmp_serde::to_vec(self)
124    }
125
126    /// Deserialize a value from MessagePack bytes.
127    pub fn from_msgpack_slice(bytes: &[u8]) -> Result<Self, rmp_serde::decode::Error> {
128        rmp_serde::from_slice(bytes)
129    }
130}
131
132impl Serialize for NxValue {
133    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
134        match self {
135            NxValue::Bool(value) => serializer.serialize_bool(*value),
136            NxValue::Int32(value) => serializer.serialize_i32(*value),
137            NxValue::Int(value) => serializer.serialize_i64(*value),
138            NxValue::Float32(value) => serializer.serialize_f32(*value),
139            NxValue::Float(value) => serializer.serialize_f64(*value),
140            NxValue::String(value) => serializer.serialize_str(value),
141            NxValue::Null => serializer.serialize_unit(),
142            NxValue::Array(elements) => {
143                let mut seq = serializer.serialize_seq(Some(elements.len()))?;
144                for element in elements {
145                    seq.serialize_element(element)?;
146                }
147                seq.end()
148            }
149            NxValue::Record {
150                type_name,
151                properties,
152            } => {
153                let len = properties.len() + usize::from(type_name.is_some());
154                let mut map = serializer.serialize_map(Some(len))?;
155
156                if let Some(type_name) = type_name {
157                    map.serialize_entry("$type", type_name)?;
158                }
159
160                for (key, value) in properties {
161                    map.serialize_entry(key, value)?;
162                }
163
164                map.end()
165            }
166        }
167    }
168}
169
170impl<'de> Deserialize<'de> for NxValue {
171    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
172        struct NxValueVisitor;
173
174        impl<'de> Visitor<'de> for NxValueVisitor {
175            type Value = NxValue;
176
177            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178                formatter.write_str("a JSON-like value")
179            }
180
181            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
182                Ok(NxValue::Null)
183            }
184
185            fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
186                Ok(NxValue::Null)
187            }
188
189            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
190                Ok(NxValue::Bool(v))
191            }
192
193            fn visit_i32<E: serde::de::Error>(self, v: i32) -> Result<Self::Value, E> {
194                Ok(NxValue::Int32(v))
195            }
196
197            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
198                Ok(NxValue::Int(v))
199            }
200
201            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
202                if v <= i64::MAX as u64 {
203                    Ok(NxValue::Int(v as i64))
204                } else {
205                    Ok(NxValue::Float(v as f64))
206                }
207            }
208
209            fn visit_f32<E: serde::de::Error>(self, v: f32) -> Result<Self::Value, E> {
210                Ok(NxValue::Float32(v))
211            }
212
213            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
214                Ok(NxValue::Float(v))
215            }
216
217            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
218                Ok(NxValue::String(v.to_owned()))
219            }
220
221            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
222                Ok(NxValue::String(v))
223            }
224
225            fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
226                let mut values = Vec::new();
227                while let Some(value) = seq.next_element::<NxValue>()? {
228                    values.push(value);
229                }
230                Ok(NxValue::Array(values))
231            }
232
233            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
234                let mut type_name = None;
235                let mut properties = BTreeMap::new();
236                while let Some((key, value)) = map.next_entry::<String, NxValue>()? {
237                    if key == "$type" {
238                        match value {
239                            NxValue::String(name) => {
240                                type_name = Some(name);
241                            }
242                            other => {
243                                return Err(A::Error::custom(format!(
244                                    "expected \"$type\" to be a string, got {:?}",
245                                    other
246                                )));
247                            }
248                        }
249                        continue;
250                    }
251
252                    properties.insert(key, value);
253                }
254
255                Ok(NxValue::Record {
256                    type_name,
257                    properties,
258                })
259            }
260        }
261
262        deserializer.deserialize_any(NxValueVisitor)
263    }
264}
265
266/// Errors for JSON file IO helpers.
267#[derive(Debug)]
268pub enum NxValueIoError {
269    Io(std::io::Error),
270    Json(serde_json::Error),
271}
272
273impl std::fmt::Display for NxValueIoError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            NxValueIoError::Io(e) => write!(f, "io error: {}", e),
277            NxValueIoError::Json(e) => write!(f, "json error: {}", e),
278        }
279    }
280}
281
282impl std::error::Error for NxValueIoError {
283    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
284        match self {
285            NxValueIoError::Io(e) => Some(e),
286            NxValueIoError::Json(e) => Some(e),
287        }
288    }
289}
290
291impl From<std::io::Error> for NxValueIoError {
292    fn from(value: std::io::Error) -> Self {
293        NxValueIoError::Io(value)
294    }
295}
296
297impl From<serde_json::Error> for NxValueIoError {
298    fn from(value: serde_json::Error) -> Self {
299        NxValueIoError::Json(value)
300    }
301}
302
303/// The README's example, compiled as a doc test.
304#[cfg(doctest)]
305#[doc = include_str!("../README.md")]
306struct ReadmeDoctests;
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::io::Cursor;
312
313    #[test]
314    fn json_round_trip_in_memory() {
315        let mut obj = BTreeMap::new();
316        obj.insert("a".to_string(), NxValue::Int(1));
317        obj.insert("b".to_string(), NxValue::Bool(true));
318        obj.insert(
319            "c".to_string(),
320            NxValue::Array(vec![NxValue::Int(2), NxValue::String("x".to_string())]),
321        );
322
323        let value = NxValue::Record {
324            type_name: None,
325            properties: obj,
326        };
327        let json = value.to_json_string().unwrap();
328        let decoded = NxValue::from_json_str(&json).unwrap();
329        assert_eq!(decoded, value);
330    }
331
332    #[test]
333    fn json_round_trip_primitives() {
334        let cases = [
335            ("true", NxValue::Bool(true)),
336            ("false", NxValue::Bool(false)),
337            ("0", NxValue::Int(0)),
338            ("-1", NxValue::Int(-1)),
339            ("2.75", NxValue::Float(2.75)),
340            ("\"hello\"", NxValue::String("hello".to_string())),
341            ("[]", NxValue::Array(vec![])),
342            (
343                "{}",
344                NxValue::Record {
345                    type_name: None,
346                    properties: BTreeMap::new(),
347                },
348            ),
349        ];
350
351        for (json, expected) in cases {
352            let decoded = NxValue::from_json_str(json).unwrap();
353            assert_eq!(decoded, expected);
354            let encoded = decoded.to_json_string().unwrap();
355            let decoded_again = NxValue::from_json_str(&encoded).unwrap();
356            assert_eq!(decoded_again, expected);
357        }
358    }
359
360    #[test]
361    fn json_round_trip_nested_structures() {
362        let value = NxValue::Record {
363            type_name: None,
364            properties: BTreeMap::from([
365                (
366                    "a".to_string(),
367                    NxValue::Array(vec![NxValue::Int(1), NxValue::Int(2)]),
368                ),
369                (
370                    "b".to_string(),
371                    NxValue::Record {
372                        type_name: None,
373                        properties: BTreeMap::from([
374                            ("x".to_string(), NxValue::Bool(false)),
375                            ("y".to_string(), NxValue::empty()),
376                        ]),
377                    },
378                ),
379            ]),
380        };
381
382        let json = value.to_json_string().unwrap();
383        let decoded = NxValue::from_json_str(&json).unwrap();
384        assert_eq!(decoded, value);
385    }
386
387    /// The canonical encoding of an occurrence-typed value, row by row: `+` and `*` are arrays, a
388    /// present `?` is its item, a host `null` decodes to [`NxValue::Null`], which NX reads as the
389    /// empty value, and `Null` is written as `null`.
390    #[test]
391    fn json_encoding_of_occurrence_typed_values() {
392        // `+` and `*` encode as arrays, the empty `*` as the empty array.
393        assert_eq!(
394            NxValue::Array(vec![NxValue::Int(1), NxValue::Int(2)])
395                .to_json_string()
396                .unwrap(),
397            "[1,2]"
398        );
399        assert_eq!(NxValue::empty().to_json_string().unwrap(), "[]");
400        // A present `?` is its item; an absent standalone `?` is `null`.
401        assert_eq!(NxValue::Int(1).to_json_string().unwrap(), "1");
402        assert_eq!(NxValue::Null.to_json_string().unwrap(), "null");
403        // Host `null` and `[]` are both read as the empty value.
404        assert_eq!(NxValue::from_json_str("null").unwrap(), NxValue::Null);
405        assert_eq!(NxValue::from_json_str("[]").unwrap(), NxValue::empty());
406        assert!(NxValue::from_json_str("null").unwrap().is_empty_value());
407        // A missing key stays missing: an absent optional field is an omitted key.
408        let book = NxValue::from_json_str(r#"{"$type":"Book","title":"A"}"#).unwrap();
409        let NxValue::Record { properties, .. } = &book else {
410            panic!("a record");
411        };
412        assert!(!properties.contains_key("author"));
413        assert_eq!(
414            book.to_json_string().unwrap(),
415            r#"{"$type":"Book","title":"A"}"#
416        );
417        // A cleared update-record field is `Null`, and round-trips as `null`.
418        let cleared = NxValue::Record {
419            type_name: Some("Book.Update".to_string()),
420            properties: BTreeMap::from([("author".to_string(), NxValue::Null)]),
421        };
422        assert_eq!(
423            cleared.to_json_string().unwrap(),
424            r#"{"$type":"Book.Update","author":null}"#
425        );
426        assert_eq!(
427            NxValue::from_json_str(r#"{"$type":"Book.Update","author":null}"#).unwrap(),
428            cleared
429        );
430        // The encoding is structural: an empty field encodes as it is stored.
431        let plain = NxValue::Record {
432            type_name: Some("Box".to_string()),
433            properties: BTreeMap::from([("items".to_string(), NxValue::empty())]),
434        };
435        assert_eq!(
436            plain.to_json_string().unwrap(),
437            r#"{"$type":"Box","items":[]}"#
438        );
439    }
440
441    #[test]
442    fn msgpack_writes_null_as_nil() {
443        let bytes = NxValue::Null.to_msgpack_vec().unwrap();
444        assert_eq!(bytes, vec![0xc0]);
445        assert_eq!(NxValue::from_msgpack_slice(&bytes).unwrap(), NxValue::Null);
446    }
447
448    #[test]
449    fn json_round_trip_constant_case_as_bare_string() {
450        let value = NxValue::String("active".to_string());
451
452        let json = value.to_json_string().unwrap();
453        assert_eq!(json, "\"active\"");
454        let decoded = NxValue::from_json_str(&json).unwrap();
455        assert_eq!(decoded, value);
456    }
457
458    #[test]
459    fn json_parses_large_u64_as_float() {
460        let value = NxValue::from_json_str("18446744073709551615").unwrap();
461        assert!(matches!(value, NxValue::Float(_)));
462    }
463
464    #[test]
465    fn json_number_boundaries() {
466        let max_i64 = NxValue::from_json_str("9223372036854775807").unwrap();
467        assert_eq!(max_i64, NxValue::Int(i64::MAX));
468
469        let min_i64 = NxValue::from_json_str("-9223372036854775808").unwrap();
470        assert_eq!(min_i64, NxValue::Int(i64::MIN));
471
472        let just_over_max_i64 = NxValue::from_json_str("9223372036854775808").unwrap();
473        assert!(matches!(just_over_max_i64, NxValue::Float(_)));
474    }
475
476    #[test]
477    fn json_supports_exponent_notation() {
478        let value = NxValue::from_json_str("1e3").unwrap();
479        assert_eq!(value, NxValue::Float(1000.0));
480    }
481
482    #[test]
483    fn json_preserves_negative_zero_sign() {
484        let value = NxValue::from_json_str("-0.0").unwrap();
485        match value {
486            NxValue::Float(f) => {
487                assert_eq!(f.to_bits(), (-0.0f64).to_bits());
488            }
489            _ => panic!("Expected float"),
490        }
491    }
492
493    #[test]
494    fn json_object_serialization_is_deterministic() {
495        let mut obj = BTreeMap::new();
496        obj.insert("b".to_string(), NxValue::Int(2));
497        obj.insert("a".to_string(), NxValue::Int(1));
498
499        let value = NxValue::Record {
500            type_name: None,
501            properties: obj,
502        };
503        let json = value.to_json_string().unwrap();
504        assert_eq!(json, "{\"a\":1,\"b\":2}");
505    }
506
507    #[test]
508    fn json_reader_writer_round_trip() {
509        let value = NxValue::Array(vec![
510            NxValue::String("x".to_string()),
511            NxValue::Int(1),
512            NxValue::Bool(false),
513        ]);
514
515        let mut buffer = Vec::new();
516        value.to_json_writer(&mut buffer).unwrap();
517
518        let decoded = NxValue::from_json_reader(Cursor::new(buffer)).unwrap();
519        assert_eq!(decoded, value);
520    }
521
522    #[test]
523    fn json_pretty_string_round_trip() {
524        let value = NxValue::Record {
525            type_name: None,
526            properties: BTreeMap::from([(
527                "a".to_string(),
528                NxValue::Array(vec![NxValue::Int(1), NxValue::Int(2)]),
529            )]),
530        };
531
532        let json = value.to_json_string_pretty().unwrap();
533        let decoded = NxValue::from_json_str(&json).unwrap();
534        assert_eq!(decoded, value);
535    }
536
537    #[test]
538    fn json_invalid_input_errors() {
539        assert!(NxValue::from_json_str("{]").is_err());
540        assert!(NxValue::from_json_str("[1,]").is_err());
541    }
542
543    #[test]
544    fn json_missing_file_errors() {
545        let tmp = tempfile::tempdir().unwrap();
546        let missing = tmp.path().join("missing.json");
547
548        let err = NxValue::from_json_file(&missing).unwrap_err();
549        assert!(matches!(err, NxValueIoError::Io(_)));
550    }
551
552    #[test]
553    fn json_file_helpers_round_trip() {
554        let tmp = tempfile::tempdir().unwrap();
555        let path = tmp.path().join("value.json");
556
557        let mut obj = BTreeMap::new();
558        obj.insert("name".to_string(), NxValue::String("Ada".to_string()));
559        obj.insert("age".to_string(), NxValue::Int(42));
560        let value = NxValue::Record {
561            type_name: None,
562            properties: obj,
563        };
564
565        value.to_json_file_pretty(&path).unwrap();
566        let decoded = NxValue::from_json_file(&path).unwrap();
567        assert_eq!(decoded, value);
568    }
569
570    #[test]
571    fn msgpack_round_trip_in_memory() {
572        let mut obj = BTreeMap::new();
573        obj.insert("name".to_string(), NxValue::String("Ada".to_string()));
574        obj.insert("age".to_string(), NxValue::Int(42));
575
576        let value = NxValue::Record {
577            type_name: Some("User".to_string()),
578            properties: obj,
579        };
580
581        let bytes = value.to_msgpack_vec().unwrap();
582        let decoded = NxValue::from_msgpack_slice(&bytes).unwrap();
583        assert_eq!(decoded, value);
584    }
585
586    #[test]
587    fn msgpack_round_trip_constant_case_as_bare_string() {
588        let value = NxValue::String("active".to_string());
589
590        let bytes = value.to_msgpack_vec().unwrap();
591        let decoded = NxValue::from_msgpack_slice(&bytes).unwrap();
592        assert_eq!(decoded, value);
593    }
594
595    #[test]
596    fn int32_round_trip_msgpack() {
597        let value = NxValue::Int32(42);
598        let bytes = value.to_msgpack_vec().unwrap();
599        let decoded = NxValue::from_msgpack_slice(&bytes).unwrap();
600        // MessagePack may widen small integers; the value should be numerically equal
601        match decoded {
602            NxValue::Int32(n) => assert_eq!(n, 42),
603            NxValue::Int(n) => assert_eq!(n, 42),
604            other => panic!("Expected integer, got {:?}", other),
605        }
606    }
607
608    #[test]
609    fn float32_serializes_to_json() {
610        let value = NxValue::Float32(2.75);
611        let json = value.to_json_string().unwrap();
612        // JSON serializes f32 as a number
613        let decoded = NxValue::from_json_str(&json).unwrap();
614        // JSON always deserializes as f64, so we get Float back
615        assert!(matches!(decoded, NxValue::Float(_)));
616    }
617
618    #[test]
619    fn int32_serializes_to_json() {
620        let value = NxValue::Int32(42);
621        let json = value.to_json_string().unwrap();
622        assert_eq!(json, "42");
623        // JSON always deserializes integers via visit_i64/visit_u64
624        let decoded = NxValue::from_json_str(&json).unwrap();
625        assert_eq!(decoded, NxValue::Int(42));
626    }
627}