1#[cfg(feature = "tracing")]
21pub mod afdata_tracing;
22
23use serde_json::Value;
24use std::collections::HashSet;
25
26pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
32 match trace {
33 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
34 None => serde_json::json!({"code": "ok", "result": result}),
35 }
36}
37
38pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
40 let mut obj = serde_json::Map::new();
41 obj.insert("code".to_string(), Value::String("error".to_string()));
42 obj.insert("error".to_string(), Value::String(message.to_string()));
43 if let Some(h) = hint {
44 obj.insert("hint".to_string(), Value::String(h.to_string()));
45 }
46 if let Some(t) = trace {
47 obj.insert("trace".to_string(), t);
48 }
49 Value::Object(obj)
50}
51
52pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
54 let mut obj = match fields {
55 Value::Object(map) => map,
56 _ => serde_json::Map::new(),
57 };
58 obj.insert("code".to_string(), Value::String(code.to_string()));
59 if let Some(t) = trace {
60 obj.insert("trace".to_string(), t);
61 }
62 Value::Object(obj)
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
71pub enum RedactionPolicy {
72 RedactionTraceOnly,
74 RedactionNone,
76 RedactionStrict,
78}
79
80#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct RedactionOptions {
83 pub policy: Option<RedactionPolicy>,
85 pub secret_names: Vec<String>,
91}
92
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
95pub enum OutputStyle {
96 #[default]
98 Readable,
99 Raw,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Eq)]
105pub struct OutputOptions {
106 pub redaction: RedactionOptions,
108 pub style: OutputStyle,
110}
111
112pub fn output_json(value: &Value) -> String {
114 serialize_json_output(&redacted_value(value))
115}
116
117pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
119 serialize_json_output(&redacted_value_with(value, redaction_policy))
120}
121
122pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
127 serialize_json_output(&redacted_value_with_options(
128 value,
129 &output_options.redaction,
130 ))
131}
132
133fn serialize_json_output(value: &Value) -> String {
134 match serde_json::to_string(value) {
135 Ok(s) => s,
136 Err(err) => serde_json::json!({
137 "error": "output_json_failed",
138 "detail": err.to_string(),
139 })
140 .to_string(),
141 }
142}
143
144pub fn output_yaml(value: &Value) -> String {
146 output_yaml_with_options(value, &OutputOptions::default())
147}
148
149pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
151 let mut lines = vec!["---".to_string()];
152 let v = redacted_value_with_options(value, &output_options.redaction);
153 match output_options.style {
154 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
155 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
156 }
157 lines.join("\n")
158}
159
160pub fn output_plain(value: &Value) -> String {
162 output_plain_with_options(value, &OutputOptions::default())
163}
164
165pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
167 let mut pairs: Vec<(String, String)> = Vec::new();
168 let v = redacted_value_with_options(value, &output_options.redaction);
169 match output_options.style {
170 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
171 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
172 }
173 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
174 pairs
175 .into_iter()
176 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
177 .collect::<Vec<_>>()
178 .join(" ")
179}
180
181pub fn internal_redact_secrets(value: &mut Value) {
187 redact_secrets(value);
188}
189
190pub fn internal_redact_secrets_with_options(
192 value: &mut Value,
193 redaction_options: &RedactionOptions,
194) {
195 apply_redaction_options(value, redaction_options);
196}
197
198pub fn redacted_value(value: &Value) -> Value {
200 let mut v = value.clone();
201 redact_secrets(&mut v);
202 v
203}
204
205pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
207 let mut v = value.clone();
208 apply_redaction_policy(&mut v, redaction_policy);
209 v
210}
211
212pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
214 let mut v = value.clone();
215 apply_redaction_options(&mut v, redaction_options);
216 v
217}
218
219pub fn redact_url_secrets(url: &str) -> String {
224 redact_url_secrets_with_options(url, &RedactionOptions::default())
225}
226
227pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
237 let context = RedactionContext::from_options(redaction_options);
238 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
239}
240
241pub fn parse_size(s: &str) -> Option<u64> {
247 let s = s.trim();
248 if s.is_empty() {
249 return None;
250 }
251 let last = *s.as_bytes().last()?;
252 let (num_str, mult) = match last {
253 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
254 b'K' | b'k' => (&s[..s.len() - 1], 1024),
255 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
256 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
257 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
258 b'0'..=b'9' | b'.' => (s, 1),
259 _ => return None,
260 };
261 if num_str.is_empty() {
262 return None;
263 }
264 if let Ok(n) = num_str.parse::<u64>() {
265 return n.checked_mul(mult);
266 }
267 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
269 return None;
270 }
271 let f: f64 = num_str.parse().ok()?;
272 if f < 0.0 || f.is_nan() || f.is_infinite() {
273 return None;
274 }
275 let result = f * mult as f64;
276 if result >= u64::MAX as f64 {
277 return None;
278 }
279 Some(result as u64)
280}
281
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
288pub enum OutputFormat {
289 Json,
290 Yaml,
291 Plain,
292}
293
294pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
304 match s {
305 "json" => Ok(OutputFormat::Json),
306 "yaml" => Ok(OutputFormat::Yaml),
307 "plain" => Ok(OutputFormat::Plain),
308 _ => Err(format!(
309 "invalid --output format '{s}': expected json, yaml, or plain"
310 )),
311 }
312}
313
314pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
324 let mut out: Vec<String> = Vec::new();
325 for entry in entries {
326 let s = entry.as_ref().trim().to_ascii_lowercase();
327 if !s.is_empty() && !out.contains(&s) {
328 out.push(s);
329 }
330 }
331 out
332}
333
334pub fn cli_output(value: &Value, format: OutputFormat) -> String {
345 match format {
346 OutputFormat::Json => output_json(value),
347 OutputFormat::Yaml => output_yaml(value),
348 OutputFormat::Plain => output_plain(value),
349 }
350}
351
352pub fn cli_output_with_options(
357 value: &Value,
358 format: OutputFormat,
359 output_options: &OutputOptions,
360) -> String {
361 match format {
362 OutputFormat::Json => output_json_with_options(value, output_options),
363 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
364 OutputFormat::Plain => output_plain_with_options(value, output_options),
365 }
366}
367
368pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
380 let mut obj = serde_json::Map::new();
381 obj.insert("code".to_string(), Value::String("error".to_string()));
382 obj.insert(
383 "error_code".to_string(),
384 Value::String("invalid_request".to_string()),
385 );
386 obj.insert("error".to_string(), Value::String(message.to_string()));
387 if let Some(h) = hint {
388 obj.insert("hint".to_string(), Value::String(h.to_string()));
389 }
390 obj.insert("retryable".to_string(), Value::Bool(false));
391 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
392 Value::Object(obj)
393}
394
395#[cfg(feature = "cli-help")]
407pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
408 let target = walk_to_subcommand(cmd, subcommand_path);
409 let mut buf = String::new();
410 render_help_recursive(target, &[], &mut buf, true);
411 buf
412}
413
414#[cfg(feature = "cli-help-markdown")]
421pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
422 let target = walk_to_subcommand(cmd, subcommand_path);
423 let md = clap_markdown::help_markdown_command(target);
424 md.rfind("\n<hr/>")
426 .map_or(md.clone(), |pos| md[..pos].to_string())
427}
428
429#[cfg(feature = "cli-help")]
430fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
431 let mut current = cmd;
432 for name in path {
433 current = current.find_subcommand(name).unwrap_or(current);
434 }
435 current
436}
437
438#[cfg(feature = "cli-help")]
439fn render_help_recursive(
440 cmd: &clap::Command,
441 parent_path: &[&str],
442 buf: &mut String,
443 is_root: bool,
444) {
445 use std::fmt::Write;
446
447 let mut cmd_path = parent_path.to_vec();
449 cmd_path.push(cmd.get_name());
450 let path_str = cmd_path.join(" ");
451
452 if !buf.is_empty() {
454 let _ = writeln!(buf);
455 let _ = writeln!(buf, "{}", "═".repeat(60));
456 }
457
458 if let Some(about) = cmd.get_about() {
460 let _ = writeln!(buf, "{path_str} — {about}");
461 } else {
462 let _ = writeln!(buf, "{path_str}");
463 }
464 let _ = writeln!(buf);
465
466 let styled = cmd.clone().render_long_help();
468 let help_text = styled.to_string();
469
470 if is_root {
472 let mut found_help = false;
473 for line in help_text.lines() {
474 let _ = writeln!(buf, "{line}");
475 if line.trim_start().starts_with("-h, --help") {
476 found_help = true;
477 } else if found_help && line.contains("Print help") {
478 let _ = writeln!(buf, " --help-markdown");
479 let _ = writeln!(
480 buf,
481 " Output help as Markdown (for documentation generation)"
482 );
483 found_help = false;
484 } else {
485 found_help = false;
486 }
487 }
488 } else {
489 let _ = write!(buf, "{help_text}");
490 }
491
492 for sub in cmd.get_subcommands() {
494 if sub.get_name() == "help" {
495 continue; }
497 render_help_recursive(sub, &cmd_path, buf, false);
498 }
499}
500
501#[derive(Default)]
506struct RedactionContext {
507 secret_names: HashSet<String>,
508}
509
510impl RedactionContext {
511 fn from_options(redaction_options: &RedactionOptions) -> Self {
512 let secret_names = redaction_options.secret_names.iter().cloned().collect();
513 Self { secret_names }
514 }
515
516 fn is_secret_key(&self, key: &str) -> bool {
517 key_has_secret_suffix(key) || self.secret_names.contains(key)
518 }
519}
520
521fn key_has_secret_suffix(key: &str) -> bool {
522 key.ends_with("_secret") || key.ends_with("_SECRET")
523}
524
525fn key_has_url_suffix(key: &str) -> bool {
526 key.ends_with("_url") || key.ends_with("_URL")
527}
528
529fn redact_secrets(value: &mut Value) {
530 let context = RedactionContext::default();
531 redact_secrets_with_context(value, &context);
532}
533
534fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
535 match value {
536 Value::Object(map) => {
537 let keys: Vec<String> = map.keys().cloned().collect();
538 for key in keys {
539 if context.is_secret_key(&key) {
540 match map.get(&key) {
541 Some(Value::Object(_)) | Some(Value::Array(_)) => {
542 }
544 _ => {
545 map.insert(key.clone(), Value::String("***".into()));
546 continue;
547 }
548 }
549 } else if key_has_url_suffix(&key) {
550 if let Some(Value::String(s)) = map.get_mut(&key) {
551 if let Some(redacted) = redact_url_in_str(s, context) {
552 *s = redacted;
553 }
554 continue;
555 }
556 }
557 if let Some(v) = map.get_mut(&key) {
558 redact_secrets_with_context(v, context);
559 }
560 }
561 }
562 Value::Array(arr) => {
563 for v in arr {
564 redact_secrets_with_context(v, context);
565 }
566 }
567 _ => {}
568 }
569}
570
571fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
572 match value {
573 Value::Object(map) => {
574 let keys: Vec<String> = map.keys().cloned().collect();
575 for key in keys {
576 if context.is_secret_key(&key) {
577 map.insert(key, Value::String("***".into()));
578 } else if key_has_url_suffix(&key) {
579 if let Some(Value::String(s)) = map.get_mut(&key) {
580 if let Some(redacted) = redact_url_in_str(s, context) {
581 *s = redacted;
582 }
583 } else if let Some(v) = map.get_mut(&key) {
584 redact_secrets_strict_with_context(v, context);
585 }
586 } else if let Some(v) = map.get_mut(&key) {
587 redact_secrets_strict_with_context(v, context);
588 }
589 }
590 }
591 Value::Array(arr) => {
592 for v in arr {
593 redact_secrets_strict_with_context(v, context);
594 }
595 }
596 _ => {}
597 }
598}
599
600fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
604 if !s.contains("://") || !is_single_url(s) || url::Url::parse(s).is_err() {
606 return None;
607 }
608 let scheme_sep = s.find("://")?;
609 let scheme = &s[..scheme_sep];
610 let rest = &s[scheme_sep + 3..];
611
612 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
614 let authority = &rest[..auth_end];
615 let remainder = &rest[auth_end..];
616
617 let new_authority = redact_userinfo_password(authority);
618
619 let new_remainder = match remainder.find('?') {
621 Some(q) => {
622 let (path, q_onwards) = remainder.split_at(q);
623 let query_body = &q_onwards[1..];
624 let (query, fragment) = match query_body.find('#') {
625 Some(h) => (&query_body[..h], &query_body[h..]),
626 None => (query_body, ""),
627 };
628 format!("{path}?{}{fragment}", redact_query(query, context))
629 }
630 None => remainder.to_string(),
631 };
632
633 Some(format!("{scheme}://{new_authority}{new_remainder}"))
634}
635
636fn redact_userinfo_password(authority: &str) -> String {
639 let Some(at) = authority.find('@') else {
640 return authority.to_string();
641 };
642 let userinfo = &authority[..at];
643 match userinfo.find(':') {
644 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
645 None => authority.to_string(),
646 }
647}
648
649fn redact_query(query: &str, context: &RedactionContext) -> String {
652 query
653 .split('&')
654 .map(|segment| {
655 let Some(eq) = segment.find('=') else {
656 return segment.to_string();
657 };
658 let raw_key = &segment[..eq];
659 let name = url::form_urlencoded::parse(segment.as_bytes())
661 .next()
662 .map(|(k, _)| k.into_owned())
663 .unwrap_or_default();
664 if context.is_secret_key(&name) {
665 format!("{raw_key}=***")
666 } else {
667 segment.to_string()
668 }
669 })
670 .collect::<Vec<_>>()
671 .join("&")
672}
673
674fn is_single_url(s: &str) -> bool {
678 if s.bytes().any(|b| b.is_ascii_whitespace()) {
679 return false;
680 }
681 let bytes = s.as_bytes();
682 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
683 return false;
684 }
685 let mut i = 1;
686 while i < bytes.len() {
687 let c = bytes[i];
688 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
689 i += 1;
690 } else {
691 break;
692 }
693 }
694 s[i..].starts_with("://")
695}
696
697fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
698 let context = RedactionContext::default();
699 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
700}
701
702fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
703 let context = RedactionContext::from_options(redaction_options);
704 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
705}
706
707fn apply_redaction_policy_with_context(
708 value: &mut Value,
709 redaction_policy: Option<RedactionPolicy>,
710 context: &RedactionContext,
711) {
712 match redaction_policy {
713 Some(RedactionPolicy::RedactionTraceOnly) => {
714 if let Value::Object(map) = value {
715 if let Some(trace) = map.get_mut("trace") {
716 redact_secrets_with_context(trace, context);
717 }
718 }
719 }
720 Some(RedactionPolicy::RedactionNone) => {}
721 Some(RedactionPolicy::RedactionStrict) => {
722 redact_secrets_strict_with_context(value, context)
723 }
724 None => redact_secrets_with_context(value, context),
725 }
726}
727
728fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
734 if let Some(s) = key.strip_suffix(suffix_lower) {
735 return Some(s.to_string());
736 }
737 let suffix_upper: String = suffix_lower
738 .chars()
739 .map(|c| c.to_ascii_uppercase())
740 .collect();
741 if let Some(s) = key.strip_suffix(&suffix_upper) {
742 return Some(s.to_string());
743 }
744 None
745}
746
747fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
749 let code = extract_currency_code(key)?;
750 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
752 if stripped.is_empty() {
753 return None;
754 }
755 Some((stripped.to_string(), code.to_string()))
756}
757
758fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
761 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
763 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
764 }
765 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
766 return value
767 .as_i64()
768 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
769 }
770 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
771 return value
772 .as_i64()
773 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
774 }
775
776 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
778 return value
779 .as_u64()
780 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
781 }
782 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
783 return value
784 .as_u64()
785 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
786 }
787 if let Some((stripped, code)) = try_strip_generic_cents(key) {
788 return value.as_u64().map(|n| {
789 (
790 stripped,
791 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
792 )
793 });
794 }
795
796 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
798 return value.as_str().map(|s| (stripped, s.to_string()));
799 }
800 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
801 return value
802 .is_number()
803 .then(|| (stripped, format!("{} minutes", number_str(value))));
804 }
805 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
806 return value
807 .is_number()
808 .then(|| (stripped, format!("{} hours", number_str(value))));
809 }
810 if let Some(stripped) = strip_suffix_ci(key, "_days") {
811 return value
812 .is_number()
813 .then(|| (stripped, format!("{} days", number_str(value))));
814 }
815
816 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
818 return value
819 .is_number()
820 .then(|| (stripped, format!("{}msats", number_str(value))));
821 }
822 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
823 return value
824 .is_number()
825 .then(|| (stripped, format!("{}sats", number_str(value))));
826 }
827 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
828 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
829 }
830 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
831 return value
832 .is_number()
833 .then(|| (stripped, format!("{}%", number_str(value))));
834 }
835 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
836 return Some((stripped, "***".to_string()));
837 }
838
839 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
841 return value
842 .is_number()
843 .then(|| (stripped, format!("{} BTC", number_str(value))));
844 }
845 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
846 return value
847 .as_u64()
848 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
849 }
850 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
851 return value
852 .is_number()
853 .then(|| (stripped, format!("{}ns", number_str(value))));
854 }
855 if let Some(stripped) = strip_suffix_ci(key, "_us") {
856 return value
857 .is_number()
858 .then(|| (stripped, format!("{}μs", number_str(value))));
859 }
860 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
861 return format_ms_value(value).map(|v| (stripped, v));
862 }
863 if let Some(stripped) = strip_suffix_ci(key, "_s") {
864 return value
865 .is_number()
866 .then(|| (stripped, format!("{}s", number_str(value))));
867 }
868
869 None
870}
871
872fn process_object_fields<'a>(
874 map: &'a serde_json::Map<String, Value>,
875) -> Vec<(String, &'a Value, Option<String>)> {
876 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
877 for (key, value) in map {
878 match try_process_field(key, value) {
879 Some((stripped, formatted)) => {
880 entries.push((stripped, key.as_str(), value, Some(formatted)));
881 }
882 None => {
883 entries.push((key.clone(), key.as_str(), value, None));
884 }
885 }
886 }
887
888 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
890 for (stripped, _, _, _) in &entries {
891 *counts.entry(stripped.clone()).or_insert(0) += 1;
892 }
893
894 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
896 .into_iter()
897 .map(|(stripped, original, value, formatted)| {
898 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
899 (original.to_string(), value, None)
900 } else {
901 (stripped, value, formatted)
902 }
903 })
904 .collect();
905
906 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
907 result
908}
909
910fn number_str(value: &Value) -> String {
915 match value {
916 Value::Number(n) => n.to_string(),
917 _ => String::new(),
918 }
919}
920
921fn format_ms_as_seconds(ms: f64) -> String {
923 let formatted = format!("{:.3}", ms / 1000.0);
924 let trimmed = formatted.trim_end_matches('0');
925 if trimmed.ends_with('.') {
926 format!("{}0s", trimmed)
927 } else {
928 format!("{}s", trimmed)
929 }
930}
931
932fn format_ms_value(value: &Value) -> Option<String> {
934 let n = value.as_f64()?;
935 if n.abs() >= 1000.0 {
936 Some(format_ms_as_seconds(n))
937 } else if let Some(i) = value.as_i64() {
938 Some(format!("{}ms", i))
939 } else {
940 Some(format!("{}ms", number_str(value)))
941 }
942}
943
944fn format_rfc3339_ms(ms: i64) -> String {
946 use chrono::{DateTime, Utc};
947 let secs = ms.div_euclid(1000);
948 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
949 match DateTime::from_timestamp(secs, nanos) {
950 Some(dt) => dt
951 .with_timezone(&Utc)
952 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
953 None => ms.to_string(),
954 }
955}
956
957fn format_bytes_human(bytes: i64) -> String {
959 const KB: f64 = 1024.0;
960 const MB: f64 = KB * 1024.0;
961 const GB: f64 = MB * 1024.0;
962 const TB: f64 = GB * 1024.0;
963
964 let sign = if bytes < 0 { "-" } else { "" };
965 let b = (bytes as f64).abs();
966 if b >= TB {
967 format!("{sign}{:.1}TB", b / TB)
968 } else if b >= GB {
969 format!("{sign}{:.1}GB", b / GB)
970 } else if b >= MB {
971 format!("{sign}{:.1}MB", b / MB)
972 } else if b >= KB {
973 format!("{sign}{:.1}KB", b / KB)
974 } else {
975 format!("{bytes}B")
976 }
977}
978
979fn format_with_commas(n: u64) -> String {
981 let s = n.to_string();
982 let mut result = String::with_capacity(s.len() + s.len() / 3);
983 for (i, c) in s.chars().enumerate() {
984 if i > 0 && (s.len() - i).is_multiple_of(3) {
985 result.push(',');
986 }
987 result.push(c);
988 }
989 result
990}
991
992fn extract_currency_code(key: &str) -> Option<&str> {
994 let without_cents = key
995 .strip_suffix("_cents")
996 .or_else(|| key.strip_suffix("_CENTS"))?;
997 let last_underscore = without_cents.rfind('_')?;
998 let code = &without_cents[last_underscore + 1..];
999 if code.is_empty() {
1000 return None;
1001 }
1002 Some(code)
1003}
1004
1005fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1010 let prefix = " ".repeat(indent);
1011 match value {
1012 Value::Object(map) => {
1013 let processed = process_object_fields(map);
1014 for (display_key, v, formatted) in processed {
1015 if let Some(fv) = formatted {
1016 lines.push(format!(
1017 "{}{}: \"{}\"",
1018 prefix,
1019 display_key,
1020 escape_yaml_str(&fv)
1021 ));
1022 } else {
1023 match v {
1024 Value::Object(inner) if !inner.is_empty() => {
1025 lines.push(format!("{}{}:", prefix, display_key));
1026 render_yaml_processed(v, indent + 1, lines);
1027 }
1028 Value::Object(_) => {
1029 lines.push(format!("{}{}: {{}}", prefix, display_key));
1030 }
1031 Value::Array(arr) => {
1032 if arr.is_empty() {
1033 lines.push(format!("{}{}: []", prefix, display_key));
1034 } else {
1035 lines.push(format!("{}{}:", prefix, display_key));
1036 for item in arr {
1037 if item.is_object() {
1038 lines.push(format!("{} -", prefix));
1039 render_yaml_processed(item, indent + 2, lines);
1040 } else {
1041 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
1042 }
1043 }
1044 }
1045 }
1046 _ => {
1047 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1048 }
1049 }
1050 }
1051 }
1052 }
1053 _ => {
1054 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1055 }
1056 }
1057}
1058
1059fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1060 let prefix = " ".repeat(indent);
1061 match value {
1062 Value::Object(map) => {
1063 for (key, v) in map {
1064 render_yaml_field_raw(&prefix, key, v, indent, lines);
1065 }
1066 }
1067 Value::Array(arr) => {
1068 render_yaml_array_raw(arr, indent, lines);
1069 }
1070 _ => {
1071 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1072 }
1073 }
1074}
1075
1076fn render_yaml_field_raw(
1077 prefix: &str,
1078 key: &str,
1079 value: &Value,
1080 indent: usize,
1081 lines: &mut Vec<String>,
1082) {
1083 match value {
1084 Value::Object(inner) if !inner.is_empty() => {
1085 lines.push(format!("{}{}:", prefix, key));
1086 render_yaml_raw(value, indent + 1, lines);
1087 }
1088 Value::Object(_) => {
1089 lines.push(format!("{}{}: {{}}", prefix, key));
1090 }
1091 Value::Array(arr) => {
1092 if arr.is_empty() {
1093 lines.push(format!("{}{}: []", prefix, key));
1094 } else {
1095 lines.push(format!("{}{}:", prefix, key));
1096 render_yaml_array_raw(arr, indent + 1, lines);
1097 }
1098 }
1099 _ => {
1100 lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1101 }
1102 }
1103}
1104
1105fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1106 let prefix = " ".repeat(indent);
1107 for item in arr {
1108 match item {
1109 Value::Object(inner) if !inner.is_empty() => {
1110 lines.push(format!("{}-", prefix));
1111 render_yaml_raw(item, indent + 1, lines);
1112 }
1113 Value::Array(nested) if !nested.is_empty() => {
1114 lines.push(format!("{}-", prefix));
1115 render_yaml_array_raw(nested, indent + 1, lines);
1116 }
1117 Value::Object(_) => {
1118 lines.push(format!("{}- {{}}", prefix));
1119 }
1120 Value::Array(_) => {
1121 lines.push(format!("{}- []", prefix));
1122 }
1123 _ => {
1124 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1125 }
1126 }
1127 }
1128}
1129
1130fn escape_yaml_str(s: &str) -> String {
1131 s.replace('\\', "\\\\")
1132 .replace('"', "\\\"")
1133 .replace('\n', "\\n")
1134 .replace('\r', "\\r")
1135 .replace('\t', "\\t")
1136}
1137
1138fn yaml_scalar(value: &Value) -> String {
1139 match value {
1140 Value::String(s) => {
1141 format!("\"{}\"", escape_yaml_str(s))
1142 }
1143 Value::Null => "null".to_string(),
1144 Value::Bool(b) => b.to_string(),
1145 Value::Number(n) => n.to_string(),
1146 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1147 }
1148}
1149
1150fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1155 if let Value::Object(map) = value {
1156 let processed = process_object_fields(map);
1157 for (display_key, v, formatted) in processed {
1158 let full_key = if prefix.is_empty() {
1159 display_key
1160 } else {
1161 format!("{}.{}", prefix, display_key)
1162 };
1163 if let Some(fv) = formatted {
1164 pairs.push((full_key, fv));
1165 } else {
1166 match v {
1167 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1168 Value::Array(arr) => {
1169 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1170 pairs.push((full_key, joined));
1171 }
1172 Value::Null => pairs.push((full_key, String::new())),
1173 _ => pairs.push((full_key, plain_scalar(v))),
1174 }
1175 }
1176 }
1177 }
1178}
1179
1180fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1181 if let Value::Object(map) = value {
1182 for (key, v) in map {
1183 let full_key = if prefix.is_empty() {
1184 key.clone()
1185 } else {
1186 format!("{}.{}", prefix, key)
1187 };
1188 match v {
1189 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1190 Value::Array(arr) => {
1191 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1192 pairs.push((full_key, joined));
1193 }
1194 Value::Null => pairs.push((full_key, String::new())),
1195 _ => pairs.push((full_key, plain_scalar(v))),
1196 }
1197 }
1198 }
1199}
1200
1201fn plain_scalar(value: &Value) -> String {
1202 match value {
1203 Value::String(s) => s.clone(),
1204 Value::Null => "null".to_string(),
1205 Value::Bool(b) => b.to_string(),
1206 Value::Number(n) => n.to_string(),
1207 other => other.to_string(),
1208 }
1209}
1210
1211fn quote_logfmt_value(value: &str) -> String {
1212 if value.is_empty() {
1213 return String::new();
1214 }
1215 if !value
1216 .chars()
1217 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1218 {
1219 return value.to_string();
1220 }
1221 let escaped = value
1222 .replace('\\', "\\\\")
1223 .replace('"', "\\\"")
1224 .replace('\n', "\\n")
1225 .replace('\r', "\\r")
1226 .replace('\t', "\\t");
1227 format!("\"{}\"", escaped)
1228}
1229
1230#[cfg(test)]
1231mod tests;