toon_format/encode/
mod.rs

1//! Encoder Implementation
2pub mod folding;
3pub mod primitives;
4pub mod writer;
5use indexmap::IndexMap;
6
7use crate::{
8    constants::MAX_DEPTH,
9    types::{
10        EncodeOptions,
11        IntoJsonValue,
12        JsonValue as Value,
13        KeyFoldingMode,
14        ToonError,
15        ToonResult,
16    },
17    utils::{
18        format_canonical_number,
19        normalize,
20        validation::validate_depth,
21        QuotingContext,
22    },
23};
24
25/// Encode any serializable value to TOON format.
26///
27/// This function accepts any type implementing `serde::Serialize`, including:
28/// - Custom structs with `#[derive(Serialize)]`
29/// - `serde_json::Value`
30/// - Built-in types (Vec, HashMap, etc.)
31///
32/// # Examples
33///
34/// **With custom structs:**
35/// ```
36/// use serde::Serialize;
37/// use toon_format::{
38///     encode,
39///     EncodeOptions,
40/// };
41///
42/// #[derive(Serialize)]
43/// struct User {
44///     name: String,
45///     age: u32,
46/// }
47///
48/// let user = User {
49///     name: "Alice".to_string(),
50///     age: 30,
51/// };
52/// let toon = encode(&user, &EncodeOptions::default())?;
53/// assert!(toon.contains("name: Alice"));
54/// # Ok::<(), toon_format::ToonError>(())
55/// ```
56///
57/// **With JSON values:**
58/// ```
59/// use toon_format::{encode, EncodeOptions, Delimiter};
60/// use serde_json::json;
61///
62/// let data = json!({"tags": ["a", "b", "c"]});
63/// let options = EncodeOptions::new().with_delimiter(Delimiter::Pipe);
64/// let toon = encode(&data, &options)?;
65/// assert!(toon.contains("|"));
66/// # Ok::<(), toon_format::ToonError>(())
67/// ```
68pub fn encode<T: serde::Serialize>(value: &T, options: &EncodeOptions) -> ToonResult<String> {
69    let json_value =
70        serde_json::to_value(value).map_err(|e| ToonError::SerializationError(e.to_string()))?;
71    let json_value: Value = json_value.into();
72    encode_impl(&json_value, options)
73}
74
75fn encode_impl(value: &Value, options: &EncodeOptions) -> ToonResult<String> {
76    let normalized: Value = normalize(value.clone());
77    let mut writer = writer::Writer::new(options.clone());
78
79    match &normalized {
80        Value::Array(arr) => {
81            write_array(&mut writer, None, arr, 0)?;
82        }
83        Value::Object(obj) => {
84            write_object(&mut writer, obj, 0)?;
85        }
86        _ => {
87            write_primitive_value(&mut writer, &normalized, QuotingContext::ObjectValue)?;
88        }
89    }
90
91    Ok(writer.finish())
92}
93
94/// Encode with default options (2-space indent, comma delimiter).
95///
96/// Works with any type implementing `serde::Serialize`.
97///
98/// # Examples
99///
100/// **With structs:**
101/// ```
102/// use serde::Serialize;
103/// use toon_format::encode_default;
104///
105/// #[derive(Serialize)]
106/// struct Person {
107///     name: String,
108///     age: u32,
109/// }
110///
111/// let person = Person {
112///     name: "Alice".to_string(),
113///     age: 30,
114/// };
115/// let toon = encode_default(&person)?;
116/// assert!(toon.contains("name: Alice"));
117/// # Ok::<(), toon_format::ToonError>(())
118/// ```
119///
120/// **With JSON values:**
121/// ```
122/// use toon_format::encode_default;
123/// use serde_json::json;
124///
125/// let data = json!({"tags": ["reading", "gaming", "coding"]});
126/// let toon = encode_default(&data)?;
127/// assert_eq!(toon, "tags[3]: reading,gaming,coding");
128/// # Ok::<(), toon_format::ToonError>(())
129/// ```
130pub fn encode_default<T: serde::Serialize>(value: &T) -> ToonResult<String> {
131    encode(value, &EncodeOptions::default())
132}
133
134/// Encode a JSON object to TOON format (errors if not an object).
135///
136/// This function accepts either `JsonValue` or `serde_json::Value` and converts
137/// automatically.
138///
139/// # Examples
140///
141/// ```
142/// use toon_format::{encode_object, EncodeOptions};
143/// use serde_json::json;
144///
145/// let data = json!({"name": "Alice", "age": 30});
146/// let toon = encode_object(&data, &EncodeOptions::default())?;
147/// assert!(toon.contains("name: Alice"));
148///
149/// // Will error if not an object
150/// assert!(encode_object(&json!(42), &EncodeOptions::default()).is_err());
151/// # Ok::<(), toon_format::ToonError>(())
152/// ```
153pub fn encode_object<V: IntoJsonValue>(value: V, options: &EncodeOptions) -> ToonResult<String> {
154    let json_value = value.into_json_value();
155    if !json_value.is_object() {
156        return Err(ToonError::TypeMismatch {
157            expected: "object".to_string(),
158            found: value_type_name(&json_value).to_string(),
159        });
160    }
161    encode_impl(&json_value, options)
162}
163
164/// Encode a JSON array to TOON format (errors if not an array).
165///
166/// This function accepts either `JsonValue` or `serde_json::Value` and converts
167/// automatically.
168///
169/// # Examples
170///
171/// ```
172/// use toon_format::{encode_array, EncodeOptions};
173/// use serde_json::json;
174///
175/// let data = json!(["a", "b", "c"]);
176/// let toon = encode_array(&data, &EncodeOptions::default())?;
177/// assert_eq!(toon, "[3]: a,b,c");
178///
179/// // Will error if not an array
180/// assert!(encode_array(&json!({"key": "value"}), &EncodeOptions::default()).is_err());
181/// # Ok::<(), toon_format::ToonError>(())
182/// ```
183pub fn encode_array<V: IntoJsonValue>(value: V, options: &EncodeOptions) -> ToonResult<String> {
184    let json_value = value.into_json_value();
185    if !json_value.is_array() {
186        return Err(ToonError::TypeMismatch {
187            expected: "array".to_string(),
188            found: value_type_name(&json_value).to_string(),
189        });
190    }
191    encode_impl(&json_value, options)
192}
193
194fn value_type_name(value: &Value) -> &'static str {
195    match value {
196        Value::Null => "null",
197        Value::Bool(_) => "boolean",
198        Value::Number(_) => "number",
199        Value::String(_) => "string",
200        Value::Array(_) => "array",
201        Value::Object(_) => "object",
202    }
203}
204
205fn write_object(
206    writer: &mut writer::Writer,
207    obj: &IndexMap<String, Value>,
208    depth: usize,
209) -> ToonResult<()> {
210    write_object_impl(writer, obj, depth, false)
211}
212
213fn write_object_impl(
214    writer: &mut writer::Writer,
215    obj: &IndexMap<String, Value>,
216    depth: usize,
217    disable_folding: bool,
218) -> ToonResult<()> {
219    validate_depth(depth, MAX_DEPTH)?;
220
221    let keys: Vec<&String> = obj.keys().collect();
222
223    for (i, key) in keys.iter().enumerate() {
224        if i > 0 {
225            writer.write_newline()?;
226        }
227
228        let value = &obj[*key];
229
230        // Check if this key-value pair can be folded (v1.5 feature)
231        // Don't fold if any sibling key is a dotted path starting with this key
232        // (e.g., don't fold inside "data" if "data.meta.items" exists as a sibling)
233        let has_conflicting_sibling = keys
234            .iter()
235            .any(|k| k.starts_with(&format!("{key}.")) || (k.contains('.') && k == key));
236
237        let folded = if !disable_folding
238            && writer.options.key_folding == KeyFoldingMode::Safe
239            && !has_conflicting_sibling
240        {
241            folding::analyze_foldable_chain(key, value, writer.options.flatten_depth, &keys)
242        } else {
243            None
244        };
245
246        if let Some(chain) = folded {
247            // Write folded key-value pair
248            if depth > 0 {
249                writer.write_indent(depth)?;
250            }
251
252            // Write the leaf value
253            match &chain.leaf_value {
254                Value::Array(arr) => {
255                    // For arrays, pass the folded key to write_array so it generates the header
256                    // correctly
257                    write_array(writer, Some(&chain.folded_key), arr, 0)?;
258                }
259                Value::Object(nested_obj) => {
260                    // Write the folded key (e.g., "a.b.c")
261                    writer.write_key(&chain.folded_key)?;
262                    writer.write_char(':')?;
263                    if !nested_obj.is_empty() {
264                        writer.write_newline()?;
265                        // After folding a chain, disable folding for the leaf object
266                        // This respects flattenDepth and prevents over-folding
267                        write_object_impl(writer, nested_obj, depth + 1, true)?;
268                    }
269                }
270                _ => {
271                    // Write the folded key (e.g., "a.b.c")
272                    writer.write_key(&chain.folded_key)?;
273                    writer.write_char(':')?;
274                    writer.write_char(' ')?;
275                    write_primitive_value(writer, &chain.leaf_value, QuotingContext::ObjectValue)?;
276                }
277            }
278        } else {
279            // Standard (non-folded) encoding
280            match value {
281                Value::Array(arr) => {
282                    write_array(writer, Some(key), arr, depth)?;
283                }
284                Value::Object(nested_obj) => {
285                    if depth > 0 {
286                        writer.write_indent(depth)?;
287                    }
288                    writer.write_key(key)?;
289                    writer.write_char(':')?;
290                    if !nested_obj.is_empty() {
291                        writer.write_newline()?;
292                        // If this key has a conflicting sibling, disable folding for its nested
293                        // objects
294                        let nested_disable_folding = disable_folding || has_conflicting_sibling;
295                        write_object_impl(writer, nested_obj, depth + 1, nested_disable_folding)?;
296                    }
297                }
298                _ => {
299                    if depth > 0 {
300                        writer.write_indent(depth)?;
301                    }
302                    writer.write_key(key)?;
303                    writer.write_char(':')?;
304                    writer.write_char(' ')?;
305                    write_primitive_value(writer, value, QuotingContext::ObjectValue)?;
306                }
307            }
308        }
309    }
310
311    Ok(())
312}
313
314fn write_array(
315    writer: &mut writer::Writer,
316    key: Option<&str>,
317    arr: &[Value],
318    depth: usize,
319) -> ToonResult<()> {
320    validate_depth(depth, MAX_DEPTH)?;
321
322    if arr.is_empty() {
323        writer.write_empty_array_with_key(key, depth)?;
324        return Ok(());
325    }
326
327    // Select format based on array content: tabular (uniform objects) > inline
328    // primitives > nested list
329    if let Some(keys) = is_tabular_array(arr) {
330        encode_tabular_array(writer, key, arr, &keys, depth)?;
331    } else if is_primitive_array(arr) {
332        encode_primitive_array(writer, key, arr, depth)?;
333    } else {
334        encode_nested_array(writer, key, arr, depth)?;
335    }
336
337    Ok(())
338}
339
340/// Check if an array can be encoded as tabular format (uniform objects with
341/// primitive values).
342fn is_tabular_array(arr: &[Value]) -> Option<Vec<String>> {
343    if arr.is_empty() {
344        return None;
345    }
346
347    let first = arr.first()?;
348    if !first.is_object() {
349        return None;
350    }
351
352    let first_obj = first.as_object()?;
353    let keys: Vec<String> = first_obj.keys().cloned().collect();
354
355    // First object must have only primitive values
356    for value in first_obj.values() {
357        if !is_primitive(value) {
358            return None;
359        }
360    }
361
362    // All remaining objects must match: same keys and all primitive values
363    for val in arr.iter().skip(1) {
364        if let Some(obj) = val.as_object() {
365            if obj.len() != keys.len() {
366                return None;
367            }
368            // Verify all keys from first object exist (order doesn't matter)
369            for key in &keys {
370                if !obj.contains_key(key) {
371                    return None;
372                }
373            }
374            // All values must be primitives
375            for value in obj.values() {
376                if !is_primitive(value) {
377                    return None;
378                }
379            }
380        } else {
381            return None;
382        }
383    }
384
385    Some(keys)
386}
387
388/// Check if a value is a primitive (not array or object).
389fn is_primitive(value: &Value) -> bool {
390    matches!(
391        value,
392        Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)
393    )
394}
395
396/// Check if all array elements are primitives.
397fn is_primitive_array(arr: &[Value]) -> bool {
398    arr.iter().all(is_primitive)
399}
400
401fn encode_primitive_array(
402    writer: &mut writer::Writer,
403    key: Option<&str>,
404    arr: &[Value],
405    depth: usize,
406) -> ToonResult<()> {
407    writer.write_array_header(key, arr.len(), None, depth)?;
408    writer.write_char(' ')?;
409    // Set delimiter context for array values (affects quoting decisions)
410    writer.push_active_delimiter(writer.options.delimiter);
411
412    for (i, val) in arr.iter().enumerate() {
413        if i > 0 {
414            writer.write_delimiter()?;
415        }
416        write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
417    }
418    writer.pop_active_delimiter();
419
420    Ok(())
421}
422
423fn write_primitive_value(
424    writer: &mut writer::Writer,
425    value: &Value,
426    context: QuotingContext,
427) -> ToonResult<()> {
428    match value {
429        Value::Null => writer.write_str("null"),
430        Value::Bool(b) => writer.write_str(&b.to_string()),
431        Value::Number(n) => {
432            // Format in canonical TOON form (no exponents, no trailing zeros)
433            let num_str = format_canonical_number(n);
434            writer.write_str(&num_str)
435        }
436        Value::String(s) => {
437            if writer.needs_quoting(s, context) {
438                writer.write_quoted_string(s)
439            } else {
440                writer.write_str(s)
441            }
442        }
443        _ => Err(ToonError::InvalidInput(
444            "Expected primitive value".to_string(),
445        )),
446    }
447}
448
449fn encode_tabular_array(
450    writer: &mut writer::Writer,
451    key: Option<&str>,
452    arr: &[Value],
453    keys: &[String],
454    depth: usize,
455) -> ToonResult<()> {
456    writer.write_array_header(key, arr.len(), Some(keys), depth)?;
457    writer.write_newline()?;
458
459    writer.push_active_delimiter(writer.options.delimiter);
460
461    // Write each row with values separated by delimiters
462    for (row_index, obj_val) in arr.iter().enumerate() {
463        if let Some(obj) = obj_val.as_object() {
464            writer.write_indent(depth + 1)?;
465
466            for (i, key) in keys.iter().enumerate() {
467                if i > 0 {
468                    writer.write_delimiter()?;
469                }
470
471                // Missing fields become null
472                if let Some(val) = obj.get(key) {
473                    write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
474                } else {
475                    writer.write_str("null")?;
476                }
477            }
478
479            if row_index < arr.len() - 1 {
480                writer.write_newline()?;
481            }
482        }
483    }
484
485    Ok(())
486}
487
488/// Encode a tabular array as the first field of a list-item object.
489///
490/// Tabular rows appear at depth +2 relative to the hyphen line when the array
491/// is the first field of a list-item object. This function handles that special
492/// indentation requirement.
493///
494/// Note: The array header is written separately before calling this function.
495fn encode_list_item_tabular_array(
496    writer: &mut writer::Writer,
497    arr: &[Value],
498    keys: &[String],
499    depth: usize,
500) -> ToonResult<()> {
501    // Write array header without key (key already written on hyphen line)
502    writer.write_char('[')?;
503    writer.write_str(&arr.len().to_string())?;
504
505    if writer.options.delimiter != crate::types::Delimiter::Comma {
506        writer.write_char(writer.options.delimiter.as_char())?;
507    }
508
509    writer.write_char(']')?;
510
511    // Write field list for tabular arrays: {field1,field2}
512    writer.write_char('{')?;
513    for (i, field) in keys.iter().enumerate() {
514        if i > 0 {
515            writer.write_char(writer.options.delimiter.as_char())?;
516        }
517        writer.write_key(field)?;
518    }
519    writer.write_char('}')?;
520    writer.write_char(':')?;
521    writer.write_newline()?;
522
523    writer.push_active_delimiter(writer.options.delimiter);
524
525    // Write rows at depth + 2 (relative to hyphen line)
526    // The hyphen line is at depth, so rows appear at depth + 2
527    for (row_index, obj_val) in arr.iter().enumerate() {
528        if let Some(obj) = obj_val.as_object() {
529            writer.write_indent(depth + 2)?;
530
531            for (i, key) in keys.iter().enumerate() {
532                if i > 0 {
533                    writer.write_delimiter()?;
534                }
535
536                // Missing fields become null
537                if let Some(val) = obj.get(key) {
538                    write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
539                } else {
540                    writer.write_str("null")?;
541                }
542            }
543
544            if row_index < arr.len() - 1 {
545                writer.write_newline()?;
546            }
547        }
548    }
549
550    writer.pop_active_delimiter();
551
552    Ok(())
553}
554
555fn encode_nested_array(
556    writer: &mut writer::Writer,
557    key: Option<&str>,
558    arr: &[Value],
559    depth: usize,
560) -> ToonResult<()> {
561    writer.write_array_header(key, arr.len(), None, depth)?;
562    writer.write_newline()?;
563    writer.push_active_delimiter(writer.options.delimiter);
564
565    for (i, val) in arr.iter().enumerate() {
566        writer.write_indent(depth + 1)?;
567        writer.write_char('-')?;
568
569        match val {
570            Value::Array(inner_arr) => {
571                writer.write_char(' ')?;
572                write_array(writer, None, inner_arr, depth + 1)?;
573            }
574            Value::Object(obj) => {
575                // Objects in list items: first field on same line as "- ", rest indented
576                // For empty objects, write only the hyphen (no space)
577                let keys: Vec<&String> = obj.keys().collect();
578                if let Some(first_key) = keys.first() {
579                    writer.write_char(' ')?;
580                    let first_val = &obj[*first_key];
581
582                    match first_val {
583                        Value::Array(arr) => {
584                            // Arrays as first field of list items require special indentation
585                            // (depth +2 relative to hyphen) for their nested content
586                            // (rows for tabular, items for non-uniform)
587                            writer.write_key(first_key)?;
588
589                            if let Some(keys) = is_tabular_array(arr) {
590                                // Tabular array: write inline with correct indentation
591                                encode_list_item_tabular_array(writer, arr, &keys, depth + 1)?;
592                            } else {
593                                // Non-tabular array: write with depth offset
594                                // (items at depth +2 instead of depth +1)
595                                write_array(writer, None, arr, depth + 2)?;
596                            }
597                        }
598                        Value::Object(nested_obj) => {
599                            writer.write_key(first_key)?;
600                            writer.write_char(':')?;
601                            if !nested_obj.is_empty() {
602                                writer.write_newline()?;
603                                write_object(writer, nested_obj, depth + 3)?;
604                            }
605                        }
606                        _ => {
607                            writer.write_key(first_key)?;
608                            writer.write_char(':')?;
609                            writer.write_char(' ')?;
610                            write_primitive_value(writer, first_val, QuotingContext::ObjectValue)?;
611                        }
612                    }
613
614                    // Remaining fields on separate lines with proper indentation
615                    for key in keys.iter().skip(1) {
616                        writer.write_newline()?;
617                        writer.write_indent(depth + 2)?;
618
619                        let value = &obj[*key];
620                        match value {
621                            Value::Array(arr) => {
622                                writer.write_key(key)?;
623                                write_array(writer, None, arr, depth + 1)?;
624                            }
625                            Value::Object(nested_obj) => {
626                                writer.write_key(key)?;
627                                writer.write_char(':')?;
628                                if !nested_obj.is_empty() {
629                                    writer.write_newline()?;
630                                    write_object(writer, nested_obj, depth + 3)?;
631                                }
632                            }
633                            _ => {
634                                writer.write_key(key)?;
635                                writer.write_char(':')?;
636                                writer.write_char(' ')?;
637                                write_primitive_value(writer, value, QuotingContext::ObjectValue)?;
638                            }
639                        }
640                    }
641                }
642            }
643            _ => {
644                writer.write_char(' ')?;
645                write_primitive_value(writer, val, QuotingContext::ArrayValue)?;
646            }
647        }
648
649        if i < arr.len() - 1 {
650            writer.write_newline()?;
651        }
652    }
653    writer.pop_active_delimiter();
654
655    Ok(())
656}
657
658#[cfg(test)]
659mod tests {
660    use core::f64;
661
662    use serde_json::json;
663
664    use super::*;
665
666    #[test]
667    fn test_encode_null() {
668        let value = json!(null);
669        assert_eq!(encode_default(&value).unwrap(), "null");
670    }
671
672    #[test]
673    fn test_encode_bool() {
674        assert_eq!(encode_default(&json!(true)).unwrap(), "true");
675        assert_eq!(encode_default(&json!(false)).unwrap(), "false");
676    }
677
678    #[test]
679    fn test_encode_number() {
680        assert_eq!(encode_default(&json!(42)).unwrap(), "42");
681        assert_eq!(
682            encode_default(&json!(f64::consts::PI)).unwrap(),
683            "3.141592653589793"
684        );
685        assert_eq!(encode_default(&json!(-5)).unwrap(), "-5");
686    }
687
688    #[test]
689    fn test_encode_string() {
690        assert_eq!(encode_default(&json!("hello")).unwrap(), "hello");
691        assert_eq!(
692            encode_default(&json!("hello world")).unwrap(),
693            "hello world"
694        );
695    }
696
697    #[test]
698    fn test_encode_simple_object() {
699        let obj = json!({"name": "Alice", "age": 30});
700        let result = encode_default(&obj).unwrap();
701        assert!(result.contains("name: Alice"));
702        assert!(result.contains("age: 30"));
703    }
704
705    #[test]
706    fn test_encode_primitive_array() {
707        let obj = json!({"tags": ["reading", "gaming", "coding"]});
708        let result = encode_default(&obj).unwrap();
709        assert_eq!(result, "tags[3]: reading,gaming,coding");
710    }
711
712    #[test]
713    fn test_encode_tabular_array() {
714        let obj = json!({
715            "users": [
716                {"id": 1, "name": "Alice"},
717                {"id": 2, "name": "Bob"}
718            ]
719        });
720        let result = encode_default(&obj).unwrap();
721        assert!(result.contains("users[2]{id,name}:"));
722        assert!(result.contains("1,Alice"));
723        assert!(result.contains("2,Bob"));
724    }
725
726    #[test]
727    fn test_encode_empty_array() {
728        let obj = json!({"items": []});
729        let result = encode_default(&obj).unwrap();
730        assert_eq!(result, "items[0]:");
731    }
732
733    #[test]
734    fn test_encode_nested_object() {
735        let obj = json!({
736            "user": {
737                "name": "Alice",
738                "age": 30
739            }
740        });
741        let result = encode_default(&obj).unwrap();
742        assert!(result.contains("user:"));
743        assert!(result.contains("name: Alice"));
744        assert!(result.contains("age: 30"));
745    }
746
747    #[test]
748    fn test_encode_list_item_tabular_array_v3() {
749        let obj = json!({
750            "items": [
751                {
752                    "users": [
753                        {"id": 1, "name": "Ada"},
754                        {"id": 2, "name": "Bob"}
755                    ],
756                    "status": "active"
757                }
758            ]
759        });
760
761        let result = encode_default(&obj).unwrap();
762
763        assert!(
764            result.contains("  - users[2]{id,name}:"),
765            "Header should be on hyphen line"
766        );
767
768        assert!(
769            result.contains("      1,Ada"),
770            "First row should be at 6 spaces (depth +2 from hyphen). Got:\n{}",
771            result
772        );
773        assert!(
774            result.contains("      2,Bob"),
775            "Second row should be at 6 spaces (depth +2 from hyphen). Got:\n{}",
776            result
777        );
778
779        assert!(
780            result.contains("    status: active"),
781            "Sibling field should be at 4 spaces (depth +1 from hyphen). Got:\n{}",
782            result
783        );
784    }
785
786    #[test]
787    fn test_encode_list_item_tabular_array_multiple_items() {
788        let obj = json!({
789            "data": [
790                {
791                    "records": [
792                        {"id": 1, "val": "x"}
793                    ],
794                    "count": 1
795                },
796                {
797                    "records": [
798                        {"id": 2, "val": "y"}
799                    ],
800                    "count": 1
801                }
802            ]
803        });
804
805        let result = encode_default(&obj).unwrap();
806
807        let lines: Vec<&str> = result.lines().collect();
808
809        let row_lines: Vec<&str> = lines
810            .iter()
811            .filter(|line| line.trim().starts_with(char::is_numeric))
812            .copied()
813            .collect();
814
815        for row in row_lines {
816            let spaces = row.len() - row.trim_start().len();
817            assert_eq!(
818                spaces, 6,
819                "Tabular rows should be at 6 spaces. Found {} spaces in: {}",
820                spaces, row
821            );
822        }
823    }
824
825    #[test]
826    fn test_encode_list_item_non_tabular_array_unchanged() {
827        let obj = json!({
828            "items": [
829                {
830                    "tags": ["a", "b", "c"],
831                    "name": "test"
832                }
833            ]
834        });
835
836        let result = encode_default(&obj).unwrap();
837
838        assert!(
839            result.contains("  - tags[3]: a,b,c"),
840            "Inline array should be on hyphen line. Got:\n{}",
841            result
842        );
843
844        assert!(
845            result.contains("    name: test"),
846            "Sibling field should be at 4 spaces. Got:\n{}",
847            result
848        );
849    }
850
851    #[test]
852    fn test_encode_list_item_tabular_array_with_nested_fields() {
853        let obj = json!({
854            "entries": [
855                {
856                    "people": [
857                        {"name": "Alice", "age": 30},
858                        {"name": "Bob", "age": 25}
859                    ],
860                    "total": 2,
861                    "category": "staff"
862                }
863            ]
864        });
865
866        let result = encode_default(&obj).unwrap();
867
868        assert!(result.contains("  - people[2]{name,age}:"));
869
870        assert!(result.contains("      Alice,30"));
871        assert!(result.contains("      Bob,25"));
872
873        assert!(result.contains("    total: 2"));
874        assert!(result.contains("    category: staff"));
875    }
876}