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