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    let f = value.as_f64()?;
156    if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
157        return Some(f as u64);
158    }
159    None
160}
161
162fn integer_text(value: &Value) -> Option<String> {
163    match value {
164        Value::Number(_) => as_int(value).map(|n| n.to_string()),
165        Value::String(s) if is_decimal_integer_string(s) => Some(s.clone()),
166        _ => None,
167    }
168}
169
170fn is_decimal_integer_string(s: &str) -> bool {
171    let digits = s.strip_prefix('-').unwrap_or(s);
172    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
173}
174
175fn epoch_ns_to_ms(value: &Value) -> Option<i64> {
176    let ns = match value {
177        Value::Number(_) => i128::from(as_int(value)?),
178        Value::String(s) if is_decimal_integer_string(s) => s.parse::<i128>().ok()?,
179        _ => return None,
180    };
181    ns.div_euclid(1_000_000).try_into().ok()
182}
183
184/// Try suffix-driven processing. Returns `Some((stripped_key, formatted_value))`
185/// when the suffix matches and the type is valid; `None` for no match or a type
186/// mismatch.
187fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
188    // Group 1: compound timestamp suffixes
189    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
190        return as_int(value)
191            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
192    }
193    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
194        return as_int(value)
195            .and_then(|s| s.checked_mul(1000))
196            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
197    }
198    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
199        return epoch_ns_to_ms(value)
200            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
201    }
202
203    // Group 2: compound currency suffixes
204    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
205        return as_int(value).map(|n| {
206            let (sign, magnitude) = signed_magnitude(n);
207            (
208                stripped,
209                format!("{sign}${}.{:02}", magnitude / 100, magnitude % 100),
210            )
211        });
212    }
213    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
214        return as_int(value).map(|n| {
215            let (sign, magnitude) = signed_magnitude(n);
216            (
217                stripped,
218                format!("{sign}€{}.{:02}", magnitude / 100, magnitude % 100),
219            )
220        });
221    }
222    if let Some((stripped, code)) = try_strip_generic_cents(key) {
223        return as_int(value).map(|n| {
224            let (sign, magnitude) = signed_magnitude(n);
225            (
226                stripped,
227                format!(
228                    "{sign}{}.{:02} {}",
229                    magnitude / 100,
230                    magnitude % 100,
231                    code.to_uppercase()
232                ),
233            )
234        });
235    }
236    if let Some((stripped, code)) = try_strip_generic_micro(key) {
237        return as_int(value).map(|n| {
238            let (sign, magnitude) = signed_magnitude(n);
239            (
240                stripped,
241                format!(
242                    "{sign}{}.{:06} {}",
243                    magnitude / 1_000_000,
244                    magnitude % 1_000_000,
245                    code.to_uppercase()
246                ),
247            )
248        });
249    }
250
251    // Group 3: multi-char suffixes
252    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
253        return value.as_str().map(|s| (stripped, s.to_string()));
254    }
255    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
256        return value
257            .is_number()
258            .then(|| (stripped, format!("{} minutes", number_str(value))));
259    }
260    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
261        return value
262            .is_number()
263            .then(|| (stripped, format!("{} hours", number_str(value))));
264    }
265    if let Some(stripped) = strip_suffix_ci(key, "_days") {
266        return value
267            .is_number()
268            .then(|| (stripped, format!("{} days", number_str(value))));
269    }
270
271    // Group 4: single-unit suffixes
272    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
273        return integer_text(value).map(|n| (stripped, format!("{n}msats")));
274    }
275    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
276        return integer_text(value).map(|n| (stripped, format!("{n}sats")));
277    }
278    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
279        return as_uint(value).map(|n| (stripped, format_bytes_human(n)));
280    }
281    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
282        return value
283            .is_number()
284            .then(|| (stripped, format!("{}%", number_str(value))));
285    }
286    // Group 5: short suffixes (last to avoid false positives)
287    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
288        return as_int(value).map(|n| {
289            let (sign, magnitude) = signed_magnitude(n);
290            (
291                stripped,
292                format!("{sign}¥{}", format_with_commas(magnitude)),
293            )
294        });
295    }
296    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
297        return value
298            .is_number()
299            .then(|| (stripped, format!("{}ns", number_str(value))));
300    }
301    if let Some(stripped) = strip_suffix_ci(key, "_us") {
302        return value
303            .is_number()
304            .then(|| (stripped, format!("{}μs", number_str(value))));
305    }
306    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
307        return format_ms_value(value).map(|v| (stripped, v));
308    }
309    if let Some(stripped) = strip_suffix_ci(key, "_s") {
310        return value
311            .is_number()
312            .then(|| (stripped, format!("{}s", number_str(value))));
313    }
314
315    None
316}
317
318/// True when a field already carries the redaction marker, i.e. redaction ran
319/// over it and hid the value.
320fn is_redacted(value: &Value) -> bool {
321    value.as_str() == Some(REDACTED_MARKER)
322}
323
324/// Process object fields: strip keys, format values, detect collisions.
325fn process_object_fields<'a>(
326    map: &'a serde_json::Map<String, Value>,
327) -> Vec<(String, &'a Value, Option<String>)> {
328    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
329    for (key, value) in map {
330        if let Some(stripped) = strip_suffix_ci(key, "_secret") {
331            // Dropping `_secret` is the readable half of an actual redaction: the
332            // marker has done its job once the value reads `***`. When the value
333            // was *not* redacted — policy `Off`, or a field outside `trace` under
334            // `TraceOnly` — the suffix is the only thing telling a downstream
335            // reader that `api_key_secret=sk-live-xxx` is a credential, so it
336            // stays.
337            let display_key = if is_redacted(value) {
338                stripped
339            } else {
340                key.clone()
341            };
342            entries.push((display_key, key.as_str(), value, None));
343            continue;
344        }
345        match try_process_field(key, value) {
346            Some((stripped, formatted)) => {
347                entries.push((stripped, key.as_str(), value, Some(formatted)));
348            }
349            None => {
350                entries.push((key.clone(), key.as_str(), value, None));
351            }
352        }
353    }
354
355    // Detect collisions
356    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
357    for (stripped, _, _, _) in &entries {
358        *counts.entry(stripped.clone()).or_insert(0) += 1;
359    }
360
361    // Resolve collisions: revert both key and formatted value
362    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
363        .into_iter()
364        .map(|(stripped, original, value, formatted)| {
365            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
366                (original.to_string(), value, None)
367            } else {
368                (stripped, value, formatted)
369            }
370        })
371        .collect();
372
373    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
374    result
375}
376
377// ═══════════════════════════════════════════
378// Formatting Helpers
379// ═══════════════════════════════════════════
380
381fn number_str(value: &Value) -> String {
382    match value {
383        Value::Number(n) => format_number(n),
384        _ => String::new(),
385    }
386}
387
388/// Render a JSON number canonically for YAML/plain output: an integral-valued
389/// float drops its trailing `.0` so `3.0` and `3` both render as `3`. This
390/// matches Go (`strconv.FormatFloat(_, 'f', -1, 64)`), TypeScript
391/// (`Number.prototype.toString`), and Python (`int(v)` for integral floats),
392/// keeping the four implementations byte-identical.
393fn format_number(n: &serde_json::Number) -> String {
394    if n.is_f64()
395        && let Some(f) = n.as_f64()
396        && f.is_finite()
397        && f.fract() == 0.0
398        && f.abs() < 1e21
399    {
400        return format!("{f:.0}");
401    }
402    normalize_exponent(&n.to_string())
403}
404
405fn normalize_exponent(s: &str) -> String {
406    let Some(e) = s.find(['e', 'E']) else {
407        return s.to_string();
408    };
409    let mantissa = &s[..e];
410    let mut exp = &s[e + 1..];
411    let mut sign = "";
412    if exp.starts_with(['+', '-']) {
413        sign = &exp[..1];
414        exp = &exp[1..];
415    }
416    let exp = exp.trim_start_matches('0');
417    let exp = if exp.is_empty() { "0" } else { exp };
418    format!("{mantissa}e{sign}{exp}")
419}
420
421/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
422fn format_ms_as_seconds(ms: f64) -> String {
423    let formatted = format!("{:.3}", ms / 1000.0);
424    let trimmed = formatted.trim_end_matches('0');
425    if trimmed.ends_with('.') {
426        format!("{}0s", trimmed)
427    } else {
428        format!("{}s", trimmed)
429    }
430}
431
432/// Split a signed integer into its sign text and magnitude.
433///
434/// Taking the magnitude before dividing keeps currency formatting identical
435/// across the four languages: Rust and Go truncate toward zero while Python
436/// floors toward negative infinity, so `-499 / 100` does not agree, but
437/// `-(499 / 100)` does.
438fn signed_magnitude(value: i64) -> (&'static str, u64) {
439    if value < 0 {
440        ("-", value.unsigned_abs())
441    } else {
442        ("", value as u64)
443    }
444}
445
446/// Format `_ms` value: < 1000 → `{n}ms`, >= 1000 → seconds.
447fn format_ms_value(value: &Value) -> Option<String> {
448    let n = value.as_f64()?;
449    if n.abs() >= 1000.0 {
450        Some(format_ms_as_seconds(n))
451    } else if let Some(i) = value.as_i64() {
452        Some(format!("{}ms", i))
453    } else {
454        Some(format!("{}ms", number_str(value)))
455    }
456}
457
458/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
459const MIN_RFC3339_MS: i64 = -62135596800000;
460const MAX_RFC3339_MS: i64 = 253402300799999;
461
462fn format_rfc3339_ms(ms: i64) -> Option<String> {
463    use chrono::{DateTime, Utc};
464    if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
465        return None;
466    }
467    let secs = ms.div_euclid(1000);
468    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
469    DateTime::from_timestamp(secs, nanos).map(|dt| {
470        dt.with_timezone(&Utc)
471            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
472    })
473}
474
475/// Format non-negative bytes as human-readable binary size.
476pub(crate) fn format_bytes_human(bytes: u64) -> String {
477    const KIB: f64 = 1024.0;
478    const MIB: f64 = KIB * 1024.0;
479    const GIB: f64 = MIB * 1024.0;
480    const TIB: f64 = GIB * 1024.0;
481
482    let b = bytes as f64;
483    if b >= TIB {
484        format!("{:.1}TiB", b / TIB)
485    } else if b >= GIB {
486        format!("{:.1}GiB", b / GIB)
487    } else if b >= MIB {
488        format!("{:.1}MiB", b / MIB)
489    } else if b >= KIB {
490        format!("{:.1}KiB", b / KIB)
491    } else {
492        format!("{bytes}B")
493    }
494}
495
496/// Format a number with thousands separators.
497pub(crate) fn format_with_commas(n: u64) -> String {
498    let s = n.to_string();
499    let mut result = String::with_capacity(s.len() + s.len() / 3);
500    for (i, c) in s.chars().enumerate() {
501        if i > 0 && (s.len() - i).is_multiple_of(3) {
502            result.push(',');
503        }
504        result.push(c);
505    }
506    result
507}
508
509/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
510pub(crate) fn extract_currency_code(key: &str) -> Option<&str> {
511    let without_cents = key
512        .strip_suffix("_cents")
513        .or_else(|| key.strip_suffix("_CENTS"))?;
514    extract_currency_code_from_stem(without_cents)
515}
516
517/// Extract currency code from a `_{code}_micro` / `_{CODE}_MICRO` suffix.
518pub(crate) fn extract_currency_code_micro(key: &str) -> Option<&str> {
519    let without_micro = key
520        .strip_suffix("_micro")
521        .or_else(|| key.strip_suffix("_MICRO"))?;
522    extract_currency_code_from_stem(without_micro)
523}
524
525fn extract_currency_code_from_stem(stem: &str) -> Option<&str> {
526    let last_underscore = stem.rfind('_')?;
527    let code = &stem[last_underscore + 1..];
528    if code.is_empty()
529        || !(3..=4).contains(&code.len())
530        || !code.bytes().all(|b| b.is_ascii_alphabetic())
531    {
532        return None;
533    }
534    Some(code)
535}
536
537// ═══════════════════════════════════════════
538// YAML Rendering (structure-preserving)
539// ═══════════════════════════════════════════
540
541fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
542    let prefix = "  ".repeat(indent);
543    match value {
544        Value::Object(map) => {
545            for key in sorted_value_keys(map) {
546                render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
547            }
548        }
549        Value::Array(arr) => {
550            render_yaml_array_raw(arr, indent, lines);
551        }
552        _ => {
553            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
554        }
555    }
556}
557
558fn render_yaml_field_raw(
559    prefix: &str,
560    key: &str,
561    value: &Value,
562    indent: usize,
563    lines: &mut Vec<String>,
564) {
565    match value {
566        Value::Object(inner) if !inner.is_empty() => {
567            lines.push(format!("{}{}:", prefix, yaml_key(key)));
568            render_yaml_raw(value, indent + 1, lines);
569        }
570        Value::Object(_) => {
571            lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
572        }
573        Value::Array(arr) => {
574            if arr.is_empty() {
575                lines.push(format!("{}{}: []", prefix, yaml_key(key)));
576            } else {
577                lines.push(format!("{}{}:", prefix, yaml_key(key)));
578                render_yaml_array_raw(arr, indent + 1, lines);
579            }
580        }
581        _ => {
582            lines.push(format!(
583                "{}{}: {}",
584                prefix,
585                yaml_key(key),
586                yaml_scalar(value)
587            ));
588        }
589    }
590}
591
592fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
593    let prefix = "  ".repeat(indent);
594    for item in arr {
595        match item {
596            Value::Object(inner) if !inner.is_empty() => {
597                lines.push(format!("{}-", prefix));
598                render_yaml_raw(item, indent + 1, lines);
599            }
600            Value::Array(nested) if !nested.is_empty() => {
601                lines.push(format!("{}-", prefix));
602                render_yaml_array_raw(nested, indent + 1, lines);
603            }
604            Value::Object(_) => {
605                lines.push(format!("{}- {{}}", prefix));
606            }
607            Value::Array(_) => {
608                lines.push(format!("{}- []", prefix));
609            }
610            _ => {
611                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
612            }
613        }
614    }
615}
616
617fn escape_yaml_str(s: &str) -> String {
618    let mut escaped = String::with_capacity(s.len());
619    for character in s.chars() {
620        match character {
621            '\\' => escaped.push_str("\\\\"),
622            '"' => escaped.push_str("\\\""),
623            '\n' => escaped.push_str("\\n"),
624            '\r' => escaped.push_str("\\r"),
625            '\t' => escaped.push_str("\\t"),
626            '\x08' => escaped.push_str("\\b"),
627            '\x0c' => escaped.push_str("\\f"),
628            '\x0b' => escaped.push_str("\\v"),
629            '\0' => escaped.push_str("\\0"),
630            control if control <= '\u{001f}' => {
631                use std::fmt::Write as _;
632                let _ = write!(escaped, "\\u{:04x}", control as u32);
633            }
634            other => escaped.push(other),
635        }
636    }
637    escaped
638}
639
640fn yaml_key(key: &str) -> String {
641    if is_safe_key(key) && !is_ambiguous_yaml_key(key) {
642        key.to_string()
643    } else {
644        format!("\"{}\"", escape_yaml_str(key))
645    }
646}
647
648fn is_ambiguous_yaml_key(key: &str) -> bool {
649    let lower = key.to_ascii_lowercase();
650    matches!(
651        lower.as_str(),
652        "true" | "false" | "null" | "~" | ".nan" | ".inf" | "+.inf" | "-.inf"
653    ) || key.parse::<f64>().is_ok()
654}
655
656fn quote_logfmt_key(key: &str) -> String {
657    if is_safe_key(key) {
658        key.to_string()
659    } else {
660        quote_logfmt_value(key)
661    }
662}
663
664fn is_safe_key(key: &str) -> bool {
665    !key.is_empty()
666        && key
667            .bytes()
668            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
669}
670
671fn yaml_scalar(value: &Value) -> String {
672    match value {
673        Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
674        Value::Null => "null".to_string(),
675        Value::Bool(b) => b.to_string(),
676        Value::Number(n) => format_number(n),
677        Value::Object(_) | Value::Array(_) => {
678            format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
679        }
680    }
681}
682
683// ═══════════════════════════════════════════
684// Plain Rendering (logfmt)
685// ═══════════════════════════════════════════
686
687fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
688    if let Value::Object(map) = value {
689        let processed = process_object_fields(map);
690        for (display_key, v, formatted) in processed {
691            let full_key = if prefix.is_empty() {
692                display_key
693            } else {
694                format!("{}.{}", prefix, display_key)
695            };
696            if let Some(fv) = formatted {
697                pairs.push((full_key, fv));
698            } else {
699                match v {
700                    Value::Object(map) if map.is_empty() => {
701                        pairs.push((full_key, "{}".to_string()));
702                    }
703                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
704                    Value::Array(arr) if arr.is_empty() => {
705                        pairs.push((full_key, "[]".to_string()));
706                    }
707                    Value::Array(arr) => {
708                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
709                        pairs.push((full_key, joined));
710                    }
711                    Value::Null => pairs.push((full_key, String::new())),
712                    _ => pairs.push((full_key, plain_scalar(v))),
713                }
714            }
715        }
716    }
717}
718
719fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
720    if let Value::Object(map) = value {
721        for key in sorted_value_keys(map) {
722            let v = &map[&key];
723            let full_key = if prefix.is_empty() {
724                key.clone()
725            } else {
726                format!("{}.{}", prefix, key)
727            };
728            match v {
729                Value::Object(map) if map.is_empty() => {
730                    pairs.push((full_key, "{}".to_string()));
731                }
732                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
733                Value::Array(arr) if arr.is_empty() => {
734                    pairs.push((full_key, "[]".to_string()));
735                }
736                Value::Array(arr) => {
737                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
738                    pairs.push((full_key, joined));
739                }
740                Value::Null => pairs.push((full_key, String::new())),
741                _ => pairs.push((full_key, plain_scalar(v))),
742            }
743        }
744    }
745}
746
747fn plain_scalar(value: &Value) -> String {
748    match value {
749        Value::String(s) => s.clone(),
750        Value::Null => "null".to_string(),
751        Value::Bool(b) => b.to_string(),
752        Value::Number(n) => format_number(n),
753        Value::Object(_) | Value::Array(_) => canonical_json(value),
754    }
755}
756
757fn quote_logfmt_value(value: &str) -> String {
758    if value.is_empty() {
759        return String::new();
760    }
761    if !value
762        .chars()
763        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
764    {
765        return value.to_string();
766    }
767    let escaped = value
768        .replace('\\', "\\\\")
769        .replace('"', "\\\"")
770        .replace('\n', "\\n")
771        .replace('\r', "\\r")
772        .replace('\t', "\\t")
773        .replace('\x0c', "\\f")
774        .replace('\x0b', "\\v");
775    format!("\"{}\"", escaped)
776}
777
778fn canonical_json(value: &Value) -> String {
779    serde_json::to_string(&sort_json_value(value))
780        .unwrap_or_else(|_| "<unsupported:json>".to_string())
781}
782
783fn sort_json_value(value: &Value) -> Value {
784    match value {
785        Value::Object(map) => {
786            let mut out = serde_json::Map::new();
787            for key in sorted_value_keys(map) {
788                if let Some(v) = map.get(&key) {
789                    out.insert(key, sort_json_value(v));
790                }
791            }
792            Value::Object(out)
793        }
794        Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
795        _ => value.clone(),
796    }
797}
798
799fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
800    let mut keys: Vec<String> = map.keys().cloned().collect();
801    keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
802    keys
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use crate::redaction::{RedactionPolicy, Redactor};
809    use serde_json::json;
810
811    fn plain(value: &Value, policy: RedactionPolicy) -> String {
812        render(
813            value,
814            OutputFormat::Plain,
815            &OutputOptions {
816                redaction: Redactor::new().policy(policy),
817                style: PlainStyle::Readable,
818            },
819        )
820    }
821
822    // ── `_secret` stripping follows redaction ──────────
823
824    #[test]
825    fn plain_strips_secret_suffix_once_the_value_is_redacted() {
826        assert_eq!(
827            plain(
828                &json!({"api_key_secret": "sk-live-xxx"}),
829                RedactionPolicy::All
830            ),
831            "api_key=***"
832        );
833    }
834
835    #[test]
836    fn plain_keeps_secret_suffix_when_redaction_is_off() {
837        // Stripping here would hand a live credential to a reader under a name
838        // that no longer says it is one.
839        assert_eq!(
840            plain(
841                &json!({"api_key_secret": "sk-live-xxx"}),
842                RedactionPolicy::Off
843            ),
844            "api_key_secret=sk-live-xxx"
845        );
846        assert_eq!(
847            plain(
848                &json!({"API_KEY_SECRET": "sk-live-xxx"}),
849                RedactionPolicy::Off
850            ),
851            "API_KEY_SECRET=sk-live-xxx"
852        );
853    }
854
855    #[test]
856    fn plain_keeps_secret_suffix_outside_trace_under_trace_only() {
857        assert_eq!(
858            plain(
859                &json!({
860                    "api_key_secret": "sk-live-xxx",
861                    "trace": {"request_secret": "top-secret"}
862                }),
863                RedactionPolicy::TraceOnly
864            ),
865            "api_key_secret=sk-live-xxx trace.request=***"
866        );
867    }
868
869    #[test]
870    fn plain_keeps_secret_suffix_on_an_unredacted_subtree() {
871        assert_eq!(
872            plain(
873                &json!({"db_secret": {"password": "hunter2"}}),
874                RedactionPolicy::Off
875            ),
876            "db_secret.password=hunter2"
877        );
878        assert_eq!(
879            plain(
880                &json!({"db_secret": {"password": "hunter2"}}),
881                RedactionPolicy::All
882            ),
883            "db=***"
884        );
885    }
886
887    #[test]
888    fn plain_unstripped_secret_key_does_not_collide_with_its_stem() {
889        // Keeping the suffix also means no collision to fall back from: both
890        // fields keep their own name and value.
891        assert_eq!(
892            plain(
893                &json!({"api_key": "public", "api_key_secret": "sk-live-xxx"}),
894                RedactionPolicy::Off
895            ),
896            "api_key=public api_key_secret=sk-live-xxx"
897        );
898    }
899}