Skip to main content

haystack_core/codecs/json/
v3.rs

1// JSON v3 codec — application/json;v=3 with type-prefix strings.
2
3use crate::codecs::shared;
4use crate::codecs::{Codec, CodecError};
5use crate::data::{HCol, HDict, HGrid};
6use crate::kinds::*;
7use chrono::{NaiveDate, NaiveTime, Timelike};
8use serde_json::{Map, Value};
9
10/// JSON v3 wire format codec (application/json;v=3).
11///
12/// Uses type-prefix strings for scalars (e.g., `"m:"`, `"n:72 °F"`, `"s:text"`).
13pub struct Json3Codec;
14
15impl Codec for Json3Codec {
16    fn mime_type(&self) -> &str {
17        "application/json;v=3"
18    }
19
20    fn encode_grid(&self, grid: &HGrid) -> Result<String, CodecError> {
21        let val = encode_grid_value(grid)?;
22        serde_json::to_string(&val).map_err(|e| CodecError::Encode(e.to_string()))
23    }
24
25    fn decode_grid(&self, input: &str) -> Result<HGrid, CodecError> {
26        let val: Value = serde_json::from_str(input).map_err(|e| CodecError::Parse {
27            pos: 0,
28            message: e.to_string(),
29        })?;
30        decode_grid_value(&val)
31    }
32
33    fn encode_scalar(&self, val: &Kind) -> Result<String, CodecError> {
34        let json = encode_kind(val)?;
35        serde_json::to_string(&json).map_err(|e| CodecError::Encode(e.to_string()))
36    }
37
38    fn decode_scalar(&self, input: &str) -> Result<Kind, CodecError> {
39        let val: Value = serde_json::from_str(input).map_err(|e| CodecError::Parse {
40            pos: 0,
41            message: e.to_string(),
42        })?;
43        decode_kind(&val)
44    }
45}
46
47// ── Encoding ──
48
49/// Encode a Kind value to a serde_json Value in v3 format.
50pub fn encode_kind(val: &Kind) -> Result<Value, CodecError> {
51    match val {
52        Kind::Null => Ok(Value::Null),
53        Kind::Bool(b) => Ok(Value::Bool(*b)),
54        Kind::Marker => Ok(Value::String("m:".into())),
55        Kind::NA => Ok(Value::String("z:".into())),
56        Kind::Remove => Ok(Value::String("-:".into())),
57        Kind::Number(n) => Ok(encode_number(n)),
58        Kind::Str(s) => Ok(Value::String(format!("s:{s}"))),
59        Kind::Ref(r) => Ok(encode_ref(r)),
60        Kind::Uri(u) => Ok(Value::String(format!("u:{}", u.val()))),
61        Kind::Symbol(s) => Ok(Value::String(format!("y:{}", s.val()))),
62        Kind::Date(d) => Ok(Value::String(format!("d:{}", d.format("%Y-%m-%d")))),
63        Kind::Time(t) => Ok(Value::String(format!("h:{}", encode_time_str(t)))),
64        Kind::DateTime(hdt) => Ok(encode_datetime(hdt)),
65        Kind::Coord(c) => Ok(Value::String(format!("c:{},{}", c.lat, c.lng))),
66        Kind::XStr(x) => Ok(Value::String(format!("x:{}:{}", x.type_name, x.val))),
67        Kind::List(items) => {
68            let arr: Result<Vec<Value>, CodecError> = items.iter().map(encode_kind).collect();
69            Ok(Value::Array(arr?))
70        }
71        Kind::Dict(d) => encode_dict(d),
72        Kind::Grid(g) => {
73            let val = encode_grid_value(g)?;
74            Ok(Value::Object(val))
75        }
76    }
77}
78
79/// Encode a Number to v3 format: `"n:72 °F"` or `"n:72"`.
80fn encode_number(n: &Number) -> Value {
81    let val_str = shared::format_number_val(n.val);
82    match &n.unit {
83        Some(u) => Value::String(format!("n:{val_str} {u}")),
84        None => Value::String(format!("n:{val_str}")),
85    }
86}
87
88/// Encode a Ref to v3 format: `"r:abc Display Name"` or `"r:abc"`.
89fn encode_ref(r: &HRef) -> Value {
90    match &r.dis {
91        Some(dis) => Value::String(format!("r:{} {}", r.val, dis)),
92        None => Value::String(format!("r:{}", r.val)),
93    }
94}
95
96/// Encode a DateTime to v3 format: `"t:2024-01-01T12:30:45-05:00 New_York"`.
97fn encode_datetime(hdt: &HDateTime) -> Value {
98    let dt_str = hdt.dt.format("%Y-%m-%dT%H:%M:%S").to_string();
99    let frac = shared::format_frac_seconds(hdt.dt.nanosecond());
100    let offset_str = hdt.dt.format("%:z").to_string();
101    if hdt.tz_name.is_empty() {
102        Value::String(format!("t:{dt_str}{frac}{offset_str}"))
103    } else {
104        Value::String(format!("t:{dt_str}{frac}{offset_str} {}", hdt.tz_name))
105    }
106}
107
108/// Format a NaiveTime to string (HH:MM:SS with optional fractional seconds).
109fn encode_time_str(t: &NaiveTime) -> String {
110    shared::format_time(t)
111}
112
113/// Encode an HDict as a plain JSON object.
114fn encode_dict(d: &HDict) -> Result<Value, CodecError> {
115    let mut m = Map::new();
116    for (k, v) in d.sorted_iter() {
117        m.insert(k.to_string(), encode_kind(v)?);
118    }
119    Ok(Value::Object(m))
120}
121
122/// Encode an HGrid as a v3 JSON object.
123fn encode_grid_value(grid: &HGrid) -> Result<Map<String, Value>, CodecError> {
124    let mut m = Map::new();
125
126    // meta — always includes ver
127    let mut meta_map = Map::new();
128    meta_map.insert("ver".into(), Value::String("3.0".into()));
129    for (k, v) in grid.meta.sorted_iter() {
130        meta_map.insert(k.to_string(), encode_kind(v)?);
131    }
132    m.insert("meta".into(), Value::Object(meta_map));
133
134    // cols
135    let cols: Result<Vec<Value>, CodecError> = grid
136        .cols
137        .iter()
138        .map(|col| {
139            let mut cm = Map::new();
140            cm.insert("name".into(), Value::String(col.name.clone()));
141            // Flatten meta into column object (v3 spec)
142            if !col.meta.is_empty()
143                && let Value::Object(meta_map) = encode_dict(&col.meta)?
144            {
145                for (k, v) in meta_map {
146                    cm.insert(k, v);
147                }
148            }
149            Ok(Value::Object(cm))
150        })
151        .collect();
152    m.insert("cols".into(), Value::Array(cols?));
153
154    // rows
155    let rows: Result<Vec<Value>, CodecError> = grid.rows.iter().map(encode_dict).collect();
156    m.insert("rows".into(), Value::Array(rows?));
157
158    Ok(m)
159}
160
161// ── Decoding ──
162
163/// Decode a serde_json Value to a Kind using v3 format.
164pub fn decode_kind(val: &Value) -> Result<Kind, CodecError> {
165    match val {
166        Value::Null => Ok(Kind::Null),
167        Value::Bool(b) => Ok(Kind::Bool(*b)),
168        Value::Number(n) => {
169            // Plain JSON numbers decode as Number(val, None)
170            let v = n.as_f64().ok_or_else(|| CodecError::Parse {
171                pos: 0,
172                message: format!("cannot convert JSON number to f64: {n}"),
173            })?;
174            Ok(Kind::Number(Number::unitless(v)))
175        }
176        Value::String(s) => decode_prefixed_string(s),
177        Value::Array(arr) => {
178            let items: Result<Vec<Kind>, CodecError> = arr.iter().map(decode_kind).collect();
179            Ok(Kind::List(items?))
180        }
181        Value::Object(m) => decode_object(m),
182    }
183}
184
185/// Decode a type-prefixed string (e.g., `"m:"`, `"n:72 °F"`, `"s:text"`).
186fn decode_prefixed_string(s: &str) -> Result<Kind, CodecError> {
187    // Check for 2-char prefix patterns (x:)
188    if s.len() >= 2 {
189        let prefix = &s[..2];
190        let rest = &s[2..];
191        match prefix {
192            "m:" => {
193                if rest.is_empty() {
194                    return Ok(Kind::Marker);
195                }
196            }
197            "z:" => {
198                if rest.is_empty() {
199                    return Ok(Kind::NA);
200                }
201            }
202            "-:" => {
203                if rest.is_empty() {
204                    return Ok(Kind::Remove);
205                }
206            }
207            "s:" => return Ok(Kind::Str(rest.to_string())),
208            "n:" => return decode_number_str(rest),
209            "r:" => return decode_ref_str(rest),
210            "u:" => return Ok(Kind::Uri(Uri::new(rest))),
211            "y:" => return Ok(Kind::Symbol(Symbol::new(rest))),
212            "d:" => return decode_date_str(rest),
213            "h:" => return decode_time_str(rest),
214            "t:" => return decode_datetime_str(rest),
215            "c:" => return decode_coord_str(rest),
216            "x:" => return decode_xstr_str(rest),
217            _ => {}
218        }
219    }
220    // No prefix match — treat as plain string
221    // (In v3, bare strings without s: prefix shouldn't normally occur in
222    // well-formed data, but we handle it gracefully.)
223    Ok(Kind::Str(s.to_string()))
224}
225
226/// Decode a number from the v3 `n:` prefix body: `"72 °F"` or `"72"`.
227fn decode_number_str(s: &str) -> Result<Kind, CodecError> {
228    // Format: "val" or "val unit" (space-separated)
229    // Special values: INF, -INF, NaN (with optional unit after space)
230    let (val_str, unit) = match s.find(' ') {
231        Some(pos) => {
232            let val_part = &s[..pos];
233            let unit_part = &s[pos + 1..];
234            (
235                val_part,
236                if unit_part.is_empty() {
237                    None
238                } else {
239                    Some(unit_part.to_string())
240                },
241            )
242        }
243        None => (s, None),
244    };
245
246    let v = match val_str {
247        "INF" => f64::INFINITY,
248        "-INF" => f64::NEG_INFINITY,
249        "NaN" => f64::NAN,
250        _ => val_str.parse::<f64>().map_err(|e| CodecError::Parse {
251            pos: 0,
252            message: format!("invalid v3 number: {e}"),
253        })?,
254    };
255    Ok(Kind::Number(Number::new(v, unit)))
256}
257
258/// Decode a ref from the v3 `r:` prefix body: `"abc Display Name"` or `"abc"`.
259fn decode_ref_str(s: &str) -> Result<Kind, CodecError> {
260    // First token is the ref val, rest (after first space) is display name
261    match s.find(' ') {
262        Some(pos) => {
263            let val = &s[..pos];
264            let dis = &s[pos + 1..];
265            Ok(Kind::Ref(HRef::new(val, Some(dis.to_string()))))
266        }
267        None => Ok(Kind::Ref(HRef::from_val(s))),
268    }
269}
270
271/// Decode a date from the v3 `d:` prefix body: `"2024-01-01"`.
272fn decode_date_str(s: &str) -> Result<Kind, CodecError> {
273    let d = NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|e| CodecError::Parse {
274        pos: 0,
275        message: format!("invalid v3 date: {e}"),
276    })?;
277    Ok(Kind::Date(d))
278}
279
280/// Decode a time from the v3 `h:` prefix body: `"12:30:45"`.
281fn decode_time_str(s: &str) -> Result<Kind, CodecError> {
282    NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
283        .or_else(|_| NaiveTime::parse_from_str(s, "%H:%M:%S"))
284        .map(Kind::Time)
285        .map_err(|e| CodecError::Parse {
286            pos: 0,
287            message: format!("invalid v3 time: {e}"),
288        })
289}
290
291/// Decode a datetime from the v3 `t:` prefix body:
292/// `"2024-01-01T12:30:45-05:00 New_York"` or `"2024-01-01T12:30:45-05:00"`.
293fn decode_datetime_str(s: &str) -> Result<Kind, CodecError> {
294    // The tz name is separated by a space after the offset.
295    // We need to find the tz name carefully — the offset ends after the timezone
296    // offset pattern (e.g., "-05:00" or "+00:00" or "Z").
297    // Strategy: try to find the last space that comes after the datetime part.
298
299    let (dt_str, tz_name) = split_datetime_tz(s);
300
301    let dt = chrono::DateTime::parse_from_rfc3339(dt_str)
302        .or_else(|_| chrono::DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%:z"))
303        .or_else(|_| chrono::DateTime::parse_from_str(dt_str, "%Y-%m-%dT%H:%M:%S%.f%:z"))
304        .map_err(|e| CodecError::Parse {
305            pos: 0,
306            message: format!("invalid v3 datetime: {e}"),
307        })?;
308
309    Ok(Kind::DateTime(HDateTime::new(dt, tz_name)))
310}
311
312/// Split a datetime string into the ISO datetime part and optional timezone name.
313///
314/// Input formats:
315/// - `"2024-01-01T12:30:45-05:00 New_York"` -> `("2024-01-01T12:30:45-05:00", "New_York")`
316/// - `"2024-01-01T12:30:45+00:00"` -> `("2024-01-01T12:30:45+00:00", "")`
317fn split_datetime_tz(s: &str) -> (&str, &str) {
318    // Look for the offset pattern — it's either +HH:MM, -HH:MM, or Z
319    // After the offset, there may be a space followed by the tz name.
320
321    // Find the offset: scan for + or - after the T
322    if let Some(t_pos) = s.find('T') {
323        let after_t = &s[t_pos..];
324        // Find the last +/- in the string after T (for the offset)
325        // The offset is the last sign followed by HH:MM
326        let offset_end = find_offset_end(after_t);
327        if let Some(end) = offset_end {
328            let abs_end = t_pos + end;
329            if abs_end < s.len() {
330                let rest = &s[abs_end..];
331                if let Some(space_pos) = rest.find(' ') {
332                    let dt_part = &s[..abs_end + space_pos];
333                    let tz_part = &s[abs_end + space_pos + 1..];
334                    return (dt_part, tz_part);
335                }
336            }
337            return (s, "");
338        }
339    }
340    (s, "")
341}
342
343/// Find the end position (relative to input) of the UTC offset in a datetime string.
344/// Returns the position after the offset (e.g., after "-05:00" or "Z" or "+00:00").
345fn find_offset_end(s: &str) -> Option<usize> {
346    // Look for Z
347    if s.ends_with('Z') {
348        return Some(s.len());
349    }
350    // Look for +HH:MM or -HH:MM at the end or before a space
351    // Find the last occurrence of +/- that could be an offset
352    for (i, c) in s.char_indices().rev() {
353        if (c == '+' || c == '-') && i + 6 <= s.len() {
354            // Check if this looks like an offset: +HH:MM or -HH:MM
355            let candidate = &s[i..];
356            if candidate.len() >= 6 {
357                let hh = &candidate[1..3];
358                let colon = &candidate[3..4];
359                let mm = &candidate[4..6];
360                if colon == ":"
361                    && hh.chars().all(|c| c.is_ascii_digit())
362                    && mm.chars().all(|c| c.is_ascii_digit())
363                {
364                    return Some(i + 6);
365                }
366            }
367        }
368    }
369    None
370}
371
372/// Decode a coord from the v3 `c:` prefix body: `"37.5,-77.4"`.
373fn decode_coord_str(s: &str) -> Result<Kind, CodecError> {
374    let parts: Vec<&str> = s.splitn(2, ',').collect();
375    if parts.len() != 2 {
376        return Err(CodecError::Parse {
377            pos: 0,
378            message: format!("invalid v3 coord: expected 'lat,lng', got '{s}'"),
379        });
380    }
381    let lat = parts[0].parse::<f64>().map_err(|e| CodecError::Parse {
382        pos: 0,
383        message: format!("invalid v3 coord lat: {e}"),
384    })?;
385    let lng = parts[1].parse::<f64>().map_err(|e| CodecError::Parse {
386        pos: 0,
387        message: format!("invalid v3 coord lng: {e}"),
388    })?;
389    Ok(Kind::Coord(Coord::new(lat, lng)))
390}
391
392/// Decode an xstr from the v3 `x:` prefix body: `"Type:value"`.
393fn decode_xstr_str(s: &str) -> Result<Kind, CodecError> {
394    match s.find(':') {
395        Some(pos) => {
396            let type_name = &s[..pos];
397            let val = &s[pos + 1..];
398            Ok(Kind::XStr(XStr::new(type_name, val)))
399        }
400        None => Err(CodecError::Parse {
401            pos: 0,
402            message: format!("invalid v3 xstr: expected 'Type:value', got '{s}'"),
403        }),
404    }
405}
406
407/// Decode a JSON object — could be a v3 grid (has "meta" and "cols") or a dict.
408fn decode_object(m: &Map<String, Value>) -> Result<Kind, CodecError> {
409    // Check if this is a v3 grid: has both "meta" and "cols" keys
410    if m.contains_key("meta") && m.contains_key("cols") {
411        let grid = decode_grid_from_map(m)?;
412        return Ok(Kind::Grid(Box::new(grid)));
413    }
414    // Otherwise it's a plain dict
415    let mut dict = HDict::new();
416    for (key, val) in m {
417        dict.set(key.clone(), decode_kind(val)?);
418    }
419    Ok(Kind::Dict(Box::new(dict)))
420}
421
422/// Decode a v3 grid from a JSON Value.
423pub fn decode_grid_value(val: &Value) -> Result<HGrid, CodecError> {
424    let m = match val {
425        Value::Object(m) => m,
426        _ => {
427            return Err(CodecError::Parse {
428                pos: 0,
429                message: "grid must be a JSON object".into(),
430            });
431        }
432    };
433    decode_grid_from_map(m)
434}
435
436/// Decode a v3 grid from a JSON object map.
437fn decode_grid_from_map(m: &Map<String, Value>) -> Result<HGrid, CodecError> {
438    // meta (skip "ver" key)
439    let meta = match m.get("meta") {
440        Some(Value::Object(meta_map)) => {
441            let mut dict = HDict::new();
442            for (key, val) in meta_map {
443                if key == "ver" {
444                    continue;
445                }
446                dict.set(key.clone(), decode_kind(val)?);
447            }
448            dict
449        }
450        _ => HDict::new(),
451    };
452
453    // cols
454    let cols = match m.get("cols") {
455        Some(Value::Array(arr)) => {
456            let mut cols = Vec::with_capacity(arr.len());
457            for col_val in arr {
458                let col_obj = col_val.as_object().ok_or_else(|| CodecError::Parse {
459                    pos: 0,
460                    message: "col must be a JSON object".into(),
461                })?;
462                let name = match col_obj.get("name") {
463                    Some(Value::String(n)) => n.clone(),
464                    _ => {
465                        return Err(CodecError::Parse {
466                            pos: 0,
467                            message: "col missing 'name' field".into(),
468                        });
469                    }
470                };
471                let mut col_meta = HDict::new();
472                for (key, val) in col_obj {
473                    if key != "name" {
474                        col_meta.set(key.clone(), decode_kind(val)?);
475                    }
476                }
477                cols.push(HCol::with_meta(name, col_meta));
478            }
479            cols
480        }
481        _ => Vec::new(),
482    };
483
484    // rows
485    let rows = match m.get("rows") {
486        Some(Value::Array(arr)) => {
487            let mut rows = Vec::with_capacity(arr.len());
488            for row_val in arr {
489                let row_obj = row_val.as_object().ok_or_else(|| CodecError::Parse {
490                    pos: 0,
491                    message: "row must be a JSON object".into(),
492                })?;
493                let mut dict = HDict::new();
494                for (key, val) in row_obj {
495                    dict.set(key.clone(), decode_kind(val)?);
496                }
497                rows.push(dict);
498            }
499            rows
500        }
501        _ => Vec::new(),
502    };
503
504    Ok(HGrid::from_parts(meta, cols, rows))
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use crate::data::{HCol, HDict, HGrid};
511    use chrono::{FixedOffset, NaiveDate, NaiveTime, TimeZone};
512
513    fn roundtrip_scalar(kind: Kind) -> Kind {
514        let codec = Json3Codec;
515        let encoded = codec.encode_scalar(&kind).unwrap();
516        codec.decode_scalar(&encoded).unwrap()
517    }
518
519    // ── Null ──
520
521    #[test]
522    fn null_roundtrip() {
523        assert_eq!(roundtrip_scalar(Kind::Null), Kind::Null);
524    }
525
526    #[test]
527    fn null_encodes_to_json_null() {
528        let codec = Json3Codec;
529        assert_eq!(codec.encode_scalar(&Kind::Null).unwrap(), "null");
530    }
531
532    // ── Bool ──
533
534    #[test]
535    fn bool_true_roundtrip() {
536        assert_eq!(roundtrip_scalar(Kind::Bool(true)), Kind::Bool(true));
537    }
538
539    #[test]
540    fn bool_false_roundtrip() {
541        assert_eq!(roundtrip_scalar(Kind::Bool(false)), Kind::Bool(false));
542    }
543
544    #[test]
545    fn bool_encodes_to_json_bool() {
546        let codec = Json3Codec;
547        assert_eq!(codec.encode_scalar(&Kind::Bool(true)).unwrap(), "true");
548        assert_eq!(codec.encode_scalar(&Kind::Bool(false)).unwrap(), "false");
549    }
550
551    // ── Marker ──
552
553    #[test]
554    fn marker_roundtrip() {
555        assert_eq!(roundtrip_scalar(Kind::Marker), Kind::Marker);
556    }
557
558    #[test]
559    fn marker_encodes_as_prefix() {
560        let codec = Json3Codec;
561        assert_eq!(codec.encode_scalar(&Kind::Marker).unwrap(), "\"m:\"");
562    }
563
564    // ── NA ──
565
566    #[test]
567    fn na_roundtrip() {
568        assert_eq!(roundtrip_scalar(Kind::NA), Kind::NA);
569    }
570
571    #[test]
572    fn na_encodes_as_prefix() {
573        let codec = Json3Codec;
574        assert_eq!(codec.encode_scalar(&Kind::NA).unwrap(), "\"z:\"");
575    }
576
577    // ── Remove ──
578
579    #[test]
580    fn remove_roundtrip() {
581        assert_eq!(roundtrip_scalar(Kind::Remove), Kind::Remove);
582    }
583
584    #[test]
585    fn remove_encodes_as_prefix() {
586        let codec = Json3Codec;
587        assert_eq!(codec.encode_scalar(&Kind::Remove).unwrap(), "\"-:\"");
588    }
589
590    // ── Number ──
591
592    #[test]
593    fn number_unitless_roundtrip() {
594        let k = Kind::Number(Number::unitless(72.5));
595        assert_eq!(roundtrip_scalar(k.clone()), k);
596    }
597
598    #[test]
599    fn number_with_unit_roundtrip() {
600        let k = Kind::Number(Number::new(72.5, Some("\u{00B0}F".into())));
601        assert_eq!(roundtrip_scalar(k.clone()), k);
602    }
603
604    #[test]
605    fn number_zero_roundtrip() {
606        let k = Kind::Number(Number::unitless(0.0));
607        assert_eq!(roundtrip_scalar(k.clone()), k);
608    }
609
610    #[test]
611    fn number_negative_roundtrip() {
612        let k = Kind::Number(Number::new(-23.45, Some("m\u{00B2}".into())));
613        assert_eq!(roundtrip_scalar(k.clone()), k);
614    }
615
616    #[test]
617    fn number_integer_roundtrip() {
618        let k = Kind::Number(Number::unitless(42.0));
619        assert_eq!(roundtrip_scalar(k.clone()), k);
620    }
621
622    #[test]
623    fn number_inf_roundtrip() {
624        let k = Kind::Number(Number::unitless(f64::INFINITY));
625        assert_eq!(roundtrip_scalar(k.clone()), k);
626    }
627
628    #[test]
629    fn number_neg_inf_roundtrip() {
630        let k = Kind::Number(Number::unitless(f64::NEG_INFINITY));
631        assert_eq!(roundtrip_scalar(k.clone()), k);
632    }
633
634    #[test]
635    fn number_nan_roundtrip() {
636        let codec = Json3Codec;
637        let k = Kind::Number(Number::unitless(f64::NAN));
638        let encoded = codec.encode_scalar(&k).unwrap();
639        let decoded = codec.decode_scalar(&encoded).unwrap();
640        match decoded {
641            Kind::Number(n) => {
642                assert!(n.val.is_nan());
643                assert_eq!(n.unit, None);
644            }
645            other => panic!("expected Number, got {other:?}"),
646        }
647    }
648
649    #[test]
650    fn number_encoding_format() {
651        let codec = Json3Codec;
652        let k = Kind::Number(Number::new(72.5, Some("\u{00B0}F".into())));
653        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"n:72.5 \u{00B0}F\"");
654    }
655
656    #[test]
657    fn number_unitless_encoding_format() {
658        let codec = Json3Codec;
659        let k = Kind::Number(Number::unitless(42.0));
660        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"n:42\"");
661    }
662
663    #[test]
664    fn number_inf_encoding_format() {
665        let codec = Json3Codec;
666        let k = Kind::Number(Number::unitless(f64::INFINITY));
667        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"n:INF\"");
668    }
669
670    #[test]
671    fn plain_json_number_decodes_as_number() {
672        let codec = Json3Codec;
673        let decoded = codec.decode_scalar("42.5").unwrap();
674        assert_eq!(decoded, Kind::Number(Number::unitless(42.5)));
675    }
676
677    // ── String ──
678
679    #[test]
680    fn string_simple_roundtrip() {
681        let k = Kind::Str("hello".into());
682        assert_eq!(roundtrip_scalar(k.clone()), k);
683    }
684
685    #[test]
686    fn string_empty_roundtrip() {
687        let k = Kind::Str(String::new());
688        assert_eq!(roundtrip_scalar(k.clone()), k);
689    }
690
691    #[test]
692    fn string_with_special_chars_roundtrip() {
693        let k = Kind::Str("line1\nline2\ttab".into());
694        assert_eq!(roundtrip_scalar(k.clone()), k);
695    }
696
697    #[test]
698    fn string_encodes_with_s_prefix() {
699        let codec = Json3Codec;
700        assert_eq!(
701            codec.encode_scalar(&Kind::Str("hello".into())).unwrap(),
702            "\"s:hello\""
703        );
704    }
705
706    #[test]
707    fn string_empty_encodes_with_s_prefix() {
708        let codec = Json3Codec;
709        assert_eq!(
710            codec.encode_scalar(&Kind::Str(String::new())).unwrap(),
711            "\"s:\""
712        );
713    }
714
715    // ── Ref ──
716
717    #[test]
718    fn ref_simple_roundtrip() {
719        let k = Kind::Ref(HRef::from_val("site-1"));
720        assert_eq!(roundtrip_scalar(k.clone()), k);
721    }
722
723    #[test]
724    fn ref_with_dis_roundtrip() {
725        let k = Kind::Ref(HRef::new("site-1", Some("Main Site".into())));
726        let rt = roundtrip_scalar(k);
727        match rt {
728            Kind::Ref(r) => {
729                assert_eq!(r.val, "site-1");
730                assert_eq!(r.dis, Some("Main Site".into()));
731            }
732            other => panic!("expected Ref, got {other:?}"),
733        }
734    }
735
736    #[test]
737    fn ref_encoding_format() {
738        let codec = Json3Codec;
739        let k = Kind::Ref(HRef::new("abc", Some("Display Name".into())));
740        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"r:abc Display Name\"");
741    }
742
743    // ── Uri ──
744
745    #[test]
746    fn uri_roundtrip() {
747        let k = Kind::Uri(Uri::new("http://example.com/api"));
748        assert_eq!(roundtrip_scalar(k.clone()), k);
749    }
750
751    #[test]
752    fn uri_encoding_format() {
753        let codec = Json3Codec;
754        let k = Kind::Uri(Uri::new("http://example.com"));
755        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"u:http://example.com\"");
756    }
757
758    // ── Symbol ──
759
760    #[test]
761    fn symbol_roundtrip() {
762        let k = Kind::Symbol(Symbol::new("hot-water"));
763        assert_eq!(roundtrip_scalar(k.clone()), k);
764    }
765
766    #[test]
767    fn symbol_encoding_format() {
768        let codec = Json3Codec;
769        let k = Kind::Symbol(Symbol::new("hot-water"));
770        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"y:hot-water\"");
771    }
772
773    // ── Date ──
774
775    #[test]
776    fn date_roundtrip() {
777        let k = Kind::Date(NaiveDate::from_ymd_opt(2024, 3, 13).unwrap());
778        assert_eq!(roundtrip_scalar(k.clone()), k);
779    }
780
781    #[test]
782    fn date_encoding_format() {
783        let codec = Json3Codec;
784        let k = Kind::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
785        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"d:2024-01-01\"");
786    }
787
788    // ── Time ──
789
790    #[test]
791    fn time_roundtrip() {
792        let k = Kind::Time(NaiveTime::from_hms_opt(8, 12, 5).unwrap());
793        assert_eq!(roundtrip_scalar(k.clone()), k);
794    }
795
796    #[test]
797    fn time_with_frac_roundtrip() {
798        let k = Kind::Time(NaiveTime::from_hms_milli_opt(14, 30, 0, 123).unwrap());
799        assert_eq!(roundtrip_scalar(k.clone()), k);
800    }
801
802    #[test]
803    fn time_encoding_format() {
804        let codec = Json3Codec;
805        let k = Kind::Time(NaiveTime::from_hms_opt(12, 30, 45).unwrap());
806        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"h:12:30:45\"");
807    }
808
809    // ── DateTime ──
810
811    #[test]
812    fn datetime_roundtrip() {
813        let offset = FixedOffset::west_opt(5 * 3600).unwrap();
814        let dt = offset.with_ymd_and_hms(2024, 1, 1, 8, 12, 5).unwrap();
815        let k = Kind::DateTime(HDateTime::new(dt, "New_York"));
816        assert_eq!(roundtrip_scalar(k.clone()), k);
817    }
818
819    #[test]
820    fn datetime_utc_roundtrip() {
821        let offset = FixedOffset::east_opt(0).unwrap();
822        let dt = offset.with_ymd_and_hms(2024, 6, 15, 12, 0, 0).unwrap();
823        let k = Kind::DateTime(HDateTime::new(dt, "UTC"));
824        assert_eq!(roundtrip_scalar(k.clone()), k);
825    }
826
827    #[test]
828    fn datetime_encoding_format() {
829        let codec = Json3Codec;
830        let offset = FixedOffset::west_opt(5 * 3600).unwrap();
831        let dt = offset.with_ymd_and_hms(2024, 1, 1, 12, 30, 45).unwrap();
832        let k = Kind::DateTime(HDateTime::new(dt, "New_York"));
833        assert_eq!(
834            codec.encode_scalar(&k).unwrap(),
835            "\"t:2024-01-01T12:30:45-05:00 New_York\""
836        );
837    }
838
839    // ── Coord ──
840
841    #[test]
842    fn coord_roundtrip() {
843        let k = Kind::Coord(Coord::new(37.5458266, -77.4491888));
844        assert_eq!(roundtrip_scalar(k.clone()), k);
845    }
846
847    #[test]
848    fn coord_encoding_format() {
849        let codec = Json3Codec;
850        let k = Kind::Coord(Coord::new(40.7, -74.0));
851        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"c:40.7,-74\"");
852    }
853
854    // ── XStr ──
855
856    #[test]
857    fn xstr_roundtrip() {
858        let k = Kind::XStr(XStr::new("Color", "red"));
859        assert_eq!(roundtrip_scalar(k.clone()), k);
860    }
861
862    #[test]
863    fn xstr_encoding_format() {
864        let codec = Json3Codec;
865        let k = Kind::XStr(XStr::new("Color", "red"));
866        assert_eq!(codec.encode_scalar(&k).unwrap(), "\"x:Color:red\"");
867    }
868
869    // ── List ──
870
871    #[test]
872    fn list_empty_roundtrip() {
873        let k = Kind::List(vec![]);
874        assert_eq!(roundtrip_scalar(k.clone()), k);
875    }
876
877    #[test]
878    fn list_mixed_roundtrip() {
879        let k = Kind::List(vec![
880            Kind::Number(Number::unitless(1.0)),
881            Kind::Str("two".into()),
882            Kind::Marker,
883            Kind::Bool(true),
884            Kind::Null,
885        ]);
886        assert_eq!(roundtrip_scalar(k.clone()), k);
887    }
888
889    #[test]
890    fn list_nested_roundtrip() {
891        let k = Kind::List(vec![
892            Kind::List(vec![Kind::Number(Number::unitless(1.0))]),
893            Kind::List(vec![Kind::Str("inner".into())]),
894        ]);
895        assert_eq!(roundtrip_scalar(k.clone()), k);
896    }
897
898    // ── Dict ──
899
900    #[test]
901    fn dict_empty_roundtrip() {
902        let k = Kind::Dict(Box::new(HDict::new()));
903        assert_eq!(roundtrip_scalar(k.clone()), k);
904    }
905
906    #[test]
907    fn dict_with_values_roundtrip() {
908        let mut d = HDict::new();
909        d.set("site", Kind::Marker);
910        d.set("dis", Kind::Str("Main".into()));
911        d.set(
912            "area",
913            Kind::Number(Number::new(4500.0, Some("ft\u{00B2}".into()))),
914        );
915        let k = Kind::Dict(Box::new(d));
916        assert_eq!(roundtrip_scalar(k.clone()), k);
917    }
918
919    // ── Grid ──
920
921    #[test]
922    fn grid_empty_roundtrip() {
923        let codec = Json3Codec;
924        let g = HGrid::new();
925        let encoded = codec.encode_grid(&g).unwrap();
926        let decoded = codec.decode_grid(&encoded).unwrap();
927        assert!(decoded.is_empty());
928        assert_eq!(decoded.num_cols(), 0);
929    }
930
931    #[test]
932    fn grid_with_data_roundtrip() {
933        let codec = Json3Codec;
934
935        let cols = vec![HCol::new("dis"), HCol::new("area")];
936        let mut row1 = HDict::new();
937        row1.set("dis", Kind::Str("Site One".into()));
938        row1.set("area", Kind::Number(Number::unitless(4500.0)));
939        let mut row2 = HDict::new();
940        row2.set("dis", Kind::Str("Site Two".into()));
941        row2.set("area", Kind::Number(Number::unitless(3200.0)));
942
943        let g = HGrid::from_parts(HDict::new(), cols, vec![row1, row2]);
944        let encoded = codec.encode_grid(&g).unwrap();
945        let decoded = codec.decode_grid(&encoded).unwrap();
946
947        assert_eq!(decoded.num_cols(), 2);
948        assert_eq!(decoded.len(), 2);
949        assert_eq!(decoded.col_names().collect::<Vec<_>>(), vec!["dis", "area"]);
950        assert_eq!(
951            decoded.row(0).unwrap().get("dis"),
952            Some(&Kind::Str("Site One".into()))
953        );
954        assert_eq!(
955            decoded.row(1).unwrap().get("dis"),
956            Some(&Kind::Str("Site Two".into()))
957        );
958    }
959
960    #[test]
961    fn grid_with_meta_roundtrip() {
962        let codec = Json3Codec;
963
964        let mut meta = HDict::new();
965        meta.set("err", Kind::Marker);
966        meta.set("dis", Kind::Str("some error".into()));
967
968        let g = HGrid::from_parts(meta, vec![], vec![]);
969        let encoded = codec.encode_grid(&g).unwrap();
970        let decoded = codec.decode_grid(&encoded).unwrap();
971
972        assert!(decoded.is_err());
973        assert_eq!(
974            decoded.meta.get("dis"),
975            Some(&Kind::Str("some error".into()))
976        );
977    }
978
979    #[test]
980    fn grid_with_col_meta_roundtrip() {
981        let codec = Json3Codec;
982
983        let mut col_meta = HDict::new();
984        col_meta.set("unit", Kind::Str("kW".into()));
985
986        let cols = vec![HCol::new("name"), HCol::with_meta("power", col_meta)];
987        let g = HGrid::from_parts(HDict::new(), cols, vec![]);
988        let encoded = codec.encode_grid(&g).unwrap();
989        let decoded = codec.decode_grid(&encoded).unwrap();
990
991        assert_eq!(decoded.num_cols(), 2);
992        let power_col = decoded.col("power").unwrap();
993        assert_eq!(power_col.meta.get("unit"), Some(&Kind::Str("kW".into())));
994    }
995
996    #[test]
997    fn grid_encoding_has_ver() {
998        let codec = Json3Codec;
999        let g = HGrid::new();
1000        let encoded = codec.encode_grid(&g).unwrap();
1001        let val: Value = serde_json::from_str(&encoded).unwrap();
1002        let meta = val.get("meta").unwrap().as_object().unwrap();
1003        assert_eq!(meta.get("ver").unwrap(), "3.0");
1004    }
1005
1006    #[test]
1007    fn grid_missing_cells() {
1008        let codec = Json3Codec;
1009
1010        let cols = vec![HCol::new("a"), HCol::new("b")];
1011        let mut row1 = HDict::new();
1012        row1.set("a", Kind::Number(Number::unitless(1.0)));
1013        // b missing
1014
1015        let g = HGrid::from_parts(HDict::new(), cols, vec![row1]);
1016        let encoded = codec.encode_grid(&g).unwrap();
1017        let decoded = codec.decode_grid(&encoded).unwrap();
1018
1019        let r = decoded.row(0).unwrap();
1020        assert!(r.has("a"));
1021        assert!(r.missing("b"));
1022    }
1023
1024    // ── Edge cases ──
1025
1026    #[test]
1027    fn disambiguation_str_vs_marker() {
1028        // "m:" should decode as Marker, not Str
1029        let codec = Json3Codec;
1030        let decoded = codec.decode_scalar("\"m:\"").unwrap();
1031        assert_eq!(decoded, Kind::Marker);
1032    }
1033
1034    #[test]
1035    fn disambiguation_str_with_colon() {
1036        // "s:hello:world" should decode as Str("hello:world")
1037        let codec = Json3Codec;
1038        let decoded = codec.decode_scalar("\"s:hello:world\"").unwrap();
1039        assert_eq!(decoded, Kind::Str("hello:world".into()));
1040    }
1041
1042    #[test]
1043    fn string_that_looks_like_prefix() {
1044        // A string that starts with "m:" but was encoded properly as "s:m:"
1045        let k = Kind::Str("m:".into());
1046        let rt = roundtrip_scalar(k.clone());
1047        assert_eq!(rt, k);
1048    }
1049
1050    #[test]
1051    fn list_with_all_types() {
1052        let offset = FixedOffset::west_opt(5 * 3600).unwrap();
1053        let dt = offset.with_ymd_and_hms(2024, 1, 1, 8, 0, 0).unwrap();
1054        let k = Kind::List(vec![
1055            Kind::Null,
1056            Kind::Marker,
1057            Kind::NA,
1058            Kind::Remove,
1059            Kind::Bool(true),
1060            Kind::Number(Number::new(42.0, Some("kW".into()))),
1061            Kind::Str("hello".into()),
1062            Kind::Ref(HRef::new("x", Some("Dis".into()))),
1063            Kind::Uri(Uri::new("http://a.com")),
1064            Kind::Symbol(Symbol::new("tag")),
1065            Kind::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
1066            Kind::Time(NaiveTime::from_hms_opt(12, 0, 0).unwrap()),
1067            Kind::DateTime(HDateTime::new(dt, "New_York")),
1068            Kind::Coord(Coord::new(37.5, -77.4)),
1069            Kind::XStr(XStr::new("Color", "red")),
1070        ]);
1071        assert_eq!(roundtrip_scalar(k.clone()), k);
1072    }
1073
1074    #[test]
1075    fn nested_dict_with_typed_values() {
1076        let mut inner = HDict::new();
1077        inner.set(
1078            "temp",
1079            Kind::Number(Number::new(72.5, Some("\u{00B0}F".into()))),
1080        );
1081        inner.set("site", Kind::Ref(HRef::from_val("s1")));
1082        let k = Kind::Dict(Box::new(inner));
1083        assert_eq!(roundtrip_scalar(k.clone()), k);
1084    }
1085
1086    #[test]
1087    fn grid_nested_in_scalar() {
1088        let codec = Json3Codec;
1089        let cols = vec![HCol::new("x")];
1090        let mut row = HDict::new();
1091        row.set("x", Kind::Number(Number::unitless(42.0)));
1092        let g = HGrid::from_parts(HDict::new(), cols, vec![row]);
1093
1094        let k = Kind::Grid(Box::new(g));
1095        let encoded = codec.encode_scalar(&k).unwrap();
1096        let decoded = codec.decode_scalar(&encoded).unwrap();
1097        match decoded {
1098            Kind::Grid(g) => {
1099                assert_eq!(g.len(), 1);
1100                assert_eq!(g.num_cols(), 1);
1101            }
1102            other => panic!("expected Grid, got {other:?}"),
1103        }
1104    }
1105}