jasn/
formatter.rs

1//! Format a [`Value`] into JASN text.
2//!
3//! The main entry points are [`format()`] and [`format_pretty()`] for common use cases.
4//! For custom formatting options, use [`format_with_opts()`] with [`Options`].
5//!
6//! ```
7//! use jasn::{Value, format};
8//!
9//! let value = Value::String("hello".to_string());
10//! assert_eq!(format(&value), r#""hello""#);
11//!
12//! // Custom formatting with advanced options
13//! use jasn::formatter::{Options, format_with_opts};
14//! let opts = Options::pretty().with_indent("\t");
15//! let formatted = format_with_opts(&value, &opts);
16//! ```
17
18use std::collections::BTreeMap;
19
20use time::{format_description, macros::format_description as fd};
21
22use crate::{Binary, Value};
23
24/// Formatting options and configuration.
25mod options;
26pub use options::{BinaryEncoding, Options, QuoteStyle, TimestampPrecision};
27
28/// Formats a JASN [`Value`] into a compact string (no unnecessary whitespace).
29pub fn format(value: &Value) -> String {
30    format_impl(value, &Options::compact(), 0)
31}
32
33/// Formats a JASN [`Value`] into a pretty-printed string with indentation and newlines.
34pub fn format_pretty(value: &Value) -> String {
35    format_impl(value, &Options::pretty(), 0)
36}
37
38/// Formats a JASN [`Value`] with custom formatting options.
39pub fn format_with_opts(value: &Value, opts: &Options) -> String {
40    format_impl(value, opts, 0)
41}
42
43fn format_impl(value: &Value, opts: &Options, depth: usize) -> String {
44    match value {
45        Value::Null => "null".to_string(),
46        Value::Bool(b) => b.to_string(),
47        Value::Int(i) => format_int(*i, opts),
48        Value::Float(f) => format_float(*f, opts),
49        Value::String(s) => {
50            let quote = match opts.quote_style {
51                QuoteStyle::Double => '"',
52                QuoteStyle::Single => '\'',
53                QuoteStyle::PreferDouble => {
54                    if s.contains('"') && !s.contains('\'') {
55                        '\''
56                    } else {
57                        '"'
58                    }
59                }
60            };
61            format_string(s, quote, opts.escape_unicode)
62        }
63        Value::Binary(b) => format_binary(b, opts.binary_encoding),
64        Value::Timestamp(t) => format_timestamp(t, opts),
65        Value::List(items) => {
66            if opts.indent.is_empty() {
67                format_list_compact(items, opts)
68            } else {
69                format_list_pretty(items, opts, depth)
70            }
71        }
72        Value::Map(map) => {
73            if opts.indent.is_empty() {
74                format_map_compact(map, opts)
75            } else {
76                format_map_pretty(map, opts, depth)
77            }
78        }
79    }
80}
81
82fn format_int(i: i64, opts: &Options) -> String {
83    if opts.leading_plus && i >= 0 {
84        format!("+{}", i)
85    } else {
86        i.to_string()
87    }
88}
89
90fn format_float(f: f64, opts: &Options) -> String {
91    let base_string = if f.is_infinite() {
92        if f.is_sign_negative() {
93            "-inf".to_string()
94        } else {
95            "inf".to_string()
96        }
97    } else if f.is_nan() {
98        "nan".to_string()
99    } else if f.fract() == 0.0 && f.abs() < 1e15 {
100        // Ensure we always have a decimal point to distinguish from integers
101        format!("{:.1}", f)
102    } else {
103        f.to_string()
104    };
105
106    // Add leading plus for positive numbers (including +inf, but not nan)
107    if opts.leading_plus && !f.is_nan() && !base_string.starts_with('-') {
108        format!("+{}", base_string)
109    } else {
110        base_string
111    }
112}
113
114const TIMESTAMP_FORMAT_SECONDS: &[format_description::FormatItem<'static>] = fd!(
115    "[year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_minute]"
116);
117
118const TIMESTAMP_FORMAT_MILLIS: &[format_description::FormatItem<'static>] = fd!(
119    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3][offset_hour sign:mandatory]:[offset_minute]"
120);
121
122const TIMESTAMP_FORMAT_MICROS: &[format_description::FormatItem<'static>] = fd!(
123    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6][offset_hour sign:mandatory]:[offset_minute]"
124);
125
126const TIMESTAMP_FORMAT_NANOS: &[format_description::FormatItem<'static>] = fd!(
127    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9][offset_hour sign:mandatory]:[offset_minute]"
128);
129
130fn format_timestamp(t: &crate::Timestamp, opts: &Options) -> String {
131    // Select format descriptor based on precision
132    let format: &[format_description::FormatItem<'_>] = match opts.timestamp_precision {
133        TimestampPrecision::Auto => {
134            // Use RFC3339 which includes fractional seconds when present
135            let formatted = t
136                .format(&time::format_description::well_known::Rfc3339)
137                .unwrap_or_else(|_| t.to_string());
138
139            // RFC3339 uses Z for UTC, convert to +00:00 if needed
140            let final_str = if !opts.use_zulu && formatted.ends_with('Z') {
141                let mut s = formatted;
142                s.pop();
143                s.push_str("+00:00");
144                s
145            } else {
146                formatted
147            };
148            return format!("ts\"{}\"", final_str);
149        }
150        TimestampPrecision::Seconds => TIMESTAMP_FORMAT_SECONDS,
151        TimestampPrecision::Milliseconds => TIMESTAMP_FORMAT_MILLIS,
152        TimestampPrecision::Microseconds => TIMESTAMP_FORMAT_MICROS,
153        TimestampPrecision::Nanoseconds => TIMESTAMP_FORMAT_NANOS,
154    };
155
156    // Custom formats output +00:00, convert to Z if needed
157    let formatted = t.format(format).unwrap_or_else(|_| t.to_string());
158    let final_str = if opts.use_zulu && formatted.ends_with("+00:00") {
159        let mut s = formatted;
160        s.truncate(s.len() - 6);
161        s.push('Z');
162        s
163    } else {
164        formatted
165    };
166
167    format!("ts\"{}\"", final_str)
168}
169
170fn format_string(s: &str, quote: char, escape_unicode: bool) -> String {
171    let mut result = String::with_capacity(s.len() + 2);
172    result.push(quote);
173
174    for ch in s.chars() {
175        match ch {
176            '"' if quote == '"' => result.push_str("\\\""),
177            '\'' if quote == '\'' => result.push_str("\\'"),
178            '\\' => result.push_str("\\\\"),
179            '/' => result.push_str("\\/"),
180            '\n' => result.push_str("\\n"),
181            '\t' => result.push_str("\\t"),
182            '\r' => result.push_str("\\r"),
183            '\x08' => result.push_str("\\b"),
184            '\x0C' => result.push_str("\\f"),
185            c if c.is_control() => {
186                use std::fmt::Write;
187                write!(&mut result, "\\u{:04x}", c as u32).unwrap();
188            }
189            c if escape_unicode && !c.is_ascii() => {
190                use std::fmt::Write;
191                let code = c as u32;
192                if code <= 0xFFFF {
193                    // BMP character - single escape sequence
194                    write!(&mut result, "\\u{:04x}", code).unwrap();
195                } else {
196                    // Non-BMP character - use UTF-16 surrogate pair
197                    let adjusted = code - 0x10000;
198                    let high = 0xD800 + (adjusted >> 10);
199                    let low = 0xDC00 + (adjusted & 0x3FF);
200                    write!(&mut result, "\\u{:04x}\\u{:04x}", high, low).unwrap();
201                }
202            }
203            c => result.push(c),
204        }
205    }
206
207    result.push(quote);
208    result
209}
210
211fn format_binary(binary: &Binary, encoding: BinaryEncoding) -> String {
212    match encoding {
213        BinaryEncoding::Base64 => {
214            use base64::{Engine as _, engine::general_purpose};
215            let encoded = general_purpose::STANDARD.encode(&binary.0);
216            format!("b64\"{}\"", encoded)
217        }
218        BinaryEncoding::Hex => {
219            let hex: String = binary.0.iter().map(|b| format!("{:02x}", b)).collect();
220            format!("hex\"{}\"", hex)
221        }
222    }
223}
224
225fn format_list_compact(items: &[Value], opts: &Options) -> String {
226    if items.is_empty() {
227        return "[]".to_string();
228    }
229
230    let formatted: Vec<String> = items
231        .iter()
232        .map(|item| format_impl(item, opts, 0))
233        .collect();
234    format!("[{}]", formatted.join(","))
235}
236
237fn format_list_pretty(items: &[Value], opts: &Options, depth: usize) -> String {
238    if items.is_empty() {
239        return "[]".to_string();
240    }
241
242    let indent = opts.indent.repeat(depth);
243    let item_indent = opts.indent.repeat(depth + 1);
244    let mut result = String::from("[\n");
245
246    for (i, item) in items.iter().enumerate() {
247        result.push_str(&item_indent);
248        result.push_str(&format_impl(item, opts, depth + 1));
249        if i < items.len() - 1 || opts.trailing_commas {
250            result.push(',');
251        }
252        result.push('\n');
253    }
254
255    result.push_str(&indent);
256    result.push(']');
257    result
258}
259
260fn format_map_compact(map: &BTreeMap<String, Value>, opts: &Options) -> String {
261    if map.is_empty() {
262        return "{}".to_string();
263    }
264
265    let entries: Vec<_> = if opts.sort_keys {
266        let mut sorted: Vec<_> = map.iter().collect();
267        sorted.sort_by_key(|(k, _)| *k);
268        sorted
269    } else {
270        map.iter().collect()
271    };
272
273    let formatted: Vec<String> = entries
274        .iter()
275        .map(|(k, v)| {
276            let key_str = if opts.unquoted_keys && can_be_unquoted(k) {
277                k.to_string()
278            } else {
279                let quote = match opts.quote_style {
280                    QuoteStyle::Double => '"',
281                    QuoteStyle::Single => '\'',
282                    QuoteStyle::PreferDouble => {
283                        if k.contains('"') && !k.contains('\'') {
284                            '\''
285                        } else {
286                            '"'
287                        }
288                    }
289                };
290                format_string(k, quote, opts.escape_unicode)
291            };
292            format!("{}:{}", key_str, format_impl(v, opts, 0))
293        })
294        .collect();
295    format!("{{{}}}", formatted.join(","))
296}
297
298fn format_map_pretty(map: &BTreeMap<String, Value>, opts: &Options, depth: usize) -> String {
299    if map.is_empty() {
300        return "{}".to_string();
301    }
302
303    let indent = opts.indent.repeat(depth);
304    let item_indent = opts.indent.repeat(depth + 1);
305    let mut result = String::from("{\n");
306
307    let entries: Vec<_> = if opts.sort_keys {
308        let mut sorted: Vec<_> = map.iter().collect();
309        sorted.sort_by_key(|(k, _)| *k);
310        sorted
311    } else {
312        map.iter().collect()
313    };
314    for (i, (key, value)) in entries.iter().enumerate() {
315        result.push_str(&item_indent);
316
317        // Format key (possibly unquoted)
318        if opts.unquoted_keys && can_be_unquoted(key) {
319            result.push_str(key);
320        } else {
321            let quote = match opts.quote_style {
322                QuoteStyle::Double => '"',
323                QuoteStyle::Single => '\'',
324                QuoteStyle::PreferDouble => {
325                    if key.contains('"') && !key.contains('\'') {
326                        '\''
327                    } else {
328                        '"'
329                    }
330                }
331            };
332            result.push_str(&format_string(key, quote, opts.escape_unicode));
333        }
334
335        result.push_str(": ");
336        result.push_str(&format_impl(value, opts, depth + 1));
337
338        if i < entries.len() - 1 || opts.trailing_commas {
339            result.push(',');
340        }
341        result.push('\n');
342    }
343
344    result.push_str(&indent);
345    result.push('}');
346    result
347}
348
349fn can_be_unquoted(key: &str) -> bool {
350    if key.is_empty() {
351        return false;
352    }
353
354    // Reserved keywords cannot be unquoted
355    if matches!(key, "null" | "true" | "false" | "inf" | "nan") {
356        return false;
357    }
358
359    let mut chars = key.chars();
360    let first = chars.next().unwrap();
361
362    // Must start with letter or underscore
363    if !first.is_ascii_alphabetic() && first != '_' {
364        return false;
365    }
366
367    // Rest must be alphanumeric or underscore
368    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
369}
370
371#[cfg(test)]
372mod tests {
373    use rstest::rstest;
374
375    use super::*;
376    use crate::parse;
377
378    #[rstest]
379    #[case(Value::Null, "null")]
380    #[case(Value::Bool(true), "true")]
381    #[case(Value::Bool(false), "false")]
382    #[case(Value::Int(42), "42")]
383    #[case(Value::Int(-123), "-123")]
384    fn test_format_primitives(#[case] value: Value, #[case] expected: &str) {
385        assert_eq!(format(&value), expected);
386    }
387
388    #[rstest]
389    #[case(3.0, "3.0")]
390    #[case(2.5, "2.5")]
391    #[case(f64::INFINITY, "inf")]
392    #[case(f64::NEG_INFINITY, "-inf")]
393    fn test_format_float(#[case] value: f64, #[case] expected: &str) {
394        assert_eq!(format(&Value::Float(value)), expected);
395    }
396
397    #[test]
398    fn test_format_float_nan() {
399        assert!(format(&Value::Float(f64::NAN)).contains("nan"));
400    }
401
402    #[test]
403    fn test_format_string() {
404        assert_eq!(format(&Value::String("hello".to_string())), "\"hello\"");
405        assert_eq!(
406            format(&Value::String("hello\nworld".to_string())),
407            "\"hello\\nworld\""
408        );
409        assert_eq!(
410            format(&Value::String("tab\there".to_string())),
411            "\"tab\\there\""
412        );
413    }
414
415    #[test]
416    fn test_format_binary() {
417        let binary = Binary(vec![72, 101, 108, 108, 111]); // "Hello"
418        assert_eq!(format(&Value::Binary(binary)), "b64\"SGVsbG8=\"");
419    }
420
421    #[test]
422    fn test_format_list() {
423        let list = vec![Value::Int(1), Value::Int(2), Value::Int(3)];
424        assert_eq!(format(&Value::List(list)), "[1,2,3]");
425
426        assert_eq!(format(&Value::List(vec![])), "[]");
427    }
428
429    #[test]
430    fn test_format_map() {
431        let mut map = BTreeMap::new();
432        map.insert("name".to_string(), Value::String("Alice".to_string()));
433        map.insert("age".to_string(), Value::Int(30));
434
435        let formatted = format(&Value::Map(map));
436        // Compact format uses unquoted keys to save bytes
437        assert!(formatted.contains("age:30"));
438        assert!(formatted.contains("name:\"Alice\""));
439    }
440
441    #[test]
442    fn test_round_trip() {
443        // Null
444        let null = Value::Null;
445        assert_eq!(parse(&format(&null)).unwrap(), null);
446
447        // Bool
448        let bool_val = Value::Bool(true);
449        assert_eq!(parse(&format(&bool_val)).unwrap(), bool_val);
450
451        // Int
452        let int_val = Value::Int(42);
453        assert_eq!(parse(&format(&int_val)).unwrap(), int_val);
454
455        // Float
456        let float_val = Value::Float(2.5);
457        assert_eq!(parse(&format(&float_val)).unwrap(), float_val);
458
459        // String
460        let string_val = Value::String("hello world".to_string());
461        assert_eq!(parse(&format(&string_val)).unwrap(), string_val);
462
463        // List
464        let list_val = Value::List(vec![Value::Int(1), Value::Int(2)]);
465        assert_eq!(parse(&format(&list_val)).unwrap(), list_val);
466
467        // Map
468        let mut map = BTreeMap::new();
469        map.insert("key".to_string(), Value::Int(42));
470        let map_val = Value::Map(map);
471        assert_eq!(parse(&format(&map_val)).unwrap(), map_val);
472    }
473
474    #[test]
475    fn test_pretty_format() {
476        let mut map = BTreeMap::new();
477        map.insert("name".to_string(), Value::String("Alice".to_string()));
478        map.insert("age".to_string(), Value::Int(30));
479
480        let pretty = format_pretty(&Value::Map(map));
481        assert!(pretty.contains('\n'));
482        assert!(pretty.contains("  "));
483    }
484
485    #[rstest]
486    #[case("hello", true)]
487    #[case("_private", true)]
488    #[case("key123", true)]
489    #[case("_", true)]
490    #[case("", false)]
491    #[case("123", false)]
492    #[case("null", false)]
493    #[case("true", false)]
494    #[case("false", false)]
495    #[case("kebab-case", false)]
496    fn test_can_be_unquoted(#[case] input: &str, #[case] expected: bool) {
497        assert_eq!(can_be_unquoted(input), expected);
498    }
499
500    #[rstest]
501    #[case(Value::Int(42), "+42")]
502    #[case(Value::Int(0), "+0")]
503    #[case(Value::Int(-42), "-42")]
504    #[case(Value::Float(2.5), "+2.5")]
505    #[case(Value::Float(-2.5), "-2.5")]
506    #[case(Value::Float(f64::INFINITY), "+inf")]
507    #[case(Value::Float(f64::NEG_INFINITY), "-inf")]
508    #[case(Value::Float(f64::NAN), "nan")]
509    fn test_leading_plus(#[case] value: Value, #[case] expected: &str) {
510        let opts = Options::compact().with_leading_plus(true);
511        assert_eq!(format_with_opts(&value, &opts), expected);
512    }
513
514    #[rstest]
515    #[case(Value::Int(42), "42")]
516    #[case(Value::Float(2.5), "2.5")]
517    fn test_no_leading_plus(#[case] value: Value, #[case] expected: &str) {
518        let opts = Options::compact();
519        assert_eq!(format_with_opts(&value, &opts), expected);
520    }
521
522    #[test]
523    fn test_sort_keys() {
524        let mut map = BTreeMap::new();
525        map.insert("zebra".to_string(), Value::Int(1));
526        map.insert("apple".to_string(), Value::Int(2));
527        map.insert("banana".to_string(), Value::Int(3));
528
529        // With sort_keys enabled
530        let sorted_opts = Options::compact().with_sort_keys(true);
531        let sorted = format_with_opts(&Value::Map(map), &sorted_opts);
532
533        // Should be alphabetically ordered
534        assert_eq!(sorted, "{apple:2,banana:3,zebra:1}");
535
536        // Pretty mode with sort_keys
537        let pretty_sorted = Options::pretty().with_sort_keys(true);
538        let mut map2 = BTreeMap::new();
539        map2.insert("z".to_string(), Value::Int(1));
540        map2.insert("a".to_string(), Value::Int(2));
541        let result = format_with_opts(&Value::Map(map2), &pretty_sorted);
542        assert!(result.find("a").unwrap() < result.find("z").unwrap());
543    }
544
545    #[test]
546    fn test_escape_unicode() {
547        let opts = Options::compact().with_escape_unicode(true);
548
549        // ASCII characters should not be escaped
550        let ascii = Value::String("hello".to_string());
551        assert_eq!(format_with_opts(&ascii, &opts), "\"hello\"");
552
553        // Non-ASCII characters should be escaped
554        let unicode = Value::String("cafΓ©".to_string());
555        assert_eq!(format_with_opts(&unicode, &opts), "\"caf\\u00e9\"");
556
557        // Emoji should be escaped using UTF-16 surrogate pairs (U+1F30D => D83C DF0D)
558        let emoji = Value::String("Hello 🌍".to_string());
559        assert_eq!(format_with_opts(&emoji, &opts), "\"Hello \\ud83c\\udf0d\"");
560
561        // Chinese characters
562        let chinese = Value::String("δ½ ε₯½".to_string());
563        assert_eq!(format_with_opts(&chinese, &opts), "\"\\u4f60\\u597d\"");
564
565        // Without escape_unicode should keep Unicode literal
566        let no_escape = Options::compact().with_escape_unicode(false);
567        let result = format_with_opts(&unicode, &no_escape);
568        assert_eq!(result, "\"cafΓ©\"");
569    }
570
571    #[rstest]
572    #[case("πŸ˜€", "\"\\ud83d\\ude00\"")]
573    #[case("πŸ‘", "\"\\ud83d\\udc4d\"")]
574    #[case("π„ž", "\"\\ud834\\udd1e\"")]
575    #[case("πŸ˜€πŸ˜πŸ˜‚", "\"\\ud83d\\ude00\\ud83d\\ude01\\ud83d\\ude02\"")]
576    #[case("Hello πŸ˜€ World", "\"Hello \\ud83d\\ude00 World\"")]
577    #[case("δΈ­ζ–‡", "\"\\u4e2d\\u6587\"")]
578    fn test_surrogate_pair_encoding(#[case] input: &str, #[case] expected: &str) {
579        let opts = Options::compact().with_escape_unicode(true);
580        let value = Value::String(input.to_string());
581        assert_eq!(format_with_opts(&value, &opts), expected);
582    }
583
584    #[rstest]
585    #[case("πŸ˜€")]
586    #[case("🌍")]
587    #[case("πŸ‘")]
588    #[case("π„ž")]
589    #[case("Hello πŸ˜€ World")]
590    #[case("πŸ˜€πŸ˜πŸ˜‚")]
591    fn test_surrogate_pair_round_trip(#[case] original: &str) {
592        let opts = Options::compact().with_escape_unicode(true);
593        let value = Value::String(original.to_string());
594        let formatted = format_with_opts(&value, &opts);
595        let parsed = crate::parse(&formatted).expect("Failed to parse");
596
597        if let Value::String(s) = parsed {
598            assert_eq!(s, original, "Round-trip failed for: {}", original);
599        } else {
600            panic!("Expected String value");
601        }
602    }
603
604    #[test]
605    fn test_format_timestamp_default() {
606        use crate::Timestamp;
607
608        let ts = Timestamp::from_unix_timestamp(1234567890).unwrap();
609        let value = Value::Timestamp(ts);
610
611        // Default (use_zulu = true) - should use Z notation
612        let result = format(&value);
613        assert_eq!(result, "ts\"2009-02-13T23:31:30Z\"");
614    }
615
616    #[rstest]
617    #[case(true, "ts\"2009-02-13T23:31:30Z\"")]
618    #[case(false, "ts\"2009-02-13T23:31:30+00:00\"")]
619    fn test_format_timestamp_zulu(#[case] use_zulu: bool, #[case] expected: &str) {
620        use crate::Timestamp;
621
622        let ts = Timestamp::from_unix_timestamp(1234567890).unwrap();
623        let value = Value::Timestamp(ts);
624        let opts = Options::compact().with_use_zulu(use_zulu);
625        let result = format_with_opts(&value, &opts);
626        assert_eq!(result, expected);
627    }
628
629    #[rstest]
630    #[case(true, "ts\"2009-02-13T23:31:30.123456789Z\"")]
631    #[case(false, "ts\"2009-02-13T23:31:30.123456789+00:00\"")]
632    fn test_format_timestamp_fractional_zulu(#[case] use_zulu: bool, #[case] expected: &str) {
633        use crate::Timestamp;
634
635        let ts = Timestamp::from_unix_timestamp_nanos(1234567890123456789).unwrap();
636        let value = Value::Timestamp(ts);
637        let opts = Options::compact().with_use_zulu(use_zulu);
638        let result = format_with_opts(&value, &opts);
639        assert_eq!(result, expected);
640    }
641
642    #[rstest]
643    #[case(TimestampPrecision::Auto, "ts\"2009-02-13T23:31:30.123456789Z\"")]
644    #[case(TimestampPrecision::Seconds, "ts\"2009-02-13T23:31:30Z\"")]
645    #[case(TimestampPrecision::Milliseconds, "ts\"2009-02-13T23:31:30.123Z\"")]
646    #[case(TimestampPrecision::Microseconds, "ts\"2009-02-13T23:31:30.123456Z\"")]
647    #[case(
648        TimestampPrecision::Nanoseconds,
649        "ts\"2009-02-13T23:31:30.123456789Z\""
650    )]
651    fn test_format_timestamp_precision(
652        #[case] precision: TimestampPrecision,
653        #[case] expected: &str,
654    ) {
655        use crate::Timestamp;
656
657        let ts = Timestamp::from_unix_timestamp_nanos(1234567890123456789).unwrap();
658        let value = Value::Timestamp(ts);
659        let opts = Options::compact().with_timestamp_precision(precision);
660        let result = format_with_opts(&value, &opts);
661        assert_eq!(result, expected);
662    }
663
664    #[test]
665    fn test_format_timestamp_precision_with_offset() {
666        use crate::Timestamp;
667
668        let ts = Timestamp::from_unix_timestamp_nanos(1234567890123456789).unwrap();
669        let value = Value::Timestamp(ts);
670        let opts = Options::compact()
671            .with_timestamp_precision(TimestampPrecision::Milliseconds)
672            .with_use_zulu(false);
673        let result = format_with_opts(&value, &opts);
674        assert_eq!(result, "ts\"2009-02-13T23:31:30.123+00:00\"");
675    }
676
677    #[test]
678    fn test_format_timestamp_precision_padding() {
679        use crate::Timestamp;
680
681        // Test precision padding (timestamp without fractional seconds)
682        // When formatted with higher precision, should add zeros
683        let ts = Timestamp::from_unix_timestamp(1234567890).unwrap();
684        let value = Value::Timestamp(ts);
685        let opts = Options::compact().with_timestamp_precision(TimestampPrecision::Milliseconds);
686        let result = format_with_opts(&value, &opts);
687        assert_eq!(result, "ts\"2009-02-13T23:31:30.000Z\"");
688    }
689}