Skip to main content

agent_first_data/
formatting.rs

1use crate::redaction::{OutputOptions, OutputStyle, redacted_value};
2use serde_json::Value;
3
4pub fn output_json(value: &Value) -> String {
5    serialize_json_output(&redacted_value(value))
6}
7
8/// Format as single-line JSON with configurable output options.
9///
10/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
11/// redaction.
12pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
13    serialize_json_output(&output_options.redaction.value(value))
14}
15
16pub(crate) fn serialize_json_output(value: &Value) -> String {
17    match serde_json::to_string(value) {
18        Ok(s) => s,
19        Err(err) => serde_json::json!({
20            "error": "output_json_failed",
21            "detail": err.to_string(),
22        })
23        .to_string(),
24    }
25}
26
27/// Format as multi-line YAML. Keys stripped, values formatted, secrets redacted.
28pub fn output_yaml(value: &Value) -> String {
29    output_yaml_with_options(value, &OutputOptions::default())
30}
31
32/// Format as multi-line YAML with configurable output options.
33pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
34    let mut lines = vec!["---".to_string()];
35    let v = output_options.redaction.value(value);
36    match output_options.style {
37        OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
38        OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
39    }
40    lines.join("\n")
41}
42
43/// Format as single-line logfmt. Keys stripped, values formatted, secrets redacted.
44pub fn output_plain(value: &Value) -> String {
45    output_plain_with_options(value, &OutputOptions::default())
46}
47
48/// Format as single-line logfmt with configurable output options.
49pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
50    let mut pairs: Vec<(String, String)> = Vec::new();
51    let v = output_options.redaction.value(value);
52    match output_options.style {
53        OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
54        OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
55    }
56    pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
57    pairs
58        .into_iter()
59        .map(|(k, v)| format!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
60        .collect::<Vec<_>>()
61        .join(" ")
62}
63
64// ═══════════════════════════════════════════
65// Suffix Processing
66// ═══════════════════════════════════════════
67
68/// Strip a suffix matching exact lowercase or exact uppercase only.
69fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
70    if let Some(s) = key.strip_suffix(suffix_lower) {
71        return Some(s.to_string());
72    }
73    let suffix_upper: String = suffix_lower
74        .chars()
75        .map(|c| c.to_ascii_uppercase())
76        .collect();
77    if let Some(s) = key.strip_suffix(&suffix_upper) {
78        return Some(s.to_string());
79    }
80    None
81}
82
83/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
84fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
85    let code = extract_currency_code(key)?;
86    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
87    let stripped = &key[..key.len() - suffix_len];
88    if stripped.is_empty() {
89        return None;
90    }
91    Some((stripped.to_string(), code.to_string()))
92}
93
94/// Extract currency code from `_{code}_micro` / `_{CODE}_MICRO` pattern.
95fn try_strip_generic_micro(key: &str) -> Option<(String, String)> {
96    let code = extract_currency_code_micro(key)?;
97    let suffix_len = code.len() + "_micro".len() + 1; // _{code}_micro
98    let stripped = &key[..key.len() - suffix_len];
99    if stripped.is_empty() {
100        return None;
101    }
102    Some((stripped.to_string(), code.to_string()))
103}
104
105/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
106/// when suffix matches and type is valid. None for no match or type mismatch.
107/// Accept an integer value, including an integral-valued float (`3.0` → `3`).
108/// Non-integral floats and out-of-range values return `None`. This keeps the
109/// four language implementations consistent: JS/TS cannot distinguish `3` from
110/// `3.0` after JSON parsing, so the value's integrality — not its lexical form —
111/// decides whether an integer-required suffix applies.
112fn as_int(value: &Value) -> Option<i64> {
113    if let Some(i) = value.as_i64() {
114        return Some(i);
115    }
116    let f = value.as_f64()?;
117    if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
118        return Some(f as i64);
119    }
120    None
121}
122
123/// Like [`as_int`] but for non-negative integers (rejects negatives).
124fn as_uint(value: &Value) -> Option<u64> {
125    if let Some(u) = value.as_u64() {
126        return Some(u);
127    }
128    let f = value.as_f64()?;
129    if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
130        return Some(f as u64);
131    }
132    None
133}
134
135fn integer_text(value: &Value) -> Option<String> {
136    match value {
137        Value::Number(_) => as_int(value).map(|n| n.to_string()),
138        Value::String(s) if is_decimal_integer_string(s) => Some(s.clone()),
139        _ => None,
140    }
141}
142
143fn is_decimal_integer_string(s: &str) -> bool {
144    let digits = s.strip_prefix('-').unwrap_or(s);
145    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
146}
147
148fn epoch_ns_to_ms(value: &Value) -> Option<i64> {
149    let ns = match value {
150        Value::Number(_) => i128::from(as_int(value)?),
151        Value::String(s) if is_decimal_integer_string(s) => s.parse::<i128>().ok()?,
152        _ => return None,
153    };
154    ns.div_euclid(1_000_000).try_into().ok()
155}
156
157fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
158    // Group 1: compound timestamp suffixes
159    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
160        return as_int(value)
161            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
162    }
163    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
164        return as_int(value)
165            .and_then(|s| s.checked_mul(1000))
166            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
167    }
168    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
169        return epoch_ns_to_ms(value)
170            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
171    }
172
173    // Group 2: compound currency suffixes
174    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
175        return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
176    }
177    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
178        return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
179    }
180    if let Some((stripped, code)) = try_strip_generic_cents(key) {
181        return as_uint(value).map(|n| {
182            (
183                stripped,
184                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
185            )
186        });
187    }
188    if let Some((stripped, code)) = try_strip_generic_micro(key) {
189        return as_uint(value).map(|n| {
190            (
191                stripped,
192                format!(
193                    "{}.{:06} {}",
194                    n / 1_000_000,
195                    n % 1_000_000,
196                    code.to_uppercase()
197                ),
198            )
199        });
200    }
201
202    // Group 3: multi-char suffixes
203    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
204        return value.as_str().map(|s| (stripped, s.to_string()));
205    }
206    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
207        return value
208            .is_number()
209            .then(|| (stripped, format!("{} minutes", number_str(value))));
210    }
211    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
212        return value
213            .is_number()
214            .then(|| (stripped, format!("{} hours", number_str(value))));
215    }
216    if let Some(stripped) = strip_suffix_ci(key, "_days") {
217        return value
218            .is_number()
219            .then(|| (stripped, format!("{} days", number_str(value))));
220    }
221
222    // Group 4: single-unit suffixes
223    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
224        return integer_text(value).map(|n| (stripped, format!("{n}msats")));
225    }
226    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
227        return integer_text(value).map(|n| (stripped, format!("{n}sats")));
228    }
229    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
230        return as_uint(value).map(|n| (stripped, format_bytes_human(n)));
231    }
232    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
233        return value
234            .is_number()
235            .then(|| (stripped, format!("{}%", number_str(value))));
236    }
237    // Group 5: short suffixes (last to avoid false positives)
238    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
239        return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
240    }
241    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
242        return value
243            .is_number()
244            .then(|| (stripped, format!("{}ns", number_str(value))));
245    }
246    if let Some(stripped) = strip_suffix_ci(key, "_us") {
247        return value
248            .is_number()
249            .then(|| (stripped, format!("{}μs", number_str(value))));
250    }
251    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
252        return format_ms_value(value).map(|v| (stripped, v));
253    }
254    if let Some(stripped) = strip_suffix_ci(key, "_s") {
255        return value
256            .is_number()
257            .then(|| (stripped, format!("{}s", number_str(value))));
258    }
259
260    None
261}
262
263/// Process object fields: strip keys, format values, detect collisions.
264fn process_object_fields<'a>(
265    map: &'a serde_json::Map<String, Value>,
266) -> Vec<(String, &'a Value, Option<String>)> {
267    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
268    for (key, value) in map {
269        if let Some(stripped) = strip_suffix_ci(key, "_secret") {
270            entries.push((stripped, key.as_str(), value, None));
271            continue;
272        }
273        match try_process_field(key, value) {
274            Some((stripped, formatted)) => {
275                entries.push((stripped, key.as_str(), value, Some(formatted)));
276            }
277            None => {
278                entries.push((key.clone(), key.as_str(), value, None));
279            }
280        }
281    }
282
283    // Detect collisions
284    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
285    for (stripped, _, _, _) in &entries {
286        *counts.entry(stripped.clone()).or_insert(0) += 1;
287    }
288
289    // Resolve collisions: revert both key and formatted value
290    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
291        .into_iter()
292        .map(|(stripped, original, value, formatted)| {
293            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
294                (original.to_string(), value, None)
295            } else {
296                (stripped, value, formatted)
297            }
298        })
299        .collect();
300
301    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
302    result
303}
304
305// ═══════════════════════════════════════════
306// Formatting Helpers
307// ═══════════════════════════════════════════
308
309fn number_str(value: &Value) -> String {
310    match value {
311        Value::Number(n) => format_number(n),
312        _ => String::new(),
313    }
314}
315
316/// Render a JSON number canonically for YAML/plain output: an integral-valued
317/// float drops its trailing `.0` so `3.0` and `3` both render as `3`. This
318/// matches Go (`strconv.FormatFloat(_, 'f', -1, 64)`), TypeScript
319/// (`Number.prototype.toString`), and Python (`int(v)` for integral floats),
320/// keeping the four implementations byte-identical.
321fn format_number(n: &serde_json::Number) -> String {
322    if n.is_f64()
323        && let Some(f) = n.as_f64()
324        && f.is_finite()
325        && f.fract() == 0.0
326        && f.abs() < 1e21
327    {
328        return format!("{f:.0}");
329    }
330    normalize_exponent(&n.to_string())
331}
332
333fn normalize_exponent(s: &str) -> String {
334    let Some(e) = s.find(['e', 'E']) else {
335        return s.to_string();
336    };
337    let mantissa = &s[..e];
338    let mut exp = &s[e + 1..];
339    let mut sign = "";
340    if exp.starts_with(['+', '-']) {
341        sign = &exp[..1];
342        exp = &exp[1..];
343    }
344    let exp = exp.trim_start_matches('0');
345    let exp = if exp.is_empty() { "0" } else { exp };
346    format!("{mantissa}e{sign}{exp}")
347}
348
349/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
350fn format_ms_as_seconds(ms: f64) -> String {
351    let formatted = format!("{:.3}", ms / 1000.0);
352    let trimmed = formatted.trim_end_matches('0');
353    if trimmed.ends_with('.') {
354        format!("{}0s", trimmed)
355    } else {
356        format!("{}s", trimmed)
357    }
358}
359
360/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
361fn format_ms_value(value: &Value) -> Option<String> {
362    let n = value.as_f64()?;
363    if n.abs() >= 1000.0 {
364        Some(format_ms_as_seconds(n))
365    } else if let Some(i) = value.as_i64() {
366        Some(format!("{}ms", i))
367    } else {
368        Some(format!("{}ms", number_str(value)))
369    }
370}
371
372/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
373const MIN_RFC3339_MS: i64 = -62135596800000;
374const MAX_RFC3339_MS: i64 = 253402300799999;
375
376fn format_rfc3339_ms(ms: i64) -> Option<String> {
377    use chrono::{DateTime, Utc};
378    if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
379        return None;
380    }
381    let secs = ms.div_euclid(1000);
382    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
383    DateTime::from_timestamp(secs, nanos).map(|dt| {
384        dt.with_timezone(&Utc)
385            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
386    })
387}
388
389/// Format non-negative bytes as human-readable binary size.
390pub(crate) fn format_bytes_human(bytes: u64) -> String {
391    const KIB: f64 = 1024.0;
392    const MIB: f64 = KIB * 1024.0;
393    const GIB: f64 = MIB * 1024.0;
394    const TIB: f64 = GIB * 1024.0;
395
396    let b = bytes as f64;
397    if b >= TIB {
398        format!("{:.1}TiB", b / TIB)
399    } else if b >= GIB {
400        format!("{:.1}GiB", b / GIB)
401    } else if b >= MIB {
402        format!("{:.1}MiB", b / MIB)
403    } else if b >= KIB {
404        format!("{:.1}KiB", b / KIB)
405    } else {
406        format!("{bytes}B")
407    }
408}
409
410/// Format a number with thousands separators.
411pub(crate) fn format_with_commas(n: u64) -> String {
412    let s = n.to_string();
413    let mut result = String::with_capacity(s.len() + s.len() / 3);
414    for (i, c) in s.chars().enumerate() {
415        if i > 0 && (s.len() - i).is_multiple_of(3) {
416            result.push(',');
417        }
418        result.push(c);
419    }
420    result
421}
422
423/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
424pub(crate) fn extract_currency_code(key: &str) -> Option<&str> {
425    let without_cents = key
426        .strip_suffix("_cents")
427        .or_else(|| key.strip_suffix("_CENTS"))?;
428    extract_currency_code_from_stem(without_cents)
429}
430
431/// Extract currency code from a `_{code}_micro` / `_{CODE}_MICRO` suffix.
432fn extract_currency_code_micro(key: &str) -> Option<&str> {
433    let without_micro = key
434        .strip_suffix("_micro")
435        .or_else(|| key.strip_suffix("_MICRO"))?;
436    extract_currency_code_from_stem(without_micro)
437}
438
439fn extract_currency_code_from_stem(stem: &str) -> Option<&str> {
440    let last_underscore = stem.rfind('_')?;
441    let code = &stem[last_underscore + 1..];
442    if code.is_empty()
443        || !(3..=4).contains(&code.len())
444        || !code.bytes().all(|b| b.is_ascii_alphabetic())
445    {
446        return None;
447    }
448    Some(code)
449}
450
451// ═══════════════════════════════════════════
452// YAML Rendering
453// ═══════════════════════════════════════════
454
455fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
456    let prefix = "  ".repeat(indent);
457    match value {
458        Value::Object(map) => {
459            let processed = process_object_fields(map);
460            for (display_key, v, formatted) in processed {
461                if let Some(fv) = formatted {
462                    lines.push(format!(
463                        "{}{}: \"{}\"",
464                        prefix,
465                        yaml_key(&display_key),
466                        escape_yaml_str(&fv)
467                    ));
468                } else {
469                    match v {
470                        Value::Object(inner) if !inner.is_empty() => {
471                            lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
472                            render_yaml_processed(v, indent + 1, lines);
473                        }
474                        Value::Object(_) => {
475                            lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
476                        }
477                        Value::Array(arr) => {
478                            if arr.is_empty() {
479                                lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
480                            } else {
481                                lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
482                                for item in arr {
483                                    if item.is_object() {
484                                        lines.push(format!("{}  -", prefix));
485                                        render_yaml_processed(item, indent + 2, lines);
486                                    } else {
487                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
488                                    }
489                                }
490                            }
491                        }
492                        _ => {
493                            lines.push(format!(
494                                "{}{}: {}",
495                                prefix,
496                                yaml_key(&display_key),
497                                yaml_scalar(v)
498                            ));
499                        }
500                    }
501                }
502            }
503        }
504        _ => {
505            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
506        }
507    }
508}
509
510fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
511    let prefix = "  ".repeat(indent);
512    match value {
513        Value::Object(map) => {
514            for key in sorted_value_keys(map) {
515                render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
516            }
517        }
518        Value::Array(arr) => {
519            render_yaml_array_raw(arr, indent, lines);
520        }
521        _ => {
522            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
523        }
524    }
525}
526
527fn render_yaml_field_raw(
528    prefix: &str,
529    key: &str,
530    value: &Value,
531    indent: usize,
532    lines: &mut Vec<String>,
533) {
534    match value {
535        Value::Object(inner) if !inner.is_empty() => {
536            lines.push(format!("{}{}:", prefix, yaml_key(key)));
537            render_yaml_raw(value, indent + 1, lines);
538        }
539        Value::Object(_) => {
540            lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
541        }
542        Value::Array(arr) => {
543            if arr.is_empty() {
544                lines.push(format!("{}{}: []", prefix, yaml_key(key)));
545            } else {
546                lines.push(format!("{}{}:", prefix, yaml_key(key)));
547                render_yaml_array_raw(arr, indent + 1, lines);
548            }
549        }
550        _ => {
551            lines.push(format!(
552                "{}{}: {}",
553                prefix,
554                yaml_key(key),
555                yaml_scalar(value)
556            ));
557        }
558    }
559}
560
561fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
562    let prefix = "  ".repeat(indent);
563    for item in arr {
564        match item {
565            Value::Object(inner) if !inner.is_empty() => {
566                lines.push(format!("{}-", prefix));
567                render_yaml_raw(item, indent + 1, lines);
568            }
569            Value::Array(nested) if !nested.is_empty() => {
570                lines.push(format!("{}-", prefix));
571                render_yaml_array_raw(nested, indent + 1, lines);
572            }
573            Value::Object(_) => {
574                lines.push(format!("{}- {{}}", prefix));
575            }
576            Value::Array(_) => {
577                lines.push(format!("{}- []", prefix));
578            }
579            _ => {
580                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
581            }
582        }
583    }
584}
585
586fn escape_yaml_str(s: &str) -> String {
587    s.replace('\\', "\\\\")
588        .replace('"', "\\\"")
589        .replace('\n', "\\n")
590        .replace('\r', "\\r")
591        .replace('\t', "\\t")
592        .replace('\x0c', "\\f")
593        .replace('\x0b', "\\v")
594}
595
596fn yaml_key(key: &str) -> String {
597    if is_safe_key(key) {
598        key.to_string()
599    } else {
600        format!("\"{}\"", escape_yaml_str(key))
601    }
602}
603
604fn quote_logfmt_key(key: &str) -> String {
605    if is_safe_key(key) {
606        key.to_string()
607    } else {
608        quote_logfmt_value(key)
609    }
610}
611
612fn is_safe_key(key: &str) -> bool {
613    !key.is_empty()
614        && key
615            .bytes()
616            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
617}
618
619fn yaml_scalar(value: &Value) -> String {
620    match value {
621        Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
622        Value::Null => "null".to_string(),
623        Value::Bool(b) => b.to_string(),
624        Value::Number(n) => format_number(n),
625        Value::Object(_) | Value::Array(_) => {
626            format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
627        }
628    }
629}
630
631// ═══════════════════════════════════════════
632// Plain Rendering (logfmt)
633// ═══════════════════════════════════════════
634
635fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
636    if let Value::Object(map) = value {
637        let processed = process_object_fields(map);
638        for (display_key, v, formatted) in processed {
639            let full_key = if prefix.is_empty() {
640                display_key
641            } else {
642                format!("{}.{}", prefix, display_key)
643            };
644            if let Some(fv) = formatted {
645                pairs.push((full_key, fv));
646            } else {
647                match v {
648                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
649                    Value::Array(arr) => {
650                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
651                        pairs.push((full_key, joined));
652                    }
653                    Value::Null => pairs.push((full_key, String::new())),
654                    _ => pairs.push((full_key, plain_scalar(v))),
655                }
656            }
657        }
658    }
659}
660
661fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
662    if let Value::Object(map) = value {
663        for key in sorted_value_keys(map) {
664            let v = &map[&key];
665            let full_key = if prefix.is_empty() {
666                key.clone()
667            } else {
668                format!("{}.{}", prefix, key)
669            };
670            match v {
671                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
672                Value::Array(arr) => {
673                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
674                    pairs.push((full_key, joined));
675                }
676                Value::Null => pairs.push((full_key, String::new())),
677                _ => pairs.push((full_key, plain_scalar(v))),
678            }
679        }
680    }
681}
682
683fn plain_scalar(value: &Value) -> String {
684    match value {
685        Value::String(s) => s.clone(),
686        Value::Null => "null".to_string(),
687        Value::Bool(b) => b.to_string(),
688        Value::Number(n) => format_number(n),
689        Value::Object(_) | Value::Array(_) => canonical_json(value),
690    }
691}
692
693fn quote_logfmt_value(value: &str) -> String {
694    if value.is_empty() {
695        return String::new();
696    }
697    if !value
698        .chars()
699        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
700    {
701        return value.to_string();
702    }
703    let escaped = value
704        .replace('\\', "\\\\")
705        .replace('"', "\\\"")
706        .replace('\n', "\\n")
707        .replace('\r', "\\r")
708        .replace('\t', "\\t")
709        .replace('\x0c', "\\f")
710        .replace('\x0b', "\\v");
711    format!("\"{}\"", escaped)
712}
713
714fn canonical_json(value: &Value) -> String {
715    serde_json::to_string(&sort_json_value(value))
716        .unwrap_or_else(|_| "<unsupported:json>".to_string())
717}
718
719fn sort_json_value(value: &Value) -> Value {
720    match value {
721        Value::Object(map) => {
722            let mut out = serde_json::Map::new();
723            for key in sorted_value_keys(map) {
724                if let Some(v) = map.get(&key) {
725                    out.insert(key, sort_json_value(v));
726                }
727            }
728            Value::Object(out)
729        }
730        Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
731        _ => value.clone(),
732    }
733}
734
735fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
736    let mut keys: Vec<String> = map.keys().cloned().collect();
737    keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
738    keys
739}