Skip to main content

tjson/
lib.rs

1//! A Rust library and CLI tool for [TJSON](https://textjson.com) — a readable,
2//! round-trip-safe alternative syntax for JSON.
3//!
4//! # Quick start
5//!
6//! ```
7//! // Deserialize TJSON directly into any serde type
8//! #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
9//! struct Person { name: String, age: u32 }
10//!
11//! let p: Person = tjson::from_str("  name: Alice  age:30").unwrap();
12//!
13//! // Serialize any serde type to TJSON
14//! let s = tjson::to_string(&p).unwrap();
15//! ```
16//!
17//! # Features
18//!
19//! - **`serde_json`** *(default)* — enables [`From<serde_json::Value>`] for [`Value`] and
20//!   [`From<Value>`] for `serde_json::Value`. Disable if you don't use serde_json directly
21//!   and want to avoid pulling its `arbitrary_precision` feature into your build.
22
23#[cfg(target_arch = "wasm32")]
24mod wasm;
25
26mod error;
27mod number;
28mod options;
29mod parse;
30mod render;
31mod util;
32mod value;
33
34pub use error::{Error, ParseError, Result};
35pub use options::{
36    BareStyle, FoldStyle, IndentGlyphMarkerStyle, IndentGlyphStyle, MultilineStyle,
37    StringArrayStyle, TableUnindentStyle, RenderOptions,
38};
39pub use number::{InvalidNumber, Number};
40pub use value::{Entry, Value};
41#[doc(hidden)]
42pub use options::TjsonConfig;
43
44pub const MIN_WRAP_WIDTH: usize = options::MIN_WRAP_WIDTH;
45pub const DEFAULT_WRAP_WIDTH: usize = options::DEFAULT_WRAP_WIDTH;
46
47use serde::Serialize;
48use serde::de::DeserializeOwned;
49
50use parse::ParseOptions;
51
52
53fn parse_str_with_options(input: &str, options: ParseOptions) -> Result<Value> {
54    parse::Parser::parse_document(input, options.start_indent).map_err(Error::Parse)
55}
56
57#[cfg(test)]
58fn render_string(value: &Value) -> String {
59    value.to_tjson_with(RenderOptions::default())
60}
61
62#[cfg(test)]
63fn render_string_with_options(value: &Value, options: RenderOptions) -> String {
64    value.to_tjson_with(options)
65}
66
67/// Parse a TJSON string and deserialize it into `T` using serde.
68///
69/// ```
70/// #[derive(serde::Deserialize, PartialEq, Debug)]
71/// struct Person { name: String, city: String }
72///
73/// let p: Person = tjson::from_str("  name: Alice  city: London").unwrap();
74/// assert_eq!(p, Person { name: "Alice".into(), city: "London".into() });
75/// ```
76pub fn from_str<T: DeserializeOwned>(input: &str) -> Result<T> {
77    from_tjson_str_with_options(input, ParseOptions::default())
78}
79
80fn from_tjson_str_with_options<T: DeserializeOwned>(
81    input: &str,
82    options: ParseOptions,
83) -> Result<T> {
84    let value = parse_str_with_options(input, options)?;
85    Ok(serde_json::from_str(&value.to_json())?)
86}
87
88/// Serialize `value` to a TJSON string using default options.
89///
90/// ```
91/// #[derive(serde::Serialize)]
92/// struct Person { name: &'static str }
93///
94/// let s = tjson::to_string(&Person { name: "Alice" }).unwrap();
95/// assert_eq!(s, "  name: Alice");
96/// ```
97pub fn to_string<T: Serialize>(value: &T) -> Result<String> {
98    to_string_with(value, RenderOptions::default())
99}
100
101/// Serialize `value` to a TJSON string using the given options.
102///
103/// ```
104/// let s = tjson::to_string_with(&vec![1, 2, 3], tjson::RenderOptions::default()).unwrap();
105/// assert_eq!(s, "  1, 2, 3");
106/// ```
107pub fn to_string_with<T: Serialize>(
108    value: &T,
109    options: RenderOptions,
110) -> Result<String> {
111    let json = serde_json::to_value(value)?;
112    let value = Value::from_serde_json(json);
113    Ok(value.to_tjson_with(options))
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::Value as JsonValue;
120
121    fn json(input: &str) -> JsonValue {
122        serde_json::from_str(input).unwrap()
123    }
124
125    fn tjson_value(input: &str) -> Value {
126        Value::from(json(input))
127    }
128
129    fn parse_str(input: &str) -> Result<Value> {
130        input.parse()
131    }
132
133    fn to_json_value(v: Value) -> JsonValue {
134        serde_json::from_str(&v.to_json()).unwrap()
135    }
136    #[test]
137    fn parses_basic_scalar_examples() {
138        assert_eq!(
139            to_json_value(parse_str("null").unwrap()),
140            json("null")
141        );
142        assert_eq!(
143            to_json_value(parse_str("5").unwrap()),
144            json("5")
145        );
146        assert_eq!(
147            to_json_value(parse_str(" a").unwrap()),
148            json("\"a\"")
149        );
150        assert_eq!(
151            to_json_value(parse_str("[]").unwrap()),
152            json("[]")
153        );
154        assert_eq!(
155            to_json_value(parse_str("{}").unwrap()),
156            json("{}")
157        );
158    }
159
160    #[test]
161    fn parses_comments_and_marker_examples() {
162        let input = "// comment\n  a:5\n// comment\n  x:\n    [ [ 1\n      { b: text";
163        let expected = json("{\"a\":5,\"x\":[[1],{\"b\":\"text\"}]}");
164        assert_eq!(
165            to_json_value(parse_str(input).unwrap()),
166            expected
167        );
168    }
169
170    // ---- Folding tests ----
171
172    // JSON string folding
173
174    #[test]
175    fn parses_folded_json_string_example() {
176        let input =
177            "\"foldingat\n/ onlyafew\\r\\n\n/ characters\n/ hereusing\n/ somejson\n/ escapes\\\\\"";
178        let expected = json("\"foldingatonlyafew\\r\\ncharactershereusingsomejsonescapes\\\\\"");
179        assert_eq!(
180            to_json_value(parse_str(input).unwrap()),
181            expected
182        );
183    }
184
185    #[test]
186    fn parses_folded_json_string_as_object_value() {
187        // JSON string fold inside an object value
188        let input = "  note:\"hello \n  / world\"";
189        let expected = json("{\"note\":\"hello world\"}");
190        assert_eq!(
191            to_json_value(parse_str(input).unwrap()),
192            expected
193        );
194    }
195
196    #[test]
197    fn parses_folded_json_string_multiple_continuations() {
198        // Three fold lines
199        let input = "\"one\n/ two\n/ three\n/ four\"";
200        let expected = json("\"onetwothreefour\"");
201        assert_eq!(
202            to_json_value(parse_str(input).unwrap()),
203            expected
204        );
205    }
206
207    #[test]
208    fn parses_folded_json_string_with_indent() {
209        // Fold continuation with leading spaces (trimmed before `/ `)
210        let input = "  key:\"hello \n  / world\"";
211        let expected = json("{\"key\":\"hello world\"}");
212        assert_eq!(
213            to_json_value(parse_str(input).unwrap()),
214            expected
215        );
216    }
217
218    // Bare string folding
219
220    #[test]
221    fn parses_folded_bare_string_root() {
222        // Root bare string folded across two lines
223        let input = " hello\n/ world";
224        let expected = json("\"helloworld\"");
225        assert_eq!(
226            to_json_value(parse_str(input).unwrap()),
227            expected
228        );
229    }
230
231    #[test]
232    fn parses_folded_bare_string_as_object_value() {
233        // Bare string value folded
234        let input = "  note: hello\n  / world";
235        let expected = json("{\"note\":\"helloworld\"}");
236        assert_eq!(
237            to_json_value(parse_str(input).unwrap()),
238            expected
239        );
240    }
241
242    #[test]
243    fn parses_folded_bare_string_multiple_continuations() {
244        let input = "  note: one\n  / two\n  / three";
245        let expected = json("{\"note\":\"onetwothree\"}");
246        assert_eq!(
247            to_json_value(parse_str(input).unwrap()),
248            expected
249        );
250    }
251
252    #[test]
253    fn parses_folded_bare_string_preserves_space_after_fold_marker() {
254        // Content after `/ ` starts with a space — that space becomes part of string
255        let input = "  note: hello\n  /  world";
256        let expected = json("{\"note\":\"hello world\"}");
257        assert_eq!(
258            to_json_value(parse_str(input).unwrap()),
259            expected
260        );
261    }
262
263    // Key folding
264
265    #[test]
266    fn parses_folded_bare_key() {
267        // A long bare key folded across two lines
268        let input = "  averylongkey\n  / continuation: value";
269        let expected = json("{\"averylongkeycontinuation\":\"value\"}");
270        assert_eq!(
271            to_json_value(parse_str(input).unwrap()),
272            expected
273        );
274    }
275
276    #[test]
277    fn parses_folded_json_key() {
278        // A long quoted key folded across two lines
279        let input = "  \"averylongkey\n  / continuation\": value";
280        let expected = json("{\"averylongkeycontinuation\":\"value\"}");
281        assert_eq!(
282            to_json_value(parse_str(input).unwrap()),
283            expected
284        );
285    }
286
287    // Table cell folding
288
289    #[test]
290    fn parses_table_with_folded_cell() {
291        // A table row where one cell is folded onto the next line using backslash continuation
292        let input = concat!(
293            "  |name     |score |\n",
294            "  | Alice   |100   |\n",
295            "  | Bob with a very long\n",
296            "/ name    |200   |\n",
297            "  | Carol   |300   |",
298        );
299        let expected = json(
300            "[{\"name\":\"Alice\",\"score\":100},{\"name\":\"Bob with a very longname\",\"score\":200},{\"name\":\"Carol\",\"score\":300}]"
301        );
302        assert_eq!(
303            to_json_value(parse_str(input).unwrap()),
304            expected
305        );
306    }
307
308    #[test]
309    fn parses_table_with_folded_cell_no_trailing_pipe() {
310        // Table fold where the continuation line lacks a trailing pipe
311        let input = concat!(
312            "  |name     |value |\n",
313            "  | short   |1     |\n",
314            "  | this is really long\n",
315            "/ continuation|2     |",
316        );
317        let expected = json(
318            "[{\"name\":\"short\",\"value\":1},{\"name\":\"this is really longcontinuation\",\"value\":2}]"
319        );
320        assert_eq!(
321            to_json_value(parse_str(input).unwrap()),
322            expected
323        );
324    }
325
326    #[test]
327    fn parses_triple_backtick_multiline_string() {
328        // ``` type: content at col 0, mandatory closing glyph
329        let input = "  note: ```\nfirst\nsecond\n  indented\n   ```";
330        let expected = json("{\"note\":\"first\\nsecond\\n  indented\"}");
331        assert_eq!(
332            to_json_value(parse_str(input).unwrap()),
333            expected
334        );
335    }
336
337    #[test]
338    fn parses_triple_backtick_crlf_multiline_string() {
339        // ``` type with \r\n local EOL indicator
340        let input = "  note: ```\\r\\n\nfirst\nsecond\n  indented\n   ```\\r\\n";
341        let expected = json("{\"note\":\"first\\r\\nsecond\\r\\n  indented\"}");
342        assert_eq!(
343            to_json_value(parse_str(input).unwrap()),
344            expected
345        );
346    }
347
348    #[test]
349    fn parses_double_backtick_multiline_string() {
350        // `` type: pipe-guarded content lines, mandatory closing glyph
351        let input = " ``\n| first\n| second\n ``";
352        let expected = json("\"first\\nsecond\"");
353        assert_eq!(
354            to_json_value(parse_str(input).unwrap()),
355            expected
356        );
357    }
358
359    #[test]
360    fn parses_double_backtick_with_explicit_lf_indicator() {
361        let input = " ``\\n\n| first\n| second\n ``\\n";
362        let expected = json("\"first\\nsecond\"");
363        assert_eq!(
364            to_json_value(parse_str(input).unwrap()),
365            expected
366        );
367    }
368
369    #[test]
370    fn parses_double_backtick_crlf_multiline_string() {
371        // `` type with \r\n local EOL indicator
372        let input = " ``\\r\\n\n| first\n| second\n ``\\r\\n";
373        let expected = json("\"first\\r\\nsecond\"");
374        assert_eq!(
375            to_json_value(parse_str(input).unwrap()),
376            expected
377        );
378    }
379
380    #[test]
381    fn parses_double_backtick_with_fold() {
382        // `` type with fold continuation line
383        let input = " ``\n| first line that is \n/ continued here\n| second\n ``";
384        let expected = json("\"first line that is continued here\\nsecond\"");
385        assert_eq!(
386            to_json_value(parse_str(input).unwrap()),
387            expected
388        );
389    }
390
391    #[test]
392    fn parses_single_backtick_multiline_string() {
393        // ` type: content at n+2, mandatory closing glyph
394        let input = "  note: `\n    first\n    second\n    indented\n   `";
395        let expected = json("{\"note\":\"first\\nsecond\\nindented\"}");
396        assert_eq!(
397            to_json_value(parse_str(input).unwrap()),
398            expected
399        );
400    }
401
402    #[test]
403    fn parses_single_backtick_with_fold() {
404        // ` type with fold continuation
405        let input = "  note: `\n    first line that is \n  / continued here\n    second\n   `";
406        let expected = json("{\"note\":\"first line that is continued here\\nsecond\"}");
407        assert_eq!(
408            to_json_value(parse_str(input).unwrap()),
409            expected
410        );
411    }
412
413    #[test]
414    fn parses_single_backtick_with_leading_spaces_in_content() {
415        // ` type preserves leading spaces after stripping n+2
416        let input = " `\n  first\n    indented two extra\n  last\n `";
417        let expected = json("\"first\\n  indented two extra\\nlast\"");
418        assert_eq!(
419            to_json_value(parse_str(input).unwrap()),
420            expected
421        );
422    }
423
424    #[test]
425    fn rejects_triple_backtick_without_closing_glyph() {
426        let input = "  note: ```\nfirst\nsecond";
427        assert!(parse_str(input).is_err());
428    }
429
430    #[test]
431    fn rejects_double_backtick_without_closing_glyph() {
432        let input = " ``\n| first\n| second";
433        assert!(parse_str(input).is_err());
434    }
435
436    #[test]
437    fn rejects_single_backtick_without_closing_glyph() {
438        let input = "  note: `\n    first\n    second";
439        assert!(parse_str(input).is_err());
440    }
441
442    #[test]
443    fn rejects_double_backtick_body_without_pipe() {
444        let input = " ``\njust some text\n| second\n ``";
445        assert!(parse_str(input).is_err());
446    }
447
448    #[test]
449    fn parses_table_array_example() {
450        let input = "  |a  |b   |c      |\n  |1  | x  |true   |\n  |2  | y  |false  |\n  |3  | z  |null   |";
451        let expected = json(
452            "[{\"a\":1,\"b\":\"x\",\"c\":true},{\"a\":2,\"b\":\"y\",\"c\":false},{\"a\":3,\"b\":\"z\",\"c\":null}]",
453        );
454        assert_eq!(
455            to_json_value(parse_str(input).unwrap()),
456            expected
457        );
458    }
459
460    #[test]
461    fn parses_minimal_json_inside_array_example() {
462        let input = "  [{\"a\":{\"b\":null},\"c\":3}]";
463        let expected = json("[[{\"a\":{\"b\":null},\"c\":3}]]");
464        assert_eq!(
465            to_json_value(parse_str(input).unwrap()),
466            expected
467        );
468    }
469
470    #[test]
471    fn renders_basic_scalar_examples() {
472        assert_eq!(render_string(&tjson_value("null")), "null");
473        assert_eq!(render_string(&tjson_value("5")), "5");
474        assert_eq!(render_string(&tjson_value("\"a\"")), " a");
475        assert_eq!(render_string(&tjson_value("[]")), "[]");
476        assert_eq!(render_string(&tjson_value("{}")), "{}");
477    }
478
479    #[test]
480    fn renders_multiline_string_example() {
481        // Default: Bold style → `` with body at col 2
482        let rendered =
483            render_string(&tjson_value("{\"note\":\"first\\nsecond\\n  indented\"}"));
484        assert_eq!(
485            rendered,
486            "  note: ``\n| first\n| second\n|   indented\n   ``"
487        );
488    }
489
490    #[test]
491    fn renders_crlf_multiline_string_example() {
492        // CrLf: Bold style with \r\n suffix
493        let rendered = render_string(&tjson_value(
494            "{\"note\":\"first\\r\\nsecond\\r\\n  indented\"}",
495        ));
496        assert_eq!(
497            rendered,
498            "  note: ``\\r\\n\n| first\n| second\n|   indented\n   ``\\r\\n"
499        );
500    }
501
502    #[test]
503    fn renders_single_backtick_root_string() {
504        // Floating: indent=0: glyph is " `", body at indent+2 (2 spaces)
505        let value = Value::String("line one\nline two".to_owned());
506        let rendered = render_string_with_options(
507            &value,
508            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
509        );
510        assert_eq!(rendered, " `\n  line one\n  line two\n `");
511    }
512
513    #[test]
514    fn renders_single_backtick_shallow_key() {
515        // Floating: pair_indent=2: glyph "   `", body at 4 spaces
516        let rendered = render_string_with_options(
517            &tjson_value("{\"note\":\"line one\\nline two\"}"),
518            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
519        );
520        assert_eq!(rendered, "  note: `\n    line one\n    line two\n   `");
521    }
522
523    #[test]
524    fn renders_single_backtick_deep_key() {
525        // Floating: pair_indent=4: glyph "     `", body at 6 spaces
526        let rendered = render_string_with_options(
527            &tjson_value("{\"outer\":{\"inner\":\"line one\\nline two\"}}"),
528            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
529        );
530        assert_eq!(
531            rendered,
532            "  outer:\n    inner: `\n      line one\n      line two\n     `"
533        );
534    }
535
536    #[test]
537    fn renders_single_backtick_three_lines() {
538        // Floating: three content lines, deeper nesting — pair_indent=6, body at 8 spaces
539        let rendered = render_string_with_options(
540            &tjson_value("{\"a\":{\"b\":{\"c\":\"x\\ny\\nz\"}}}"),
541            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
542        );
543        assert_eq!(
544            rendered,
545            "  a:\n    b:\n      c: `\n        x\n        y\n        z\n       `"
546        );
547    }
548
549    #[test]
550    fn renders_double_backtick_with_bold_style() {
551        // MultilineStyle::Bold → always `` with body at col 2
552        let value = Value::String("line one\nline two".to_owned());
553        let rendered = render_string_with_options(
554            &value,
555            RenderOptions {
556                multiline_style: MultilineStyle::Bold,
557                ..RenderOptions::default()
558            },
559        );
560        assert_eq!(rendered, " ``\n| line one\n| line two\n ``");
561    }
562
563    #[test]
564    fn renders_triple_backtick_with_fullwidth_style() {
565        // MultilineStyle::Transparent → ``` with body at col 0
566        let value = Value::String("normal line\nsecond line".to_owned());
567        let rendered = render_string_with_options(
568            &value,
569            RenderOptions {
570                multiline_style: MultilineStyle::Transparent,
571                ..RenderOptions::default()
572            },
573        );
574        assert_eq!(rendered, " ```\nnormal line\nsecond line\n ```");
575    }
576
577    #[test]
578    fn renders_triple_backtick_falls_back_to_bold_when_pipe_heavy() {
579        // Transparent falls back to Bold when content is pipe-heavy
580        let value = Value::String("| piped\n| also piped\nnormal".to_owned());
581        let rendered = render_string_with_options(
582            &value,
583            RenderOptions {
584                multiline_style: MultilineStyle::Transparent,
585                ..RenderOptions::default()
586            },
587        );
588        assert!(rendered.contains(" ``"), "expected `` fallback, got: {rendered}");
589    }
590
591    #[test]
592    fn transparent_never_folds_body_lines_regardless_of_wrap() {
593        // ``` bodies must never have / continuations — it's against spec.
594        // Even with a very narrow wrap width and a long body line, no / appears.
595        let long_line = "a".repeat(200);
596        let value = Value::String(format!("{long_line}\nsecond line"));
597        let rendered = render_string_with_options(
598            &value,
599            RenderOptions::default()
600                .wrap_width(Some(20))
601                .multiline_style(MultilineStyle::Transparent)
602                .string_multiline_fold_style(FoldStyle::Auto),
603        );
604        // Falls back to Bold when body would need folding? Either way: no / inside the body.
605        // Strip opener and closer lines and check no fold marker in body.
606        let body_lines: Vec<&str> = rendered.lines()
607            .filter(|l| !l.trim_start().starts_with("```") && !l.trim_start().starts_with("``"))
608            .collect();
609        for line in &body_lines {
610            assert!(!line.trim_start().starts_with("/ "), "``` body must not have fold continuations: {rendered}");
611        }
612    }
613
614    #[test]
615    fn transparent_with_string_multiline_fold_style_auto_still_no_fold() {
616        // Explicitly setting fold style to Auto on a Transparent multiline must not fold.
617        // The note in the doc says it's ignored for Transparent.
618        let value = Value::String("short\nsecond".to_owned());
619        let rendered = render_string_with_options(
620            &value,
621            RenderOptions::default()
622                .multiline_style(MultilineStyle::Transparent)
623                .string_multiline_fold_style(FoldStyle::Auto),
624        );
625        assert!(rendered.contains("```"), "should use triple backtick: {rendered}");
626        assert!(!rendered.contains("/ "), "Transparent must never fold: {rendered}");
627    }
628
629    #[test]
630    fn floating_falls_back_to_bold_when_line_count_exceeds_max() {
631        // 11 lines > multiline_max_lines default of 10 → fall back from ` to ``
632        let value = Value::String("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk".to_owned());
633        let rendered = render_string_with_options(
634            &value,
635            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
636        );
637        assert!(rendered.starts_with(" ``"), "expected `` fallback for >10 lines, got: {rendered}");
638    }
639
640    #[test]
641    fn floating_falls_back_to_bold_when_line_overflows_width() {
642        // A content line longer than wrap_width - indent - 2 triggers fallback
643        let long_line = "x".repeat(80); // exactly 80 chars: indent=0 + 2 = 82 > wrap_width=80
644        let value = Value::String(format!("short\n{long_line}"));
645        let rendered = render_string_with_options(
646            &value,
647            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
648        );
649        assert!(rendered.starts_with(" ``"), "expected `` fallback for overflow, got: {rendered}");
650    }
651
652    #[test]
653    fn floating_renders_single_backtick_when_lines_fit() {
654        // Only 2 lines, short content — stays as `
655        let value = Value::String("normal line\nsecond line".to_owned());
656        let rendered = render_string_with_options(
657            &value,
658            RenderOptions { multiline_style: MultilineStyle::Floating, ..RenderOptions::default() },
659        );
660        assert!(rendered.starts_with(" `\n"), "expected ` glyph, got: {rendered}");
661        assert!(!rendered.contains("| "), "should not have pipe markers");
662    }
663
664    #[test]
665    fn light_uses_single_backtick_when_safe() {
666        let value = Value::String("short\nsecond".to_owned());
667        let rendered = render_string_with_options(
668            &value,
669            RenderOptions { multiline_style: MultilineStyle::Light, ..RenderOptions::default() },
670        );
671        assert!(rendered.starts_with(" `\n"), "expected ` glyph, got: {rendered}");
672    }
673
674    #[test]
675    fn light_stays_single_backtick_on_overflow() {
676        // Width overflow does NOT trigger fallback for Light — stays as `
677        let long = "x".repeat(80);
678        let value = Value::String(format!("short\n{long}"));
679        let rendered = render_string_with_options(
680            &value,
681            RenderOptions { multiline_style: MultilineStyle::Light, ..RenderOptions::default() },
682        );
683        assert!(rendered.starts_with(" `\n"), "Light should stay as `, got: {rendered}");
684        assert!(!rendered.contains("``"), "Light must not escalate to `` on overflow");
685    }
686
687    #[test]
688    fn light_stays_single_backtick_on_too_many_lines() {
689        // Too many lines does NOT trigger fallback for Light — stays as `
690        let value = Value::String("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\nk".to_owned());
691        let rendered = render_string_with_options(
692            &value,
693            RenderOptions { multiline_style: MultilineStyle::Light, ..RenderOptions::default() },
694        );
695        assert!(rendered.starts_with(" `\n"), "Light should stay as `, got: {rendered}");
696        assert!(!rendered.contains("``"), "Light must not escalate to `` on line count");
697    }
698
699    #[test]
700    fn light_falls_back_to_bold_on_dangerous_content() {
701        // Pipe-heavy content IS dangerous → Light falls back to ``
702        let value = Value::String("| piped\n| also piped\nnormal".to_owned());
703        let rendered = render_string_with_options(
704            &value,
705            RenderOptions { multiline_style: MultilineStyle::Light, ..RenderOptions::default() },
706        );
707        assert!(rendered.starts_with(" ``"), "Light should fall back to `` for pipe-heavy content, got: {rendered}");
708    }
709
710    #[test]
711    fn folding_quotes_uses_json_string_for_eol_strings() {
712        let value = Value::String("first line\nsecond line".to_owned());
713        let rendered = render_string_with_options(
714            &value,
715            RenderOptions { multiline_style: MultilineStyle::FoldingQuotes, ..RenderOptions::default() },
716        );
717        assert!(rendered.starts_with(" \"") || rendered.starts_with("\""),
718            "expected JSON string, got: {rendered}");
719        assert!(!rendered.contains('`'), "FoldingQuotes must not use multiline glyphs");
720    }
721
722    #[test]
723    fn folding_quotes_single_line_strings_unchanged() {
724        // No EOL → FoldingQuotes does not apply, normal bare string rendering
725        let value = Value::String("hello world".to_owned());
726        let rendered = render_string_with_options(
727            &value,
728            RenderOptions { multiline_style: MultilineStyle::FoldingQuotes, ..RenderOptions::default() },
729        );
730        assert_eq!(rendered, " hello world");
731    }
732
733    #[test]
734    fn folding_quotes_folds_long_eol_string() {
735        // A string with EOL that encodes long enough to need folding.
736        // JSON encoding of "long string with spaces that needs folding\nsecond" = 52 chars,
737        // overrun=12 > 25% of 40=10 → fold is triggered (has spaces for fold points).
738        let value = Value::String("long string with spaces that needs folding\nsecond".to_owned());
739        let rendered = render_string_with_options(
740            &value,
741            RenderOptions {
742                multiline_style: MultilineStyle::FoldingQuotes,
743                wrap_width: Some(40),
744                ..RenderOptions::default()
745            },
746        );
747        assert!(rendered.contains("/ "), "expected fold continuation, got: {rendered}");
748        assert!(!rendered.contains('`'), "must not use multiline glyphs");
749    }
750
751    #[test]
752    fn folding_quotes_skips_fold_when_overrun_within_25_percent() {
753        // String whose JSON encoding slightly exceeds wrap_width=40 but by less than 25% (10).
754        // FoldingQuotes always folds at \n boundaries regardless of line length.
755        let value = Value::String("abcdefghijklmnopqrstuvwxyz123456\nsecond".to_owned());
756        let rendered = render_string_with_options(
757            &value,
758            RenderOptions {
759                multiline_style: MultilineStyle::FoldingQuotes,
760                wrap_width: Some(40),
761                ..RenderOptions::default()
762            },
763        );
764        assert_eq!(rendered, "\"abcdefghijklmnopqrstuvwxyz123456\\n\n/ second\"");
765    }
766
767    #[test]
768    fn mixed_newlines_fall_back_to_json_string() {
769        let rendered =
770            render_string(&tjson_value("{\"note\":\"first\\r\\nsecond\\nthird\"}"));
771        assert_eq!(rendered, "  note:\"first\\r\\nsecond\\nthird\"");
772    }
773
774    #[test]
775    fn escapes_forbidden_characters_in_json_strings() {
776        let rendered = render_string(&tjson_value("{\"note\":\"a\\u200Db\"}"));
777        assert_eq!(rendered, "  note:\"a\\u200db\"");
778    }
779
780    #[test]
781    fn forbidden_characters_force_multiline_fallback_to_json_string() {
782        let rendered = render_string(&tjson_value("{\"lines\":\"x\\ny\\u200Dz\"}"));
783        assert_eq!(rendered, "  lines:\"x\\ny\\u200dz\"");
784    }
785
786    #[test]
787    fn pipe_heavy_content_falls_back_to_double_backtick() {
788        // >10% of lines start with whitespace then | → use `` instead of `
789        // 2 out of 3 lines start with |, which is >10%
790        let value = Value::String("| line one\n| line two\nnormal line".to_owned());
791        let rendered = render_string(&value);
792        assert!(rendered.contains(" ``"), "expected `` glyph, got: {rendered}");
793        assert!(rendered.contains("| | line one"), "expected piped body");
794    }
795
796    #[test]
797    fn triple_backtick_collision_falls_back_to_double_backtick() {
798        // A content line starting with backtick triggers the backtick_start heuristic → use ``
799        // (` ``` ` starts with a backtick, so backtick_start is true)
800        let value = Value::String(" ```\nsecond line".to_owned());
801        let rendered = render_string(&value);
802        assert!(rendered.contains(" ``"), "expected `` glyph, got: {rendered}");
803    }
804
805    #[test]
806    fn backtick_content_falls_back_to_double_backtick() {
807        // A content line starting with whitespace then any backtick forces fallback from ` to ``
808        // (visually confusing for humans even if parseable)
809        let value = Value::String("normal line\n  `` something".to_owned());
810        let rendered = render_string(&value);
811        assert!(rendered.contains(" ``"), "expected `` glyph, got: {rendered}");
812        assert!(rendered.contains("| normal line"), "expected pipe-guarded body");
813    }
814
815    #[test]
816    fn rejects_raw_forbidden_characters() {
817        let input = format!("  note:\"a{}b\"", '\u{200D}');
818        let error = parse_str(&input).unwrap_err();
819        assert!(error.to_string().contains("U+200D"));
820    }
821
822    #[test]
823    fn renders_table_when_eligible() {
824        let value = tjson_value(
825            "[{\"a\":1,\"b\":\"x\",\"c\":true},{\"a\":2,\"b\":\"y\",\"c\":false},{\"a\":3,\"b\":\"z\",\"c\":null}]",
826        );
827        let rendered = render_string(&value);
828        assert_eq!(
829            rendered,
830            "  |a  |b   |c      |\n  |1  | x  |true   |\n  |2  | y  |false  |\n  |3  | z  |null   |"
831        );
832    }
833
834    #[test]
835    fn table_rejected_when_shared_keys_have_different_order() {
836        // {"a":1,"b":2} has keys [a, b]; {"b":3,"a":4} has keys [b, a].
837        // Rendering as a table would silently reorder keys on round-trip — hard stop.
838        let value = tjson_value(
839            "[{\"a\":1,\"b\":2,\"c\":3},{\"b\":4,\"a\":5,\"c\":6},{\"a\":7,\"b\":8,\"c\":9}]",
840        );
841        let rendered = render_string(&value);
842        assert!(!rendered.contains('|'), "should not render as table when key order differs: {rendered}");
843    }
844
845    #[test]
846    fn table_allowed_when_rows_have_subset_of_keys() {
847        // Row 2 is missing "c" — that's fine, it's sparse not reordered.
848        let value = tjson_value(
849            "[{\"a\":1,\"b\":2,\"c\":3},{\"a\":4,\"b\":5},{\"a\":6,\"b\":7,\"c\":8}]",
850        );
851        let rendered = render_string_with_options(
852            &value,
853            RenderOptions::default().table_min_similarity(0.5),
854        );
855        assert!(rendered.contains('|'), "should render as table when rows are a subset: {rendered}");
856    }
857
858    #[test]
859    fn renders_table_for_array_object_values() {
860        let value = tjson_value(
861            "{\"people\":[{\"name\":\"Alice\",\"age\":30,\"active\":true},{\"name\":\"Bob\",\"age\":25,\"active\":false},{\"name\":\"Carol\",\"age\":35,\"active\":true}]}",
862        );
863        let rendered = render_string(&value);
864        assert_eq!(
865            rendered,
866            "  people:\n    |name    |age  |active  |\n    | Alice  |30   |true    |\n    | Bob    |25   |false   |\n    | Carol  |35   |true    |"
867        );
868    }
869
870    #[test]
871    fn packs_explicit_nested_arrays_and_objects_kv1() {
872        let value = tjson_value(
873            "{\"nested\":[[1,2],[3,4]],\"rows\":[{\"a\":1,\"b\":2},{\"c\":3,\"d\":4}]}",
874        );
875        let rendered = render_string_with_options(&value, RenderOptions::default().kv_pack_multiple(1).unwrap());
876        assert_eq!(
877            rendered,
878            "  nested:\n  [ [ 1, 2\n    [ 3, 4\n  rows:\n  [ { a:1  b:2\n    { c:3  d:4"
879        );
880    }
881
882    #[test]
883    fn packs_explicit_nested_arrays_and_objects() {
884        let value = tjson_value(
885            "{\"nested\":[[1,2],[3,4]],\"rows\":[{\"a\":1,\"b\":2},{\"c\":3,\"d\":4}]}",
886        );
887        let rendered = render_string(&value);
888        assert_eq!(
889            rendered,
890            "  nested:\n  [ [ 1, 2\n    [ 3, 4\n  rows:\n  [ { a:1    b:2\n    { c:3    d:4"
891        );
892    }
893
894    #[test]
895    fn wraps_long_packed_arrays_before_falling_back_to_multiline() {
896        let value =
897            tjson_value("{\"data\":[100,200,300,400,500,600,700,800,900,1000,1100,1200,1300]}");
898        let rendered = render_string_with_options(
899            &value,
900            RenderOptions {
901                wrap_width: Some(40),
902                ..RenderOptions::default()
903            },
904        );
905        assert_eq!(
906            rendered,
907            "  data:  100, 200, 300, 400, 500, 600,\n    700, 800, 900, 1000, 1100, 1200,\n    1300"
908        );
909    }
910
911    #[test]
912    fn default_string_array_style_is_prefer_comma() {
913        let value = tjson_value("{\"items\":[\"alpha\",\"beta\",\"gamma\"]}");
914        let rendered = render_string(&value);
915        assert_eq!(rendered, "  items:   alpha,  beta,  gamma");
916    }
917
918    #[test]
919    fn bare_strings_none_quotes_single_line_strings() {
920        let value = tjson_value("{\"greeting\":\"hello world\",\"items\":[\"alpha\",\"beta\"]}");
921        let rendered = render_string_with_options(
922            &value,
923            RenderOptions {
924                bare_strings: BareStyle::None,
925                ..RenderOptions::default()
926            },
927        );
928        assert_eq!(
929            rendered,
930            "  greeting:\"hello world\"\n  items:  \"alpha\", \"beta\""
931        );
932        let reparsed = to_json_value(parse_str(&rendered).unwrap());
933        assert_eq!(reparsed, to_json_value(value));
934    }
935
936    #[test]
937    fn bare_keys_none_quotes_keys_in_objects_and_tables_kv1() {
938        let object_value = tjson_value("{\"alpha\":1,\"beta key\":2}");
939        let rendered_object = render_string_with_options(
940            &object_value,
941            RenderOptions {
942                bare_keys: BareStyle::None,
943                kv_pack_multiple: 1,
944                ..RenderOptions::default()
945            },
946        );
947        assert_eq!(rendered_object, "  \"alpha\":1  \"beta key\":2");
948    }
949
950    #[test]
951    fn bare_keys_none_quotes_keys_in_objects_and_tables() {
952        let object_value = tjson_value("{\"alpha\":1,\"beta key\":2}");
953        let rendered_object = render_string_with_options(
954            &object_value,
955            RenderOptions {
956                bare_keys: BareStyle::None,
957                ..RenderOptions::default()
958            },
959        );
960        assert_eq!(rendered_object, "  \"alpha\":1    \"beta key\":2");
961
962        let table_value = tjson_value(
963            "{\"rows\":[{\"alpha\":1,\"beta\":2},{\"alpha\":3,\"beta\":4},{\"alpha\":5,\"beta\":6}]}",
964        );
965        let rendered_table = render_string_with_options(
966            &table_value,
967            RenderOptions {
968                bare_keys: BareStyle::None,
969                table_min_columns: 2,
970                ..RenderOptions::default()
971            },
972        );
973        assert_eq!(
974            rendered_table,
975            "  \"rows\":\n    |\"alpha\"  |\"beta\"  |\n    |1        |2       |\n    |3        |4       |\n    |5        |6       |"
976        );
977        let reparsed = to_json_value(parse_str(&rendered_table).unwrap());
978        assert_eq!(reparsed, to_json_value(table_value));
979    }
980
981    #[test]
982    fn force_markers_applies_to_root_and_key_nested_single_levels_kv1() {
983        let value =
984            tjson_value("{\"a\":5,\"6\":\"fred\",\"xy\":[],\"de\":{},\"e\":[1],\"o\":{\"k\":2}}");
985        let rendered = render_string_with_options(
986            &value,
987            RenderOptions {
988                force_markers: true,
989                kv_pack_multiple: 1,
990                ..RenderOptions::default()
991            },
992        );
993        assert_eq!(
994            rendered,
995            "{ a:5  6: fred  xy:[]  de:{}\n  e:  1\n  o:\n  { k:2"
996        );
997        let reparsed = to_json_value(parse_str(&rendered).unwrap());
998        assert_eq!(reparsed, to_json_value(value));
999    }
1000
1001    #[test]
1002    fn force_markers_applies_to_root_and_key_nested_single_levels() {
1003        let value =
1004            tjson_value("{\"a\":5,\"6\":\"fred\",\"xy\":[],\"de\":{},\"e\":[1],\"o\":{\"k\":2}}");
1005        let rendered = render_string_with_options(
1006            &value,
1007            RenderOptions {
1008                force_markers: true,
1009                ..RenderOptions::default()
1010            },
1011        );
1012        assert_eq!(
1013            rendered,
1014            "{ a:5    6: fred    xy:[]    de:{}\n  e:  1\n  o:\n  { k:2"
1015        );
1016        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1017        assert_eq!(reparsed, to_json_value(value));
1018    }
1019
1020    #[test]
1021    fn force_markers_applies_to_root_arrays() {
1022        let value = tjson_value("[1,2,3]");
1023        let rendered = render_string_with_options(
1024            &value,
1025            RenderOptions {
1026                force_markers: true,
1027                ..RenderOptions::default()
1028            },
1029        );
1030        assert_eq!(rendered, "[ 1, 2, 3");
1031        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1032        assert_eq!(reparsed, to_json_value(value));
1033    }
1034
1035    #[test]
1036    fn force_markers_suppresses_table_rendering_for_array_containers() {
1037        let value = tjson_value("[{\"a\":1,\"b\":2},{\"a\":3,\"b\":4},{\"a\":5,\"b\":6}]");
1038        let rendered = render_string_with_options(
1039            &value,
1040            RenderOptions {
1041                force_markers: true,
1042                table_min_columns: 2,
1043                ..RenderOptions::default()
1044            },
1045        );
1046        assert_eq!(rendered, "[ |a  |b  |\n  |1  |2  |\n  |3  |4  |\n  |5  |6  |");
1047    }
1048
1049    #[test]
1050    fn string_array_style_spaces_forces_space_packing() {
1051        let value = tjson_value("{\"items\":[\"alpha\",\"beta\",\"gamma\"]}");
1052        let rendered = render_string_with_options(
1053            &value,
1054            RenderOptions {
1055                string_array_style: StringArrayStyle::Spaces,
1056                ..RenderOptions::default()
1057            },
1058        );
1059        assert_eq!(rendered, "  items:   alpha   beta   gamma");
1060    }
1061
1062    #[test]
1063    fn string_array_style_none_disables_string_array_packing() {
1064        let value = tjson_value("{\"items\":[\"alpha\",\"beta\",\"gamma\"]}");
1065        let rendered = render_string_with_options(
1066            &value,
1067            RenderOptions {
1068                string_array_style: StringArrayStyle::None,
1069                ..RenderOptions::default()
1070            },
1071        );
1072        assert_eq!(rendered, "  items:\n     alpha\n     beta\n     gamma");
1073    }
1074
1075    #[test]
1076    fn prefer_comma_can_fall_back_to_spaces_when_wrap_is_cleaner() {
1077        let value = tjson_value("{\"items\":[\"aa\",\"bb\",\"cc\"]}");
1078        let comma = render_string_with_options(
1079            &value,
1080            RenderOptions {
1081                string_array_style: StringArrayStyle::Comma,
1082                wrap_width: Some(18),
1083                ..RenderOptions::default()
1084            },
1085        );
1086        let prefer_comma = render_string_with_options(
1087            &value,
1088            RenderOptions {
1089                string_array_style: StringArrayStyle::PreferComma,
1090                wrap_width: Some(18),
1091                ..RenderOptions::default()
1092            },
1093        );
1094        assert_eq!(comma, "  items:   aa,  bb,\n     cc");
1095        assert_eq!(prefer_comma, "  items:   aa   bb\n     cc");
1096    }
1097
1098    #[test]
1099    fn quotes_comma_strings_in_packed_arrays_so_they_round_trip() {
1100        let value = tjson_value("{\"items\":[\"apples, oranges\",\"pears, plums\",\"grapes\"]}");
1101        let rendered = render_string(&value);
1102        assert_eq!(
1103            rendered,
1104            "  items:  \"apples, oranges\", \"pears, plums\",  grapes"
1105        );
1106        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1107        assert_eq!(reparsed, to_json_value(value));
1108    }
1109
1110    #[test]
1111    fn spaces_style_quotes_comma_strings_and_round_trips() {
1112        let value = tjson_value("{\"items\":[\"apples, oranges\",\"pears, plums\"]}");
1113        let rendered = render_string_with_options(
1114            &value,
1115            RenderOptions {
1116                string_array_style: StringArrayStyle::Spaces,
1117                ..RenderOptions::default()
1118            },
1119        );
1120        assert_eq!(rendered, "  items:  \"apples, oranges\"  \"pears, plums\"");
1121        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1122        assert_eq!(reparsed, to_json_value(value));
1123    }
1124
1125    #[test]
1126    fn canonical_rendering_disables_tables_and_inline_packing() {
1127        let value = tjson_value(
1128            "[{\"a\":1,\"b\":\"x\",\"c\":true},{\"a\":2,\"b\":\"y\",\"c\":false},{\"a\":3,\"b\":\"z\",\"c\":null}]",
1129        );
1130        let rendered = render_string_with_options(&value, RenderOptions::canonical());
1131        assert!(!rendered.contains('|'));
1132        assert!(!rendered.contains(", "));
1133    }
1134
1135    // --- Fold style tests ---
1136    // Fixed and None have deterministic output — exact assertions.
1137    // Auto tests use strings with exactly one reasonable fold point (one space between
1138    // two equal-length words) so the fold position is unambiguous.
1139
1140    #[test]
1141    fn bare_fold_none_does_not_fold() {
1142        // "aaaaa bbbbb" at wrap=15 overflows (line would be 17 chars), but None means no fold.
1143        let value = Value::from(json(r#"{"k":"aaaaa bbbbb"}"#));
1144        let rendered = render_string_with_options(
1145            &value,
1146            RenderOptions::default()
1147                .wrap_width(Some(15))
1148                .string_bare_fold_style(FoldStyle::None),
1149        );
1150        assert!(!rendered.contains("/ "), "None fold style must not fold: {rendered}");
1151    }
1152
1153    #[test]
1154    fn bare_fold_fixed_folds_at_wrap_width() {
1155        // "aaaaabbbbbcccccdddd" (19 chars, no spaces), wrap=20.
1156        // Line "  k: aaaaabbbbbcccccdddd" = 24 chars > 20.
1157        // first_avail = 20-2(indent)-1(space)-2(k:) = 15.
1158        // Fixed splits at 15: first="aaaaabbbbbccccc", cont="dddd".
1159        let value = Value::from(json(r#"{"k":"aaaaabbbbbcccccdddd"}"#));
1160        let rendered = render_string_with_options(
1161            &value,
1162            RenderOptions::default()
1163                .wrap_width(Some(20))
1164                .string_bare_fold_style(FoldStyle::Fixed),
1165        );
1166        assert!(rendered.contains("/ "), "Fixed must fold: {rendered}");
1167        assert!(!rendered.contains("/ ") || rendered.lines().count() == 2, "exactly one fold: {rendered}");
1168        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1169        assert_eq!(reparsed, json(r#"{"k":"aaaaabbbbbcccccdddd"}"#));
1170    }
1171
1172    #[test]
1173    fn bare_fold_auto_folds_at_single_space() {
1174        // "aaaaa bbbbbccccc": single space at pos 5, total 16 chars.
1175        // wrap=20: first_avail = 20-2(indent)-1(space)-2(k:) = 15. 16 > 15 → must fold.
1176        // Auto folds before the space: "aaaaa" / " bbbbbccccc".
1177        let value = Value::from(json(r#"{"k":"aaaaa bbbbbccccc"}"#));
1178        let rendered = render_string_with_options(
1179            &value,
1180            RenderOptions::default()
1181                .wrap_width(Some(20))
1182                .string_bare_fold_style(FoldStyle::Auto),
1183        );
1184        assert_eq!(rendered, "  k: aaaaa\n  /  bbbbbccccc");
1185    }
1186
1187    #[test]
1188    fn bare_fold_auto_folds_at_word_boundary_slash() {
1189        // "aaaaa/bbbbbccccc": StickyEnd→Letter boundary after '/' at pos 6, total 16 chars.
1190        // No spaces → P2 fires: fold after '/', slash trails the line.
1191        // wrap=20: first_avail=15. 16 > 15 → must fold. Fold at pos 6: first="aaaaa/".
1192        let value = Value::from(json(r#"{"k":"aaaaa/bbbbbccccc"}"#));
1193        let rendered = render_string_with_options(
1194            &value,
1195            RenderOptions::default()
1196                .wrap_width(Some(20))
1197                .string_bare_fold_style(FoldStyle::Auto),
1198        );
1199        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1200        assert!(rendered.contains("aaaaa/\n"), "slash must trail the line: {rendered}");
1201        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1202        assert_eq!(reparsed, json(r#"{"k":"aaaaa/bbbbbccccc"}"#));
1203    }
1204
1205    #[test]
1206    fn bare_fold_auto_prefers_space_over_word_boundary() {
1207        // "aa/bbbbbbbbb cccc": slash at pos 2, space at pos 11, total 17 chars.
1208        // wrap=20: first_avail=15. 17 > 15 → must fold. Space at pos 11 ≤ 15 → fold at 11.
1209        // Space pass runs first and finds pos 11 — fold before space: "aa/bbbbbbbbb" / " cccc".
1210        let value = Value::from(json(r#"{"k":"aa/bbbbbbbbb cccc"}"#));
1211        let rendered = render_string_with_options(
1212            &value,
1213            RenderOptions::default()
1214                .wrap_width(Some(20))
1215                .string_bare_fold_style(FoldStyle::Auto),
1216        );
1217        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1218        // Must fold at the space, not at the slash
1219        assert!(rendered.contains("aa/bbbbbbbbb\n"), "must fold at space not slash: {rendered}");
1220        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1221        assert_eq!(reparsed, json(r#"{"k":"aa/bbbbbbbbb cccc"}"#));
1222    }
1223
1224    #[test]
1225    fn quoted_fold_auto_folds_at_word_boundary_slash() {
1226        // bare_strings=None forces quoting. "aaaaa/bbbbbcccccc" has one slash boundary.
1227        // encoded = "\"aaaaa/bbbbbcccccc\"" = 19 chars. wrap=20, indent=2, key+colon=2 → first_avail=16.
1228        // 19 > 16 → folds. Word boundary before '/' at inner pos 5. Slash → unambiguous.
1229        let value = Value::from(json(r#"{"k":"aaaaa/bbbbbcccccc"}"#));
1230        let rendered = render_string_with_options(
1231            &value,
1232            RenderOptions::default()
1233                .wrap_width(Some(20))
1234                .bare_strings(BareStyle::None)
1235                .string_quoted_fold_style(FoldStyle::Auto),
1236        );
1237        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1238        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1239        assert_eq!(reparsed, json(r#"{"k":"aaaaa/bbbbbcccccc"}"#));
1240    }
1241
1242    #[test]
1243    fn quoted_fold_none_does_not_fold() {
1244        // bare_strings=None and bare_keys=None force quoting of both key and value.
1245        // wrap=20 overflows ("\"kk\": \"aaaaabbbbbcccccdddd\"" = 27 chars), but fold style None means no fold.
1246        let value = Value::from(json(r#"{"kk":"aaaaabbbbbcccccdddd"}"#));
1247        let rendered = render_string_with_options(
1248            &value,
1249            RenderOptions::default()
1250                .wrap_width(Some(20))
1251                .bare_strings(BareStyle::None)
1252                .bare_keys(BareStyle::None)
1253                .string_quoted_fold_style(FoldStyle::None),
1254        );
1255        assert!(rendered.contains('"'), "must be quoted");
1256        assert!(!rendered.contains("/ "), "None fold style must not fold: {rendered}");
1257    }
1258
1259    #[test]
1260    fn quoted_fold_fixed_folds_and_roundtrips() {
1261        // bare_strings=None forces quoting. "aaaaabbbbbcccccdd" encoded = "\"aaaaabbbbbcccccdd\"" = 19 chars.
1262        // wrap=20, indent=2, key "k"+colon = 2 → first_avail = 20-2-2 = 16. 19 > 16 → folds.
1263        let value = Value::from(json(r#"{"k":"aaaaabbbbbcccccdd"}"#));
1264        let rendered = render_string_with_options(
1265            &value,
1266            RenderOptions::default()
1267                .wrap_width(Some(20))
1268                .bare_strings(BareStyle::None)
1269                .string_quoted_fold_style(FoldStyle::Fixed),
1270        );
1271        assert!(rendered.contains("/ "), "Fixed must fold: {rendered}");
1272        assert!(!rendered.contains('`'), "must be a JSON string fold, not multiline");
1273        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1274        assert_eq!(reparsed, json(r#"{"k":"aaaaabbbbbcccccdd"}"#));
1275    }
1276
1277    #[test]
1278    fn quoted_fold_auto_folds_at_single_space() {
1279        // bare_strings=None forces quoting. "aaaaa bbbbbccccc" has one space at pos 5.
1280        // encoded "\"aaaaa bbbbbccccc\"" = 18 chars. wrap=20, indent=2, key+colon=2 → first_avail=16.
1281        // 18 > 16 → folds. Auto folds before the space.
1282        let value = Value::from(json(r#"{"k":"aaaaa bbbbbccccc"}"#));
1283        let rendered = render_string_with_options(
1284            &value,
1285            RenderOptions::default()
1286                .wrap_width(Some(20))
1287                .bare_strings(BareStyle::None)
1288                .string_quoted_fold_style(FoldStyle::Auto),
1289        );
1290        assert!(rendered.contains("/ "), "Auto must fold: {rendered}");
1291        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1292        assert_eq!(reparsed, json(r#"{"k":"aaaaa bbbbbccccc"}"#));
1293    }
1294
1295    #[test]
1296    fn multiline_fold_none_does_not_fold_body_lines() {
1297        // Body line overflows wrap but None means no fold inside multiline body.
1298        let value = Value::String("aaaaabbbbbcccccdddddeeeeefff\nsecond".to_owned());
1299        let rendered = render_string_with_options(
1300            &value,
1301            RenderOptions::default()
1302                .wrap_width(Some(20))
1303                .string_multiline_fold_style(FoldStyle::None),
1304        );
1305        assert!(rendered.contains('`'), "must be multiline");
1306        assert!(rendered.contains("aaaaabbbbbcccccddddd"), "body must not be folded: {rendered}");
1307    }
1308
1309    #[test]
1310    fn fold_style_none_on_all_types_produces_no_fold_continuations() {
1311        // With all fold styles None, no / continuations should appear anywhere.
1312        let value = Value::from(json(r#"{"a":"aaaaa bbbbbccccc","b":"x,y,z abcdefghij"}"#));
1313        let rendered = render_string_with_options(
1314            &value,
1315            RenderOptions::default()
1316                .wrap_width(Some(20))
1317                .string_bare_fold_style(FoldStyle::None)
1318                .string_quoted_fold_style(FoldStyle::None)
1319                .string_multiline_fold_style(FoldStyle::None),
1320        );
1321        assert!(!rendered.contains("/ "), "no fold continuations expected: {rendered}");
1322    }
1323
1324    #[test]
1325    fn number_fold_none_does_not_fold() {
1326        // number_fold_style None: long number is never folded even when it overflows wrap.
1327        let value = Value::Number("123456789012345678901234".parse().unwrap());
1328        let rendered = value.to_tjson_with(
1329            RenderOptions::default()
1330                .wrap_width(Some(20))
1331                .number_fold_style(FoldStyle::None),
1332        );
1333        assert!(!rendered.contains("/ "), "expected no fold: {rendered}");
1334        assert!(rendered.contains("123456789012345678901234"), "must contain full number: {rendered}");
1335    }
1336
1337    #[test]
1338    fn number_fold_fixed_splits_between_digits() {
1339        // 24 digits, wrap=20, indent=0 → avail=20. Fixed splits at pos 20.
1340        let value = Value::Number("123456789012345678901234".parse().unwrap());
1341        let rendered = value.to_tjson_with(
1342            RenderOptions::default()
1343                .wrap_width(Some(20))
1344                .number_fold_style(FoldStyle::Fixed),
1345        );
1346        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1347        let reparsed = rendered.parse::<Value>().unwrap();
1348        assert_eq!(reparsed, Value::Number("123456789012345678901234".parse().unwrap()),
1349            "roundtrip must recover original number");
1350    }
1351
1352    #[test]
1353    fn number_fold_auto_prefers_decimal_point() {
1354        // "1234567890123456789.01" (22 chars, '.' at pos 19), wrap=20, avail=20.
1355        // rfind('.') in first 20 chars = pos 19. Fold before '.'.
1356        // First line ends with the integer part.
1357        let value = Value::Number("1234567890123456789.01".parse().unwrap());
1358        let rendered = value.to_tjson_with(
1359            RenderOptions::default()
1360                .wrap_width(Some(20))
1361                .number_fold_style(FoldStyle::Auto),
1362        );
1363        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1364        let first_line = rendered.lines().next().unwrap();
1365        assert!(first_line.ends_with("1234567890123456789"), "should fold before `.`: {rendered}");
1366        let reparsed = rendered.parse::<Value>().unwrap();
1367        assert_eq!(reparsed, Value::Number("1234567890123456789.01".parse().unwrap()),
1368            "roundtrip must recover original number");
1369    }
1370
1371    #[test]
1372    fn number_fold_auto_prefers_exponent() {
1373        // "1.23456789012345678e+97" (23 chars, 'e' at pos 19), wrap=20, avail=20.
1374        // rfind('.') or 'e'/'E' in first 20 chars: '.' at 1, 'e' at 19 → picks 'e' (rightmost).
1375        // First line: "1.23456789012345678", continuation: "/ e+97".
1376        let value = Value::Number("1.23456789012345678e+97".parse().unwrap());
1377        let rendered = value.to_tjson_with(
1378            RenderOptions::default()
1379                .wrap_width(Some(20))
1380                .number_fold_style(FoldStyle::Auto),
1381        );
1382        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1383        let first_line = rendered.lines().next().unwrap();
1384        assert!(first_line.ends_with("1.23456789012345678"), "should fold before `e`: {rendered}");
1385        let reparsed = rendered.parse::<Value>().unwrap();
1386        assert_eq!(reparsed, Value::Number("1.23456789012345678e+97".parse().unwrap()),
1387            "roundtrip must recover original number");
1388    }
1389
1390    #[test]
1391    fn number_fold_auto_folds_before_decimal_point() {
1392        // "1234567890123456789.01" (22 chars, '.' at pos 19), wrap=20, avail=20.
1393        // rfind('.') in first 20 = pos 19. Fold before '.'.
1394        // First line: "1234567890123456789", continuation: "/ .01".
1395        let value = Value::Number("1234567890123456789.01".parse().unwrap());
1396        let rendered = value.to_tjson_with(
1397            RenderOptions::default()
1398                .wrap_width(Some(20))
1399                .number_fold_style(FoldStyle::Auto),
1400        );
1401        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1402        let first_line = rendered.lines().next().unwrap();
1403        assert!(first_line.ends_with("1234567890123456789"),
1404            "should fold before '.': {rendered}");
1405        let cont_line = rendered.lines().nth(1).unwrap();
1406        assert!(cont_line.starts_with("/ ."),
1407            "continuation must start with '/ .': {rendered}");
1408        let reparsed = rendered.parse::<Value>().unwrap();
1409        assert_eq!(reparsed, Value::Number("1234567890123456789.01".parse().unwrap()),
1410            "roundtrip must recover original number");
1411    }
1412
1413    #[test]
1414    fn number_fold_auto_folds_before_exponent() {
1415        // "1.23456789012345678e+97" (23 chars, 'e' at pos 19), wrap=20, avail=20.
1416        // rfind('e') in first 20 chars = pos 19. Fold before 'e'.
1417        // First line: "1.23456789012345678", continuation: "/ e+97".
1418        let value = Value::Number("1.23456789012345678e+97".parse().unwrap());
1419        let rendered = value.to_tjson_with(
1420            RenderOptions::default()
1421                .wrap_width(Some(20))
1422                .number_fold_style(FoldStyle::Auto),
1423        );
1424        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1425        let first_line = rendered.lines().next().unwrap();
1426        assert!(first_line.ends_with("1.23456789012345678"),
1427            "should fold before 'e': {rendered}");
1428        let cont_line = rendered.lines().nth(1).unwrap();
1429        assert!(cont_line.starts_with("/ e"),
1430            "continuation must start with '/ e': {rendered}");
1431        let reparsed = rendered.parse::<Value>().unwrap();
1432        assert_eq!(reparsed, Value::Number("1.23456789012345678e+97".parse().unwrap()),
1433            "roundtrip must recover original number");
1434    }
1435
1436    #[test]
1437    fn number_fold_fixed_splits_at_wrap_boundary() {
1438        // 21 digits, wrap=20, indent=0: avail=20. Fixed splits exactly at pos 20.
1439        // First line: "12345678901234567890", continuation: "/ 1".
1440        let value = Value::Number("123456789012345678901".parse().unwrap());
1441        let rendered = value.to_tjson_with(
1442            RenderOptions::default()
1443                .wrap_width(Some(20))
1444                .number_fold_style(FoldStyle::Fixed),
1445        );
1446        assert!(rendered.contains("/ "), "expected fold: {rendered}");
1447        let first_line = rendered.lines().next().unwrap();
1448        assert_eq!(first_line, "12345678901234567890",
1449            "fixed fold must split exactly at wrap=20: {rendered}");
1450        let reparsed = rendered.parse::<Value>().unwrap();
1451        assert_eq!(reparsed, Value::Number("123456789012345678901".parse().unwrap()),
1452            "roundtrip must recover original number");
1453    }
1454
1455    #[test]
1456    fn number_fold_auto_falls_back_to_digit_split() {
1457        // 24 digits, no '.'/`e`: auto falls back to digit-boundary split.
1458        // wrap=20, indent=0 → avail=20. Split at pos 20 (digit-digit boundary).
1459        let value = Value::Number("123456789012345678901234".parse().unwrap());
1460        let rendered = value.to_tjson_with(
1461            RenderOptions::default()
1462                .wrap_width(Some(20))
1463                .number_fold_style(FoldStyle::Auto),
1464        );
1465        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1466        let first_line = rendered.lines().next().unwrap();
1467        assert_eq!(first_line, "12345678901234567890",
1468            "auto fallback must split at digit boundary at wrap=20: {rendered}");
1469        let reparsed = rendered.parse::<Value>().unwrap();
1470        assert_eq!(reparsed, Value::Number("123456789012345678901234".parse().unwrap()),
1471            "roundtrip must recover original number");
1472    }
1473
1474    #[test]
1475    fn bare_key_fold_fixed_folds_and_roundtrips() {
1476        // Key "abcdefghijklmnopqrst" (20 chars) + ":" = 21, indent=0, wrap=15.
1477        // Only one place to fold: at the wrap boundary between two key chars.
1478        let value = Value::from(json(r#"{"abcdefghijklmnopqrst":1}"#));
1479        let rendered = value.to_tjson_with(
1480            RenderOptions::default()
1481                .wrap_width(Some(15))
1482                .string_bare_fold_style(FoldStyle::Fixed),
1483        );
1484        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1485        let reparsed = to_json_value(rendered.parse::<Value>().unwrap());
1486        assert_eq!(reparsed, json(r#"{"abcdefghijklmnopqrst":1}"#),
1487            "roundtrip must recover original key");
1488    }
1489
1490    #[test]
1491    fn bare_key_fold_none_does_not_fold() {
1492        // Same long key but fold style None — must not fold.
1493        let value = Value::from(json(r#"{"abcdefghijklmnopqrst":1}"#));
1494        let rendered = value.to_tjson_with(
1495            RenderOptions::default()
1496                .wrap_width(Some(15))
1497                .string_bare_fold_style(FoldStyle::None),
1498        );
1499        assert!(!rendered.contains("/ "), "expected no fold: {rendered}");
1500    }
1501
1502    #[test]
1503    fn quoted_key_fold_fixed_folds_and_roundtrips() {
1504        // bare_keys=None forces quoting. Key "abcdefghijklmnop" (16 chars),
1505        // quoted = "\"abcdefghijklmnop\"" = 18 chars, indent=0, wrap=15.
1506        // Single fold at the wrap boundary.
1507        let value = Value::from(json(r#"{"abcdefghijklmnop":1}"#));
1508        let rendered = value.to_tjson_with(
1509            RenderOptions::default()
1510                .wrap_width(Some(15))
1511                .bare_keys(BareStyle::None)
1512                .string_quoted_fold_style(FoldStyle::Fixed),
1513        );
1514        assert!(rendered.contains("/ "), "expected fold continuation: {rendered}");
1515        let reparsed = to_json_value(rendered.parse::<Value>().unwrap());
1516        assert_eq!(reparsed, json(r#"{"abcdefghijklmnop":1}"#),
1517            "roundtrip must recover original key");
1518    }
1519
1520    #[test]
1521    fn round_trips_generated_examples() {
1522        let values = [
1523            json("{\"a\":5,\"6\":\"fred\",\"xy\":[],\"de\":{},\"e\":[1]}"),
1524            json("{\"nested\":[[1],[2,3],{\"x\":\"y\"}],\"empty\":[],\"text\":\"plain english\"}"),
1525            json("{\"note\":\"first\\nsecond\\n  indented\"}"),
1526            json(
1527                "[{\"a\":1,\"b\":\"x\",\"c\":true},{\"a\":2,\"b\":\"y\",\"c\":false},{\"a\":3,\"b\":\"z\",\"c\":null}]",
1528            ),
1529        ];
1530        for value in values {
1531            let rendered = render_string(&Value::from(value.clone()));
1532            let reparsed = to_json_value(parse_str(&rendered).unwrap());
1533            assert_eq!(reparsed, value);
1534        }
1535    }
1536
1537    #[test]
1538    fn keeps_key_order_at_the_ast_and_json_boundary() {
1539        let input = "  first:1\n  second:2\n  third:3";
1540        let value = parse_str(input).unwrap();
1541        match &value {
1542            Value::Object(entries) => {
1543                let keys = entries
1544                    .iter()
1545                    .map(|e| e.key.as_str())
1546                    .collect::<Vec<_>>();
1547                assert_eq!(keys, vec!["first", "second", "third"]);
1548            }
1549            other => panic!("expected an object, found {other:?}"),
1550        }
1551        let json: serde_json::Value = serde_json::from_str(&value.to_json()).unwrap();
1552        let keys = json
1553            .as_object()
1554            .unwrap()
1555            .keys()
1556            .map(String::as_str)
1557            .collect::<Vec<_>>();
1558        assert_eq!(keys, vec!["first", "second", "third"]);
1559    }
1560
1561    #[test]
1562    fn duplicate_keys_are_localized_to_the_json_boundary() {
1563        let input = "  dup:1\n  dup:2\n  keep:3";
1564        let value = parse_str(input).unwrap();
1565        match &value {
1566            Value::Object(entries) => assert_eq!(entries.len(), 3),
1567            other => panic!("expected an object, found {other:?}"),
1568        }
1569        let json_value: serde_json::Value = serde_json::from_str(&value.to_json()).unwrap();
1570        assert_eq!(json_value, json("{\"dup\":2,\"keep\":3}"));
1571    }
1572
1573    // ---- /< /> indent-offset tests ----
1574
1575    #[test]
1576    fn parses_indent_offset_table() {
1577        // pair_indent=4 ("    h: /<"), table at visual 2 → actual 6.
1578        let input = concat!(
1579            "  outer:\n",
1580            "    h: /<\n",
1581            "  |name  |score  |\n",
1582            "  | Alice  |100  |\n",
1583            "  | Bob    |200  |\n",
1584            "  | Carol  |300  |\n",
1585            "     />\n",
1586            "    sib: value\n",
1587        );
1588        let value = to_json_value(parse_str(input).unwrap());
1589        let expected = serde_json::json!({
1590            "outer": {
1591                "h": [
1592                    {"name": "Alice",  "score": 100},
1593                    {"name": "Bob",    "score": 200},
1594                    {"name": "Carol",  "score": 300},
1595                ],
1596                "sib": "value"
1597            }
1598        });
1599        assert_eq!(value, expected);
1600    }
1601
1602    #[test]
1603    fn parses_indent_offset_deep_nesting() {
1604        // Verify that a second /< context stacks correctly and /> restores it.
1605        let input = concat!(
1606            "  a:\n",
1607            "    b: /<\n",
1608            "  c: /<\n",
1609            "  d:99\n",
1610            "   />\n",
1611            "  e:42\n",
1612            "     />\n",
1613            "  f:1\n",
1614        );
1615        let value = to_json_value(parse_str(input).unwrap());
1616        // After both /> pops, offset returns to 0, so "  f:1" is at pair_indent 2 —
1617        // a sibling of "a", not inside "b".
1618        let expected = serde_json::json!({
1619            "a": {"b": {"c": {"d": 99}, "e": 42}},
1620            "f": 1
1621        });
1622        assert_eq!(value, expected);
1623    }
1624
1625    #[test]
1626    fn renderer_uses_indent_offset_for_deep_tables_that_overflow() {
1627        // 8 levels deep → pair_indent=16, n*5=80 >= w=80.
1628        // Table is wide enough to overflow at natural indent but fit at offset.
1629        let deep_table_json = r#"{
1630            "a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":[
1631                {"c1":"really long value 1","c2":"somewhat long val 1","c3":"another long val 12"},
1632                {"c1":"row two c1 value","c2":"row two c2 value","c3":"row two c3 value"},
1633                {"c1":"row three c1 val","c2":"row three c2 val","c3":"row three c3 val"}
1634            ]}}}}}}}}
1635        "#;
1636        let value = Value::from(serde_json::from_str::<JsonValue>(deep_table_json).unwrap());
1637        let rendered = render_string_with_options(
1638            &value,
1639            RenderOptions {
1640                wrap_width: Some(80),
1641                ..RenderOptions::default()
1642            },
1643        );
1644        assert!(
1645            rendered.contains(" /<"),
1646            "expected /< in rendered output:\n{rendered}"
1647        );
1648        assert!(
1649            rendered.contains("/>"),
1650            "expected /> in rendered output:\n{rendered}"
1651        );
1652        // Round-trip: parse the rendered output and verify it matches.
1653        let reparsed = to_json_value(parse_str(&rendered).unwrap());
1654        assert_eq!(reparsed, to_json_value(value));
1655    }
1656
1657    #[test]
1658    fn renderer_does_not_use_indent_offset_with_unlimited_wrap() {
1659        let deep_table_json = r#"{
1660            "a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":[
1661                {"c1":"really long value 1","c2":"somewhat long val 1","c3":"another long val 12"},
1662                {"c1":"row two c1 value","c2":"row two c2 value","c3":"row two c3 value"},
1663                {"c1":"row three c1 val","c2":"row three c2 val","c3":"row three c3 val"}
1664            ]}}}}}}}}
1665        "#;
1666        let value = Value::from(serde_json::from_str::<JsonValue>(deep_table_json).unwrap());
1667        let rendered = render_string_with_options(
1668            &value,
1669            RenderOptions {
1670                wrap_width: None, // unlimited
1671                ..RenderOptions::default()
1672            },
1673        );
1674        assert!(
1675            !rendered.contains(" /<"),
1676            "expected no /< with unlimited wrap:\n{rendered}"
1677        );
1678    }
1679
1680    // --- TableUnindentStyle tests ---
1681    // Uses a 3-level-deep table that overflows at its natural indent but fits at 0.
1682    // pair_indent = 6 (3 nesting levels × 2), table rows are ~60 chars wide.
1683
1684    fn deep3_table_value() -> Value {
1685        Value::from(serde_json::from_str::<JsonValue>(r#"{
1686            "a":{"b":{"c":[
1687                {"col1":"value one here","col2":"value two here","col3":"value three here"},
1688                {"col1":"row two col1","col2":"row two col2","col3":"row two col3"},
1689                {"col1":"row three c1","col2":"row three c2","col3":"row three c3"}
1690            ]}}}"#).unwrap())
1691    }
1692
1693    #[test]
1694    fn table_unindent_style_none_never_uses_glyphs() {
1695        // None: never unindent even if table overflows. No /< /> in output.
1696        let rendered = render_string_with_options(
1697            &deep3_table_value(),
1698            RenderOptions::default()
1699                .wrap_width(Some(50))
1700                .table_unindent_style(TableUnindentStyle::None),
1701        );
1702        assert!(!rendered.contains("/<"), "None must not use indent glyphs: {rendered}");
1703    }
1704
1705    #[test]
1706    fn table_unindent_style_left_always_uses_glyphs_when_fits_at_zero() {
1707        // Left: always push to indent 0 even when table fits at natural indent.
1708        // Use unlimited width so table fits naturally, but Left still unindents.
1709        let rendered = render_string_with_options(
1710            &deep3_table_value(),
1711            RenderOptions::default()
1712                .wrap_width(None)
1713                .table_unindent_style(TableUnindentStyle::Left),
1714        );
1715        assert!(rendered.contains("/<"), "Left must always use indent glyphs: {rendered}");
1716        let reparsed = to_json_value(rendered.parse::<Value>().unwrap());
1717        assert_eq!(reparsed, to_json_value(deep3_table_value()));
1718    }
1719
1720    #[test]
1721    fn table_unindent_style_auto_uses_glyphs_only_on_overflow() {
1722        let value = deep3_table_value();
1723        // With wide wrap: table fits at natural indent → no glyphs.
1724        let wide = render_string_with_options(
1725            &value,
1726            RenderOptions::default()
1727                .wrap_width(None)
1728                .table_unindent_style(TableUnindentStyle::Auto),
1729        );
1730        assert!(!wide.contains("/<"), "Auto must not use glyphs when table fits: {wide}");
1731
1732        // With narrow wrap (60): table rows are 65 chars, overflows. data_width=57 ≤ 60 → fits at 0.
1733        let narrow = render_string_with_options(
1734            &value,
1735            RenderOptions::default()
1736                .wrap_width(Some(60))
1737                .table_unindent_style(TableUnindentStyle::Auto),
1738        );
1739        assert!(narrow.contains("/<"), "Auto must use glyphs on overflow: {narrow}");
1740        let reparsed = to_json_value(narrow.parse::<Value>().unwrap());
1741        assert_eq!(reparsed, to_json_value(value));
1742    }
1743
1744    #[test]
1745    fn table_unindent_style_floating_pushes_minimum_needed() {
1746        // Floating: push left only enough to fit, not all the way to 0.
1747        // pair_indent=6, table data_width ≈ 58 chars. With wrap=70:
1748        // natural width = 6+2+58=66 ≤ 70 → fits → no glyphs.
1749        // With wrap=60: natural=66 > 60, but data_width=58 > 60-2=58 → exactly fits at target=0.
1750        // Use wrap=65: natural=66 > 65, target = 65-58-2=5 < 6=n → unindents to 5 (not 0).
1751        let value = deep3_table_value();
1752        let rendered = render_string_with_options(
1753            &value,
1754            RenderOptions::default()
1755                .wrap_width(Some(65))
1756                .table_unindent_style(TableUnindentStyle::Floating),
1757        );
1758        // Should use glyphs but NOT go all the way to indent 0.
1759        // If it goes to 0, rows start at indent 2 ("  |col1...").
1760        // If floating, rows are at indent > 2.
1761        if rendered.contains("/<") {
1762            let row_line = rendered.lines().find(|l| l.contains('|') && !l.contains("/<") && !l.contains("/>")).unwrap_or("");
1763            let row_indent = row_line.len() - row_line.trim_start().len();
1764            assert!(row_indent > 2, "Floating must not push all the way to indent 0: {rendered}");
1765        }
1766        let reparsed = to_json_value(rendered.parse::<Value>().unwrap());
1767        assert_eq!(reparsed, to_json_value(value));
1768    }
1769
1770    #[test]
1771    fn table_unindent_style_none_with_indent_glyph_none_also_no_glyphs() {
1772        // Both None: definitely no glyphs. Belt and suspenders.
1773        let rendered = render_string_with_options(
1774            &deep3_table_value(),
1775            RenderOptions::default()
1776                .wrap_width(Some(50))
1777                .table_unindent_style(TableUnindentStyle::None)
1778                .indent_glyph_style(IndentGlyphStyle::None),
1779        );
1780        assert!(!rendered.contains("/<"), "must not use indent glyphs: {rendered}");
1781    }
1782
1783    #[test]
1784    fn table_unindent_style_left_independent_of_indent_glyph_none() {
1785        // indent_glyph_style=None disables object glyphs but does not block table unindent.
1786        let rendered = render_string_with_options(
1787            &deep3_table_value(),
1788            RenderOptions::default()
1789                .wrap_width(None)
1790                .table_unindent_style(TableUnindentStyle::Left)
1791                .indent_glyph_style(IndentGlyphStyle::None),
1792        );
1793        assert!(rendered.contains("/<"), "table_unindent_style=Left must still fire with indent_glyph_style=None: {rendered}");
1794    }
1795
1796    #[test]
1797    fn renderer_does_not_use_indent_offset_when_indent_is_small() {
1798        // pair_indent=2 → n*5=10 < w=80, so offset should never apply.
1799        let json_str = r#"{"h":[
1800            {"c1":"really long value 1","c2":"somewhat long val 1","c3":"another long val 12"},
1801            {"c1":"row two c1 value","c2":"row two c2 value","c3":"row two c3 value"},
1802            {"c1":"row three c1 val","c2":"row three c2 val","c3":"row three c3 val"}
1803        ]}"#;
1804        let value = Value::from(serde_json::from_str::<JsonValue>(json_str).unwrap());
1805        let rendered = render_string_with_options(
1806            &value,
1807            RenderOptions {
1808                wrap_width: Some(80),
1809                ..RenderOptions::default()
1810            },
1811        );
1812        assert!(
1813            !rendered.contains(" /<"),
1814            "expected no /< when indent is small:\n{rendered}"
1815        );
1816    }
1817
1818    #[test]
1819    fn tjson_config_camel_case_enums() {
1820        // multi-word camelCase variants
1821        let c: TjsonConfig = serde_json::from_str(r#"{"stringArrayStyle":"preferSpaces","multilineStyle":"boldFloating"}"#).unwrap();
1822        assert_eq!(c.string_array_style, Some(StringArrayStyle::PreferSpaces));
1823        assert_eq!(c.multiline_style, Some(MultilineStyle::BoldFloating));
1824
1825        // PascalCase still works
1826        let c: TjsonConfig = serde_json::from_str(r#"{"stringArrayStyle":"PreferComma","multilineStyle":"FoldingQuotes"}"#).unwrap();
1827        assert_eq!(c.string_array_style, Some(StringArrayStyle::PreferComma));
1828        assert_eq!(c.multiline_style, Some(MultilineStyle::FoldingQuotes));
1829
1830        // single-word lowercase (BareStyle, FoldStyle, IndentGlyphStyle, TableUnindentStyle, IndentGlyphMarkerStyle)
1831        let c: TjsonConfig = serde_json::from_str(r#"{
1832            "bareStrings": "prefer",
1833            "numberFoldStyle": "auto",
1834            "indentGlyphStyle": "fixed",
1835            "tableUnindentStyle": "floating",
1836            "indentGlyphMarkerStyle": "compact"
1837        }"#).unwrap();
1838        assert_eq!(c.bare_strings, Some(BareStyle::Prefer));
1839        assert_eq!(c.number_fold_style, Some(FoldStyle::Auto));
1840        assert_eq!(c.indent_glyph_style, Some(IndentGlyphStyle::Fixed));
1841        assert_eq!(c.table_unindent_style, Some(TableUnindentStyle::Floating));
1842        assert_eq!(c.indent_glyph_marker_style, Some(IndentGlyphMarkerStyle::Compact));
1843    }
1844}