1#[cfg(feature = "tracing")]
15pub mod afdata_tracing;
16
17use serde_json::Value;
18
19pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
25 match trace {
26 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
27 None => serde_json::json!({"code": "ok", "result": result}),
28 }
29}
30
31pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
33 let mut obj = serde_json::Map::new();
34 obj.insert("code".to_string(), Value::String("error".to_string()));
35 obj.insert("error".to_string(), Value::String(message.to_string()));
36 if let Some(h) = hint {
37 obj.insert("hint".to_string(), Value::String(h.to_string()));
38 }
39 if let Some(t) = trace {
40 obj.insert("trace".to_string(), t);
41 }
42 Value::Object(obj)
43}
44
45pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
47 let mut obj = match fields {
48 Value::Object(map) => map,
49 _ => serde_json::Map::new(),
50 };
51 obj.insert("code".to_string(), Value::String(code.to_string()));
52 if let Some(t) = trace {
53 obj.insert("trace".to_string(), t);
54 }
55 Value::Object(obj)
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum RedactionPolicy {
65 RedactionTraceOnly,
67 RedactionNone,
69 RedactionStrict,
71}
72
73pub fn output_json(value: &Value) -> String {
75 serialize_json_output(&redacted_value(value))
76}
77
78pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
80 serialize_json_output(&redacted_value_with(value, redaction_policy))
81}
82
83fn serialize_json_output(value: &Value) -> String {
84 match serde_json::to_string(value) {
85 Ok(s) => s,
86 Err(err) => serde_json::json!({
87 "error": "output_json_failed",
88 "detail": err.to_string(),
89 })
90 .to_string(),
91 }
92}
93
94pub fn output_yaml(value: &Value) -> String {
96 let mut lines = vec!["---".to_string()];
97 let v = redacted_value(value);
98 render_yaml_processed(&v, 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 let v = redacted_value(value);
106 collect_plain_pairs(&v, "", &mut pairs);
107 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
108 pairs
109 .into_iter()
110 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
111 .collect::<Vec<_>>()
112 .join(" ")
113}
114
115pub fn internal_redact_secrets(value: &mut Value) {
121 redact_secrets(value);
122}
123
124pub fn redacted_value(value: &Value) -> Value {
126 let mut v = value.clone();
127 redact_secrets(&mut v);
128 v
129}
130
131pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
133 let mut v = value.clone();
134 apply_redaction_policy(&mut v, redaction_policy);
135 v
136}
137
138pub fn parse_size(s: &str) -> Option<u64> {
144 let s = s.trim();
145 if s.is_empty() {
146 return None;
147 }
148 let last = *s.as_bytes().last()?;
149 let (num_str, mult) = match last {
150 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
151 b'K' | b'k' => (&s[..s.len() - 1], 1024),
152 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
153 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
154 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
155 b'0'..=b'9' | b'.' => (s, 1),
156 _ => return None,
157 };
158 if num_str.is_empty() {
159 return None;
160 }
161 if let Ok(n) = num_str.parse::<u64>() {
162 return n.checked_mul(mult);
163 }
164 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
166 return None;
167 }
168 let f: f64 = num_str.parse().ok()?;
169 if f < 0.0 || f.is_nan() || f.is_infinite() {
170 return None;
171 }
172 let result = f * mult as f64;
173 if result >= u64::MAX as f64 {
174 return None;
175 }
176 Some(result as u64)
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
185pub enum OutputFormat {
186 Json,
187 Yaml,
188 Plain,
189}
190
191pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
201 match s {
202 "json" => Ok(OutputFormat::Json),
203 "yaml" => Ok(OutputFormat::Yaml),
204 "plain" => Ok(OutputFormat::Plain),
205 _ => Err(format!(
206 "invalid --output format '{s}': expected json, yaml, or plain"
207 )),
208 }
209}
210
211pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
221 let mut out: Vec<String> = Vec::new();
222 for entry in entries {
223 let s = entry.as_ref().trim().to_ascii_lowercase();
224 if !s.is_empty() && !out.contains(&s) {
225 out.push(s);
226 }
227 }
228 out
229}
230
231pub fn cli_output(value: &Value, format: OutputFormat) -> String {
242 match format {
243 OutputFormat::Json => output_json(value),
244 OutputFormat::Yaml => output_yaml(value),
245 OutputFormat::Plain => output_plain(value),
246 }
247}
248
249pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
261 let mut obj = serde_json::Map::new();
262 obj.insert("code".to_string(), Value::String("error".to_string()));
263 obj.insert(
264 "error_code".to_string(),
265 Value::String("invalid_request".to_string()),
266 );
267 obj.insert("error".to_string(), Value::String(message.to_string()));
268 if let Some(h) = hint {
269 obj.insert("hint".to_string(), Value::String(h.to_string()));
270 }
271 obj.insert("retryable".to_string(), Value::Bool(false));
272 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
273 Value::Object(obj)
274}
275
276#[cfg(feature = "cli-help")]
288pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
289 let target = walk_to_subcommand(cmd, subcommand_path);
290 let mut buf = String::new();
291 render_help_recursive(target, &[], &mut buf, true);
292 buf
293}
294
295#[cfg(feature = "cli-help-markdown")]
302pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
303 let target = walk_to_subcommand(cmd, subcommand_path);
304 let md = clap_markdown::help_markdown_command(target);
305 md.rfind("\n<hr/>")
307 .map_or(md.clone(), |pos| md[..pos].to_string())
308}
309
310#[cfg(feature = "cli-help")]
311fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
312 let mut current = cmd;
313 for name in path {
314 current = current.find_subcommand(name).unwrap_or(current);
315 }
316 current
317}
318
319#[cfg(feature = "cli-help")]
320fn render_help_recursive(
321 cmd: &clap::Command,
322 parent_path: &[&str],
323 buf: &mut String,
324 is_root: bool,
325) {
326 use std::fmt::Write;
327
328 let mut cmd_path = parent_path.to_vec();
330 cmd_path.push(cmd.get_name());
331 let path_str = cmd_path.join(" ");
332
333 if !buf.is_empty() {
335 let _ = writeln!(buf);
336 let _ = writeln!(buf, "{}", "═".repeat(60));
337 }
338
339 if let Some(about) = cmd.get_about() {
341 let _ = writeln!(buf, "{path_str} — {about}");
342 } else {
343 let _ = writeln!(buf, "{path_str}");
344 }
345 let _ = writeln!(buf);
346
347 let styled = cmd.clone().render_long_help();
349 let help_text = styled.to_string();
350
351 if is_root {
353 let mut found_help = false;
354 for line in help_text.lines() {
355 let _ = writeln!(buf, "{line}");
356 if line.trim_start().starts_with("-h, --help") {
357 found_help = true;
358 } else if found_help && line.contains("Print help") {
359 let _ = writeln!(buf, " --help-markdown");
360 let _ = writeln!(
361 buf,
362 " Output help as Markdown (for documentation generation)"
363 );
364 found_help = false;
365 } else {
366 found_help = false;
367 }
368 }
369 } else {
370 let _ = write!(buf, "{help_text}");
371 }
372
373 for sub in cmd.get_subcommands() {
375 if sub.get_name() == "help" {
376 continue; }
378 render_help_recursive(sub, &cmd_path, buf, false);
379 }
380}
381
382fn redact_secrets(value: &mut Value) {
387 match value {
388 Value::Object(map) => {
389 let keys: Vec<String> = map.keys().cloned().collect();
390 for key in keys {
391 if key.ends_with("_secret") || key.ends_with("_SECRET") {
392 match map.get(&key) {
393 Some(Value::Object(_)) | Some(Value::Array(_)) => {
394 }
396 _ => {
397 map.insert(key.clone(), Value::String("***".into()));
398 continue;
399 }
400 }
401 }
402 if let Some(v) = map.get_mut(&key) {
403 redact_secrets(v);
404 }
405 }
406 }
407 Value::Array(arr) => {
408 for v in arr {
409 redact_secrets(v);
410 }
411 }
412 _ => {}
413 }
414}
415
416fn redact_secrets_strict(value: &mut Value) {
417 match value {
418 Value::Object(map) => {
419 let keys: Vec<String> = map.keys().cloned().collect();
420 for key in keys {
421 if key.ends_with("_secret") || key.ends_with("_SECRET") {
422 map.insert(key, Value::String("***".into()));
423 } else if let Some(v) = map.get_mut(&key) {
424 redact_secrets_strict(v);
425 }
426 }
427 }
428 Value::Array(arr) => {
429 for v in arr {
430 redact_secrets_strict(v);
431 }
432 }
433 _ => {}
434 }
435}
436
437fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
438 match redaction_policy {
439 RedactionPolicy::RedactionTraceOnly => {
440 if let Value::Object(map) = value {
441 if let Some(trace) = map.get_mut("trace") {
442 redact_secrets(trace);
443 }
444 }
445 }
446 RedactionPolicy::RedactionNone => {}
447 RedactionPolicy::RedactionStrict => redact_secrets_strict(value),
448 }
449}
450
451fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
457 if let Some(s) = key.strip_suffix(suffix_lower) {
458 return Some(s.to_string());
459 }
460 let suffix_upper: String = suffix_lower
461 .chars()
462 .map(|c| c.to_ascii_uppercase())
463 .collect();
464 if let Some(s) = key.strip_suffix(&suffix_upper) {
465 return Some(s.to_string());
466 }
467 None
468}
469
470fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
472 let code = extract_currency_code(key)?;
473 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
475 if stripped.is_empty() {
476 return None;
477 }
478 Some((stripped.to_string(), code.to_string()))
479}
480
481fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
484 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
486 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
487 }
488 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
489 return value
490 .as_i64()
491 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
492 }
493 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
494 return value
495 .as_i64()
496 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
497 }
498
499 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
501 return value
502 .as_u64()
503 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
504 }
505 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
506 return value
507 .as_u64()
508 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
509 }
510 if let Some((stripped, code)) = try_strip_generic_cents(key) {
511 return value.as_u64().map(|n| {
512 (
513 stripped,
514 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
515 )
516 });
517 }
518
519 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
521 return value.as_str().map(|s| (stripped, s.to_string()));
522 }
523 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
524 return value
525 .is_number()
526 .then(|| (stripped, format!("{} minutes", number_str(value))));
527 }
528 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
529 return value
530 .is_number()
531 .then(|| (stripped, format!("{} hours", number_str(value))));
532 }
533 if let Some(stripped) = strip_suffix_ci(key, "_days") {
534 return value
535 .is_number()
536 .then(|| (stripped, format!("{} days", number_str(value))));
537 }
538
539 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
541 return value
542 .is_number()
543 .then(|| (stripped, format!("{}msats", number_str(value))));
544 }
545 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
546 return value
547 .is_number()
548 .then(|| (stripped, format!("{}sats", number_str(value))));
549 }
550 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
551 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
552 }
553 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
554 return value
555 .is_number()
556 .then(|| (stripped, format!("{}%", number_str(value))));
557 }
558 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
559 return Some((stripped, "***".to_string()));
560 }
561
562 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
564 return value
565 .is_number()
566 .then(|| (stripped, format!("{} BTC", number_str(value))));
567 }
568 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
569 return value
570 .as_u64()
571 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
572 }
573 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
574 return value
575 .is_number()
576 .then(|| (stripped, format!("{}ns", number_str(value))));
577 }
578 if let Some(stripped) = strip_suffix_ci(key, "_us") {
579 return value
580 .is_number()
581 .then(|| (stripped, format!("{}μs", number_str(value))));
582 }
583 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
584 return format_ms_value(value).map(|v| (stripped, v));
585 }
586 if let Some(stripped) = strip_suffix_ci(key, "_s") {
587 return value
588 .is_number()
589 .then(|| (stripped, format!("{}s", number_str(value))));
590 }
591
592 None
593}
594
595fn process_object_fields<'a>(
597 map: &'a serde_json::Map<String, Value>,
598) -> Vec<(String, &'a Value, Option<String>)> {
599 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
600 for (key, value) in map {
601 match try_process_field(key, value) {
602 Some((stripped, formatted)) => {
603 entries.push((stripped, key.as_str(), value, Some(formatted)));
604 }
605 None => {
606 entries.push((key.clone(), key.as_str(), value, None));
607 }
608 }
609 }
610
611 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
613 for (stripped, _, _, _) in &entries {
614 *counts.entry(stripped.clone()).or_insert(0) += 1;
615 }
616
617 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
619 .into_iter()
620 .map(|(stripped, original, value, formatted)| {
621 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
622 (original.to_string(), value, None)
623 } else {
624 (stripped, value, formatted)
625 }
626 })
627 .collect();
628
629 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
630 result
631}
632
633fn number_str(value: &Value) -> String {
638 match value {
639 Value::Number(n) => n.to_string(),
640 _ => String::new(),
641 }
642}
643
644fn format_ms_as_seconds(ms: f64) -> String {
646 let formatted = format!("{:.3}", ms / 1000.0);
647 let trimmed = formatted.trim_end_matches('0');
648 if trimmed.ends_with('.') {
649 format!("{}0s", trimmed)
650 } else {
651 format!("{}s", trimmed)
652 }
653}
654
655fn format_ms_value(value: &Value) -> Option<String> {
657 let n = value.as_f64()?;
658 if n.abs() >= 1000.0 {
659 Some(format_ms_as_seconds(n))
660 } else if let Some(i) = value.as_i64() {
661 Some(format!("{}ms", i))
662 } else {
663 Some(format!("{}ms", number_str(value)))
664 }
665}
666
667fn format_rfc3339_ms(ms: i64) -> String {
669 use chrono::{DateTime, Utc};
670 let secs = ms.div_euclid(1000);
671 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
672 match DateTime::from_timestamp(secs, nanos) {
673 Some(dt) => dt
674 .with_timezone(&Utc)
675 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
676 None => ms.to_string(),
677 }
678}
679
680fn format_bytes_human(bytes: i64) -> String {
682 const KB: f64 = 1024.0;
683 const MB: f64 = KB * 1024.0;
684 const GB: f64 = MB * 1024.0;
685 const TB: f64 = GB * 1024.0;
686
687 let sign = if bytes < 0 { "-" } else { "" };
688 let b = (bytes as f64).abs();
689 if b >= TB {
690 format!("{sign}{:.1}TB", b / TB)
691 } else if b >= GB {
692 format!("{sign}{:.1}GB", b / GB)
693 } else if b >= MB {
694 format!("{sign}{:.1}MB", b / MB)
695 } else if b >= KB {
696 format!("{sign}{:.1}KB", b / KB)
697 } else {
698 format!("{bytes}B")
699 }
700}
701
702fn format_with_commas(n: u64) -> String {
704 let s = n.to_string();
705 let mut result = String::with_capacity(s.len() + s.len() / 3);
706 for (i, c) in s.chars().enumerate() {
707 if i > 0 && (s.len() - i).is_multiple_of(3) {
708 result.push(',');
709 }
710 result.push(c);
711 }
712 result
713}
714
715fn extract_currency_code(key: &str) -> Option<&str> {
717 let without_cents = key
718 .strip_suffix("_cents")
719 .or_else(|| key.strip_suffix("_CENTS"))?;
720 let last_underscore = without_cents.rfind('_')?;
721 let code = &without_cents[last_underscore + 1..];
722 if code.is_empty() {
723 return None;
724 }
725 Some(code)
726}
727
728fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
733 let prefix = " ".repeat(indent);
734 match value {
735 Value::Object(map) => {
736 let processed = process_object_fields(map);
737 for (display_key, v, formatted) in processed {
738 if let Some(fv) = formatted {
739 lines.push(format!(
740 "{}{}: \"{}\"",
741 prefix,
742 display_key,
743 escape_yaml_str(&fv)
744 ));
745 } else {
746 match v {
747 Value::Object(inner) if !inner.is_empty() => {
748 lines.push(format!("{}{}:", prefix, display_key));
749 render_yaml_processed(v, indent + 1, lines);
750 }
751 Value::Object(_) => {
752 lines.push(format!("{}{}: {{}}", prefix, display_key));
753 }
754 Value::Array(arr) => {
755 if arr.is_empty() {
756 lines.push(format!("{}{}: []", prefix, display_key));
757 } else {
758 lines.push(format!("{}{}:", prefix, display_key));
759 for item in arr {
760 if item.is_object() {
761 lines.push(format!("{} -", prefix));
762 render_yaml_processed(item, indent + 2, lines);
763 } else {
764 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
765 }
766 }
767 }
768 }
769 _ => {
770 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
771 }
772 }
773 }
774 }
775 }
776 _ => {
777 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
778 }
779 }
780}
781
782fn escape_yaml_str(s: &str) -> String {
783 s.replace('\\', "\\\\")
784 .replace('"', "\\\"")
785 .replace('\n', "\\n")
786 .replace('\r', "\\r")
787 .replace('\t', "\\t")
788}
789
790fn yaml_scalar(value: &Value) -> String {
791 match value {
792 Value::String(s) => {
793 format!("\"{}\"", escape_yaml_str(s))
794 }
795 Value::Null => "null".to_string(),
796 Value::Bool(b) => b.to_string(),
797 Value::Number(n) => n.to_string(),
798 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
799 }
800}
801
802fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
807 if let Value::Object(map) = value {
808 let processed = process_object_fields(map);
809 for (display_key, v, formatted) in processed {
810 let full_key = if prefix.is_empty() {
811 display_key
812 } else {
813 format!("{}.{}", prefix, display_key)
814 };
815 if let Some(fv) = formatted {
816 pairs.push((full_key, fv));
817 } else {
818 match v {
819 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
820 Value::Array(arr) => {
821 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
822 pairs.push((full_key, joined));
823 }
824 Value::Null => pairs.push((full_key, String::new())),
825 _ => pairs.push((full_key, plain_scalar(v))),
826 }
827 }
828 }
829 }
830}
831
832fn plain_scalar(value: &Value) -> String {
833 match value {
834 Value::String(s) => s.clone(),
835 Value::Null => "null".to_string(),
836 Value::Bool(b) => b.to_string(),
837 Value::Number(n) => n.to_string(),
838 other => other.to_string(),
839 }
840}
841
842fn quote_logfmt_value(value: &str) -> String {
843 if value.is_empty() {
844 return String::new();
845 }
846 if !value
847 .chars()
848 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
849 {
850 return value.to_string();
851 }
852 let escaped = value
853 .replace('\\', "\\\\")
854 .replace('"', "\\\"")
855 .replace('\n', "\\n")
856 .replace('\r', "\\r")
857 .replace('\t', "\\t");
858 format!("\"{}\"", escaped)
859}
860
861#[cfg(test)]
862mod tests;