1use crate::output::OutputFormat;
2use crate::redaction::{OutputOptions, PlainStyle, REDACTED_MARKER};
3use serde_json::Value;
4
5pub 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
29pub(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
40pub(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
62pub(crate) fn has_suffix_ci(key: &str, suffix_lower: &str) -> bool {
71 strip_suffix_ci(key, suffix_lower).is_some()
72}
73
74pub(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
89fn 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; 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
100fn 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; 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
111fn 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 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
139pub(crate) fn number_is_integer_literal(number: &serde_json::Number) -> bool {
144 !number.to_string().contains(['.', 'e', 'E'])
145}
146
147fn 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
170fn 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
197fn 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
205fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
209 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 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 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 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 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
339fn is_redacted(value: &Value) -> bool {
342 value.as_str() == Some(REDACTED_MARKER)
343}
344
345fn 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 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 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 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
398fn number_str(value: &Value) -> String {
403 match value {
404 Value::Number(n) => format_number(n),
405 _ => String::new(),
406 }
407}
408
409fn 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
426fn 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 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 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
481fn 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 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 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
542fn 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
553fn signed_magnitude(value: i64) -> (&'static str, u64) {
560 if value < 0 {
561 ("-", value.unsigned_abs())
562 } else {
563 ("", value as u64)
564 }
565}
566
567fn format_ms_value(value: &Value) -> Option<String> {
569 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
579const 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
596pub(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
617pub(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
630pub(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
638pub(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
658fn 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
840fn 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
937fn 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 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 #[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 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 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}