1#[cfg(feature = "tracing")]
16pub mod afdata_tracing;
17
18use serde_json::Value;
19use std::collections::HashSet;
20
21pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
27 match trace {
28 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
29 None => serde_json::json!({"code": "ok", "result": result}),
30 }
31}
32
33pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
35 let mut obj = serde_json::Map::new();
36 obj.insert("code".to_string(), Value::String("error".to_string()));
37 obj.insert("error".to_string(), Value::String(message.to_string()));
38 if let Some(h) = hint {
39 obj.insert("hint".to_string(), Value::String(h.to_string()));
40 }
41 if let Some(t) = trace {
42 obj.insert("trace".to_string(), t);
43 }
44 Value::Object(obj)
45}
46
47pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
49 let mut obj = match fields {
50 Value::Object(map) => map,
51 _ => serde_json::Map::new(),
52 };
53 obj.insert("code".to_string(), Value::String(code.to_string()));
54 if let Some(t) = trace {
55 obj.insert("trace".to_string(), t);
56 }
57 Value::Object(obj)
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum RedactionPolicy {
67 RedactionTraceOnly,
69 RedactionNone,
71 RedactionStrict,
73}
74
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
77pub struct RedactionOptions {
78 pub policy: Option<RedactionPolicy>,
80 pub secret_names: Vec<String>,
84}
85
86pub fn output_json(value: &Value) -> String {
88 serialize_json_output(&redacted_value(value))
89}
90
91pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
93 serialize_json_output(&redacted_value_with(value, redaction_policy))
94}
95
96pub fn output_json_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
98 serialize_json_output(&redacted_value_with_options(value, redaction_options))
99}
100
101fn serialize_json_output(value: &Value) -> String {
102 match serde_json::to_string(value) {
103 Ok(s) => s,
104 Err(err) => serde_json::json!({
105 "error": "output_json_failed",
106 "detail": err.to_string(),
107 })
108 .to_string(),
109 }
110}
111
112pub fn output_yaml(value: &Value) -> String {
114 let mut lines = vec!["---".to_string()];
115 let v = redacted_value(value);
116 render_yaml_processed(&v, 0, &mut lines);
117 lines.join("\n")
118}
119
120pub fn output_yaml_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
122 let mut lines = vec!["---".to_string()];
123 let v = redacted_value_with_options(value, redaction_options);
124 render_yaml_processed(&v, 0, &mut lines);
125 lines.join("\n")
126}
127
128pub fn output_plain(value: &Value) -> String {
130 let mut pairs: Vec<(String, String)> = Vec::new();
131 let v = redacted_value(value);
132 collect_plain_pairs(&v, "", &mut pairs);
133 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
134 pairs
135 .into_iter()
136 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
137 .collect::<Vec<_>>()
138 .join(" ")
139}
140
141pub fn output_plain_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
143 let mut pairs: Vec<(String, String)> = Vec::new();
144 let v = redacted_value_with_options(value, redaction_options);
145 collect_plain_pairs(&v, "", &mut pairs);
146 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
147 pairs
148 .into_iter()
149 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
150 .collect::<Vec<_>>()
151 .join(" ")
152}
153
154pub fn internal_redact_secrets(value: &mut Value) {
160 redact_secrets(value);
161}
162
163pub fn internal_redact_secrets_with_options(
165 value: &mut Value,
166 redaction_options: &RedactionOptions,
167) {
168 apply_redaction_options(value, redaction_options);
169}
170
171pub fn redacted_value(value: &Value) -> Value {
173 let mut v = value.clone();
174 redact_secrets(&mut v);
175 v
176}
177
178pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
180 let mut v = value.clone();
181 apply_redaction_policy(&mut v, redaction_policy);
182 v
183}
184
185pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
187 let mut v = value.clone();
188 apply_redaction_options(&mut v, redaction_options);
189 v
190}
191
192pub fn parse_size(s: &str) -> Option<u64> {
198 let s = s.trim();
199 if s.is_empty() {
200 return None;
201 }
202 let last = *s.as_bytes().last()?;
203 let (num_str, mult) = match last {
204 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
205 b'K' | b'k' => (&s[..s.len() - 1], 1024),
206 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
207 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
208 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
209 b'0'..=b'9' | b'.' => (s, 1),
210 _ => return None,
211 };
212 if num_str.is_empty() {
213 return None;
214 }
215 if let Ok(n) = num_str.parse::<u64>() {
216 return n.checked_mul(mult);
217 }
218 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
220 return None;
221 }
222 let f: f64 = num_str.parse().ok()?;
223 if f < 0.0 || f.is_nan() || f.is_infinite() {
224 return None;
225 }
226 let result = f * mult as f64;
227 if result >= u64::MAX as f64 {
228 return None;
229 }
230 Some(result as u64)
231}
232
233#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum OutputFormat {
240 Json,
241 Yaml,
242 Plain,
243}
244
245pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
255 match s {
256 "json" => Ok(OutputFormat::Json),
257 "yaml" => Ok(OutputFormat::Yaml),
258 "plain" => Ok(OutputFormat::Plain),
259 _ => Err(format!(
260 "invalid --output format '{s}': expected json, yaml, or plain"
261 )),
262 }
263}
264
265pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
275 let mut out: Vec<String> = Vec::new();
276 for entry in entries {
277 let s = entry.as_ref().trim().to_ascii_lowercase();
278 if !s.is_empty() && !out.contains(&s) {
279 out.push(s);
280 }
281 }
282 out
283}
284
285pub fn cli_output(value: &Value, format: OutputFormat) -> String {
296 match format {
297 OutputFormat::Json => output_json(value),
298 OutputFormat::Yaml => output_yaml(value),
299 OutputFormat::Plain => output_plain(value),
300 }
301}
302
303pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
315 let mut obj = serde_json::Map::new();
316 obj.insert("code".to_string(), Value::String("error".to_string()));
317 obj.insert(
318 "error_code".to_string(),
319 Value::String("invalid_request".to_string()),
320 );
321 obj.insert("error".to_string(), Value::String(message.to_string()));
322 if let Some(h) = hint {
323 obj.insert("hint".to_string(), Value::String(h.to_string()));
324 }
325 obj.insert("retryable".to_string(), Value::Bool(false));
326 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
327 Value::Object(obj)
328}
329
330#[cfg(feature = "cli-help")]
342pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
343 let target = walk_to_subcommand(cmd, subcommand_path);
344 let mut buf = String::new();
345 render_help_recursive(target, &[], &mut buf, true);
346 buf
347}
348
349#[cfg(feature = "cli-help-markdown")]
356pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
357 let target = walk_to_subcommand(cmd, subcommand_path);
358 let md = clap_markdown::help_markdown_command(target);
359 md.rfind("\n<hr/>")
361 .map_or(md.clone(), |pos| md[..pos].to_string())
362}
363
364#[cfg(feature = "cli-help")]
365fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
366 let mut current = cmd;
367 for name in path {
368 current = current.find_subcommand(name).unwrap_or(current);
369 }
370 current
371}
372
373#[cfg(feature = "cli-help")]
374fn render_help_recursive(
375 cmd: &clap::Command,
376 parent_path: &[&str],
377 buf: &mut String,
378 is_root: bool,
379) {
380 use std::fmt::Write;
381
382 let mut cmd_path = parent_path.to_vec();
384 cmd_path.push(cmd.get_name());
385 let path_str = cmd_path.join(" ");
386
387 if !buf.is_empty() {
389 let _ = writeln!(buf);
390 let _ = writeln!(buf, "{}", "═".repeat(60));
391 }
392
393 if let Some(about) = cmd.get_about() {
395 let _ = writeln!(buf, "{path_str} — {about}");
396 } else {
397 let _ = writeln!(buf, "{path_str}");
398 }
399 let _ = writeln!(buf);
400
401 let styled = cmd.clone().render_long_help();
403 let help_text = styled.to_string();
404
405 if is_root {
407 let mut found_help = false;
408 for line in help_text.lines() {
409 let _ = writeln!(buf, "{line}");
410 if line.trim_start().starts_with("-h, --help") {
411 found_help = true;
412 } else if found_help && line.contains("Print help") {
413 let _ = writeln!(buf, " --help-markdown");
414 let _ = writeln!(
415 buf,
416 " Output help as Markdown (for documentation generation)"
417 );
418 found_help = false;
419 } else {
420 found_help = false;
421 }
422 }
423 } else {
424 let _ = write!(buf, "{help_text}");
425 }
426
427 for sub in cmd.get_subcommands() {
429 if sub.get_name() == "help" {
430 continue; }
432 render_help_recursive(sub, &cmd_path, buf, false);
433 }
434}
435
436#[derive(Default)]
441struct RedactionContext {
442 secret_names: HashSet<String>,
443}
444
445impl RedactionContext {
446 fn from_options(redaction_options: &RedactionOptions) -> Self {
447 let secret_names = redaction_options.secret_names.iter().cloned().collect();
448 Self { secret_names }
449 }
450
451 fn is_secret_key(&self, key: &str) -> bool {
452 key_has_secret_suffix(key) || self.secret_names.contains(key)
453 }
454}
455
456fn key_has_secret_suffix(key: &str) -> bool {
457 key.ends_with("_secret") || key.ends_with("_SECRET")
458}
459
460fn redact_secrets(value: &mut Value) {
461 let context = RedactionContext::default();
462 redact_secrets_with_context(value, &context);
463}
464
465fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
466 match value {
467 Value::Object(map) => {
468 let keys: Vec<String> = map.keys().cloned().collect();
469 for key in keys {
470 if context.is_secret_key(&key) {
471 match map.get(&key) {
472 Some(Value::Object(_)) | Some(Value::Array(_)) => {
473 }
475 _ => {
476 map.insert(key.clone(), Value::String("***".into()));
477 continue;
478 }
479 }
480 }
481 if let Some(v) = map.get_mut(&key) {
482 redact_secrets_with_context(v, context);
483 }
484 }
485 }
486 Value::Array(arr) => {
487 for v in arr {
488 redact_secrets_with_context(v, context);
489 }
490 }
491 _ => {}
492 }
493}
494
495fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
496 match value {
497 Value::Object(map) => {
498 let keys: Vec<String> = map.keys().cloned().collect();
499 for key in keys {
500 if context.is_secret_key(&key) {
501 map.insert(key, Value::String("***".into()));
502 } else if let Some(v) = map.get_mut(&key) {
503 redact_secrets_strict_with_context(v, context);
504 }
505 }
506 }
507 Value::Array(arr) => {
508 for v in arr {
509 redact_secrets_strict_with_context(v, context);
510 }
511 }
512 _ => {}
513 }
514}
515
516fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
517 let context = RedactionContext::default();
518 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
519}
520
521fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
522 let context = RedactionContext::from_options(redaction_options);
523 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
524}
525
526fn apply_redaction_policy_with_context(
527 value: &mut Value,
528 redaction_policy: Option<RedactionPolicy>,
529 context: &RedactionContext,
530) {
531 match redaction_policy {
532 Some(RedactionPolicy::RedactionTraceOnly) => {
533 if let Value::Object(map) = value {
534 if let Some(trace) = map.get_mut("trace") {
535 redact_secrets_with_context(trace, context);
536 }
537 }
538 }
539 Some(RedactionPolicy::RedactionNone) => {}
540 Some(RedactionPolicy::RedactionStrict) => {
541 redact_secrets_strict_with_context(value, context)
542 }
543 None => redact_secrets_with_context(value, context),
544 }
545}
546
547fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
553 if let Some(s) = key.strip_suffix(suffix_lower) {
554 return Some(s.to_string());
555 }
556 let suffix_upper: String = suffix_lower
557 .chars()
558 .map(|c| c.to_ascii_uppercase())
559 .collect();
560 if let Some(s) = key.strip_suffix(&suffix_upper) {
561 return Some(s.to_string());
562 }
563 None
564}
565
566fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
568 let code = extract_currency_code(key)?;
569 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
571 if stripped.is_empty() {
572 return None;
573 }
574 Some((stripped.to_string(), code.to_string()))
575}
576
577fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
580 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
582 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
583 }
584 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
585 return value
586 .as_i64()
587 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
588 }
589 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
590 return value
591 .as_i64()
592 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
593 }
594
595 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
597 return value
598 .as_u64()
599 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
600 }
601 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
602 return value
603 .as_u64()
604 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
605 }
606 if let Some((stripped, code)) = try_strip_generic_cents(key) {
607 return value.as_u64().map(|n| {
608 (
609 stripped,
610 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
611 )
612 });
613 }
614
615 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
617 return value.as_str().map(|s| (stripped, s.to_string()));
618 }
619 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
620 return value
621 .is_number()
622 .then(|| (stripped, format!("{} minutes", number_str(value))));
623 }
624 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
625 return value
626 .is_number()
627 .then(|| (stripped, format!("{} hours", number_str(value))));
628 }
629 if let Some(stripped) = strip_suffix_ci(key, "_days") {
630 return value
631 .is_number()
632 .then(|| (stripped, format!("{} days", number_str(value))));
633 }
634
635 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
637 return value
638 .is_number()
639 .then(|| (stripped, format!("{}msats", number_str(value))));
640 }
641 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
642 return value
643 .is_number()
644 .then(|| (stripped, format!("{}sats", number_str(value))));
645 }
646 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
647 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
648 }
649 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
650 return value
651 .is_number()
652 .then(|| (stripped, format!("{}%", number_str(value))));
653 }
654 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
655 return Some((stripped, "***".to_string()));
656 }
657
658 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
660 return value
661 .is_number()
662 .then(|| (stripped, format!("{} BTC", number_str(value))));
663 }
664 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
665 return value
666 .as_u64()
667 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
668 }
669 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
670 return value
671 .is_number()
672 .then(|| (stripped, format!("{}ns", number_str(value))));
673 }
674 if let Some(stripped) = strip_suffix_ci(key, "_us") {
675 return value
676 .is_number()
677 .then(|| (stripped, format!("{}μs", number_str(value))));
678 }
679 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
680 return format_ms_value(value).map(|v| (stripped, v));
681 }
682 if let Some(stripped) = strip_suffix_ci(key, "_s") {
683 return value
684 .is_number()
685 .then(|| (stripped, format!("{}s", number_str(value))));
686 }
687
688 None
689}
690
691fn process_object_fields<'a>(
693 map: &'a serde_json::Map<String, Value>,
694) -> Vec<(String, &'a Value, Option<String>)> {
695 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
696 for (key, value) in map {
697 match try_process_field(key, value) {
698 Some((stripped, formatted)) => {
699 entries.push((stripped, key.as_str(), value, Some(formatted)));
700 }
701 None => {
702 entries.push((key.clone(), key.as_str(), value, None));
703 }
704 }
705 }
706
707 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
709 for (stripped, _, _, _) in &entries {
710 *counts.entry(stripped.clone()).or_insert(0) += 1;
711 }
712
713 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
715 .into_iter()
716 .map(|(stripped, original, value, formatted)| {
717 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
718 (original.to_string(), value, None)
719 } else {
720 (stripped, value, formatted)
721 }
722 })
723 .collect();
724
725 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
726 result
727}
728
729fn number_str(value: &Value) -> String {
734 match value {
735 Value::Number(n) => n.to_string(),
736 _ => String::new(),
737 }
738}
739
740fn format_ms_as_seconds(ms: f64) -> String {
742 let formatted = format!("{:.3}", ms / 1000.0);
743 let trimmed = formatted.trim_end_matches('0');
744 if trimmed.ends_with('.') {
745 format!("{}0s", trimmed)
746 } else {
747 format!("{}s", trimmed)
748 }
749}
750
751fn format_ms_value(value: &Value) -> Option<String> {
753 let n = value.as_f64()?;
754 if n.abs() >= 1000.0 {
755 Some(format_ms_as_seconds(n))
756 } else if let Some(i) = value.as_i64() {
757 Some(format!("{}ms", i))
758 } else {
759 Some(format!("{}ms", number_str(value)))
760 }
761}
762
763fn format_rfc3339_ms(ms: i64) -> String {
765 use chrono::{DateTime, Utc};
766 let secs = ms.div_euclid(1000);
767 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
768 match DateTime::from_timestamp(secs, nanos) {
769 Some(dt) => dt
770 .with_timezone(&Utc)
771 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
772 None => ms.to_string(),
773 }
774}
775
776fn format_bytes_human(bytes: i64) -> String {
778 const KB: f64 = 1024.0;
779 const MB: f64 = KB * 1024.0;
780 const GB: f64 = MB * 1024.0;
781 const TB: f64 = GB * 1024.0;
782
783 let sign = if bytes < 0 { "-" } else { "" };
784 let b = (bytes as f64).abs();
785 if b >= TB {
786 format!("{sign}{:.1}TB", b / TB)
787 } else if b >= GB {
788 format!("{sign}{:.1}GB", b / GB)
789 } else if b >= MB {
790 format!("{sign}{:.1}MB", b / MB)
791 } else if b >= KB {
792 format!("{sign}{:.1}KB", b / KB)
793 } else {
794 format!("{bytes}B")
795 }
796}
797
798fn format_with_commas(n: u64) -> String {
800 let s = n.to_string();
801 let mut result = String::with_capacity(s.len() + s.len() / 3);
802 for (i, c) in s.chars().enumerate() {
803 if i > 0 && (s.len() - i).is_multiple_of(3) {
804 result.push(',');
805 }
806 result.push(c);
807 }
808 result
809}
810
811fn extract_currency_code(key: &str) -> Option<&str> {
813 let without_cents = key
814 .strip_suffix("_cents")
815 .or_else(|| key.strip_suffix("_CENTS"))?;
816 let last_underscore = without_cents.rfind('_')?;
817 let code = &without_cents[last_underscore + 1..];
818 if code.is_empty() {
819 return None;
820 }
821 Some(code)
822}
823
824fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
829 let prefix = " ".repeat(indent);
830 match value {
831 Value::Object(map) => {
832 let processed = process_object_fields(map);
833 for (display_key, v, formatted) in processed {
834 if let Some(fv) = formatted {
835 lines.push(format!(
836 "{}{}: \"{}\"",
837 prefix,
838 display_key,
839 escape_yaml_str(&fv)
840 ));
841 } else {
842 match v {
843 Value::Object(inner) if !inner.is_empty() => {
844 lines.push(format!("{}{}:", prefix, display_key));
845 render_yaml_processed(v, indent + 1, lines);
846 }
847 Value::Object(_) => {
848 lines.push(format!("{}{}: {{}}", prefix, display_key));
849 }
850 Value::Array(arr) => {
851 if arr.is_empty() {
852 lines.push(format!("{}{}: []", prefix, display_key));
853 } else {
854 lines.push(format!("{}{}:", prefix, display_key));
855 for item in arr {
856 if item.is_object() {
857 lines.push(format!("{} -", prefix));
858 render_yaml_processed(item, indent + 2, lines);
859 } else {
860 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
861 }
862 }
863 }
864 }
865 _ => {
866 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
867 }
868 }
869 }
870 }
871 }
872 _ => {
873 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
874 }
875 }
876}
877
878fn escape_yaml_str(s: &str) -> String {
879 s.replace('\\', "\\\\")
880 .replace('"', "\\\"")
881 .replace('\n', "\\n")
882 .replace('\r', "\\r")
883 .replace('\t', "\\t")
884}
885
886fn yaml_scalar(value: &Value) -> String {
887 match value {
888 Value::String(s) => {
889 format!("\"{}\"", escape_yaml_str(s))
890 }
891 Value::Null => "null".to_string(),
892 Value::Bool(b) => b.to_string(),
893 Value::Number(n) => n.to_string(),
894 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
895 }
896}
897
898fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
903 if let Value::Object(map) = value {
904 let processed = process_object_fields(map);
905 for (display_key, v, formatted) in processed {
906 let full_key = if prefix.is_empty() {
907 display_key
908 } else {
909 format!("{}.{}", prefix, display_key)
910 };
911 if let Some(fv) = formatted {
912 pairs.push((full_key, fv));
913 } else {
914 match v {
915 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
916 Value::Array(arr) => {
917 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
918 pairs.push((full_key, joined));
919 }
920 Value::Null => pairs.push((full_key, String::new())),
921 _ => pairs.push((full_key, plain_scalar(v))),
922 }
923 }
924 }
925 }
926}
927
928fn plain_scalar(value: &Value) -> String {
929 match value {
930 Value::String(s) => s.clone(),
931 Value::Null => "null".to_string(),
932 Value::Bool(b) => b.to_string(),
933 Value::Number(n) => n.to_string(),
934 other => other.to_string(),
935 }
936}
937
938fn quote_logfmt_value(value: &str) -> String {
939 if value.is_empty() {
940 return String::new();
941 }
942 if !value
943 .chars()
944 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
945 {
946 return value.to_string();
947 }
948 let escaped = value
949 .replace('\\', "\\\\")
950 .replace('"', "\\\"")
951 .replace('\n', "\\n")
952 .replace('\r', "\\r")
953 .replace('\t', "\\t");
954 format!("\"{}\"", escaped)
955}
956
957#[cfg(test)]
958mod tests;