hedl_json/
to_json.rs

1// Dweve HEDL - Hierarchical Entity Data Language
2//
3// Copyright (c) 2025 Dweve IP B.V. and individual contributors.
4//
5// SPDX-License-Identifier: Apache-2.0
6//
7// Licensed under the Apache License, Version 2.0 (the "License");
8// you may not use this file except in compliance with the License.
9// You may obtain a copy of the License in the LICENSE file at the
10// root of this repository or at: http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! HEDL to JSON conversion
19
20use hedl_core::lex::Tensor;
21use hedl_core::{Document, Item, MatrixList, Node, Value};
22use serde_json::{json, Map, Number, Value as JsonValue};
23use std::collections::BTreeMap;
24
25/// Configuration for JSON output
26#[derive(Debug, Clone)]
27pub struct ToJsonConfig {
28    /// Include HEDL metadata (__type__, __schema__)
29    pub include_metadata: bool,
30    /// Flatten matrix lists to plain arrays
31    pub flatten_lists: bool,
32    /// Include children as nested arrays (default: true)
33    pub include_children: bool,
34    /// Escape all non-ASCII characters as `\uXXXX`
35    ///
36    /// When enabled, outputs JSON with only ASCII characters, escaping all
37    /// Unicode code points >= 128 using `\uXXXX` notation. Characters outside
38    /// the Basic Multilingual Plane (emoji, etc.) are encoded as UTF-16
39    /// surrogate pairs.
40    ///
41    /// Use cases:
42    /// - Legacy systems requiring 7-bit ASCII
43    /// - Email transport with ASCII-only requirements
44    /// - Maximum interoperability with older JSON parsers
45    ///
46    /// Default: false (output UTF-8 directly)
47    pub ascii_safe: bool,
48}
49
50impl Default for ToJsonConfig {
51    fn default() -> Self {
52        Self {
53            include_metadata: false,
54            flatten_lists: false,
55            include_children: true, // Children should be included by default
56            ascii_safe: false,
57        }
58    }
59}
60
61impl hedl_core::convert::ExportConfig for ToJsonConfig {
62    fn include_metadata(&self) -> bool {
63        self.include_metadata
64    }
65
66    fn pretty(&self) -> bool {
67        // JSON always uses pretty printing in to_json
68        true
69    }
70}
71
72/// Convert Document to JSON string
73pub fn to_json(doc: &Document, config: &ToJsonConfig) -> Result<String, String> {
74    let value = to_json_value(doc, config)?;
75
76    if config.ascii_safe {
77        // Use custom ASCII-safe serialization
78        let json = serde_json::to_string_pretty(&value)
79            .map_err(|e| format!("JSON serialization error: {e}"))?;
80        Ok(escape_non_ascii(&json))
81    } else {
82        serde_json::to_string_pretty(&value).map_err(|e| format!("JSON serialization error: {e}"))
83    }
84}
85
86/// Escape all non-ASCII characters in a JSON string as `\uXXXX`
87///
88/// This post-processes a JSON string to escape all non-ASCII characters.
89/// Characters outside the BMP are encoded as UTF-16 surrogate pairs.
90fn escape_non_ascii(json: &str) -> String {
91    let mut result = String::with_capacity(json.len());
92    let mut in_string = false;
93    let mut escape_next = false;
94
95    for ch in json.chars() {
96        if escape_next {
97            // Previous char was a backslash, this is an escaped char
98            result.push(ch);
99            escape_next = false;
100            continue;
101        }
102
103        if ch == '\\' && in_string {
104            result.push(ch);
105            escape_next = true;
106            continue;
107        }
108
109        if ch == '"' {
110            in_string = !in_string;
111            result.push(ch);
112            continue;
113        }
114
115        if in_string && !ch.is_ascii() {
116            // Escape non-ASCII character
117            let code_point = ch as u32;
118
119            if code_point <= 0xFFFF {
120                // BMP character - single \uXXXX escape
121                result.push_str(&format!("\\u{code_point:04x}"));
122            } else {
123                // Non-BMP character - encode as UTF-16 surrogate pair
124                let adjusted = code_point - 0x10000;
125                let high = 0xD800 | ((adjusted >> 10) & 0x3FF);
126                let low = 0xDC00 | (adjusted & 0x3FF);
127                result.push_str(&format!("\\u{high:04x}\\u{low:04x}"));
128            }
129        } else {
130            result.push(ch);
131        }
132    }
133
134    result
135}
136
137/// Convert Document to `serde_json::Value`
138pub fn to_json_value(doc: &Document, config: &ToJsonConfig) -> Result<JsonValue, String> {
139    root_to_json(&doc.root, doc, config)
140}
141
142fn root_to_json(
143    root: &BTreeMap<String, Item>,
144    doc: &Document,
145    config: &ToJsonConfig,
146) -> Result<JsonValue, String> {
147    // P1 OPTIMIZATION: Pre-allocate map capacity (1.05-1.1x speedup)
148    let mut map = Map::with_capacity(root.len());
149
150    for (key, item) in root {
151        let json_value = item_to_json(item, doc, config)?;
152        map.insert(key.clone(), json_value);
153    }
154
155    Ok(JsonValue::Object(map))
156}
157
158fn item_to_json(item: &Item, doc: &Document, config: &ToJsonConfig) -> Result<JsonValue, String> {
159    match item {
160        Item::Scalar(value) => Ok(value_to_json(value)),
161        Item::Object(obj) => object_to_json(obj, doc, config),
162        Item::List(list) => matrix_list_to_json(list, doc, config),
163    }
164}
165
166fn object_to_json(
167    obj: &BTreeMap<String, Item>,
168    doc: &Document,
169    config: &ToJsonConfig,
170) -> Result<JsonValue, String> {
171    // P1 OPTIMIZATION: Pre-allocate map capacity
172    let mut map = Map::with_capacity(obj.len());
173
174    for (key, item) in obj {
175        let json_value = item_to_json(item, doc, config)?;
176        map.insert(key.clone(), json_value);
177    }
178
179    Ok(JsonValue::Object(map))
180}
181
182fn value_to_json(value: &Value) -> JsonValue {
183    match value {
184        Value::Null => JsonValue::Null,
185        Value::Bool(b) => JsonValue::Bool(*b),
186        Value::Int(n) => JsonValue::Number(Number::from(*n)),
187        Value::Float(f) => Number::from_f64(*f).map_or(JsonValue::Null, JsonValue::Number),
188        Value::String(s) => JsonValue::String(s.to_string()),
189        Value::Tensor(t) => tensor_to_json(t),
190        Value::Reference(r) => {
191            // Represent references as objects with special key
192            json!({ "@ref": r.to_ref_string() })
193        }
194        Value::Expression(e) => {
195            // Represent expressions as strings with $() wrapper
196            JsonValue::String(format!("$({e})"))
197        }
198    }
199}
200
201fn tensor_to_json(tensor: &Tensor) -> JsonValue {
202    // Convert tensor to nested arrays recursively
203    match tensor {
204        Tensor::Scalar(n) => Number::from_f64(*n).map_or(JsonValue::Null, JsonValue::Number),
205        Tensor::Array(items) => {
206            // OPTIMIZATION: Pre-allocate array with exact capacity
207            // Reduces reallocations during recursive tensor serialization
208            let mut arr = Vec::with_capacity(items.len());
209            for item in items {
210                arr.push(tensor_to_json(item));
211            }
212            JsonValue::Array(arr)
213        }
214    }
215}
216
217fn matrix_list_to_json(
218    list: &MatrixList,
219    doc: &Document,
220    config: &ToJsonConfig,
221) -> Result<JsonValue, String> {
222    // P1 OPTIMIZATION: Pre-allocate array capacity
223    let mut array = Vec::with_capacity(list.rows.len());
224
225    for row in &list.rows {
226        // P1 OPTIMIZATION: Pre-allocate row object capacity
227        let mut row_obj = Map::with_capacity(list.schema.len() + 2); // +2 for metadata fields
228
229        // Add field values according to schema
230        // Per SPEC.md: Node.fields contains ALL values including ID (first column)
231        // MatrixList.schema includes all column names with ID first
232        for (i, col_name) in list.schema.iter().enumerate() {
233            if let Some(field_value) = row.fields.get(i) {
234                row_obj.insert(col_name.clone(), value_to_json(field_value));
235            }
236        }
237
238        // Add metadata if configured
239        if config.include_metadata {
240            row_obj.insert(
241                "__type__".to_string(),
242                JsonValue::String(list.type_name.clone()),
243            );
244        }
245
246        // Add children if configured and present
247        if config.include_children {
248            if let Some(ref children) = row.children {
249                for (child_type, child_nodes) in children.as_ref() {
250                    let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
251                    row_obj.insert(child_type.clone(), child_json);
252                }
253            }
254        }
255
256        array.push(JsonValue::Object(row_obj));
257    }
258
259    // Wrap with metadata if configured
260    if config.include_metadata && !config.flatten_lists {
261        let mut metadata = json!({
262            "__type__": list.type_name,
263            "__schema__": list.schema,
264            "items": array
265        });
266
267        // Include count_hint if present
268        if let Some(count) = list.count_hint {
269            if let Some(obj) = metadata.as_object_mut() {
270                obj.insert(
271                    "__count_hint__".to_string(),
272                    JsonValue::Number(count.into()),
273                );
274            }
275        }
276
277        Ok(metadata)
278    } else {
279        Ok(JsonValue::Array(array))
280    }
281}
282
283fn nodes_to_json(
284    type_name: &str,
285    nodes: &[Node],
286    doc: &Document,
287    config: &ToJsonConfig,
288) -> Result<JsonValue, String> {
289    // OPTIMIZATION: Pre-allocate array with exact capacity
290    // Reduces reallocation during node processing
291    let mut array = Vec::with_capacity(nodes.len());
292
293    // Look up the schema for this type from the document
294    let schema = doc.get_schema(type_name);
295
296    for node in nodes {
297        // OPTIMIZATION: Pre-allocate map capacity based on schema size + metadata + children
298        let capacity = if let Some(field_names) = schema {
299            field_names.len()
300                + usize::from(config.include_metadata)
301                + node.children.as_ref().map_or(0, |c| c.len())
302        } else {
303            node.fields.len()
304                + usize::from(config.include_metadata)
305                + node.children.as_ref().map_or(0, |c| c.len())
306        };
307        let mut obj = Map::with_capacity(capacity);
308
309        // Add fields according to schema if available
310        if let Some(field_names) = schema {
311            for (i, col_name) in field_names.iter().enumerate() {
312                if let Some(field_value) = node.fields.get(i) {
313                    obj.insert(col_name.clone(), value_to_json(field_value));
314                }
315            }
316        } else {
317            // Fallback: use id + field_N naming when schema not available
318            obj.insert("id".to_string(), JsonValue::String(node.id.clone()));
319            for (i, value) in node.fields.iter().enumerate() {
320                obj.insert(format!("field_{i}"), value_to_json(value));
321            }
322        }
323
324        // Add metadata if configured
325        if config.include_metadata {
326            obj.insert(
327                "__type__".to_string(),
328                JsonValue::String(type_name.to_string()),
329            );
330        }
331
332        // Add children if configured
333        if config.include_children {
334            if let Some(ref children) = node.children {
335                for (child_type, child_nodes) in children.as_ref() {
336                    let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
337                    obj.insert(child_type.clone(), child_json);
338                }
339            }
340        }
341
342        array.push(JsonValue::Object(obj));
343    }
344
345    Ok(JsonValue::Array(array))
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use hedl_core::{Expression, Reference};
352
353    // ==================== ToJsonConfig tests ====================
354
355    #[test]
356    fn test_to_json_config_default() {
357        let config = ToJsonConfig::default();
358        assert!(!config.include_metadata);
359        assert!(!config.flatten_lists);
360        assert!(config.include_children);
361    }
362
363    #[test]
364    fn test_to_json_config_debug() {
365        let config = ToJsonConfig::default();
366        let debug = format!("{config:?}");
367        assert!(debug.contains("ToJsonConfig"));
368        assert!(debug.contains("include_metadata"));
369        assert!(debug.contains("flatten_lists"));
370        assert!(debug.contains("include_children"));
371    }
372
373    #[test]
374    fn test_to_json_config_clone() {
375        let config = ToJsonConfig {
376            include_metadata: true,
377            flatten_lists: true,
378            include_children: false,
379            ascii_safe: true,
380        };
381        let cloned = config.clone();
382        assert!(cloned.include_metadata);
383        assert!(cloned.flatten_lists);
384        assert!(!cloned.include_children);
385        assert!(cloned.ascii_safe);
386    }
387
388    // ==================== value_to_json tests ====================
389
390    #[test]
391    fn test_value_to_json() {
392        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
393        assert_eq!(value_to_json(&Value::Bool(true)), JsonValue::Bool(true));
394        assert_eq!(value_to_json(&Value::Int(42)), json!(42));
395        assert_eq!(
396            value_to_json(&Value::String("hello".into())),
397            json!("hello")
398        );
399    }
400
401    #[test]
402    fn test_value_to_json_null() {
403        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
404    }
405
406    #[test]
407    fn test_value_to_json_bool() {
408        assert_eq!(value_to_json(&Value::Bool(true)), json!(true));
409        assert_eq!(value_to_json(&Value::Bool(false)), json!(false));
410    }
411
412    #[test]
413    fn test_value_to_json_int() {
414        assert_eq!(value_to_json(&Value::Int(0)), json!(0));
415        assert_eq!(value_to_json(&Value::Int(-42)), json!(-42));
416        assert_eq!(value_to_json(&Value::Int(i64::MAX)), json!(i64::MAX));
417    }
418
419    #[test]
420    fn test_value_to_json_float() {
421        assert_eq!(value_to_json(&Value::Float(3.5)), json!(3.5));
422        assert_eq!(value_to_json(&Value::Float(0.0)), json!(0.0));
423        assert_eq!(value_to_json(&Value::Float(-1.5)), json!(-1.5));
424    }
425
426    #[test]
427    fn test_value_to_json_float_nan() {
428        // NaN cannot be represented in JSON, becomes null
429        assert_eq!(value_to_json(&Value::Float(f64::NAN)), JsonValue::Null);
430    }
431
432    #[test]
433    fn test_value_to_json_float_infinity() {
434        // Infinity cannot be represented in JSON, becomes null
435        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), JsonValue::Null);
436        assert_eq!(
437            value_to_json(&Value::Float(f64::NEG_INFINITY)),
438            JsonValue::Null
439        );
440    }
441
442    #[test]
443    fn test_value_to_json_string() {
444        assert_eq!(value_to_json(&Value::String("".into())), json!(""));
445        assert_eq!(
446            value_to_json(&Value::String("hello world".into())),
447            json!("hello world")
448        );
449        assert_eq!(
450            value_to_json(&Value::String("with\nnewline".into())),
451            json!("with\nnewline")
452        );
453    }
454
455    #[test]
456    fn test_value_to_json_string_unicode() {
457        assert_eq!(
458            value_to_json(&Value::String("héllo 世界".into())),
459            json!("héllo 世界")
460        );
461    }
462
463    #[test]
464    fn test_value_to_json_reference() {
465        let reference = Reference::qualified("User", "123");
466        let json = value_to_json(&Value::Reference(reference));
467        assert_eq!(json, json!({"@ref": "@User:123"}));
468    }
469
470    #[test]
471    fn test_value_to_json_reference_local() {
472        let reference = Reference::local("123");
473        let json = value_to_json(&Value::Reference(reference));
474        assert_eq!(json, json!({"@ref": "@123"}));
475    }
476
477    #[test]
478    fn test_value_to_json_expression() {
479        use hedl_core::lex::Span;
480        let expr = Expression::Identifier {
481            name: "foo".to_string(),
482            span: Span::synthetic(),
483        };
484        let json = value_to_json(&Value::Expression(Box::new(expr)));
485        assert_eq!(json, json!("$(foo)"));
486    }
487
488    // ==================== tensor_to_json tests ====================
489
490    #[test]
491    fn test_tensor_to_json_scalar() {
492        assert_eq!(tensor_to_json(&Tensor::Scalar(1.0)), json!(1.0));
493        assert_eq!(tensor_to_json(&Tensor::Scalar(3.5)), json!(3.5));
494    }
495
496    #[test]
497    fn test_tensor_to_json_1d() {
498        let tensor = Tensor::Array(vec![
499            Tensor::Scalar(1.0),
500            Tensor::Scalar(2.0),
501            Tensor::Scalar(3.0),
502        ]);
503        assert_eq!(tensor_to_json(&tensor), json!([1.0, 2.0, 3.0]));
504    }
505
506    #[test]
507    fn test_tensor_to_json_2d() {
508        let tensor = Tensor::Array(vec![
509            Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]),
510            Tensor::Array(vec![Tensor::Scalar(3.0), Tensor::Scalar(4.0)]),
511        ]);
512        assert_eq!(tensor_to_json(&tensor), json!([[1.0, 2.0], [3.0, 4.0]]));
513    }
514
515    #[test]
516    fn test_tensor_to_json_empty() {
517        let tensor = Tensor::Array(vec![]);
518        assert_eq!(tensor_to_json(&tensor), json!([]));
519    }
520
521    #[test]
522    fn test_tensor_to_json_nan_becomes_null() {
523        let tensor = Tensor::Scalar(f64::NAN);
524        assert_eq!(tensor_to_json(&tensor), JsonValue::Null);
525    }
526
527    // ==================== item_to_json tests ====================
528
529    #[test]
530    fn test_item_to_json_scalar() {
531        let doc = Document::new((1, 0));
532        let config = ToJsonConfig::default();
533        let item = Item::Scalar(Value::Int(42));
534        let result = item_to_json(&item, &doc, &config).unwrap();
535        assert_eq!(result, json!(42));
536    }
537
538    #[test]
539    fn test_item_to_json_object() {
540        let doc = Document::new((1, 0));
541        let config = ToJsonConfig::default();
542        let mut obj = BTreeMap::new();
543        obj.insert(
544            "key".to_string(),
545            Item::Scalar(Value::String("value".into())),
546        );
547        let item = Item::Object(obj);
548        let result = item_to_json(&item, &doc, &config).unwrap();
549        assert_eq!(result, json!({"key": "value"}));
550    }
551
552    // ==================== object_to_json tests ====================
553
554    #[test]
555    fn test_object_to_json_empty() {
556        let doc = Document::new((1, 0));
557        let config = ToJsonConfig::default();
558        let obj = BTreeMap::new();
559        let result = object_to_json(&obj, &doc, &config).unwrap();
560        assert_eq!(result, json!({}));
561    }
562
563    #[test]
564    fn test_object_to_json_nested() {
565        let doc = Document::new((1, 0));
566        let config = ToJsonConfig::default();
567        let mut inner = BTreeMap::new();
568        inner.insert("nested".to_string(), Item::Scalar(Value::Bool(true)));
569        let mut outer = BTreeMap::new();
570        outer.insert("inner".to_string(), Item::Object(inner));
571        let result = object_to_json(&outer, &doc, &config).unwrap();
572        assert_eq!(result, json!({"inner": {"nested": true}}));
573    }
574
575    // ==================== root_to_json tests ====================
576
577    #[test]
578    fn test_root_to_json_empty() {
579        let doc = Document::new((1, 0));
580        let config = ToJsonConfig::default();
581        let root = BTreeMap::new();
582        let result = root_to_json(&root, &doc, &config).unwrap();
583        assert_eq!(result, json!({}));
584    }
585
586    #[test]
587    fn test_root_to_json_with_items() {
588        let doc = Document::new((1, 0));
589        let config = ToJsonConfig::default();
590        let mut root = BTreeMap::new();
591        root.insert(
592            "name".to_string(),
593            Item::Scalar(Value::String("test".into())),
594        );
595        root.insert("count".to_string(), Item::Scalar(Value::Int(42)));
596        let result = root_to_json(&root, &doc, &config).unwrap();
597        assert_eq!(result, json!({"name": "test", "count": 42}));
598    }
599
600    // ==================== to_json tests ====================
601
602    #[test]
603    fn test_to_json_empty_document() {
604        let doc = Document {
605            version: (1, 0),
606            aliases: BTreeMap::new(),
607            structs: BTreeMap::new(),
608            nests: BTreeMap::new(),
609            root: BTreeMap::new(),
610            schema_versions: BTreeMap::new(),
611        };
612        let config = ToJsonConfig::default();
613        let result = to_json(&doc, &config).unwrap();
614        assert_eq!(result.trim(), "{}");
615    }
616
617    #[test]
618    fn test_to_json_with_scalars() {
619        let mut root = BTreeMap::new();
620        root.insert(
621            "name".to_string(),
622            Item::Scalar(Value::String("test".into())),
623        );
624        root.insert("active".to_string(), Item::Scalar(Value::Bool(true)));
625        let doc = Document {
626            version: (1, 0),
627            aliases: BTreeMap::new(),
628            structs: BTreeMap::new(),
629            nests: BTreeMap::new(),
630            root,
631            schema_versions: BTreeMap::new(),
632        };
633        let config = ToJsonConfig::default();
634        let result = to_json(&doc, &config).unwrap();
635        let parsed: JsonValue = serde_json::from_str(&result).unwrap();
636        assert_eq!(parsed["name"], json!("test"));
637        assert_eq!(parsed["active"], json!(true));
638    }
639
640    // ==================== to_json_value tests ====================
641
642    #[test]
643    fn test_to_json_value_simple() {
644        let mut root = BTreeMap::new();
645        root.insert("key".to_string(), Item::Scalar(Value::Int(42)));
646        let doc = Document {
647            version: (1, 0),
648            aliases: BTreeMap::new(),
649            structs: BTreeMap::new(),
650            nests: BTreeMap::new(),
651            root,
652            schema_versions: BTreeMap::new(),
653        };
654        let config = ToJsonConfig::default();
655        let result = to_json_value(&doc, &config).unwrap();
656        assert_eq!(result, json!({"key": 42}));
657    }
658
659    // ==================== matrix_list_to_json tests ====================
660
661    #[test]
662    fn test_matrix_list_to_json_simple() {
663        let doc = Document::new((1, 0));
664        let config = ToJsonConfig::default();
665        let list = MatrixList {
666            type_name: "User".to_string(),
667            schema: vec!["id".to_string(), "name".to_string()],
668            rows: vec![Node {
669                type_name: "User".to_string(),
670                id: "1".to_string(),
671                fields: vec![Value::String("1".into()), Value::String("Alice".into())].into(),
672                children: None,
673                child_count: 0,
674            }],
675            count_hint: None,
676        };
677        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
678        assert_eq!(result, json!([{"id": "1", "name": "Alice"}]));
679    }
680
681    #[test]
682    fn test_matrix_list_to_json_with_metadata() {
683        let doc = Document::new((1, 0));
684        let config = ToJsonConfig {
685            include_metadata: true,
686            flatten_lists: false,
687            include_children: true,
688            ascii_safe: false,
689        };
690        let list = MatrixList {
691            type_name: "User".to_string(),
692            schema: vec!["id".to_string()],
693            rows: vec![Node {
694                type_name: "User".to_string(),
695                id: "1".to_string(),
696                fields: vec![Value::String("1".into())].into(),
697                children: None,
698                child_count: 0,
699            }],
700            count_hint: None,
701        };
702        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
703        assert!(result["__type__"] == json!("User"));
704        assert!(result["__schema__"] == json!(["id"]));
705    }
706
707    #[test]
708    fn test_matrix_list_to_json_empty() {
709        let doc = Document::new((1, 0));
710        let config = ToJsonConfig::default();
711        let list = MatrixList {
712            type_name: "User".to_string(),
713            schema: vec!["id".to_string()],
714            rows: vec![],
715            count_hint: None,
716        };
717        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
718        assert_eq!(result, json!([]));
719    }
720
721    #[test]
722    fn test_matrix_list_to_json_with_count_hint() {
723        let doc = Document::new((1, 0));
724        let config = ToJsonConfig {
725            include_metadata: true,
726            flatten_lists: false,
727            include_children: true,
728            ascii_safe: false,
729        };
730        let list = MatrixList {
731            type_name: "Team".to_string(),
732            schema: vec!["id".to_string(), "name".to_string()],
733            rows: vec![Node {
734                type_name: "Team".to_string(),
735                id: "1".to_string(),
736                fields: vec![Value::String("1".into()), Value::String("Alpha".into())].into(),
737                children: None,
738                child_count: 0,
739            }],
740            count_hint: Some(5),
741        };
742        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
743
744        // Should include count_hint in metadata
745        assert_eq!(result["__count_hint__"], json!(5));
746        assert_eq!(result["__type__"], json!("Team"));
747        assert_eq!(result["__schema__"], json!(["id", "name"]));
748    }
749}