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