Skip to main content

improv_extfn/
marshal.rs

1//! Marshal `improv_core_model::Value` <-> JSON for the subprocess boundary.
2//!
3//! The Python side sees plain JSON: numbers, bools, strings, and (for
4//! DateTime/Enum/Error) small tagged objects. Args go in as a JSON array; the
5//! function returns exactly one JSON value which we map back and type-check.
6
7use improv_core_model::{Value, ValueError, ValueErrorKind, ValueType};
8use serde_json::{json, Value as J};
9
10/// Encode one `Value` as JSON for stdin.
11///
12/// * Number  -> JSON number
13/// * Boolean -> JSON bool
14/// * Text    -> JSON string
15/// * DateTime-> `{"__improv__":"datetime","value":"<rfc3339>"}`
16/// * Enum    -> `{"__improv__":"enum","value":<u32>}`
17/// * Error   -> `{"__improv__":"error","kind":"...","message":"..."}`
18pub fn value_to_json(v: &Value) -> J {
19    match v {
20        Value::Number(n) => json!(n),
21        Value::Boolean(b) => json!(b),
22        Value::Text(s) => json!(s),
23        Value::DateTime(dt) => json!({
24            "__improv__": "datetime",
25            "value": dt.to_rfc3339(),
26        }),
27        Value::Enum(i) => json!({ "__improv__": "enum", "value": i }),
28        Value::Error(e) => json!({
29            "__improv__": "error",
30            "kind": err_kind_str(&e.kind),
31            "message": err_message(&e.kind),
32        }),
33    }
34}
35
36/// Decode one JSON value returned by the function, validating it against the
37/// declared return type. Returns a marshalling error string on mismatch.
38pub fn json_to_value(j: &J, expected: ValueType) -> Result<Value, String> {
39    match expected {
40        ValueType::Number => j
41            .as_f64()
42            .map(Value::Number)
43            .ok_or_else(|| format!("expected Number, got {}", short(j))),
44        ValueType::Boolean => j
45            .as_bool()
46            .map(Value::Boolean)
47            .ok_or_else(|| format!("expected Boolean, got {}", short(j))),
48        ValueType::Text => j
49            .as_str()
50            .map(|s| Value::Text(s.to_string()))
51            .ok_or_else(|| format!("expected Text, got {}", short(j))),
52        ValueType::DateTime => {
53            let s = tagged_str(j, "datetime")
54                .or_else(|| j.as_str().map(str::to_string))
55                .ok_or_else(|| format!("expected DateTime, got {}", short(j)))?;
56            chrono::DateTime::parse_from_rfc3339(&s)
57                .map(|dt| Value::DateTime(dt.with_timezone(&chrono::Utc)))
58                .map_err(|e| format!("invalid DateTime {s:?}: {e}"))
59        }
60        ValueType::Enum => tagged_u64(j, "enum")
61            .or_else(|| j.as_u64())
62            .and_then(|n| u32::try_from(n).ok())
63            .map(Value::Enum)
64            .ok_or_else(|| format!("expected Enum index, got {}", short(j))),
65    }
66}
67
68/// Type-check an argument against its declared type (used before invocation).
69/// `Value::Error` args are always rejected: external functions receive
70/// well-typed inputs (the engine short-circuits errors before dispatch).
71pub fn arg_matches(v: &Value, expected: ValueType) -> bool {
72    v.type_of() == Some(expected)
73}
74
75fn tagged_str(j: &J, tag: &str) -> Option<String> {
76    if j.get("__improv__")?.as_str()? == tag {
77        j.get("value")?.as_str().map(str::to_string)
78    } else {
79        None
80    }
81}
82
83fn tagged_u64(j: &J, tag: &str) -> Option<u64> {
84    if j.get("__improv__")?.as_str()? == tag {
85        j.get("value")?.as_u64()
86    } else {
87        None
88    }
89}
90
91fn err_kind_str(k: &ValueErrorKind) -> &'static str {
92    match k {
93        ValueErrorKind::TypeMismatch => "type_mismatch",
94        ValueErrorKind::DimensionMismatch => "dimension_mismatch",
95        ValueErrorKind::DivisionByZero => "division_by_zero",
96        ValueErrorKind::MissingInput => "missing_input",
97        ValueErrorKind::Custom(_) => "custom",
98        ValueErrorKind::Null => "null",
99        ValueErrorKind::Ref => "ref",
100        ValueErrorKind::Name => "name",
101        ValueErrorKind::Num => "num",
102        ValueErrorKind::Na => "na",
103    }
104}
105
106fn err_message(k: &ValueErrorKind) -> String {
107    match k {
108        ValueErrorKind::Custom(m) => m.clone(),
109        other => err_kind_str(other).to_string(),
110    }
111}
112
113/// A short, non-panicking preview of a JSON value for error messages.
114fn short(j: &J) -> String {
115    let s = j.to_string();
116    if s.len() > 60 {
117        format!("{}…", &s[..60])
118    } else {
119        s
120    }
121}
122
123/// Convenience: build a `Value::Error` (used by callers mapping ExtFnError).
124pub fn error_value(msg: impl Into<String>) -> Value {
125    Value::Error(ValueError::new(ValueErrorKind::Custom(msg.into())))
126}
127
128#[cfg(test)]
129#[cfg_attr(coverage_nightly, coverage(off))]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn roundtrip_scalars() {
135        assert_eq!(
136            json_to_value(&value_to_json(&Value::Number(2.5)), ValueType::Number).unwrap(),
137            Value::Number(2.5)
138        );
139        assert_eq!(
140            json_to_value(&value_to_json(&Value::Boolean(true)), ValueType::Boolean).unwrap(),
141            Value::Boolean(true)
142        );
143        assert_eq!(
144            json_to_value(&value_to_json(&Value::Text("hi".into())), ValueType::Text).unwrap(),
145            Value::Text("hi".into())
146        );
147        assert_eq!(
148            json_to_value(&value_to_json(&Value::Enum(7)), ValueType::Enum).unwrap(),
149            Value::Enum(7)
150        );
151    }
152
153    #[test]
154    fn roundtrip_datetime() {
155        let dt = chrono::DateTime::parse_from_rfc3339("2025-01-02T03:04:05Z")
156            .unwrap()
157            .with_timezone(&chrono::Utc);
158        let back =
159            json_to_value(&value_to_json(&Value::DateTime(dt)), ValueType::DateTime).unwrap();
160        assert_eq!(back, Value::DateTime(dt));
161    }
162
163    #[test]
164    fn type_mismatch_is_error() {
165        // A JSON string cannot become a Number.
166        assert!(json_to_value(&json!("nope"), ValueType::Number).is_err());
167        // A JSON number cannot become a Boolean.
168        assert!(json_to_value(&json!(1), ValueType::Boolean).is_err());
169    }
170
171    #[test]
172    fn arg_check() {
173        assert!(arg_matches(&Value::Number(1.0), ValueType::Number));
174        assert!(!arg_matches(&Value::Number(1.0), ValueType::Text));
175        assert!(!arg_matches(&error_value("x"), ValueType::Number));
176    }
177
178    #[test]
179    fn error_values_encode_kind_and_message() {
180        use improv_core_model::ValueError;
181        let enc = |k: ValueErrorKind| value_to_json(&Value::Error(ValueError::new(k)));
182        for (k, name) in [
183            (ValueErrorKind::TypeMismatch, "type_mismatch"),
184            (ValueErrorKind::DimensionMismatch, "dimension_mismatch"),
185            (ValueErrorKind::DivisionByZero, "division_by_zero"),
186            (ValueErrorKind::MissingInput, "missing_input"),
187        ] {
188            assert_eq!(
189                enc(k),
190                json!({"__improv__": "error", "kind": name, "message": name})
191            );
192        }
193        assert_eq!(
194            enc(ValueErrorKind::Custom("boom".into())),
195            json!({"__improv__": "error", "kind": "custom", "message": "boom"})
196        );
197    }
198
199    #[test]
200    fn decode_untagged_and_mismatched_shapes() {
201        // Plain RFC 3339 string decodes as DateTime; plain u64 as Enum.
202        assert!(matches!(
203            json_to_value(&json!("2025-01-02T03:04:05Z"), ValueType::DateTime),
204            Ok(Value::DateTime(_))
205        ));
206        assert_eq!(
207            json_to_value(&json!(3), ValueType::Enum).unwrap(),
208            Value::Enum(3)
209        );
210        // A tagged object with the wrong tag falls through to the untagged path.
211        let wrong_tag = json!({"__improv__": "enum", "value": "x"});
212        assert!(json_to_value(&wrong_tag, ValueType::DateTime)
213            .unwrap_err()
214            .contains("expected DateTime"));
215        let wrong_tag = json!({"__improv__": "datetime", "value": 1});
216        assert!(json_to_value(&wrong_tag, ValueType::Enum)
217            .unwrap_err()
218            .contains("expected Enum"));
219        assert!(json_to_value(&json!("not a date"), ValueType::DateTime)
220            .unwrap_err()
221            .contains("invalid DateTime"));
222        assert!(json_to_value(&json!(1), ValueType::Text)
223            .unwrap_err()
224            .contains("expected Text"));
225        // Enum index past u32 is rejected, not truncated.
226        assert!(json_to_value(&json!(u64::MAX), ValueType::Enum).is_err());
227    }
228
229    #[test]
230    fn long_values_are_previewed_not_dumped() {
231        let long = json!("y".repeat(200));
232        let msg = json_to_value(&long, ValueType::Number).unwrap_err();
233        assert!(msg.ends_with('…'), "{msg}");
234        assert!(msg.len() < 100, "{msg}");
235    }
236}