Skip to main content

agent_first_data/
formatting.rs

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