1use serde_json::Value;
12
13#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
15pub enum OutputFormat {
16 #[default]
17 Json,
18 Yaml,
19 Plain,
20}
21
22impl OutputFormat {
23 pub fn format(&self, value: &Value) -> String {
25 match self {
26 Self::Json => serde_json::to_string(value).unwrap_or_default(),
27 Self::Yaml => to_yaml(value),
28 Self::Plain => to_plain(value),
29 }
30 }
31
32 pub fn format_pretty(&self, value: &Value) -> String {
34 match self {
35 Self::Json => serde_json::to_string_pretty(value).unwrap_or_default(),
36 Self::Yaml => to_yaml(value),
37 Self::Plain => to_plain(value),
38 }
39 }
40}
41
42pub fn to_yaml(value: &Value) -> String {
52 let mut lines = vec!["---".to_string()];
53 render_yaml(value, 0, &mut lines);
54 lines.join("\n")
55}
56
57fn render_yaml(value: &Value, indent: usize, lines: &mut Vec<String>) {
58 let prefix = " ".repeat(indent);
59 match value {
60 Value::Object(map) => {
61 for (k, v) in jcs_sorted(map) {
62 match v {
63 Value::Object(inner) if !inner.is_empty() => {
64 lines.push(format!("{}{}:", prefix, k));
65 render_yaml(v, indent + 1, lines);
66 }
67 Value::Object(_) => {
68 lines.push(format!("{}{}: {{}}", prefix, k));
69 }
70 Value::Array(arr) => {
71 if arr.is_empty() {
72 lines.push(format!("{}{}: []", prefix, k));
73 } else {
74 lines.push(format!("{}{}:", prefix, k));
75 for item in arr {
76 if item.is_object() {
77 lines.push(format!("{} -", prefix));
78 render_yaml(item, indent + 2, lines);
79 } else {
80 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
81 }
82 }
83 }
84 }
85 _ => {
86 lines.push(format!("{}{}: {}", prefix, k, yaml_scalar(v)));
87 }
88 }
89 }
90 }
91 _ => {
92 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
93 }
94 }
95}
96
97fn jcs_sorted(map: &serde_json::Map<String, Value>) -> Vec<(&String, &Value)> {
99 let mut entries: Vec<_> = map.iter().collect();
100 entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
101 entries
102}
103
104fn yaml_scalar(value: &Value) -> String {
105 match value {
106 Value::String(s) => {
107 let escaped = s
108 .replace('\\', "\\\\")
109 .replace('"', "\\\"")
110 .replace('\n', "\\n")
111 .replace('\r', "\\r")
112 .replace('\t', "\\t");
113 format!("\"{}\"", escaped)
114 }
115 Value::Null => "null".to_string(),
116 Value::Bool(b) => b.to_string(),
117 Value::Number(n) => n.to_string(),
118 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
119 }
120}
121
122pub fn to_plain(value: &Value) -> String {
135 let mut lines = Vec::new();
136 render_plain(value, 0, &mut lines);
137 lines.join("\n")
138}
139
140fn render_plain(value: &Value, indent: usize, lines: &mut Vec<String>) {
141 let prefix = " ".repeat(indent);
142 match value {
143 Value::Object(map) => {
144 for (k, v) in jcs_sorted(map) {
145 match v {
146 Value::Object(_) => {
147 lines.push(format!("{}{}:", prefix, k));
148 render_plain(v, indent + 1, lines);
149 }
150 Value::Array(arr) => {
151 if arr.is_empty() {
152 lines.push(format!("{}{}: []", prefix, k));
153 } else if arr.iter().all(|v| !v.is_object() && !v.is_array()) {
154 lines.push(format!("{}{}:", prefix, k));
155 for item in arr {
156 lines.push(format!("{} - {}", prefix, plain_scalar(item)));
157 }
158 } else {
159 lines.push(format!("{}{}:", prefix, k));
160 for item in arr {
161 if item.is_object() {
162 lines.push(format!("{} -", prefix));
163 render_plain(item, indent + 2, lines);
164 } else {
165 lines.push(format!("{} - {}", prefix, plain_scalar(item)));
166 }
167 }
168 }
169 }
170 _ => {
171 lines.push(format!("{}{}: {}", prefix, k, format_plain_field(k, v)));
172 }
173 }
174 }
175 }
176 _ => {
177 lines.push(format!("{}{}", prefix, plain_scalar(value)));
178 }
179 }
180}
181
182fn format_plain_field(key: &str, value: &Value) -> String {
192 let lower = key.to_ascii_lowercase();
193
194 if lower.ends_with("_secret") {
196 return "***".to_string();
197 }
198
199 if lower.ends_with("_epoch_ms") {
201 if let Some(ms) = value.as_i64() {
202 return format_rfc3339_ms(ms);
203 }
204 }
205 if lower.ends_with("_epoch_s") {
206 if let Some(s) = value.as_i64() {
207 return format_rfc3339_ms(s * 1000);
208 }
209 }
210 if lower.ends_with("_epoch_ns") {
211 if let Some(ns) = value.as_i64() {
212 return format_rfc3339_ms(ns.div_euclid(1_000_000));
213 }
214 }
215 if lower.ends_with("_rfc3339") {
216 return plain_scalar(value);
217 }
218
219 if lower.ends_with("_bytes") {
221 if let Some(n) = value.as_i64() {
222 return format_bytes_human(n);
223 }
224 }
225
226 if lower.ends_with("_percent") {
228 if value.is_number() {
229 return format!("{}%", plain_scalar(value));
230 }
231 }
232
233 if lower.ends_with("_msats") {
235 if value.is_number() {
236 return format!("{}msats", plain_scalar(value));
237 }
238 }
239 if lower.ends_with("_sats") {
240 if value.is_number() {
241 return format!("{}sats", plain_scalar(value));
242 }
243 }
244 if lower.ends_with("_btc") {
245 if value.is_number() {
246 return format!("{} BTC", plain_scalar(value));
247 }
248 }
249
250 if lower.ends_with("_usd_cents") {
252 if let Some(n) = value.as_u64() {
253 return format!("${}.{:02}", n / 100, n % 100);
254 }
255 }
256 if lower.ends_with("_eur_cents") {
257 if let Some(n) = value.as_u64() {
258 return format!("€{}.{:02}", n / 100, n % 100);
259 }
260 }
261 if lower.ends_with("_jpy") {
262 if let Some(n) = value.as_u64() {
263 return format!("¥{}", format_with_commas(n));
264 }
265 }
266 if lower.ends_with("_cents") {
268 if let Some(code) = extract_currency_code(&lower) {
269 if let Some(n) = value.as_u64() {
270 return format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase());
271 }
272 }
273 }
274
275 if lower.ends_with("_minutes") {
277 if value.is_number() {
278 return format!("{} minutes", plain_scalar(value));
279 }
280 }
281 if lower.ends_with("_hours") {
282 if value.is_number() {
283 return format!("{} hours", plain_scalar(value));
284 }
285 }
286 if lower.ends_with("_days") {
287 if value.is_number() {
288 return format!("{} days", plain_scalar(value));
289 }
290 }
291
292 if lower.ends_with("_ms") && !lower.ends_with("_epoch_ms") {
294 if let Some(n) = value.as_u64() {
295 return if n >= 1000 {
296 format!("{:.2}s", n as f64 / 1000.0)
297 } else {
298 format!("{}ms", n)
299 };
300 }
301 if let Some(n) = value.as_f64() {
302 return if n >= 1000.0 {
303 format!("{:.2}s", n / 1000.0)
304 } else {
305 format!("{}ms", plain_scalar(value))
306 };
307 }
308 }
309
310 if lower.ends_with("_ns") && !lower.ends_with("_epoch_ns") {
312 if value.is_number() {
313 return format!("{}ns", plain_scalar(value));
314 }
315 }
316 if lower.ends_with("_us") {
317 if value.is_number() {
318 return format!("{}μs", plain_scalar(value));
319 }
320 }
321 if lower.ends_with("_s") && !lower.ends_with("_epoch_s") {
322 if value.is_number() {
323 return format!("{}s", plain_scalar(value));
324 }
325 }
326
327 plain_scalar(value)
329}
330
331fn plain_scalar(value: &Value) -> String {
333 match value {
334 Value::String(s) => s.clone(),
335 Value::Null => "null".to_string(),
336 Value::Bool(b) => b.to_string(),
337 Value::Number(n) => n.to_string(),
338 other => other.to_string(),
339 }
340}
341
342pub fn redact_secrets(value: &mut Value) {
352 match value {
353 Value::Object(map) => {
354 let secret_keys: Vec<String> = map
355 .keys()
356 .filter(|k| k.to_ascii_lowercase().ends_with("_secret"))
357 .cloned()
358 .collect();
359 for key in secret_keys {
360 if let Some(Value::String(s)) = map.get_mut(&key) {
361 *s = "***".into();
362 }
363 }
364 for v in map.values_mut() {
365 redact_secrets(v);
366 }
367 }
368 Value::Array(arr) => {
369 for v in arr {
370 redact_secrets(v);
371 }
372 }
373 _ => {}
374 }
375}
376
377pub fn ok(result: Value) -> Value {
383 serde_json::json!({"code": "ok", "result": result})
384}
385
386pub fn ok_trace(result: Value, trace: Value) -> Value {
388 serde_json::json!({"code": "ok", "result": result, "trace": trace})
389}
390
391pub fn error(message: &str) -> Value {
393 serde_json::json!({"code": "error", "error": message})
394}
395
396pub fn error_trace(message: &str, trace: Value) -> Value {
398 serde_json::json!({"code": "error", "error": message, "trace": trace})
399}
400
401pub fn startup(config: Value, args: Value, env: Value) -> Value {
403 serde_json::json!({"code": "startup", "config": config, "args": args, "env": env})
404}
405
406pub fn status(code: &str, fields: Value) -> Value {
408 let mut obj = match fields {
409 Value::Object(map) => map,
410 _ => serde_json::Map::new(),
411 };
412 obj.insert("code".to_string(), Value::String(code.to_string()));
413 Value::Object(obj)
414}
415
416fn format_rfc3339_ms(ms: i64) -> String {
422 use chrono::{DateTime, Utc};
423 let secs = ms.div_euclid(1000);
424 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
425 match DateTime::from_timestamp(secs, nanos) {
426 Some(dt) => dt
427 .with_timezone(&Utc)
428 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
429 None => ms.to_string(),
430 }
431}
432
433fn format_bytes_human(bytes: i64) -> String {
435 const KB: f64 = 1024.0;
436 const MB: f64 = KB * 1024.0;
437 const GB: f64 = MB * 1024.0;
438 const TB: f64 = GB * 1024.0;
439
440 let sign = if bytes < 0 { "-" } else { "" };
441 let b = (bytes as f64).abs();
442 if b >= TB {
443 format!("{sign}{:.1}TB", b / TB)
444 } else if b >= GB {
445 format!("{sign}{:.1}GB", b / GB)
446 } else if b >= MB {
447 format!("{sign}{:.1}MB", b / MB)
448 } else if b >= KB {
449 format!("{sign}{:.1}KB", b / KB)
450 } else {
451 format!("{bytes}B")
452 }
453}
454
455fn format_with_commas(n: u64) -> String {
457 let s = n.to_string();
458 let mut result = String::with_capacity(s.len() + s.len() / 3);
459 for (i, c) in s.chars().enumerate() {
460 if i > 0 && (s.len() - i).is_multiple_of(3) {
461 result.push(',');
462 }
463 result.push(c);
464 }
465 result
466}
467
468fn extract_currency_code(key: &str) -> Option<&str> {
471 let without_cents = key.strip_suffix("_cents")?;
472 let last_underscore = without_cents.rfind('_')?;
473 Some(&without_cents[last_underscore + 1..])
474}
475
476pub fn parse_size(s: &str) -> Option<u64> {
493 let s = s.trim();
494 if s.is_empty() {
495 return None;
496 }
497 let last = *s.as_bytes().last()?;
498 let (num_str, mult) = match last {
499 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
500 b'K' | b'k' => (&s[..s.len() - 1], 1024),
501 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
502 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
503 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
504 b'0'..=b'9' | b'.' => (s, 1),
505 _ => return None,
506 };
507 if num_str.is_empty() {
508 return None;
509 }
510 if let Ok(n) = num_str.parse::<u64>() {
511 return n.checked_mul(mult);
512 }
513 let f: f64 = num_str.parse().ok()?;
514 if f < 0.0 || f.is_nan() || f.is_infinite() {
515 return None;
516 }
517 let result = f * mult as f64;
518 if result > u64::MAX as f64 {
519 return None;
520 }
521 Some(result as u64)
522}
523
524#[cfg(test)]
529mod tests {
530 use super::*;
531 use serde_json::Value;
532
533 const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../spec/fixtures");
534
535 fn load_fixture(name: &str) -> Value {
536 let path = format!("{}/{}", FIXTURES_DIR, name);
537 let data = std::fs::read_to_string(&path)
538 .unwrap_or_else(|e| panic!("failed to read {}: {}", path, e));
539 serde_json::from_str(&data)
540 .unwrap_or_else(|e| panic!("failed to parse {}: {}", path, e))
541 }
542
543 #[test]
544 fn test_plain_fixtures() {
545 let cases = load_fixture("plain.json");
546 for case in cases.as_array().expect("plain.json must be an array") {
547 let name = case["name"].as_str().expect("missing name");
548 let input = &case["input"];
549 let plain = to_plain(input);
550 for expected in case["contains"].as_array().expect("missing contains") {
551 let s = expected.as_str().expect("contains must be strings");
552 assert!(plain.contains(s), "[plain/{name}] expected {s:?} in {plain:?}");
553 }
554 if let Some(not_contains) = case.get("not_contains") {
555 for nc in not_contains.as_array().expect("not_contains must be array") {
556 let s = nc.as_str().expect("not_contains must be strings");
557 assert!(!plain.contains(s), "[plain/{name}] unexpected {s:?} in {plain:?}");
558 }
559 }
560 }
561 }
562
563 #[test]
564 fn test_yaml_fixtures() {
565 let cases = load_fixture("yaml.json");
566 for case in cases.as_array().expect("yaml.json must be an array") {
567 let name = case["name"].as_str().expect("missing name");
568 let input = &case["input"];
569 let yaml = to_yaml(input);
570 if let Some(prefix) = case.get("starts_with") {
571 let s = prefix.as_str().expect("starts_with must be string");
572 assert!(yaml.starts_with(s), "[yaml/{name}] expected starts_with {s:?} in {yaml:?}");
573 }
574 if let Some(contains) = case.get("contains") {
575 for expected in contains.as_array().expect("contains must be array") {
576 let s = expected.as_str().expect("contains must be strings");
577 assert!(yaml.contains(s), "[yaml/{name}] expected {s:?} in {yaml:?}");
578 }
579 }
580 }
581 }
582
583 #[test]
584 fn test_redact_fixtures() {
585 let cases = load_fixture("redact.json");
586 for case in cases.as_array().expect("redact.json must be an array") {
587 let name = case["name"].as_str().expect("missing name");
588 let mut input = case["input"].clone();
589 let expected = &case["expected"];
590 redact_secrets(&mut input);
591 assert_eq!(&input, expected, "[redact/{name}]");
592 }
593 }
594
595 #[test]
596 fn test_protocol_fixtures() {
597 let cases = load_fixture("protocol.json");
598 for case in cases.as_array().expect("protocol.json must be an array") {
599 let name = case["name"].as_str().expect("missing name");
600 let typ = case["type"].as_str().expect("missing type");
601 let args = &case["args"];
602 let result = match typ {
603 "ok" => ok(args["result"].clone()),
604 "ok_trace" => ok_trace(args["result"].clone(), args["trace"].clone()),
605 "error" => error(args["message"].as_str().expect("missing message")),
606 "error_trace" => error_trace(
607 args["message"].as_str().expect("missing message"),
608 args["trace"].clone(),
609 ),
610 "startup" => startup(
611 args["config"].clone(),
612 args["args"].clone(),
613 args["env"].clone(),
614 ),
615 "status" => {
616 let code = args["code"].as_str().expect("missing code");
617 let fields = args["fields"].clone();
618 status(code, fields)
619 }
620 other => panic!("unknown protocol type: {other}"),
621 };
622 if let Some(expected) = case.get("expected") {
623 assert_eq!(&result, expected, "[protocol/{name}]");
624 }
625 if let Some(expected_contains) = case.get("expected_contains") {
626 let ec = expected_contains.as_object().expect("expected_contains must be object");
627 let ro = result.as_object().expect("result must be object");
628 for (k, v) in ec {
629 assert_eq!(ro.get(k).unwrap_or(&Value::Null), v, "[protocol/{name}] key {k}");
630 }
631 }
632 }
633 }
634
635 #[test]
636 fn test_exact_fixtures() {
637 let cases = load_fixture("exact.json");
638 for case in cases.as_array().expect("exact.json must be an array") {
639 let name = case["name"].as_str().expect("missing name");
640 let format = case["format"].as_str().expect("missing format");
641 let input = &case["input"];
642 let expected = case["expected"].as_str().expect("missing expected");
643 let got = match format {
644 "plain" => to_plain(input),
645 "yaml" => to_yaml(input),
646 other => panic!("unknown format: {other}"),
647 };
648 assert_eq!(got, expected, "[exact/{name}]");
649 }
650 }
651
652 #[test]
653 fn test_helper_fixtures() {
654 let cases = load_fixture("helpers.json");
655 for case in cases.as_array().expect("helpers.json must be an array") {
656 let name = case["name"].as_str().expect("missing name");
657 let test_cases = case["cases"].as_array().expect("missing cases");
658 match name {
659 "format_bytes_human" => {
660 for tc in test_cases {
661 let arr = tc.as_array().expect("case must be [input, expected]");
662 let input = arr[0].as_i64().expect("input must be i64");
663 let expected = arr[1].as_str().expect("expected must be string");
664 assert_eq!(format_bytes_human(input), expected, "[helpers/format_bytes_human({input})]");
665 }
666 }
667 "format_with_commas" => {
668 for tc in test_cases {
669 let arr = tc.as_array().expect("case must be [input, expected]");
670 let input = arr[0].as_u64().expect("input must be u64");
671 let expected = arr[1].as_str().expect("expected must be string");
672 assert_eq!(format_with_commas(input), expected, "[helpers/format_with_commas({input})]");
673 }
674 }
675 "extract_currency_code" => {
676 for tc in test_cases {
677 let arr = tc.as_array().expect("case must be [input, expected]");
678 let input = arr[0].as_str().expect("input must be string");
679 let expected = if arr[1].is_null() { None } else { arr[1].as_str() };
680 assert_eq!(extract_currency_code(input), expected, "[helpers/extract_currency_code({input})]");
681 }
682 }
683 "parse_size" => {
684 for tc in test_cases {
685 let arr = tc.as_array().expect("case must be [input, expected]");
686 let input = arr[0].as_str().expect("input must be string");
687 let expected = if arr[1].is_null() { None } else { arr[1].as_u64() };
688 assert_eq!(parse_size(input), expected, "[helpers/parse_size({input:?})]");
689 }
690 }
691 other => panic!("unknown helper: {other}"),
692 }
693 }
694 }
695}