Skip to main content

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        Value::List(items) => {
199            // Convert list to JSON array, recursively converting each element
200            let mut arr = Vec::with_capacity(items.len());
201            for item in items.as_ref() {
202                arr.push(value_to_json(item));
203            }
204            JsonValue::Array(arr)
205        }
206    }
207}
208
209fn tensor_to_json(tensor: &Tensor) -> JsonValue {
210    // Convert tensor to nested arrays recursively
211    match tensor {
212        Tensor::Scalar(n) => Number::from_f64(*n).map_or(JsonValue::Null, JsonValue::Number),
213        Tensor::Array(items) => {
214            // OPTIMIZATION: Pre-allocate array with exact capacity
215            // Reduces reallocations during recursive tensor serialization
216            let mut arr = Vec::with_capacity(items.len());
217            for item in items {
218                arr.push(tensor_to_json(item));
219            }
220            JsonValue::Array(arr)
221        }
222    }
223}
224
225fn matrix_list_to_json(
226    list: &MatrixList,
227    doc: &Document,
228    config: &ToJsonConfig,
229) -> Result<JsonValue, String> {
230    // P1 OPTIMIZATION: Pre-allocate array capacity
231    let mut array = Vec::with_capacity(list.rows.len());
232
233    for row in &list.rows {
234        // P1 OPTIMIZATION: Pre-allocate row object capacity
235        let mut row_obj = Map::with_capacity(list.schema.len() + 2); // +2 for metadata fields
236
237        // Add field values according to schema
238        // Per SPEC.md: Node.fields contains ALL values including ID (first column)
239        // MatrixList.schema includes all column names with ID first
240        for (i, col_name) in list.schema.iter().enumerate() {
241            if let Some(field_value) = row.fields.get(i) {
242                row_obj.insert(col_name.clone(), value_to_json(field_value));
243            }
244        }
245
246        // Add metadata if configured
247        if config.include_metadata {
248            row_obj.insert(
249                "__type__".to_string(),
250                JsonValue::String(list.type_name.clone()),
251            );
252        }
253
254        // Add children if configured and present
255        if config.include_children {
256            if let Some(ref children) = row.children {
257                for (child_type, child_nodes) in children.as_ref() {
258                    let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
259                    row_obj.insert(child_type.clone(), child_json);
260                }
261            }
262        }
263
264        array.push(JsonValue::Object(row_obj));
265    }
266
267    // Wrap with metadata if configured
268    if config.include_metadata && !config.flatten_lists {
269        let mut metadata = json!({
270            "__type__": list.type_name,
271            "__schema__": list.schema,
272            "items": array
273        });
274
275        // Include count_hint if present
276        if let Some(count) = list.count_hint {
277            if let Some(obj) = metadata.as_object_mut() {
278                obj.insert(
279                    "__count_hint__".to_string(),
280                    JsonValue::Number(count.into()),
281                );
282            }
283        }
284
285        Ok(metadata)
286    } else {
287        Ok(JsonValue::Array(array))
288    }
289}
290
291fn nodes_to_json(
292    type_name: &str,
293    nodes: &[Node],
294    doc: &Document,
295    config: &ToJsonConfig,
296) -> Result<JsonValue, String> {
297    // OPTIMIZATION: Pre-allocate array with exact capacity
298    // Reduces reallocation during node processing
299    let mut array = Vec::with_capacity(nodes.len());
300
301    // Look up the schema for this type from the document
302    let schema = doc.get_schema(type_name);
303
304    for node in nodes {
305        // OPTIMIZATION: Pre-allocate map capacity based on schema size + metadata + children
306        let capacity = if let Some(field_names) = schema {
307            field_names.len()
308                + usize::from(config.include_metadata)
309                + node.children.as_ref().map_or(0, |c| c.len())
310        } else {
311            node.fields.len()
312                + usize::from(config.include_metadata)
313                + node.children.as_ref().map_or(0, |c| c.len())
314        };
315        let mut obj = Map::with_capacity(capacity);
316
317        // Add fields according to schema if available
318        if let Some(field_names) = schema {
319            for (i, col_name) in field_names.iter().enumerate() {
320                if let Some(field_value) = node.fields.get(i) {
321                    obj.insert(col_name.clone(), value_to_json(field_value));
322                }
323            }
324        } else {
325            // Fallback: use id + field_N naming when schema not available
326            obj.insert("id".to_string(), JsonValue::String(node.id.clone()));
327            for (i, value) in node.fields.iter().enumerate() {
328                obj.insert(format!("field_{i}"), value_to_json(value));
329            }
330        }
331
332        // Add metadata if configured
333        if config.include_metadata {
334            obj.insert(
335                "__type__".to_string(),
336                JsonValue::String(type_name.to_string()),
337            );
338        }
339
340        // Add children if configured
341        if config.include_children {
342            if let Some(ref children) = node.children {
343                for (child_type, child_nodes) in children.as_ref() {
344                    let child_json = nodes_to_json(child_type, child_nodes, doc, config)?;
345                    obj.insert(child_type.clone(), child_json);
346                }
347            }
348        }
349
350        array.push(JsonValue::Object(obj));
351    }
352
353    Ok(JsonValue::Array(array))
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use hedl_core::{Expression, Reference};
360
361    // ==================== ToJsonConfig tests ====================
362
363    #[test]
364    fn test_to_json_config_default() {
365        let config = ToJsonConfig::default();
366        assert!(!config.include_metadata);
367        assert!(!config.flatten_lists);
368        assert!(config.include_children);
369    }
370
371    #[test]
372    fn test_to_json_config_debug() {
373        let config = ToJsonConfig::default();
374        let debug = format!("{config:?}");
375        assert!(debug.contains("ToJsonConfig"));
376        assert!(debug.contains("include_metadata"));
377        assert!(debug.contains("flatten_lists"));
378        assert!(debug.contains("include_children"));
379    }
380
381    #[test]
382    fn test_to_json_config_clone() {
383        let config = ToJsonConfig {
384            include_metadata: true,
385            flatten_lists: true,
386            include_children: false,
387            ascii_safe: true,
388        };
389        let cloned = config.clone();
390        assert!(cloned.include_metadata);
391        assert!(cloned.flatten_lists);
392        assert!(!cloned.include_children);
393        assert!(cloned.ascii_safe);
394    }
395
396    // ==================== value_to_json tests ====================
397
398    #[test]
399    fn test_value_to_json() {
400        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
401        assert_eq!(value_to_json(&Value::Bool(true)), JsonValue::Bool(true));
402        assert_eq!(value_to_json(&Value::Int(42)), json!(42));
403        assert_eq!(
404            value_to_json(&Value::String("hello".into())),
405            json!("hello")
406        );
407    }
408
409    #[test]
410    fn test_value_to_json_null() {
411        assert_eq!(value_to_json(&Value::Null), JsonValue::Null);
412    }
413
414    #[test]
415    fn test_value_to_json_bool() {
416        assert_eq!(value_to_json(&Value::Bool(true)), json!(true));
417        assert_eq!(value_to_json(&Value::Bool(false)), json!(false));
418    }
419
420    #[test]
421    fn test_value_to_json_int() {
422        assert_eq!(value_to_json(&Value::Int(0)), json!(0));
423        assert_eq!(value_to_json(&Value::Int(-42)), json!(-42));
424        assert_eq!(value_to_json(&Value::Int(i64::MAX)), json!(i64::MAX));
425    }
426
427    #[test]
428    fn test_value_to_json_float() {
429        assert_eq!(value_to_json(&Value::Float(3.5)), json!(3.5));
430        assert_eq!(value_to_json(&Value::Float(0.0)), json!(0.0));
431        assert_eq!(value_to_json(&Value::Float(-1.5)), json!(-1.5));
432    }
433
434    #[test]
435    fn test_value_to_json_float_nan() {
436        // NaN cannot be represented in JSON, becomes null
437        assert_eq!(value_to_json(&Value::Float(f64::NAN)), JsonValue::Null);
438    }
439
440    #[test]
441    fn test_value_to_json_float_infinity() {
442        // Infinity cannot be represented in JSON, becomes null
443        assert_eq!(value_to_json(&Value::Float(f64::INFINITY)), JsonValue::Null);
444        assert_eq!(
445            value_to_json(&Value::Float(f64::NEG_INFINITY)),
446            JsonValue::Null
447        );
448    }
449
450    #[test]
451    fn test_value_to_json_string() {
452        assert_eq!(value_to_json(&Value::String("".into())), json!(""));
453        assert_eq!(
454            value_to_json(&Value::String("hello world".into())),
455            json!("hello world")
456        );
457        assert_eq!(
458            value_to_json(&Value::String("with\nnewline".into())),
459            json!("with\nnewline")
460        );
461    }
462
463    #[test]
464    fn test_value_to_json_string_unicode() {
465        assert_eq!(
466            value_to_json(&Value::String("héllo 世界".into())),
467            json!("héllo 世界")
468        );
469    }
470
471    #[test]
472    fn test_value_to_json_reference() {
473        let reference = Reference::qualified("User", "123");
474        let json = value_to_json(&Value::Reference(reference));
475        assert_eq!(json, json!({"@ref": "@User:123"}));
476    }
477
478    #[test]
479    fn test_value_to_json_reference_local() {
480        let reference = Reference::local("123");
481        let json = value_to_json(&Value::Reference(reference));
482        assert_eq!(json, json!({"@ref": "@123"}));
483    }
484
485    #[test]
486    fn test_value_to_json_expression() {
487        use hedl_core::lex::Span;
488        let expr = Expression::Identifier {
489            name: "foo".to_string(),
490            span: Span::synthetic(),
491        };
492        let json = value_to_json(&Value::Expression(Box::new(expr)));
493        assert_eq!(json, json!("$(foo)"));
494    }
495
496    // ==================== tensor_to_json tests ====================
497
498    #[test]
499    fn test_tensor_to_json_scalar() {
500        assert_eq!(tensor_to_json(&Tensor::Scalar(1.0)), json!(1.0));
501        assert_eq!(tensor_to_json(&Tensor::Scalar(3.5)), json!(3.5));
502    }
503
504    #[test]
505    fn test_tensor_to_json_1d() {
506        let tensor = Tensor::Array(vec![
507            Tensor::Scalar(1.0),
508            Tensor::Scalar(2.0),
509            Tensor::Scalar(3.0),
510        ]);
511        assert_eq!(tensor_to_json(&tensor), json!([1.0, 2.0, 3.0]));
512    }
513
514    #[test]
515    fn test_tensor_to_json_2d() {
516        let tensor = Tensor::Array(vec![
517            Tensor::Array(vec![Tensor::Scalar(1.0), Tensor::Scalar(2.0)]),
518            Tensor::Array(vec![Tensor::Scalar(3.0), Tensor::Scalar(4.0)]),
519        ]);
520        assert_eq!(tensor_to_json(&tensor), json!([[1.0, 2.0], [3.0, 4.0]]));
521    }
522
523    #[test]
524    fn test_tensor_to_json_empty() {
525        let tensor = Tensor::Array(vec![]);
526        assert_eq!(tensor_to_json(&tensor), json!([]));
527    }
528
529    #[test]
530    fn test_tensor_to_json_nan_becomes_null() {
531        let tensor = Tensor::Scalar(f64::NAN);
532        assert_eq!(tensor_to_json(&tensor), JsonValue::Null);
533    }
534
535    // ==================== item_to_json tests ====================
536
537    #[test]
538    fn test_item_to_json_scalar() {
539        let doc = Document::new((2, 0));
540        let config = ToJsonConfig::default();
541        let item = Item::Scalar(Value::Int(42));
542        let result = item_to_json(&item, &doc, &config).unwrap();
543        assert_eq!(result, json!(42));
544    }
545
546    #[test]
547    fn test_item_to_json_object() {
548        let doc = Document::new((2, 0));
549        let config = ToJsonConfig::default();
550        let mut obj = BTreeMap::new();
551        obj.insert(
552            "key".to_string(),
553            Item::Scalar(Value::String("value".into())),
554        );
555        let item = Item::Object(obj);
556        let result = item_to_json(&item, &doc, &config).unwrap();
557        assert_eq!(result, json!({"key": "value"}));
558    }
559
560    // ==================== object_to_json tests ====================
561
562    #[test]
563    fn test_object_to_json_empty() {
564        let doc = Document::new((2, 0));
565        let config = ToJsonConfig::default();
566        let obj = BTreeMap::new();
567        let result = object_to_json(&obj, &doc, &config).unwrap();
568        assert_eq!(result, json!({}));
569    }
570
571    #[test]
572    fn test_object_to_json_nested() {
573        let doc = Document::new((2, 0));
574        let config = ToJsonConfig::default();
575        let mut inner = BTreeMap::new();
576        inner.insert("nested".to_string(), Item::Scalar(Value::Bool(true)));
577        let mut outer = BTreeMap::new();
578        outer.insert("inner".to_string(), Item::Object(inner));
579        let result = object_to_json(&outer, &doc, &config).unwrap();
580        assert_eq!(result, json!({"inner": {"nested": true}}));
581    }
582
583    // ==================== root_to_json tests ====================
584
585    #[test]
586    fn test_root_to_json_empty() {
587        let doc = Document::new((2, 0));
588        let config = ToJsonConfig::default();
589        let root = BTreeMap::new();
590        let result = root_to_json(&root, &doc, &config).unwrap();
591        assert_eq!(result, json!({}));
592    }
593
594    #[test]
595    fn test_root_to_json_with_items() {
596        let doc = Document::new((2, 0));
597        let config = ToJsonConfig::default();
598        let mut root = BTreeMap::new();
599        root.insert(
600            "name".to_string(),
601            Item::Scalar(Value::String("test".into())),
602        );
603        root.insert("count".to_string(), Item::Scalar(Value::Int(42)));
604        let result = root_to_json(&root, &doc, &config).unwrap();
605        assert_eq!(result, json!({"name": "test", "count": 42}));
606    }
607
608    // ==================== to_json tests ====================
609
610    #[test]
611    fn test_to_json_empty_document() {
612        let doc = Document {
613            version: (1, 0),
614            aliases: BTreeMap::new(),
615            structs: BTreeMap::new(),
616            nests: BTreeMap::new(),
617            root: BTreeMap::new(),
618            schema_versions: BTreeMap::new(),
619        };
620        let config = ToJsonConfig::default();
621        let result = to_json(&doc, &config).unwrap();
622        assert_eq!(result.trim(), "{}");
623    }
624
625    #[test]
626    fn test_to_json_with_scalars() {
627        let mut root = BTreeMap::new();
628        root.insert(
629            "name".to_string(),
630            Item::Scalar(Value::String("test".into())),
631        );
632        root.insert("active".to_string(), Item::Scalar(Value::Bool(true)));
633        let doc = Document {
634            version: (1, 0),
635            aliases: BTreeMap::new(),
636            structs: BTreeMap::new(),
637            nests: BTreeMap::new(),
638            root,
639            schema_versions: BTreeMap::new(),
640        };
641        let config = ToJsonConfig::default();
642        let result = to_json(&doc, &config).unwrap();
643        let parsed: JsonValue = serde_json::from_str(&result).unwrap();
644        assert_eq!(parsed["name"], json!("test"));
645        assert_eq!(parsed["active"], json!(true));
646    }
647
648    // ==================== to_json_value tests ====================
649
650    #[test]
651    fn test_to_json_value_simple() {
652        let mut root = BTreeMap::new();
653        root.insert("key".to_string(), Item::Scalar(Value::Int(42)));
654        let doc = Document {
655            version: (1, 0),
656            aliases: BTreeMap::new(),
657            structs: BTreeMap::new(),
658            nests: BTreeMap::new(),
659            root,
660            schema_versions: BTreeMap::new(),
661        };
662        let config = ToJsonConfig::default();
663        let result = to_json_value(&doc, &config).unwrap();
664        assert_eq!(result, json!({"key": 42}));
665    }
666
667    // ==================== matrix_list_to_json tests ====================
668
669    #[test]
670    fn test_matrix_list_to_json_simple() {
671        let doc = Document::new((2, 0));
672        let config = ToJsonConfig::default();
673        let list = MatrixList {
674            type_name: "User".to_string(),
675            schema: vec!["id".to_string(), "name".to_string()],
676            rows: vec![Node {
677                type_name: "User".to_string(),
678                id: "1".to_string(),
679                fields: vec![Value::String("1".into()), Value::String("Alice".into())].into(),
680                children: None,
681                child_count: 0,
682            }],
683            count_hint: None,
684        };
685        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
686        assert_eq!(result, json!([{"id": "1", "name": "Alice"}]));
687    }
688
689    #[test]
690    fn test_matrix_list_to_json_with_metadata() {
691        let doc = Document::new((2, 0));
692        let config = ToJsonConfig {
693            include_metadata: true,
694            flatten_lists: false,
695            include_children: true,
696            ascii_safe: false,
697        };
698        let list = MatrixList {
699            type_name: "User".to_string(),
700            schema: vec!["id".to_string()],
701            rows: vec![Node {
702                type_name: "User".to_string(),
703                id: "1".to_string(),
704                fields: vec![Value::String("1".into())].into(),
705                children: None,
706                child_count: 0,
707            }],
708            count_hint: None,
709        };
710        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
711        assert!(result["__type__"] == json!("User"));
712        assert!(result["__schema__"] == json!(["id"]));
713    }
714
715    #[test]
716    fn test_matrix_list_to_json_empty() {
717        let doc = Document::new((2, 0));
718        let config = ToJsonConfig::default();
719        let list = MatrixList {
720            type_name: "User".to_string(),
721            schema: vec!["id".to_string()],
722            rows: vec![],
723            count_hint: None,
724        };
725        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
726        assert_eq!(result, json!([]));
727    }
728
729    #[test]
730    fn test_matrix_list_to_json_with_count_hint() {
731        let doc = Document::new((2, 0));
732        let config = ToJsonConfig {
733            include_metadata: true,
734            flatten_lists: false,
735            include_children: true,
736            ascii_safe: false,
737        };
738        let list = MatrixList {
739            type_name: "Team".to_string(),
740            schema: vec!["id".to_string(), "name".to_string()],
741            rows: vec![Node {
742                type_name: "Team".to_string(),
743                id: "1".to_string(),
744                fields: vec![Value::String("1".into()), Value::String("Alpha".into())].into(),
745                children: None,
746                child_count: 0,
747            }],
748            count_hint: Some(5),
749        };
750        let result = matrix_list_to_json(&list, &doc, &config).unwrap();
751
752        // Should include count_hint in metadata
753        assert_eq!(result["__count_hint__"], json!(5));
754        assert_eq!(result["__type__"], json!("Team"));
755        assert_eq!(result["__schema__"], json!(["id", "name"]));
756    }
757}