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. Converting `i64::MIN - 1` through f64 would round it back into
125    // range, formatting a plausible but wrong amount on a money field. The
126    // positive side is already refused by the `is_u64` check above. Floating
127    // literals below are also checked on their exact decimal digits.
128    if let Value::Number(number) = value
129        && number_is_integer_literal(number)
130    {
131        return None;
132    }
133    let Value::Number(number) = value else {
134        return None;
135    };
136    integral_float_text(&number.to_string())?.parse().ok()
137}
138
139/// Whether the producer wrote this number as an integer literal.
140///
141/// Shared with the linter: both need to tell "the author wrote an integer" from
142/// "the value happens to be integral", and they must not answer it differently.
143pub(crate) fn number_is_integer_literal(number: &serde_json::Number) -> bool {
144    !number.to_string().contains(['.', 'e', 'E'])
145}
146
147/// Like [`as_int`] but for non-negative integers (rejects negatives).
148fn as_uint(value: &Value) -> Option<u64> {
149    if let Some(u) = value.as_u64() {
150        return Some(u);
151    }
152    if value.as_i64() == Some(0) {
153        return Some(0);
154    }
155    if let Value::Number(number) = value
156        && number_is_integer_literal(number)
157    {
158        return None;
159    }
160    let Value::Number(number) = value else {
161        return None;
162    };
163    let integer = integral_float_text(&number.to_string())?;
164    if integer == "-0" {
165        return Some(0);
166    }
167    integer.parse().ok()
168}
169
170/// The exact digits of an integer-valued number or decimal integer string, of
171/// any magnitude. Bitcoin amounts and `_epoch_ns` only append a unit or
172/// divide, so unlike money suffixes they are not bounded by `i64`: an integer
173/// literal keeps its digits, and a float literal counts when its value is an
174/// integer below 1e21 (the canonical number form's integer range). A numeric
175/// `-0` is zero.
176fn integer_text(value: &Value) -> Option<String> {
177    match value {
178        Value::Number(number) => {
179            let literal = number.to_string();
180            let integer = if number_is_integer_literal(number) {
181                literal
182            } else {
183                integral_float_text(&literal)?
184            };
185            Some(if integer == "-0" { "0".into() } else { integer })
186        }
187        Value::String(s) if is_decimal_integer_string(s) => Some(s.clone()),
188        _ => None,
189    }
190}
191
192fn is_decimal_integer_string(s: &str) -> bool {
193    let digits = s.strip_prefix('-').unwrap_or(s);
194    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
195}
196
197/// Floor-divide an integer nanosecond count of any size to milliseconds.
198/// `i128` spans ±1.7e38 ns, far beyond the RFC 3339 year range, so a count it
199/// cannot hold is out of range for the same reason a smaller one can be.
200fn epoch_ns_to_ms(value: &Value) -> Option<i64> {
201    let ns = integer_text(value)?.parse::<i128>().ok()?;
202    ns.div_euclid(1_000_000).try_into().ok()
203}
204
205/// Try suffix-driven processing. Returns `Some((stripped_key, formatted_value))`
206/// when the suffix matches and the type is valid; `None` for no match or a type
207/// mismatch.
208fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
209    // Group 1: compound timestamp suffixes
210    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
211        return as_int(value)
212            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
213    }
214    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
215        return as_int(value)
216            .and_then(|s| s.checked_mul(1000))
217            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
218    }
219    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
220        return epoch_ns_to_ms(value)
221            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
222    }
223
224    // Group 2: compound currency suffixes
225    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
226        return as_int(value).map(|n| {
227            let (sign, magnitude) = signed_magnitude(n);
228            (
229                stripped,
230                format!("{sign}${}.{:02}", magnitude / 100, magnitude % 100),
231            )
232        });
233    }
234    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
235        return as_int(value).map(|n| {
236            let (sign, magnitude) = signed_magnitude(n);
237            (
238                stripped,
239                format!("{sign}€{}.{:02}", magnitude / 100, magnitude % 100),
240            )
241        });
242    }
243    if let Some((stripped, code)) = try_strip_generic_cents(key) {
244        return as_int(value).map(|n| {
245            let (sign, magnitude) = signed_magnitude(n);
246            (
247                stripped,
248                format!(
249                    "{sign}{}.{:02} {}",
250                    magnitude / 100,
251                    magnitude % 100,
252                    code.to_uppercase()
253                ),
254            )
255        });
256    }
257    if let Some((stripped, code)) = try_strip_generic_micro(key) {
258        return as_int(value).map(|n| {
259            let (sign, magnitude) = signed_magnitude(n);
260            (
261                stripped,
262                format!(
263                    "{sign}{}.{:06} {}",
264                    magnitude / 1_000_000,
265                    magnitude % 1_000_000,
266                    code.to_uppercase()
267                ),
268            )
269        });
270    }
271
272    // Group 3: multi-char suffixes
273    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
274        return value.as_str().map(|s| (stripped, s.to_string()));
275    }
276    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
277        return value
278            .is_number()
279            .then(|| (stripped, format!("{} minutes", number_str(value))));
280    }
281    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
282        return value
283            .is_number()
284            .then(|| (stripped, format!("{} hours", number_str(value))));
285    }
286    if let Some(stripped) = strip_suffix_ci(key, "_days") {
287        return value
288            .is_number()
289            .then(|| (stripped, format!("{} days", number_str(value))));
290    }
291
292    // Group 4: single-unit suffixes
293    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
294        return integer_text(value).map(|n| (stripped, format!("{n}msats")));
295    }
296    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
297        return integer_text(value).map(|n| (stripped, format!("{n}sats")));
298    }
299    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
300        return as_uint(value).map(|n| (stripped, format_bytes_human(n)));
301    }
302    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
303        return value
304            .is_number()
305            .then(|| (stripped, format!("{}%", number_str(value))));
306    }
307    // Group 5: short suffixes (last to avoid false positives)
308    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
309        return as_int(value).map(|n| {
310            let (sign, magnitude) = signed_magnitude(n);
311            (
312                stripped,
313                format!("{sign}¥{}", format_with_commas(magnitude)),
314            )
315        });
316    }
317    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
318        return value
319            .is_number()
320            .then(|| (stripped, format!("{}ns", number_str(value))));
321    }
322    if let Some(stripped) = strip_suffix_ci(key, "_us") {
323        return value
324            .is_number()
325            .then(|| (stripped, format!("{}μs", number_str(value))));
326    }
327    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
328        return format_ms_value(value).map(|v| (stripped, v));
329    }
330    if let Some(stripped) = strip_suffix_ci(key, "_s") {
331        return value
332            .is_number()
333            .then(|| (stripped, format!("{}s", number_str(value))));
334    }
335
336    None
337}
338
339/// True when a field already carries the redaction marker, i.e. redaction ran
340/// over it and hid the value.
341fn is_redacted(value: &Value) -> bool {
342    value.as_str() == Some(REDACTED_MARKER)
343}
344
345/// Process object fields: strip keys, format values, detect collisions.
346fn process_object_fields<'a>(
347    map: &'a serde_json::Map<String, Value>,
348) -> Vec<(String, &'a Value, Option<String>)> {
349    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
350    for (key, value) in map {
351        if let Some(stripped) = strip_suffix_ci(key, "_secret") {
352            // Dropping `_secret` is the readable half of an actual redaction: the
353            // marker has done its job once the value reads `***`. When the value
354            // was *not* redacted — policy `Off`, or a field outside `trace` under
355            // `TraceOnly` — the suffix is the only thing telling a downstream
356            // reader that `api_key_secret=sk-live-xxx` is a credential, so it
357            // stays.
358            let display_key = if is_redacted(value) {
359                stripped
360            } else {
361                key.clone()
362            };
363            entries.push((display_key, key.as_str(), value, None));
364            continue;
365        }
366        match try_process_field(key, value) {
367            Some((stripped, formatted)) => {
368                entries.push((stripped, key.as_str(), value, Some(formatted)));
369            }
370            None => {
371                entries.push((key.clone(), key.as_str(), value, None));
372            }
373        }
374    }
375
376    // Detect collisions
377    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
378    for (stripped, _, _, _) in &entries {
379        *counts.entry(stripped.clone()).or_insert(0) += 1;
380    }
381
382    // Resolve collisions: revert both key and formatted value
383    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
384        .into_iter()
385        .map(|(stripped, original, value, formatted)| {
386            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
387                (original.to_string(), value, None)
388            } else {
389                (stripped, value, formatted)
390            }
391        })
392        .collect();
393
394    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
395    result
396}
397
398// ═══════════════════════════════════════════
399// Formatting Helpers
400// ═══════════════════════════════════════════
401
402fn number_str(value: &Value) -> String {
403    match value {
404        Value::Number(n) => format_number(n),
405        _ => String::new(),
406    }
407}
408
409/// Render a JSON number canonically for YAML/plain output: a float literal
410/// whose value is an integer below 1e21 in magnitude renders as that integer
411/// (`3.0` → `3`, `-0.0` → `-0`, `1.5e2` → `150`); everything else keeps its
412/// digits, placed by [`place_decimal_point`]. Go, Python and TypeScript apply
413/// the same rule to native floats and to decoded literals alike, so the four
414/// implementations stay byte-identical.
415///
416/// Decided on the literal's text rather than through `f64`, which would round
417/// `9007199254740993.0` to `…992`: rendering may change a number's spelling,
418/// never its digits.
419fn format_number(n: &serde_json::Number) -> String {
420    let literal = n.to_string();
421    integral_float_text(&literal)
422        .or_else(|| place_decimal_point(&literal))
423        .unwrap_or_else(|| normalize_exponent(&literal))
424}
425
426/// Spell a non-integral float literal with the notation ECMAScript's
427/// `Number#toString` picks: positional when the value lies in [1e-6, 1e21),
428/// exponent form otherwise. Each runtime's native formatter switches at a
429/// different magnitude (ryu below 1e-5, Python and Go below 1e-4, Go also from
430/// 1e6), so the choice is made here, on the digits as written. `None` for
431/// integer literals and exponents beyond `i64`, which keep their spelling.
432fn place_decimal_point(literal: &str) -> Option<String> {
433    if !literal.contains(['.', 'e', 'E']) {
434        return None;
435    }
436    let (sign, unsigned) = match literal.strip_prefix('-') {
437        Some(rest) => ("-", rest),
438        None => ("", literal),
439    };
440    let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
441        Some(index) => (&unsigned[..index], &unsigned[index + 1..]),
442        None => (unsigned, "0"),
443    };
444    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
445    let digits = format!("{whole}{fraction}");
446    let leading = digits.bytes().take_while(|digit| *digit == b'0').count();
447    let significant = &digits[leading..];
448    if significant.is_empty() {
449        return None;
450    }
451    // `point`: how many significant digits precede the decimal point.
452    let point = i64::try_from(whole.len())
453        .ok()?
454        .checked_sub(i64::try_from(leading).ok()?)?
455        .checked_add(exponent.parse::<i64>().ok()?)?;
456    let text = if (1..=21).contains(&point) {
457        let split = usize::try_from(point).ok()?.min(significant.len());
458        let (integer, rest) = significant.split_at(split);
459        if rest.is_empty() {
460            // An integral value at or beyond the point is integral_float_text's.
461            return None;
462        }
463        format!("{integer}.{rest}")
464    } else if (-5..=0).contains(&point) {
465        let zeros = "0".repeat(usize::try_from(-point).ok()?);
466        format!("0.{zeros}{significant}")
467    } else {
468        let (first, rest) = significant.split_at(1);
469        let scale = point.checked_sub(1)?;
470        let marker = if scale < 0 { "-" } else { "+" };
471        let rest = if rest.is_empty() {
472            String::new()
473        } else {
474            format!(".{rest}")
475        };
476        format!("{first}{rest}e{marker}{}", scale.unsigned_abs())
477    };
478    Some(format!("{sign}{text}"))
479}
480
481/// The exact integer a float literal denotes, when it denotes one below 1e21
482/// in magnitude. `None` for integer literals (already canonical), fractional
483/// values, and anything at or beyond 1e21, which keeps exponent form.
484fn integral_float_text(literal: &str) -> Option<String> {
485    if !literal.contains(['.', 'e', 'E']) {
486        return None;
487    }
488    let (negative, unsigned) = match literal.strip_prefix('-') {
489        Some(rest) => (true, rest),
490        None => (false, literal),
491    };
492    let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
493        Some(index) => (&unsigned[..index], &unsigned[index + 1..]),
494        None => (unsigned, "0"),
495    };
496    let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, ""));
497    let digits = format!("{whole}{fraction}");
498    if digits.bytes().all(|digit| digit == b'0') {
499        return Some(if negative { "-0" } else { "0" }.into());
500    }
501    let exponent = exponent.parse::<i64>().ok()?;
502    // Where the decimal point falls in `digits` once the exponent is applied.
503    let point = i64::try_from(whole.len()).ok()?.checked_add(exponent)?;
504    let split = usize::try_from(point.clamp(0, i64::try_from(digits.len()).ok()?)).ok()?;
505    if digits[split..].bytes().any(|digit| digit != b'0') {
506        return None;
507    }
508    let padding = usize::try_from(point)
509        .unwrap_or(0)
510        .saturating_sub(digits.len());
511    let integer = format!("{}{}", &digits[..split], "0".repeat(padding.min(22)));
512    let integer = integer.trim_start_matches('0');
513    if integer.len() > 21 {
514        return None;
515    }
516    let integer = if integer.is_empty() { "0" } else { integer };
517    Some(if negative {
518        format!("-{integer}")
519    } else {
520        integer.to_string()
521    })
522}
523
524fn normalize_exponent(s: &str) -> String {
525    let Some(e) = s.find(['e', 'E']) else {
526        return s.to_string();
527    };
528    let mantissa = &s[..e];
529    let mut exp = &s[e + 1..];
530    // An unsigned exponent is a positive one: spell it `e+` like every
531    // native float formatter, so a decoded literal renders the same.
532    let mut sign = "+";
533    if exp.starts_with(['+', '-']) {
534        sign = &exp[..1];
535        exp = &exp[1..];
536    }
537    let exp = exp.trim_start_matches('0');
538    let exp = if exp.is_empty() { "0" } else { exp };
539    format!("{mantissa}e{sign}{exp}")
540}
541
542/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
543fn format_ms_as_seconds(ms: f64) -> String {
544    let formatted = format!("{:.3}", ms / 1000.0);
545    let trimmed = formatted.trim_end_matches('0');
546    if trimmed.ends_with('.') {
547        format!("{}0s", trimmed)
548    } else {
549        format!("{}s", trimmed)
550    }
551}
552
553/// Split a signed integer into its sign text and magnitude.
554///
555/// Taking the magnitude before dividing keeps currency formatting identical
556/// across the four languages: Rust and Go truncate toward zero while Python
557/// floors toward negative infinity, so `-499 / 100` does not agree, but
558/// `-(499 / 100)` does.
559fn signed_magnitude(value: i64) -> (&'static str, u64) {
560    if value < 0 {
561        ("-", value.unsigned_abs())
562    } else {
563        ("", value as u64)
564    }
565}
566
567/// Format `_ms` value: < 1000 → `{n}ms`, >= 1000 → seconds.
568fn format_ms_value(value: &Value) -> Option<String> {
569    // A decoded literal beyond f64 parses as infinity; it cannot be converted
570    // to seconds, so it keeps its raw key and digits as in the other SDKs.
571    let n = value.as_f64().filter(|n| n.is_finite())?;
572    if n.abs() >= 1000.0 {
573        Some(format_ms_as_seconds(n))
574    } else {
575        Some(format!("{}ms", number_str(value)))
576    }
577}
578
579/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
580const MIN_RFC3339_MS: i64 = -62135596800000;
581const MAX_RFC3339_MS: i64 = 253402300799999;
582
583fn format_rfc3339_ms(ms: i64) -> Option<String> {
584    use chrono::{DateTime, Utc};
585    if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
586        return None;
587    }
588    let secs = ms.div_euclid(1000);
589    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
590    DateTime::from_timestamp(secs, nanos).map(|dt| {
591        dt.with_timezone(&Utc)
592            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
593    })
594}
595
596/// Format non-negative bytes as human-readable binary size.
597pub(crate) fn format_bytes_human(bytes: u64) -> String {
598    const KIB: f64 = 1024.0;
599    const MIB: f64 = KIB * 1024.0;
600    const GIB: f64 = MIB * 1024.0;
601    const TIB: f64 = GIB * 1024.0;
602
603    let b = bytes as f64;
604    if b >= TIB {
605        format!("{:.1}TiB", b / TIB)
606    } else if b >= GIB {
607        format!("{:.1}GiB", b / GIB)
608    } else if b >= MIB {
609        format!("{:.1}MiB", b / MIB)
610    } else if b >= KIB {
611        format!("{:.1}KiB", b / KIB)
612    } else {
613        format!("{bytes}B")
614    }
615}
616
617/// Format a number with thousands separators.
618pub(crate) fn format_with_commas(n: u64) -> String {
619    let s = n.to_string();
620    let mut result = String::with_capacity(s.len() + s.len() / 3);
621    for (i, c) in s.chars().enumerate() {
622        if i > 0 && (s.len() - i).is_multiple_of(3) {
623            result.push(',');
624        }
625        result.push(c);
626    }
627    result
628}
629
630/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
631pub(crate) fn extract_currency_code(key: &str) -> Option<&str> {
632    let without_cents = key
633        .strip_suffix("_cents")
634        .or_else(|| key.strip_suffix("_CENTS"))?;
635    extract_currency_code_from_stem(without_cents)
636}
637
638/// Extract currency code from a `_{code}_micro` / `_{CODE}_MICRO` suffix.
639pub(crate) fn extract_currency_code_micro(key: &str) -> Option<&str> {
640    let without_micro = key
641        .strip_suffix("_micro")
642        .or_else(|| key.strip_suffix("_MICRO"))?;
643    extract_currency_code_from_stem(without_micro)
644}
645
646fn extract_currency_code_from_stem(stem: &str) -> Option<&str> {
647    let last_underscore = stem.rfind('_')?;
648    let code = &stem[last_underscore + 1..];
649    if code.is_empty()
650        || !(3..=4).contains(&code.len())
651        || !code.bytes().all(|b| b.is_ascii_alphabetic())
652    {
653        return None;
654    }
655    Some(code)
656}
657
658// ═══════════════════════════════════════════
659// YAML Rendering (structure-preserving)
660// ═══════════════════════════════════════════
661
662fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
663    let prefix = "  ".repeat(indent);
664    match value {
665        Value::Object(map) => {
666            for key in sorted_value_keys(map) {
667                render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
668            }
669        }
670        Value::Array(arr) => {
671            render_yaml_array_raw(arr, indent, lines);
672        }
673        _ => {
674            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
675        }
676    }
677}
678
679fn render_yaml_field_raw(
680    prefix: &str,
681    key: &str,
682    value: &Value,
683    indent: usize,
684    lines: &mut Vec<String>,
685) {
686    match value {
687        Value::Object(inner) if !inner.is_empty() => {
688            lines.push(format!("{}{}:", prefix, yaml_key(key)));
689            render_yaml_raw(value, indent + 1, lines);
690        }
691        Value::Object(_) => {
692            lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
693        }
694        Value::Array(arr) => {
695            if arr.is_empty() {
696                lines.push(format!("{}{}: []", prefix, yaml_key(key)));
697            } else {
698                lines.push(format!("{}{}:", prefix, yaml_key(key)));
699                render_yaml_array_raw(arr, indent + 1, lines);
700            }
701        }
702        _ => {
703            lines.push(format!(
704                "{}{}: {}",
705                prefix,
706                yaml_key(key),
707                yaml_scalar(value)
708            ));
709        }
710    }
711}
712
713fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
714    let prefix = "  ".repeat(indent);
715    for item in arr {
716        match item {
717            Value::Object(inner) if !inner.is_empty() => {
718                lines.push(format!("{}-", prefix));
719                render_yaml_raw(item, indent + 1, lines);
720            }
721            Value::Array(nested) if !nested.is_empty() => {
722                lines.push(format!("{}-", prefix));
723                render_yaml_array_raw(nested, indent + 1, lines);
724            }
725            Value::Object(_) => {
726                lines.push(format!("{}- {{}}", prefix));
727            }
728            Value::Array(_) => {
729                lines.push(format!("{}- []", prefix));
730            }
731            _ => {
732                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
733            }
734        }
735    }
736}
737
738fn escape_yaml_str(s: &str) -> String {
739    let mut escaped = String::with_capacity(s.len());
740    for character in s.chars() {
741        match character {
742            '\\' => escaped.push_str("\\\\"),
743            '"' => escaped.push_str("\\\""),
744            '\n' => escaped.push_str("\\n"),
745            '\r' => escaped.push_str("\\r"),
746            '\t' => escaped.push_str("\\t"),
747            '\x08' => escaped.push_str("\\b"),
748            '\x0c' => escaped.push_str("\\f"),
749            '\x0b' => escaped.push_str("\\v"),
750            '\0' => escaped.push_str("\\0"),
751            control if control <= '\u{001f}' => {
752                use std::fmt::Write as _;
753                let _ = write!(escaped, "\\u{:04x}", control as u32);
754            }
755            other => escaped.push(other),
756        }
757    }
758    escaped
759}
760
761fn yaml_key(key: &str) -> String {
762    if is_safe_key(key) && !is_ambiguous_yaml_key(key) {
763        key.to_string()
764    } else {
765        format!("\"{}\"", escape_yaml_str(key))
766    }
767}
768
769fn is_ambiguous_yaml_key(key: &str) -> bool {
770    let lower = key.to_ascii_lowercase();
771    matches!(
772        lower.as_str(),
773        "true" | "false" | "null" | "~" | ".nan" | ".inf" | "+.inf" | "-.inf"
774    ) || matches!(
775        lower.strip_prefix(['+', '-']).unwrap_or(&lower),
776        "inf" | "infinity" | "nan"
777    ) || is_decimal_yaml_key(key)
778}
779
780fn is_decimal_yaml_key(key: &str) -> bool {
781    let mut bytes = key.bytes().peekable();
782    if matches!(bytes.peek(), Some(b'+' | b'-')) {
783        bytes.next();
784    }
785    let mut digits = 0;
786    while bytes.next_if(u8::is_ascii_digit).is_some() {
787        digits += 1;
788    }
789    if bytes.next_if_eq(&b'.').is_some() {
790        while bytes.next_if(u8::is_ascii_digit).is_some() {
791            digits += 1;
792        }
793    }
794    if digits == 0 {
795        return false;
796    }
797    if matches!(bytes.peek(), Some(b'e' | b'E')) {
798        bytes.next();
799        if matches!(bytes.peek(), Some(b'+' | b'-')) {
800            bytes.next();
801        }
802        let mut exponent_digits = 0;
803        while bytes.next_if(u8::is_ascii_digit).is_some() {
804            exponent_digits += 1;
805        }
806        if exponent_digits == 0 {
807            return false;
808        }
809    }
810    bytes.next().is_none()
811}
812
813fn quote_logfmt_key(key: &str) -> String {
814    if is_safe_key(key) {
815        key.to_string()
816    } else {
817        quote_logfmt_value(key)
818    }
819}
820
821fn is_safe_key(key: &str) -> bool {
822    !key.is_empty()
823        && key
824            .bytes()
825            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
826}
827
828fn yaml_scalar(value: &Value) -> String {
829    match value {
830        Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
831        Value::Null => "null".to_string(),
832        Value::Bool(b) => b.to_string(),
833        Value::Number(n) => format_number(n),
834        Value::Object(_) | Value::Array(_) => {
835            format!("\"{}\"", escape_yaml_str(&embedded_json(value)))
836        }
837    }
838}
839
840// ═══════════════════════════════════════════
841// Plain Rendering (logfmt)
842// ═══════════════════════════════════════════
843
844fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
845    if let Value::Object(map) = value {
846        let processed = process_object_fields(map);
847        for (display_key, v, formatted) in processed {
848            let full_key = if prefix.is_empty() {
849                display_key
850            } else {
851                format!("{}.{}", prefix, display_key)
852            };
853            if let Some(fv) = formatted {
854                pairs.push((full_key, fv));
855            } else {
856                match v {
857                    Value::Object(map) if map.is_empty() => {
858                        pairs.push((full_key, "{}".to_string()));
859                    }
860                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
861                    Value::Array(arr) if arr.is_empty() => {
862                        pairs.push((full_key, "[]".to_string()));
863                    }
864                    Value::Array(arr) => {
865                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
866                        pairs.push((full_key, joined));
867                    }
868                    Value::Null => pairs.push((full_key, String::new())),
869                    _ => pairs.push((full_key, plain_scalar(v))),
870                }
871            }
872        }
873    }
874}
875
876fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
877    if let Value::Object(map) = value {
878        for key in sorted_value_keys(map) {
879            let v = &map[&key];
880            let full_key = if prefix.is_empty() {
881                key.clone()
882            } else {
883                format!("{}.{}", prefix, key)
884            };
885            match v {
886                Value::Object(map) if map.is_empty() => {
887                    pairs.push((full_key, "{}".to_string()));
888                }
889                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
890                Value::Array(arr) if arr.is_empty() => {
891                    pairs.push((full_key, "[]".to_string()));
892                }
893                Value::Array(arr) => {
894                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
895                    pairs.push((full_key, joined));
896                }
897                Value::Null => pairs.push((full_key, String::new())),
898                _ => pairs.push((full_key, plain_scalar(v))),
899            }
900        }
901    }
902}
903
904fn plain_scalar(value: &Value) -> String {
905    match value {
906        Value::String(s) => s.clone(),
907        Value::Null => "null".to_string(),
908        Value::Bool(b) => b.to_string(),
909        Value::Number(n) => format_number(n),
910        Value::Object(_) | Value::Array(_) => embedded_json(value),
911    }
912}
913
914fn quote_logfmt_value(value: &str) -> String {
915    if value.is_empty() {
916        return String::new();
917    }
918    if !value.chars().any(|c| {
919        matches!(
920            c,
921            ' ' | '\t' | '\n' | '\r' | '\x0c' | '\x0b' | '\u{a0}' | '=' | '"' | '\\'
922        )
923    }) {
924        return value.to_string();
925    }
926    let escaped = value
927        .replace('\\', "\\\\")
928        .replace('"', "\\\"")
929        .replace('\n', "\\n")
930        .replace('\r', "\\r")
931        .replace('\t', "\\t")
932        .replace('\x0c', "\\f")
933        .replace('\x0b', "\\v");
934    format!("\"{}\"", escaped)
935}
936
937/// A container inside a Plain array (or a YAML scalar position) is written as
938/// compact JSON with sorted keys. Its number leaves take the canonical number
939/// form, the same text the value prints as a scalar, so a nested `1.0` reads
940/// `1` and a decoded `1e5` reads `100000` in every SDK rather than in each
941/// runtime's own JSON spelling.
942fn embedded_json(value: &Value) -> String {
943    let mut out = String::new();
944    write_embedded_json(value, &mut out);
945    out
946}
947
948fn write_embedded_json(value: &Value, out: &mut String) {
949    match value {
950        Value::Object(map) => {
951            out.push('{');
952            for (index, key) in sorted_value_keys(map).iter().enumerate() {
953                if index > 0 {
954                    out.push(',');
955                }
956                write_json_string(key, out);
957                out.push(':');
958                write_embedded_json(&map[key], out);
959            }
960            out.push('}');
961        }
962        Value::Array(items) => {
963            out.push('[');
964            for (index, item) in items.iter().enumerate() {
965                if index > 0 {
966                    out.push(',');
967                }
968                write_embedded_json(item, out);
969            }
970            out.push(']');
971        }
972        Value::String(text) => write_json_string(text, out),
973        Value::Number(number) => out.push_str(&format_number(number)),
974        Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
975        Value::Null => out.push_str("null"),
976    }
977}
978
979fn write_json_string(text: &str, out: &mut String) {
980    // Serializing a `str` cannot fail; the fallback keeps this infallible.
981    out.push_str(&serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string()));
982}
983
984fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
985    let mut keys: Vec<String> = map.keys().cloned().collect();
986    keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
987    keys
988}
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use crate::redaction::{RedactionPolicy, Redactor};
994    use serde_json::json;
995
996    fn plain(value: &Value, policy: RedactionPolicy) -> String {
997        render(
998            value,
999            OutputFormat::Plain,
1000            &OutputOptions {
1001                redaction: Redactor::new().policy(policy),
1002                style: PlainStyle::Readable,
1003            },
1004        )
1005    }
1006
1007    // ── `_secret` stripping follows redaction ──────────
1008
1009    #[test]
1010    fn plain_strips_secret_suffix_once_the_value_is_redacted() {
1011        assert_eq!(
1012            plain(
1013                &json!({"api_key_secret": "sk-live-xxx"}),
1014                RedactionPolicy::All
1015            ),
1016            "api_key=***"
1017        );
1018    }
1019
1020    #[test]
1021    fn plain_keeps_secret_suffix_when_redaction_is_off() {
1022        // Stripping here would hand a live credential to a reader under a name
1023        // that no longer says it is one.
1024        assert_eq!(
1025            plain(
1026                &json!({"api_key_secret": "sk-live-xxx"}),
1027                RedactionPolicy::Off
1028            ),
1029            "api_key_secret=sk-live-xxx"
1030        );
1031        assert_eq!(
1032            plain(
1033                &json!({"API_KEY_SECRET": "sk-live-xxx"}),
1034                RedactionPolicy::Off
1035            ),
1036            "API_KEY_SECRET=sk-live-xxx"
1037        );
1038    }
1039
1040    #[test]
1041    fn plain_keeps_secret_suffix_outside_trace_under_trace_only() {
1042        assert_eq!(
1043            plain(
1044                &json!({
1045                    "api_key_secret": "sk-live-xxx",
1046                    "trace": {"request_secret": "top-secret"}
1047                }),
1048                RedactionPolicy::TraceOnly
1049            ),
1050            "api_key_secret=sk-live-xxx trace.request=***"
1051        );
1052    }
1053
1054    #[test]
1055    fn plain_keeps_secret_suffix_on_an_unredacted_subtree() {
1056        assert_eq!(
1057            plain(
1058                &json!({"db_secret": {"password": "hunter2"}}),
1059                RedactionPolicy::Off
1060            ),
1061            "db_secret.password=hunter2"
1062        );
1063        assert_eq!(
1064            plain(
1065                &json!({"db_secret": {"password": "hunter2"}}),
1066                RedactionPolicy::All
1067            ),
1068            "db=***"
1069        );
1070    }
1071
1072    #[test]
1073    fn plain_unstripped_secret_key_does_not_collide_with_its_stem() {
1074        // Keeping the suffix also means no collision to fall back from: both
1075        // fields keep their own name and value.
1076        assert_eq!(
1077            plain(
1078                &json!({"api_key": "public", "api_key_secret": "sk-live-xxx"}),
1079                RedactionPolicy::Off
1080            ),
1081            "api_key=public api_key_secret=sk-live-xxx"
1082        );
1083    }
1084}