Skip to main content

agent_first_data/
formatting.rs

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