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
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
142pub(crate) fn number_is_integer_literal(number: &serde_json::Number) -> bool {
147 !number.to_string().contains(['.', 'e', 'E'])
148}
149
150fn 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
184fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
188 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 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 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 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 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
318fn is_redacted(value: &Value) -> bool {
321 value.as_str() == Some(REDACTED_MARKER)
322}
323
324fn 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 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 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 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
377fn number_str(value: &Value) -> String {
382 match value {
383 Value::Number(n) => format_number(n),
384 _ => String::new(),
385 }
386}
387
388fn 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
421fn 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
432fn signed_magnitude(value: i64) -> (&'static str, u64) {
439 if value < 0 {
440 ("-", value.unsigned_abs())
441 } else {
442 ("", value as u64)
443 }
444}
445
446fn 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
458const 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
475pub(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
496pub(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
509pub(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
517pub(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
537fn 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
683fn 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 #[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 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 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}