1#[cfg(feature = "tracing")]
14pub mod afdata_tracing;
15
16use serde_json::Value;
17
18pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
24 match trace {
25 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
26 None => serde_json::json!({"code": "ok", "result": result}),
27 }
28}
29
30pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
32 let mut obj = serde_json::Map::new();
33 obj.insert("code".to_string(), Value::String("error".to_string()));
34 obj.insert("error".to_string(), Value::String(message.to_string()));
35 if let Some(h) = hint {
36 obj.insert("hint".to_string(), Value::String(h.to_string()));
37 }
38 if let Some(t) = trace {
39 obj.insert("trace".to_string(), t);
40 }
41 Value::Object(obj)
42}
43
44pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
46 let mut obj = match fields {
47 Value::Object(map) => map,
48 _ => serde_json::Map::new(),
49 };
50 obj.insert("code".to_string(), Value::String(code.to_string()));
51 if let Some(t) = trace {
52 obj.insert("trace".to_string(), t);
53 }
54 Value::Object(obj)
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum RedactionPolicy {
64 RedactionTraceOnly,
66 RedactionNone,
68}
69
70pub fn output_json(value: &Value) -> String {
72 let mut v = value.clone();
73 redact_secrets(&mut v);
74 serialize_json_output(&v)
75}
76
77pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
79 let mut v = value.clone();
80 apply_redaction_policy(&mut v, redaction_policy);
81 serialize_json_output(&v)
82}
83
84fn serialize_json_output(value: &Value) -> String {
85 match serde_json::to_string(value) {
86 Ok(s) => s,
87 Err(err) => serde_json::json!({
88 "error": "output_json_failed",
89 "detail": err.to_string(),
90 })
91 .to_string(),
92 }
93}
94
95pub fn output_yaml(value: &Value) -> String {
97 let mut lines = vec!["---".to_string()];
98 render_yaml_processed(value, 0, &mut lines);
99 lines.join("\n")
100}
101
102pub fn output_plain(value: &Value) -> String {
104 let mut pairs: Vec<(String, String)> = Vec::new();
105 collect_plain_pairs(value, "", &mut pairs);
106 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
107 pairs
108 .into_iter()
109 .map(|(k, v)| {
110 if v.contains(' ') {
111 format!("{}=\"{}\"", k, v)
112 } else {
113 format!("{}={}", k, v)
114 }
115 })
116 .collect::<Vec<_>>()
117 .join(" ")
118}
119
120pub fn internal_redact_secrets(value: &mut Value) {
126 redact_secrets(value);
127}
128
129pub fn parse_size(s: &str) -> Option<u64> {
135 let s = s.trim();
136 if s.is_empty() {
137 return None;
138 }
139 let last = *s.as_bytes().last()?;
140 let (num_str, mult) = match last {
141 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
142 b'K' | b'k' => (&s[..s.len() - 1], 1024),
143 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
144 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
145 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
146 b'0'..=b'9' | b'.' => (s, 1),
147 _ => return None,
148 };
149 if num_str.is_empty() {
150 return None;
151 }
152 if let Ok(n) = num_str.parse::<u64>() {
153 return n.checked_mul(mult);
154 }
155 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
157 return None;
158 }
159 let f: f64 = num_str.parse().ok()?;
160 if f < 0.0 || f.is_nan() || f.is_infinite() {
161 return None;
162 }
163 let result = f * mult as f64;
164 if result >= u64::MAX as f64 {
165 return None;
166 }
167 Some(result as u64)
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub enum OutputFormat {
177 Json,
178 Yaml,
179 Plain,
180}
181
182pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
192 match s {
193 "json" => Ok(OutputFormat::Json),
194 "yaml" => Ok(OutputFormat::Yaml),
195 "plain" => Ok(OutputFormat::Plain),
196 _ => Err(format!(
197 "invalid --output format '{s}': expected json, yaml, or plain"
198 )),
199 }
200}
201
202pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
212 let mut out: Vec<String> = Vec::new();
213 for entry in entries {
214 let s = entry.as_ref().trim().to_ascii_lowercase();
215 if !s.is_empty() && !out.contains(&s) {
216 out.push(s);
217 }
218 }
219 out
220}
221
222pub fn cli_output(value: &Value, format: OutputFormat) -> String {
233 match format {
234 OutputFormat::Json => output_json(value),
235 OutputFormat::Yaml => output_yaml(value),
236 OutputFormat::Plain => output_plain(value),
237 }
238}
239
240pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
252 let mut obj = serde_json::Map::new();
253 obj.insert("code".to_string(), Value::String("error".to_string()));
254 obj.insert(
255 "error_code".to_string(),
256 Value::String("invalid_request".to_string()),
257 );
258 obj.insert("error".to_string(), Value::String(message.to_string()));
259 if let Some(h) = hint {
260 obj.insert("hint".to_string(), Value::String(h.to_string()));
261 }
262 obj.insert("retryable".to_string(), Value::Bool(false));
263 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
264 Value::Object(obj)
265}
266
267#[cfg(feature = "cli-help")]
279pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
280 let target = walk_to_subcommand(cmd, subcommand_path);
281 let mut buf = String::new();
282 render_help_recursive(target, &[], &mut buf);
283 buf
284}
285
286#[cfg(feature = "cli-help-markdown")]
293pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
294 let target = walk_to_subcommand(cmd, subcommand_path);
295 let md = clap_markdown::help_markdown_command(target);
296 md.rfind("\n<hr/>")
298 .map_or(md.clone(), |pos| md[..pos].to_string())
299}
300
301#[cfg(feature = "cli-help")]
302fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
303 let mut current = cmd;
304 for name in path {
305 current = current.find_subcommand(name).unwrap_or(current);
306 }
307 current
308}
309
310#[cfg(feature = "cli-help")]
311fn render_help_recursive(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
312 use std::fmt::Write;
313
314 let mut cmd_path = parent_path.to_vec();
316 cmd_path.push(cmd.get_name());
317 let path_str = cmd_path.join(" ");
318
319 if !buf.is_empty() {
321 let _ = writeln!(buf);
322 let _ = writeln!(buf, "{}", "═".repeat(60));
323 }
324
325 if let Some(about) = cmd.get_about() {
327 let _ = writeln!(buf, "{path_str} — {about}");
328 } else {
329 let _ = writeln!(buf, "{path_str}");
330 }
331 let _ = writeln!(buf);
332
333 let styled = cmd.clone().render_long_help();
335 let _ = write!(buf, "{styled}");
336
337 for sub in cmd.get_subcommands() {
339 if sub.get_name() == "help" {
340 continue; }
342 render_help_recursive(sub, &cmd_path, buf);
343 }
344}
345
346fn redact_secrets(value: &mut Value) {
351 match value {
352 Value::Object(map) => {
353 let keys: Vec<String> = map.keys().cloned().collect();
354 for key in keys {
355 if key.ends_with("_secret") || key.ends_with("_SECRET") {
356 match map.get(&key) {
357 Some(Value::Object(_)) | Some(Value::Array(_)) => {
358 }
360 _ => {
361 map.insert(key.clone(), Value::String("***".into()));
362 continue;
363 }
364 }
365 }
366 if let Some(v) = map.get_mut(&key) {
367 redact_secrets(v);
368 }
369 }
370 }
371 Value::Array(arr) => {
372 for v in arr {
373 redact_secrets(v);
374 }
375 }
376 _ => {}
377 }
378}
379
380fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
381 match redaction_policy {
382 RedactionPolicy::RedactionTraceOnly => {
383 if let Value::Object(map) = value {
384 if let Some(trace) = map.get_mut("trace") {
385 redact_secrets(trace);
386 }
387 }
388 }
389 RedactionPolicy::RedactionNone => {}
390 }
391}
392
393fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
399 if let Some(s) = key.strip_suffix(suffix_lower) {
400 return Some(s.to_string());
401 }
402 let suffix_upper: String = suffix_lower
403 .chars()
404 .map(|c| c.to_ascii_uppercase())
405 .collect();
406 if let Some(s) = key.strip_suffix(&suffix_upper) {
407 return Some(s.to_string());
408 }
409 None
410}
411
412fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
414 let code = extract_currency_code(key)?;
415 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
417 if stripped.is_empty() {
418 return None;
419 }
420 Some((stripped.to_string(), code.to_string()))
421}
422
423fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
426 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
428 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
429 }
430 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
431 return value
432 .as_i64()
433 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
434 }
435 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
436 return value
437 .as_i64()
438 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
439 }
440
441 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
443 return value
444 .as_u64()
445 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
446 }
447 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
448 return value
449 .as_u64()
450 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
451 }
452 if let Some((stripped, code)) = try_strip_generic_cents(key) {
453 return value.as_u64().map(|n| {
454 (
455 stripped,
456 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
457 )
458 });
459 }
460
461 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
463 return value.as_str().map(|s| (stripped, s.to_string()));
464 }
465 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
466 return value
467 .is_number()
468 .then(|| (stripped, format!("{} minutes", number_str(value))));
469 }
470 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
471 return value
472 .is_number()
473 .then(|| (stripped, format!("{} hours", number_str(value))));
474 }
475 if let Some(stripped) = strip_suffix_ci(key, "_days") {
476 return value
477 .is_number()
478 .then(|| (stripped, format!("{} days", number_str(value))));
479 }
480
481 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
483 return value
484 .is_number()
485 .then(|| (stripped, format!("{}msats", number_str(value))));
486 }
487 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
488 return value
489 .is_number()
490 .then(|| (stripped, format!("{}sats", number_str(value))));
491 }
492 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
493 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
494 }
495 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
496 return value
497 .is_number()
498 .then(|| (stripped, format!("{}%", number_str(value))));
499 }
500 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
501 return Some((stripped, "***".to_string()));
502 }
503
504 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
506 return value
507 .is_number()
508 .then(|| (stripped, format!("{} BTC", number_str(value))));
509 }
510 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
511 return value
512 .as_u64()
513 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
514 }
515 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
516 return value
517 .is_number()
518 .then(|| (stripped, format!("{}ns", number_str(value))));
519 }
520 if let Some(stripped) = strip_suffix_ci(key, "_us") {
521 return value
522 .is_number()
523 .then(|| (stripped, format!("{}μs", number_str(value))));
524 }
525 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
526 return format_ms_value(value).map(|v| (stripped, v));
527 }
528 if let Some(stripped) = strip_suffix_ci(key, "_s") {
529 return value
530 .is_number()
531 .then(|| (stripped, format!("{}s", number_str(value))));
532 }
533
534 None
535}
536
537fn process_object_fields<'a>(
539 map: &'a serde_json::Map<String, Value>,
540) -> Vec<(String, &'a Value, Option<String>)> {
541 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
542 for (key, value) in map {
543 match try_process_field(key, value) {
544 Some((stripped, formatted)) => {
545 entries.push((stripped, key.as_str(), value, Some(formatted)));
546 }
547 None => {
548 entries.push((key.clone(), key.as_str(), value, None));
549 }
550 }
551 }
552
553 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
555 for (stripped, _, _, _) in &entries {
556 *counts.entry(stripped.clone()).or_insert(0) += 1;
557 }
558
559 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
561 .into_iter()
562 .map(|(stripped, original, value, formatted)| {
563 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
564 (original.to_string(), value, None)
565 } else {
566 (stripped, value, formatted)
567 }
568 })
569 .collect();
570
571 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
572 result
573}
574
575fn number_str(value: &Value) -> String {
580 match value {
581 Value::Number(n) => n.to_string(),
582 _ => String::new(),
583 }
584}
585
586fn format_ms_as_seconds(ms: f64) -> String {
588 let formatted = format!("{:.3}", ms / 1000.0);
589 let trimmed = formatted.trim_end_matches('0');
590 if trimmed.ends_with('.') {
591 format!("{}0s", trimmed)
592 } else {
593 format!("{}s", trimmed)
594 }
595}
596
597fn format_ms_value(value: &Value) -> Option<String> {
599 let n = value.as_f64()?;
600 if n.abs() >= 1000.0 {
601 Some(format_ms_as_seconds(n))
602 } else if let Some(i) = value.as_i64() {
603 Some(format!("{}ms", i))
604 } else {
605 Some(format!("{}ms", number_str(value)))
606 }
607}
608
609fn format_rfc3339_ms(ms: i64) -> String {
611 use chrono::{DateTime, Utc};
612 let secs = ms.div_euclid(1000);
613 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
614 match DateTime::from_timestamp(secs, nanos) {
615 Some(dt) => dt
616 .with_timezone(&Utc)
617 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
618 None => ms.to_string(),
619 }
620}
621
622fn format_bytes_human(bytes: i64) -> String {
624 const KB: f64 = 1024.0;
625 const MB: f64 = KB * 1024.0;
626 const GB: f64 = MB * 1024.0;
627 const TB: f64 = GB * 1024.0;
628
629 let sign = if bytes < 0 { "-" } else { "" };
630 let b = (bytes as f64).abs();
631 if b >= TB {
632 format!("{sign}{:.1}TB", b / TB)
633 } else if b >= GB {
634 format!("{sign}{:.1}GB", b / GB)
635 } else if b >= MB {
636 format!("{sign}{:.1}MB", b / MB)
637 } else if b >= KB {
638 format!("{sign}{:.1}KB", b / KB)
639 } else {
640 format!("{bytes}B")
641 }
642}
643
644fn format_with_commas(n: u64) -> String {
646 let s = n.to_string();
647 let mut result = String::with_capacity(s.len() + s.len() / 3);
648 for (i, c) in s.chars().enumerate() {
649 if i > 0 && (s.len() - i).is_multiple_of(3) {
650 result.push(',');
651 }
652 result.push(c);
653 }
654 result
655}
656
657fn extract_currency_code(key: &str) -> Option<&str> {
659 let without_cents = key
660 .strip_suffix("_cents")
661 .or_else(|| key.strip_suffix("_CENTS"))?;
662 let last_underscore = without_cents.rfind('_')?;
663 let code = &without_cents[last_underscore + 1..];
664 if code.is_empty() {
665 return None;
666 }
667 Some(code)
668}
669
670fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
675 let prefix = " ".repeat(indent);
676 match value {
677 Value::Object(map) => {
678 let processed = process_object_fields(map);
679 for (display_key, v, formatted) in processed {
680 if let Some(fv) = formatted {
681 lines.push(format!(
682 "{}{}: \"{}\"",
683 prefix,
684 display_key,
685 escape_yaml_str(&fv)
686 ));
687 } else {
688 match v {
689 Value::Object(inner) if !inner.is_empty() => {
690 lines.push(format!("{}{}:", prefix, display_key));
691 render_yaml_processed(v, indent + 1, lines);
692 }
693 Value::Object(_) => {
694 lines.push(format!("{}{}: {{}}", prefix, display_key));
695 }
696 Value::Array(arr) => {
697 if arr.is_empty() {
698 lines.push(format!("{}{}: []", prefix, display_key));
699 } else {
700 lines.push(format!("{}{}:", prefix, display_key));
701 for item in arr {
702 if item.is_object() {
703 lines.push(format!("{} -", prefix));
704 render_yaml_processed(item, indent + 2, lines);
705 } else {
706 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
707 }
708 }
709 }
710 }
711 _ => {
712 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
713 }
714 }
715 }
716 }
717 }
718 _ => {
719 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
720 }
721 }
722}
723
724fn escape_yaml_str(s: &str) -> String {
725 s.replace('\\', "\\\\")
726 .replace('"', "\\\"")
727 .replace('\n', "\\n")
728 .replace('\r', "\\r")
729 .replace('\t', "\\t")
730}
731
732fn yaml_scalar(value: &Value) -> String {
733 match value {
734 Value::String(s) => {
735 format!("\"{}\"", escape_yaml_str(s))
736 }
737 Value::Null => "null".to_string(),
738 Value::Bool(b) => b.to_string(),
739 Value::Number(n) => n.to_string(),
740 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
741 }
742}
743
744fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
749 if let Value::Object(map) = value {
750 let processed = process_object_fields(map);
751 for (display_key, v, formatted) in processed {
752 let full_key = if prefix.is_empty() {
753 display_key
754 } else {
755 format!("{}.{}", prefix, display_key)
756 };
757 if let Some(fv) = formatted {
758 pairs.push((full_key, fv));
759 } else {
760 match v {
761 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
762 Value::Array(arr) => {
763 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
764 pairs.push((full_key, joined));
765 }
766 Value::Null => pairs.push((full_key, String::new())),
767 _ => pairs.push((full_key, plain_scalar(v))),
768 }
769 }
770 }
771 }
772}
773
774fn plain_scalar(value: &Value) -> String {
775 match value {
776 Value::String(s) => s.clone(),
777 Value::Null => "null".to_string(),
778 Value::Bool(b) => b.to_string(),
779 Value::Number(n) => n.to_string(),
780 other => other.to_string(),
781 }
782}
783
784#[cfg(test)]
785mod tests;