1use crate::redaction::{OutputOptions, OutputStyle, redacted_value};
2use serde_json::Value;
3
4pub fn output_json(value: &Value) -> String {
5 serialize_json_output(&redacted_value(value))
6}
7
8pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
13 serialize_json_output(&output_options.redaction.value(value))
14}
15
16pub(crate) fn serialize_json_output(value: &Value) -> String {
17 match serde_json::to_string(value) {
18 Ok(s) => s,
19 Err(err) => serde_json::json!({
20 "error": "output_json_failed",
21 "detail": err.to_string(),
22 })
23 .to_string(),
24 }
25}
26
27pub fn output_yaml(value: &Value) -> String {
29 output_yaml_with_options(value, &OutputOptions::default())
30}
31
32pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
34 let mut lines = vec!["---".to_string()];
35 let v = output_options.redaction.value(value);
36 match output_options.style {
37 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
38 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
39 }
40 lines.join("\n")
41}
42
43pub fn output_plain(value: &Value) -> String {
45 output_plain_with_options(value, &OutputOptions::default())
46}
47
48pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
50 let mut pairs: Vec<(String, String)> = Vec::new();
51 let v = output_options.redaction.value(value);
52 match output_options.style {
53 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
54 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
55 }
56 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
57 pairs
58 .into_iter()
59 .map(|(k, v)| format!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
60 .collect::<Vec<_>>()
61 .join(" ")
62}
63
64fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
70 if let Some(s) = key.strip_suffix(suffix_lower) {
71 return Some(s.to_string());
72 }
73 let suffix_upper: String = suffix_lower
74 .chars()
75 .map(|c| c.to_ascii_uppercase())
76 .collect();
77 if let Some(s) = key.strip_suffix(&suffix_upper) {
78 return Some(s.to_string());
79 }
80 None
81}
82
83fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
85 let code = extract_currency_code(key)?;
86 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
88 if stripped.is_empty() {
89 return None;
90 }
91 Some((stripped.to_string(), code.to_string()))
92}
93
94fn try_strip_generic_micro(key: &str) -> Option<(String, String)> {
96 let code = extract_currency_code_micro(key)?;
97 let suffix_len = code.len() + "_micro".len() + 1; let stripped = &key[..key.len() - suffix_len];
99 if stripped.is_empty() {
100 return None;
101 }
102 Some((stripped.to_string(), code.to_string()))
103}
104
105fn as_int(value: &Value) -> Option<i64> {
113 if let Some(i) = value.as_i64() {
114 return Some(i);
115 }
116 let f = value.as_f64()?;
117 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
118 return Some(f as i64);
119 }
120 None
121}
122
123fn as_uint(value: &Value) -> Option<u64> {
125 if let Some(u) = value.as_u64() {
126 return Some(u);
127 }
128 let f = value.as_f64()?;
129 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
130 return Some(f as u64);
131 }
132 None
133}
134
135fn integer_text(value: &Value) -> Option<String> {
136 match value {
137 Value::Number(_) => as_int(value).map(|n| n.to_string()),
138 Value::String(s) if is_decimal_integer_string(s) => Some(s.clone()),
139 _ => None,
140 }
141}
142
143fn is_decimal_integer_string(s: &str) -> bool {
144 let digits = s.strip_prefix('-').unwrap_or(s);
145 !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
146}
147
148fn epoch_ns_to_ms(value: &Value) -> Option<i64> {
149 let ns = match value {
150 Value::Number(_) => i128::from(as_int(value)?),
151 Value::String(s) if is_decimal_integer_string(s) => s.parse::<i128>().ok()?,
152 _ => return None,
153 };
154 ns.div_euclid(1_000_000).try_into().ok()
155}
156
157fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
158 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
160 return as_int(value)
161 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
162 }
163 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
164 return as_int(value)
165 .and_then(|s| s.checked_mul(1000))
166 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
167 }
168 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
169 return epoch_ns_to_ms(value)
170 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
171 }
172
173 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
175 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
176 }
177 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
178 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
179 }
180 if let Some((stripped, code)) = try_strip_generic_cents(key) {
181 return as_uint(value).map(|n| {
182 (
183 stripped,
184 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
185 )
186 });
187 }
188 if let Some((stripped, code)) = try_strip_generic_micro(key) {
189 return as_uint(value).map(|n| {
190 (
191 stripped,
192 format!(
193 "{}.{:06} {}",
194 n / 1_000_000,
195 n % 1_000_000,
196 code.to_uppercase()
197 ),
198 )
199 });
200 }
201
202 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
204 return value.as_str().map(|s| (stripped, s.to_string()));
205 }
206 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
207 return value
208 .is_number()
209 .then(|| (stripped, format!("{} minutes", number_str(value))));
210 }
211 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
212 return value
213 .is_number()
214 .then(|| (stripped, format!("{} hours", number_str(value))));
215 }
216 if let Some(stripped) = strip_suffix_ci(key, "_days") {
217 return value
218 .is_number()
219 .then(|| (stripped, format!("{} days", number_str(value))));
220 }
221
222 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
224 return integer_text(value).map(|n| (stripped, format!("{n}msats")));
225 }
226 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
227 return integer_text(value).map(|n| (stripped, format!("{n}sats")));
228 }
229 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
230 return as_uint(value).map(|n| (stripped, format_bytes_human(n)));
231 }
232 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
233 return value
234 .is_number()
235 .then(|| (stripped, format!("{}%", number_str(value))));
236 }
237 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
239 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
240 }
241 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
242 return value
243 .is_number()
244 .then(|| (stripped, format!("{}ns", number_str(value))));
245 }
246 if let Some(stripped) = strip_suffix_ci(key, "_us") {
247 return value
248 .is_number()
249 .then(|| (stripped, format!("{}μs", number_str(value))));
250 }
251 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
252 return format_ms_value(value).map(|v| (stripped, v));
253 }
254 if let Some(stripped) = strip_suffix_ci(key, "_s") {
255 return value
256 .is_number()
257 .then(|| (stripped, format!("{}s", number_str(value))));
258 }
259
260 None
261}
262
263fn process_object_fields<'a>(
265 map: &'a serde_json::Map<String, Value>,
266) -> Vec<(String, &'a Value, Option<String>)> {
267 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
268 for (key, value) in map {
269 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
270 entries.push((stripped, key.as_str(), value, None));
271 continue;
272 }
273 match try_process_field(key, value) {
274 Some((stripped, formatted)) => {
275 entries.push((stripped, key.as_str(), value, Some(formatted)));
276 }
277 None => {
278 entries.push((key.clone(), key.as_str(), value, None));
279 }
280 }
281 }
282
283 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
285 for (stripped, _, _, _) in &entries {
286 *counts.entry(stripped.clone()).or_insert(0) += 1;
287 }
288
289 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
291 .into_iter()
292 .map(|(stripped, original, value, formatted)| {
293 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
294 (original.to_string(), value, None)
295 } else {
296 (stripped, value, formatted)
297 }
298 })
299 .collect();
300
301 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
302 result
303}
304
305fn number_str(value: &Value) -> String {
310 match value {
311 Value::Number(n) => format_number(n),
312 _ => String::new(),
313 }
314}
315
316fn format_number(n: &serde_json::Number) -> String {
322 if n.is_f64()
323 && let Some(f) = n.as_f64()
324 && f.is_finite()
325 && f.fract() == 0.0
326 && f.abs() < 1e21
327 {
328 return format!("{f:.0}");
329 }
330 normalize_exponent(&n.to_string())
331}
332
333fn normalize_exponent(s: &str) -> String {
334 let Some(e) = s.find(['e', 'E']) else {
335 return s.to_string();
336 };
337 let mantissa = &s[..e];
338 let mut exp = &s[e + 1..];
339 let mut sign = "";
340 if exp.starts_with(['+', '-']) {
341 sign = &exp[..1];
342 exp = &exp[1..];
343 }
344 let exp = exp.trim_start_matches('0');
345 let exp = if exp.is_empty() { "0" } else { exp };
346 format!("{mantissa}e{sign}{exp}")
347}
348
349fn format_ms_as_seconds(ms: f64) -> String {
351 let formatted = format!("{:.3}", ms / 1000.0);
352 let trimmed = formatted.trim_end_matches('0');
353 if trimmed.ends_with('.') {
354 format!("{}0s", trimmed)
355 } else {
356 format!("{}s", trimmed)
357 }
358}
359
360fn format_ms_value(value: &Value) -> Option<String> {
362 let n = value.as_f64()?;
363 if n.abs() >= 1000.0 {
364 Some(format_ms_as_seconds(n))
365 } else if let Some(i) = value.as_i64() {
366 Some(format!("{}ms", i))
367 } else {
368 Some(format!("{}ms", number_str(value)))
369 }
370}
371
372const MIN_RFC3339_MS: i64 = -62135596800000;
374const MAX_RFC3339_MS: i64 = 253402300799999;
375
376fn format_rfc3339_ms(ms: i64) -> Option<String> {
377 use chrono::{DateTime, Utc};
378 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
379 return None;
380 }
381 let secs = ms.div_euclid(1000);
382 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
383 DateTime::from_timestamp(secs, nanos).map(|dt| {
384 dt.with_timezone(&Utc)
385 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
386 })
387}
388
389pub(crate) fn format_bytes_human(bytes: u64) -> String {
391 const KIB: f64 = 1024.0;
392 const MIB: f64 = KIB * 1024.0;
393 const GIB: f64 = MIB * 1024.0;
394 const TIB: f64 = GIB * 1024.0;
395
396 let b = bytes as f64;
397 if b >= TIB {
398 format!("{:.1}TiB", b / TIB)
399 } else if b >= GIB {
400 format!("{:.1}GiB", b / GIB)
401 } else if b >= MIB {
402 format!("{:.1}MiB", b / MIB)
403 } else if b >= KIB {
404 format!("{:.1}KiB", b / KIB)
405 } else {
406 format!("{bytes}B")
407 }
408}
409
410pub(crate) fn format_with_commas(n: u64) -> String {
412 let s = n.to_string();
413 let mut result = String::with_capacity(s.len() + s.len() / 3);
414 for (i, c) in s.chars().enumerate() {
415 if i > 0 && (s.len() - i).is_multiple_of(3) {
416 result.push(',');
417 }
418 result.push(c);
419 }
420 result
421}
422
423pub(crate) fn extract_currency_code(key: &str) -> Option<&str> {
425 let without_cents = key
426 .strip_suffix("_cents")
427 .or_else(|| key.strip_suffix("_CENTS"))?;
428 extract_currency_code_from_stem(without_cents)
429}
430
431fn extract_currency_code_micro(key: &str) -> Option<&str> {
433 let without_micro = key
434 .strip_suffix("_micro")
435 .or_else(|| key.strip_suffix("_MICRO"))?;
436 extract_currency_code_from_stem(without_micro)
437}
438
439fn extract_currency_code_from_stem(stem: &str) -> Option<&str> {
440 let last_underscore = stem.rfind('_')?;
441 let code = &stem[last_underscore + 1..];
442 if code.is_empty()
443 || !(3..=4).contains(&code.len())
444 || !code.bytes().all(|b| b.is_ascii_alphabetic())
445 {
446 return None;
447 }
448 Some(code)
449}
450
451fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
456 let prefix = " ".repeat(indent);
457 match value {
458 Value::Object(map) => {
459 let processed = process_object_fields(map);
460 for (display_key, v, formatted) in processed {
461 if let Some(fv) = formatted {
462 lines.push(format!(
463 "{}{}: \"{}\"",
464 prefix,
465 yaml_key(&display_key),
466 escape_yaml_str(&fv)
467 ));
468 } else {
469 match v {
470 Value::Object(inner) if !inner.is_empty() => {
471 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
472 render_yaml_processed(v, indent + 1, lines);
473 }
474 Value::Object(_) => {
475 lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
476 }
477 Value::Array(arr) => {
478 if arr.is_empty() {
479 lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
480 } else {
481 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
482 for item in arr {
483 if item.is_object() {
484 lines.push(format!("{} -", prefix));
485 render_yaml_processed(item, indent + 2, lines);
486 } else {
487 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
488 }
489 }
490 }
491 }
492 _ => {
493 lines.push(format!(
494 "{}{}: {}",
495 prefix,
496 yaml_key(&display_key),
497 yaml_scalar(v)
498 ));
499 }
500 }
501 }
502 }
503 }
504 _ => {
505 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
506 }
507 }
508}
509
510fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
511 let prefix = " ".repeat(indent);
512 match value {
513 Value::Object(map) => {
514 for key in sorted_value_keys(map) {
515 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
516 }
517 }
518 Value::Array(arr) => {
519 render_yaml_array_raw(arr, indent, lines);
520 }
521 _ => {
522 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
523 }
524 }
525}
526
527fn render_yaml_field_raw(
528 prefix: &str,
529 key: &str,
530 value: &Value,
531 indent: usize,
532 lines: &mut Vec<String>,
533) {
534 match value {
535 Value::Object(inner) if !inner.is_empty() => {
536 lines.push(format!("{}{}:", prefix, yaml_key(key)));
537 render_yaml_raw(value, indent + 1, lines);
538 }
539 Value::Object(_) => {
540 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
541 }
542 Value::Array(arr) => {
543 if arr.is_empty() {
544 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
545 } else {
546 lines.push(format!("{}{}:", prefix, yaml_key(key)));
547 render_yaml_array_raw(arr, indent + 1, lines);
548 }
549 }
550 _ => {
551 lines.push(format!(
552 "{}{}: {}",
553 prefix,
554 yaml_key(key),
555 yaml_scalar(value)
556 ));
557 }
558 }
559}
560
561fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
562 let prefix = " ".repeat(indent);
563 for item in arr {
564 match item {
565 Value::Object(inner) if !inner.is_empty() => {
566 lines.push(format!("{}-", prefix));
567 render_yaml_raw(item, indent + 1, lines);
568 }
569 Value::Array(nested) if !nested.is_empty() => {
570 lines.push(format!("{}-", prefix));
571 render_yaml_array_raw(nested, indent + 1, lines);
572 }
573 Value::Object(_) => {
574 lines.push(format!("{}- {{}}", prefix));
575 }
576 Value::Array(_) => {
577 lines.push(format!("{}- []", prefix));
578 }
579 _ => {
580 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
581 }
582 }
583 }
584}
585
586fn escape_yaml_str(s: &str) -> String {
587 s.replace('\\', "\\\\")
588 .replace('"', "\\\"")
589 .replace('\n', "\\n")
590 .replace('\r', "\\r")
591 .replace('\t', "\\t")
592 .replace('\x0c', "\\f")
593 .replace('\x0b', "\\v")
594}
595
596fn yaml_key(key: &str) -> String {
597 if is_safe_key(key) {
598 key.to_string()
599 } else {
600 format!("\"{}\"", escape_yaml_str(key))
601 }
602}
603
604fn quote_logfmt_key(key: &str) -> String {
605 if is_safe_key(key) {
606 key.to_string()
607 } else {
608 quote_logfmt_value(key)
609 }
610}
611
612fn is_safe_key(key: &str) -> bool {
613 !key.is_empty()
614 && key
615 .bytes()
616 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
617}
618
619fn yaml_scalar(value: &Value) -> String {
620 match value {
621 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
622 Value::Null => "null".to_string(),
623 Value::Bool(b) => b.to_string(),
624 Value::Number(n) => format_number(n),
625 Value::Object(_) | Value::Array(_) => {
626 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
627 }
628 }
629}
630
631fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
636 if let Value::Object(map) = value {
637 let processed = process_object_fields(map);
638 for (display_key, v, formatted) in processed {
639 let full_key = if prefix.is_empty() {
640 display_key
641 } else {
642 format!("{}.{}", prefix, display_key)
643 };
644 if let Some(fv) = formatted {
645 pairs.push((full_key, fv));
646 } else {
647 match v {
648 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
649 Value::Array(arr) => {
650 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
651 pairs.push((full_key, joined));
652 }
653 Value::Null => pairs.push((full_key, String::new())),
654 _ => pairs.push((full_key, plain_scalar(v))),
655 }
656 }
657 }
658 }
659}
660
661fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
662 if let Value::Object(map) = value {
663 for key in sorted_value_keys(map) {
664 let v = &map[&key];
665 let full_key = if prefix.is_empty() {
666 key.clone()
667 } else {
668 format!("{}.{}", prefix, key)
669 };
670 match v {
671 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
672 Value::Array(arr) => {
673 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
674 pairs.push((full_key, joined));
675 }
676 Value::Null => pairs.push((full_key, String::new())),
677 _ => pairs.push((full_key, plain_scalar(v))),
678 }
679 }
680 }
681}
682
683fn plain_scalar(value: &Value) -> String {
684 match value {
685 Value::String(s) => s.clone(),
686 Value::Null => "null".to_string(),
687 Value::Bool(b) => b.to_string(),
688 Value::Number(n) => format_number(n),
689 Value::Object(_) | Value::Array(_) => canonical_json(value),
690 }
691}
692
693fn quote_logfmt_value(value: &str) -> String {
694 if value.is_empty() {
695 return String::new();
696 }
697 if !value
698 .chars()
699 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
700 {
701 return value.to_string();
702 }
703 let escaped = value
704 .replace('\\', "\\\\")
705 .replace('"', "\\\"")
706 .replace('\n', "\\n")
707 .replace('\r', "\\r")
708 .replace('\t', "\\t")
709 .replace('\x0c', "\\f")
710 .replace('\x0b', "\\v");
711 format!("\"{}\"", escaped)
712}
713
714fn canonical_json(value: &Value) -> String {
715 serde_json::to_string(&sort_json_value(value))
716 .unwrap_or_else(|_| "<unsupported:json>".to_string())
717}
718
719fn sort_json_value(value: &Value) -> Value {
720 match value {
721 Value::Object(map) => {
722 let mut out = serde_json::Map::new();
723 for key in sorted_value_keys(map) {
724 if let Some(v) = map.get(&key) {
725 out.insert(key, sort_json_value(v));
726 }
727 }
728 Value::Object(out)
729 }
730 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
731 _ => value.clone(),
732 }
733}
734
735fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
736 let mut keys: Vec<String> = map.keys().cloned().collect();
737 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
738 keys
739}