Skip to main content

parallax_ir/
lib.rs

1//! Parallax Intermediate Representation (PIR).
2//!
3//! Language-neutral value graphs used for capture, snapshot, and migration.
4
5#![deny(unsafe_code)]
6#![warn(missing_docs)]
7
8mod document;
9mod hash;
10mod value;
11
12pub use document::{PirBinding, PirDocument, PirRoot};
13pub use hash::content_hash;
14pub use value::{PirInteger, PirMapEntry, PirValue};
15
16use parallax_core::{ErrorCode, ParallaxError, PIR_SCHEMA_VERSION};
17
18/// Current PIR schema version re-export.
19pub const SCHEMA_VERSION: u32 = PIR_SCHEMA_VERSION;
20
21/// Result alias for IR operations.
22pub type Result<T> = parallax_core::Result<T>;
23
24/// Serialize a PIR document to pretty JSON bytes.
25pub fn to_json_bytes(doc: &PirDocument) -> Result<Vec<u8>> {
26    serde_json::to_vec_pretty(doc).map_err(|e| {
27        ParallaxError::new(ErrorCode::SerializationFailure, e.to_string())
28            .with_source("parallax-ir")
29            .with_operation("to_json_bytes")
30    })
31}
32
33/// Serialize a PIR document to compact JSON bytes.
34pub fn to_json_bytes_compact(doc: &PirDocument) -> Result<Vec<u8>> {
35    serde_json::to_vec(doc).map_err(|e| {
36        ParallaxError::new(ErrorCode::SerializationFailure, e.to_string())
37            .with_source("parallax-ir")
38            .with_operation("to_json_bytes_compact")
39    })
40}
41
42/// Deserialize a PIR document from JSON bytes.
43pub fn from_json_bytes(bytes: &[u8]) -> Result<PirDocument> {
44    let doc: PirDocument = serde_json::from_slice(bytes).map_err(|e| {
45        ParallaxError::new(ErrorCode::SerializationFailure, e.to_string())
46            .with_source("parallax-ir")
47            .with_operation("from_json_bytes")
48    })?;
49    doc.validate()?;
50    Ok(doc)
51}
52
53/// Parse a PIR value from a JSON value using the tagged wire format.
54pub fn value_from_json(v: &serde_json::Value) -> Result<PirValue> {
55    PirValue::from_json(v)
56}
57
58/// Convert a PIR value to the tagged JSON wire format.
59pub fn value_to_json(v: &PirValue) -> serde_json::Value {
60    v.to_json()
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use indexmap::IndexMap;
67    use parallax_core::{ErrorCode, ObjectId};
68
69    #[test]
70    fn rejects_future_schema_on_deserialize() {
71        let raw = br#"{"schema":99,"bindings":{},"objects":{},"roots":[],"metadata":{}}"#;
72        let err = from_json_bytes(raw).unwrap_err();
73        assert_eq!(err.code, ErrorCode::InvalidSnapshot);
74        assert!(err.message.contains("unsupported PIR schema"));
75    }
76
77    #[test]
78    fn rejects_zero_schema() {
79        let mut doc = PirDocument::new();
80        doc.schema = 0;
81        assert!(doc.validate().is_err());
82    }
83
84    #[test]
85    fn round_trip_demo_state() {
86        let mut fields: IndexMap<String, PirValue> = IndexMap::new();
87        fields.insert("username".into(), PirValue::string("Ada"));
88        fields.insert("score".into(), PirValue::int_i64(42));
89        fields.insert(
90            "projects".into(),
91            PirValue::list(vec![
92                PirValue::string("compiler"),
93                PirValue::string("runtime"),
94                PirValue::string("vm"),
95            ]),
96        );
97        let state = PirValue::Map {
98            entries: fields
99                .into_iter()
100                .map(|(k, v)| PirMapEntry {
101                    key: PirValue::string(k),
102                    value: v,
103                })
104                .collect(),
105        };
106        let mut doc = PirDocument::new();
107        doc.set_binding("state", state);
108        let bytes = to_json_bytes(&doc).unwrap();
109        let restored = from_json_bytes(&bytes).unwrap();
110        assert_eq!(doc.bindings.len(), restored.bindings.len());
111        let s = restored.binding("state").unwrap();
112        match s {
113            PirValue::Map { entries } => {
114                assert_eq!(entries.len(), 3);
115            }
116            other => panic!("expected map, got {other:?}"),
117        }
118    }
119
120    #[test]
121    fn non_finite_float_round_trips_without_becoming_null() {
122        for (v, token) in [
123            (f64::NAN, "NaN"),
124            (f64::INFINITY, "Infinity"),
125            (f64::NEG_INFINITY, "-Infinity"),
126        ] {
127            let pir = PirValue::Float { v };
128            let json = pir.to_json();
129            assert_eq!(json["t"], "float");
130            assert_eq!(json["v"], token);
131            let restored = PirValue::from_json(&json).unwrap();
132            match restored {
133                PirValue::Float { v: got } => {
134                    if v.is_nan() {
135                        assert!(got.is_nan());
136                    } else {
137                        assert_eq!(got, v);
138                    }
139                }
140                other => panic!("expected float, got {other:?}"),
141            }
142        }
143    }
144
145    #[test]
146    fn summary_handles_multibyte_prefix_without_panic() {
147        // Multi-byte UTF-8 beyond 45 bytes previously panicked on &s[..45].
148        let s = "café".repeat(20);
149        let summary = PirValue::string(s).summary();
150        assert!(summary.starts_with('"'));
151        assert!(summary.ends_with('"') || summary.contains('…'));
152    }
153
154    #[test]
155    fn validate_rejects_dangling_refs() {
156        let mut doc = PirDocument::new();
157        doc.set_binding(
158            "x",
159            PirValue::Ref {
160                id: ObjectId::new(99),
161            },
162        );
163        let err = doc.validate().unwrap_err();
164        assert_eq!(err.code, ErrorCode::InvalidSnapshot);
165    }
166}