Skip to main content

toon_format/encode/
mod.rs

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