use std::collections::BTreeMap;
use time::{format_description, macros::format_description as fd};
use crate::{Binary, Value};
mod options;
pub use options::{BinaryEncoding, Options, QuoteStyle, TimestampPrecision};
pub fn format(value: &Value) -> String {
format_impl(value, &Options::default(), 0, false)
}
pub fn format_with_opts(value: &Value, opts: &Options) -> String {
format_impl(value, opts, 0, false)
}
fn format_impl(value: &Value, opts: &Options, depth: usize, inline: bool) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => format_int(*i, opts),
Value::Float(f) => format_float(*f, opts),
Value::String(s) => {
let quote = match opts.quote_style {
QuoteStyle::Double => '"',
QuoteStyle::Single => '\'',
QuoteStyle::PreferDouble => {
if s.contains('"') && !s.contains('\'') {
'\''
} else {
'"'
}
}
};
format_string(s, quote, opts.escape_unicode)
}
Value::Binary(b) => format_binary(b, opts.binary_encoding),
Value::Timestamp(t) => format_timestamp(t, opts),
Value::List(items) => format_list(items, opts, depth, inline),
Value::Map(map) => format_map(map, opts, depth, inline),
}
}
fn format_int(i: i64, opts: &Options) -> String {
if opts.leading_plus && i >= 0 {
format!("+{}", i)
} else {
i.to_string()
}
}
fn format_float(f: f64, opts: &Options) -> String {
let base_string = if f.is_infinite() {
if f.is_sign_negative() {
"-inf".to_string()
} else {
"inf".to_string()
}
} else if f.is_nan() {
"nan".to_string()
} else if f.fract() == 0.0 && f.abs() < 1e15 {
format!("{:.1}", f)
} else {
f.to_string()
};
if opts.leading_plus && !f.is_nan() && !base_string.starts_with('-') {
format!("+{}", base_string)
} else {
base_string
}
}
const TIMESTAMP_FORMAT_SECONDS: &[format_description::FormatItem<'static>] = fd!(
"[year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_minute]"
);
const TIMESTAMP_FORMAT_MILLIS: &[format_description::FormatItem<'static>] = fd!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3][offset_hour sign:mandatory]:[offset_minute]"
);
const TIMESTAMP_FORMAT_MICROS: &[format_description::FormatItem<'static>] = fd!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6][offset_hour sign:mandatory]:[offset_minute]"
);
const TIMESTAMP_FORMAT_NANOS: &[format_description::FormatItem<'static>] = fd!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9][offset_hour sign:mandatory]:[offset_minute]"
);
fn format_timestamp(t: &crate::Timestamp, opts: &Options) -> String {
let format: &[format_description::FormatItem<'_>] = match opts.timestamp_precision {
TimestampPrecision::Auto => {
let formatted = t
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| t.to_string());
let final_str = if !opts.use_zulu && formatted.ends_with('Z') {
let mut s = formatted;
s.pop();
s.push_str("+00:00");
s
} else {
formatted
};
return format!("ts\"{}\"", final_str);
}
TimestampPrecision::Seconds => TIMESTAMP_FORMAT_SECONDS,
TimestampPrecision::Milliseconds => TIMESTAMP_FORMAT_MILLIS,
TimestampPrecision::Microseconds => TIMESTAMP_FORMAT_MICROS,
TimestampPrecision::Nanoseconds => TIMESTAMP_FORMAT_NANOS,
};
let formatted = t.format(format).unwrap_or_else(|_| t.to_string());
let final_str = if opts.use_zulu && formatted.ends_with("+00:00") {
let mut s = formatted;
s.truncate(s.len() - 6);
s.push('Z');
s
} else {
formatted
};
format!("ts\"{}\"", final_str)
}
fn format_string(s: &str, quote: char, escape_unicode: bool) -> String {
let mut result = String::with_capacity(s.len() + 2);
result.push(quote);
for ch in s.chars() {
match ch {
'"' if quote == '"' => result.push_str("\\\""),
'\'' if quote == '\'' => result.push_str("\\'"),
'\\' => result.push_str("\\\\"),
'/' => result.push_str("\\/"),
'\n' => result.push_str("\\n"),
'\t' => result.push_str("\\t"),
'\r' => result.push_str("\\r"),
'\x08' => result.push_str("\\b"),
'\x0C' => result.push_str("\\f"),
c if c.is_control() => {
use std::fmt::Write;
write!(&mut result, "\\u{:04x}", c as u32).unwrap();
}
c if escape_unicode && !c.is_ascii() => {
use std::fmt::Write;
let code = c as u32;
if code <= 0xFFFF {
write!(&mut result, "\\u{:04x}", code).unwrap();
} else {
let adjusted = code - 0x10000;
let high = 0xD800 + (adjusted >> 10);
let low = 0xDC00 + (adjusted & 0x3FF);
write!(&mut result, "\\u{:04x}\\u{:04x}", high, low).unwrap();
}
}
c => result.push(c),
}
}
result.push(quote);
result
}
fn format_binary(binary: &Binary, encoding: BinaryEncoding) -> String {
match encoding {
BinaryEncoding::Base64 => {
use base64::{Engine as _, engine::general_purpose};
let encoded = general_purpose::STANDARD.encode(&binary.0);
format!("b64\"{}\"", encoded)
}
BinaryEncoding::Hex => {
let hex: String = binary.0.iter().map(|b| format!("{:02x}", b)).collect();
format!("hex\"{}\"", hex)
}
}
}
fn format_list(items: &[Value], opts: &Options, depth: usize, inline: bool) -> String {
if items.is_empty() {
return "[]".to_string();
}
let indent = " ".repeat(depth);
let mut result = String::new();
for (i, item) in items.iter().enumerate() {
if i > 0 || !inline {
result.push_str(&indent);
}
result.push_str("- ");
match item {
Value::List(items) if !items.is_empty() => {
result.push('\n');
result.push_str(&format_impl(item, opts, depth + 1, false));
}
Value::Map(m) if !m.is_empty() => {
result.push('\n');
result.push_str(&format_impl(item, opts, depth + 1, false));
}
_ => {
result.push_str(&format_impl(item, opts, depth + 1, true));
result.push('\n');
}
}
}
result
}
fn format_map(map: &BTreeMap<String, Value>, opts: &Options, depth: usize, inline: bool) -> String {
if map.is_empty() {
return "{}".to_string();
}
let indent = " ".repeat(depth);
let mut result = String::new();
let entries: Vec<_> = if opts.sort_keys {
let mut sorted: Vec<_> = map.iter().collect();
sorted.sort_by_key(|(k, _)| *k);
sorted
} else {
map.iter().collect()
};
for (i, (key, value)) in entries.iter().enumerate() {
if i > 0 || !inline {
result.push_str(&indent);
}
if opts.unquoted_keys && can_be_unquoted(key) {
result.push_str(key);
} else {
let quote = match opts.quote_style {
QuoteStyle::Double => '"',
QuoteStyle::Single => '\'',
QuoteStyle::PreferDouble => {
if key.contains('"') && !key.contains('\'') {
'\''
} else {
'"'
}
}
};
result.push_str(&format_string(key, quote, opts.escape_unicode));
}
result.push(':');
match value {
Value::List(items) if !items.is_empty() => {
result.push('\n');
result.push_str(&format_impl(value, opts, depth + 1, false));
}
Value::Map(m) if !m.is_empty() => {
result.push('\n');
result.push_str(&format_impl(value, opts, depth + 1, false));
}
_ => {
result.push(' ');
result.push_str(&format_impl(value, opts, depth + 1, true));
result.push('\n');
}
}
}
result
}
fn can_be_unquoted(key: &str) -> bool {
if key.is_empty() {
return false;
}
if matches!(key, "null" | "true" | "false" | "inf" | "nan") {
return false;
}
let mut chars = key.chars();
let first = chars.next().unwrap();
if !first.is_ascii_alphabetic() && first != '_' {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}