1#[cfg(feature = "tracing")]
18pub mod afdata_tracing;
19
20use serde_json::Value;
21use std::collections::HashSet;
22
23pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
29 match trace {
30 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
31 None => serde_json::json!({"code": "ok", "result": result}),
32 }
33}
34
35pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
37 let mut obj = serde_json::Map::new();
38 obj.insert("code".to_string(), Value::String("error".to_string()));
39 obj.insert("error".to_string(), Value::String(message.to_string()));
40 if let Some(h) = hint {
41 obj.insert("hint".to_string(), Value::String(h.to_string()));
42 }
43 if let Some(t) = trace {
44 obj.insert("trace".to_string(), t);
45 }
46 Value::Object(obj)
47}
48
49pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
51 let mut obj = match fields {
52 Value::Object(map) => map,
53 _ => serde_json::Map::new(),
54 };
55 obj.insert("code".to_string(), Value::String(code.to_string()));
56 if let Some(t) = trace {
57 obj.insert("trace".to_string(), t);
58 }
59 Value::Object(obj)
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum RedactionPolicy {
69 RedactionTraceOnly,
71 RedactionNone,
73 RedactionStrict,
75}
76
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
79pub struct RedactionOptions {
80 pub policy: Option<RedactionPolicy>,
82 pub secret_names: Vec<String>,
86}
87
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub enum OutputStyle {
91 #[default]
93 Readable,
94 Raw,
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Eq)]
100pub struct OutputOptions {
101 pub redaction: RedactionOptions,
103 pub style: OutputStyle,
105}
106
107pub fn output_json(value: &Value) -> String {
109 serialize_json_output(&redacted_value(value))
110}
111
112pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
114 serialize_json_output(&redacted_value_with(value, redaction_policy))
115}
116
117pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
122 serialize_json_output(&redacted_value_with_options(
123 value,
124 &output_options.redaction,
125 ))
126}
127
128fn serialize_json_output(value: &Value) -> String {
129 match serde_json::to_string(value) {
130 Ok(s) => s,
131 Err(err) => serde_json::json!({
132 "error": "output_json_failed",
133 "detail": err.to_string(),
134 })
135 .to_string(),
136 }
137}
138
139pub fn output_yaml(value: &Value) -> String {
141 output_yaml_with_options(value, &OutputOptions::default())
142}
143
144pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
146 let mut lines = vec!["---".to_string()];
147 let v = redacted_value_with_options(value, &output_options.redaction);
148 match output_options.style {
149 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
150 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
151 }
152 lines.join("\n")
153}
154
155pub fn output_plain(value: &Value) -> String {
157 output_plain_with_options(value, &OutputOptions::default())
158}
159
160pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
162 let mut pairs: Vec<(String, String)> = Vec::new();
163 let v = redacted_value_with_options(value, &output_options.redaction);
164 match output_options.style {
165 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
166 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
167 }
168 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
169 pairs
170 .into_iter()
171 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
172 .collect::<Vec<_>>()
173 .join(" ")
174}
175
176pub fn internal_redact_secrets(value: &mut Value) {
182 redact_secrets(value);
183}
184
185pub fn internal_redact_secrets_with_options(
187 value: &mut Value,
188 redaction_options: &RedactionOptions,
189) {
190 apply_redaction_options(value, redaction_options);
191}
192
193pub fn redacted_value(value: &Value) -> Value {
195 let mut v = value.clone();
196 redact_secrets(&mut v);
197 v
198}
199
200pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
202 let mut v = value.clone();
203 apply_redaction_policy(&mut v, redaction_policy);
204 v
205}
206
207pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
209 let mut v = value.clone();
210 apply_redaction_options(&mut v, redaction_options);
211 v
212}
213
214pub fn parse_size(s: &str) -> Option<u64> {
220 let s = s.trim();
221 if s.is_empty() {
222 return None;
223 }
224 let last = *s.as_bytes().last()?;
225 let (num_str, mult) = match last {
226 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
227 b'K' | b'k' => (&s[..s.len() - 1], 1024),
228 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
229 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
230 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
231 b'0'..=b'9' | b'.' => (s, 1),
232 _ => return None,
233 };
234 if num_str.is_empty() {
235 return None;
236 }
237 if let Ok(n) = num_str.parse::<u64>() {
238 return n.checked_mul(mult);
239 }
240 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
242 return None;
243 }
244 let f: f64 = num_str.parse().ok()?;
245 if f < 0.0 || f.is_nan() || f.is_infinite() {
246 return None;
247 }
248 let result = f * mult as f64;
249 if result >= u64::MAX as f64 {
250 return None;
251 }
252 Some(result as u64)
253}
254
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
261pub enum OutputFormat {
262 Json,
263 Yaml,
264 Plain,
265}
266
267pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
277 match s {
278 "json" => Ok(OutputFormat::Json),
279 "yaml" => Ok(OutputFormat::Yaml),
280 "plain" => Ok(OutputFormat::Plain),
281 _ => Err(format!(
282 "invalid --output format '{s}': expected json, yaml, or plain"
283 )),
284 }
285}
286
287pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
297 let mut out: Vec<String> = Vec::new();
298 for entry in entries {
299 let s = entry.as_ref().trim().to_ascii_lowercase();
300 if !s.is_empty() && !out.contains(&s) {
301 out.push(s);
302 }
303 }
304 out
305}
306
307pub fn cli_output(value: &Value, format: OutputFormat) -> String {
318 match format {
319 OutputFormat::Json => output_json(value),
320 OutputFormat::Yaml => output_yaml(value),
321 OutputFormat::Plain => output_plain(value),
322 }
323}
324
325pub fn cli_output_with_options(
330 value: &Value,
331 format: OutputFormat,
332 output_options: &OutputOptions,
333) -> String {
334 match format {
335 OutputFormat::Json => output_json_with_options(value, output_options),
336 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
337 OutputFormat::Plain => output_plain_with_options(value, output_options),
338 }
339}
340
341pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
353 let mut obj = serde_json::Map::new();
354 obj.insert("code".to_string(), Value::String("error".to_string()));
355 obj.insert(
356 "error_code".to_string(),
357 Value::String("invalid_request".to_string()),
358 );
359 obj.insert("error".to_string(), Value::String(message.to_string()));
360 if let Some(h) = hint {
361 obj.insert("hint".to_string(), Value::String(h.to_string()));
362 }
363 obj.insert("retryable".to_string(), Value::Bool(false));
364 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
365 Value::Object(obj)
366}
367
368#[cfg(feature = "cli-help")]
380pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
381 let target = walk_to_subcommand(cmd, subcommand_path);
382 let mut buf = String::new();
383 render_help_recursive(target, &[], &mut buf, true);
384 buf
385}
386
387#[cfg(feature = "cli-help-markdown")]
394pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
395 let target = walk_to_subcommand(cmd, subcommand_path);
396 let md = clap_markdown::help_markdown_command(target);
397 md.rfind("\n<hr/>")
399 .map_or(md.clone(), |pos| md[..pos].to_string())
400}
401
402#[cfg(feature = "cli-help")]
403fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
404 let mut current = cmd;
405 for name in path {
406 current = current.find_subcommand(name).unwrap_or(current);
407 }
408 current
409}
410
411#[cfg(feature = "cli-help")]
412fn render_help_recursive(
413 cmd: &clap::Command,
414 parent_path: &[&str],
415 buf: &mut String,
416 is_root: bool,
417) {
418 use std::fmt::Write;
419
420 let mut cmd_path = parent_path.to_vec();
422 cmd_path.push(cmd.get_name());
423 let path_str = cmd_path.join(" ");
424
425 if !buf.is_empty() {
427 let _ = writeln!(buf);
428 let _ = writeln!(buf, "{}", "═".repeat(60));
429 }
430
431 if let Some(about) = cmd.get_about() {
433 let _ = writeln!(buf, "{path_str} — {about}");
434 } else {
435 let _ = writeln!(buf, "{path_str}");
436 }
437 let _ = writeln!(buf);
438
439 let styled = cmd.clone().render_long_help();
441 let help_text = styled.to_string();
442
443 if is_root {
445 let mut found_help = false;
446 for line in help_text.lines() {
447 let _ = writeln!(buf, "{line}");
448 if line.trim_start().starts_with("-h, --help") {
449 found_help = true;
450 } else if found_help && line.contains("Print help") {
451 let _ = writeln!(buf, " --help-markdown");
452 let _ = writeln!(
453 buf,
454 " Output help as Markdown (for documentation generation)"
455 );
456 found_help = false;
457 } else {
458 found_help = false;
459 }
460 }
461 } else {
462 let _ = write!(buf, "{help_text}");
463 }
464
465 for sub in cmd.get_subcommands() {
467 if sub.get_name() == "help" {
468 continue; }
470 render_help_recursive(sub, &cmd_path, buf, false);
471 }
472}
473
474#[derive(Default)]
479struct RedactionContext {
480 secret_names: HashSet<String>,
481}
482
483impl RedactionContext {
484 fn from_options(redaction_options: &RedactionOptions) -> Self {
485 let secret_names = redaction_options.secret_names.iter().cloned().collect();
486 Self { secret_names }
487 }
488
489 fn is_secret_key(&self, key: &str) -> bool {
490 key_has_secret_suffix(key) || self.secret_names.contains(key)
491 }
492}
493
494fn key_has_secret_suffix(key: &str) -> bool {
495 key.ends_with("_secret") || key.ends_with("_SECRET")
496}
497
498fn redact_secrets(value: &mut Value) {
499 let context = RedactionContext::default();
500 redact_secrets_with_context(value, &context);
501}
502
503fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
504 match value {
505 Value::Object(map) => {
506 let keys: Vec<String> = map.keys().cloned().collect();
507 for key in keys {
508 if context.is_secret_key(&key) {
509 match map.get(&key) {
510 Some(Value::Object(_)) | Some(Value::Array(_)) => {
511 }
513 _ => {
514 map.insert(key.clone(), Value::String("***".into()));
515 continue;
516 }
517 }
518 }
519 if let Some(v) = map.get_mut(&key) {
520 redact_secrets_with_context(v, context);
521 }
522 }
523 }
524 Value::Array(arr) => {
525 for v in arr {
526 redact_secrets_with_context(v, context);
527 }
528 }
529 _ => {}
530 }
531}
532
533fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
534 match value {
535 Value::Object(map) => {
536 let keys: Vec<String> = map.keys().cloned().collect();
537 for key in keys {
538 if context.is_secret_key(&key) {
539 map.insert(key, Value::String("***".into()));
540 } else if let Some(v) = map.get_mut(&key) {
541 redact_secrets_strict_with_context(v, context);
542 }
543 }
544 }
545 Value::Array(arr) => {
546 for v in arr {
547 redact_secrets_strict_with_context(v, context);
548 }
549 }
550 _ => {}
551 }
552}
553
554fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
555 let context = RedactionContext::default();
556 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
557}
558
559fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
560 let context = RedactionContext::from_options(redaction_options);
561 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
562}
563
564fn apply_redaction_policy_with_context(
565 value: &mut Value,
566 redaction_policy: Option<RedactionPolicy>,
567 context: &RedactionContext,
568) {
569 match redaction_policy {
570 Some(RedactionPolicy::RedactionTraceOnly) => {
571 if let Value::Object(map) = value {
572 if let Some(trace) = map.get_mut("trace") {
573 redact_secrets_with_context(trace, context);
574 }
575 }
576 }
577 Some(RedactionPolicy::RedactionNone) => {}
578 Some(RedactionPolicy::RedactionStrict) => {
579 redact_secrets_strict_with_context(value, context)
580 }
581 None => redact_secrets_with_context(value, context),
582 }
583}
584
585fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
591 if let Some(s) = key.strip_suffix(suffix_lower) {
592 return Some(s.to_string());
593 }
594 let suffix_upper: String = suffix_lower
595 .chars()
596 .map(|c| c.to_ascii_uppercase())
597 .collect();
598 if let Some(s) = key.strip_suffix(&suffix_upper) {
599 return Some(s.to_string());
600 }
601 None
602}
603
604fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
606 let code = extract_currency_code(key)?;
607 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
609 if stripped.is_empty() {
610 return None;
611 }
612 Some((stripped.to_string(), code.to_string()))
613}
614
615fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
618 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
620 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
621 }
622 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
623 return value
624 .as_i64()
625 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
626 }
627 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
628 return value
629 .as_i64()
630 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
631 }
632
633 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
635 return value
636 .as_u64()
637 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
638 }
639 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
640 return value
641 .as_u64()
642 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
643 }
644 if let Some((stripped, code)) = try_strip_generic_cents(key) {
645 return value.as_u64().map(|n| {
646 (
647 stripped,
648 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
649 )
650 });
651 }
652
653 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
655 return value.as_str().map(|s| (stripped, s.to_string()));
656 }
657 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
658 return value
659 .is_number()
660 .then(|| (stripped, format!("{} minutes", number_str(value))));
661 }
662 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
663 return value
664 .is_number()
665 .then(|| (stripped, format!("{} hours", number_str(value))));
666 }
667 if let Some(stripped) = strip_suffix_ci(key, "_days") {
668 return value
669 .is_number()
670 .then(|| (stripped, format!("{} days", number_str(value))));
671 }
672
673 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
675 return value
676 .is_number()
677 .then(|| (stripped, format!("{}msats", number_str(value))));
678 }
679 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
680 return value
681 .is_number()
682 .then(|| (stripped, format!("{}sats", number_str(value))));
683 }
684 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
685 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
686 }
687 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
688 return value
689 .is_number()
690 .then(|| (stripped, format!("{}%", number_str(value))));
691 }
692 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
693 return Some((stripped, "***".to_string()));
694 }
695
696 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
698 return value
699 .is_number()
700 .then(|| (stripped, format!("{} BTC", number_str(value))));
701 }
702 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
703 return value
704 .as_u64()
705 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
706 }
707 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
708 return value
709 .is_number()
710 .then(|| (stripped, format!("{}ns", number_str(value))));
711 }
712 if let Some(stripped) = strip_suffix_ci(key, "_us") {
713 return value
714 .is_number()
715 .then(|| (stripped, format!("{}μs", number_str(value))));
716 }
717 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
718 return format_ms_value(value).map(|v| (stripped, v));
719 }
720 if let Some(stripped) = strip_suffix_ci(key, "_s") {
721 return value
722 .is_number()
723 .then(|| (stripped, format!("{}s", number_str(value))));
724 }
725
726 None
727}
728
729fn process_object_fields<'a>(
731 map: &'a serde_json::Map<String, Value>,
732) -> Vec<(String, &'a Value, Option<String>)> {
733 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
734 for (key, value) in map {
735 match try_process_field(key, value) {
736 Some((stripped, formatted)) => {
737 entries.push((stripped, key.as_str(), value, Some(formatted)));
738 }
739 None => {
740 entries.push((key.clone(), key.as_str(), value, None));
741 }
742 }
743 }
744
745 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
747 for (stripped, _, _, _) in &entries {
748 *counts.entry(stripped.clone()).or_insert(0) += 1;
749 }
750
751 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
753 .into_iter()
754 .map(|(stripped, original, value, formatted)| {
755 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
756 (original.to_string(), value, None)
757 } else {
758 (stripped, value, formatted)
759 }
760 })
761 .collect();
762
763 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
764 result
765}
766
767fn number_str(value: &Value) -> String {
772 match value {
773 Value::Number(n) => n.to_string(),
774 _ => String::new(),
775 }
776}
777
778fn format_ms_as_seconds(ms: f64) -> String {
780 let formatted = format!("{:.3}", ms / 1000.0);
781 let trimmed = formatted.trim_end_matches('0');
782 if trimmed.ends_with('.') {
783 format!("{}0s", trimmed)
784 } else {
785 format!("{}s", trimmed)
786 }
787}
788
789fn format_ms_value(value: &Value) -> Option<String> {
791 let n = value.as_f64()?;
792 if n.abs() >= 1000.0 {
793 Some(format_ms_as_seconds(n))
794 } else if let Some(i) = value.as_i64() {
795 Some(format!("{}ms", i))
796 } else {
797 Some(format!("{}ms", number_str(value)))
798 }
799}
800
801fn format_rfc3339_ms(ms: i64) -> String {
803 use chrono::{DateTime, Utc};
804 let secs = ms.div_euclid(1000);
805 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
806 match DateTime::from_timestamp(secs, nanos) {
807 Some(dt) => dt
808 .with_timezone(&Utc)
809 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
810 None => ms.to_string(),
811 }
812}
813
814fn format_bytes_human(bytes: i64) -> String {
816 const KB: f64 = 1024.0;
817 const MB: f64 = KB * 1024.0;
818 const GB: f64 = MB * 1024.0;
819 const TB: f64 = GB * 1024.0;
820
821 let sign = if bytes < 0 { "-" } else { "" };
822 let b = (bytes as f64).abs();
823 if b >= TB {
824 format!("{sign}{:.1}TB", b / TB)
825 } else if b >= GB {
826 format!("{sign}{:.1}GB", b / GB)
827 } else if b >= MB {
828 format!("{sign}{:.1}MB", b / MB)
829 } else if b >= KB {
830 format!("{sign}{:.1}KB", b / KB)
831 } else {
832 format!("{bytes}B")
833 }
834}
835
836fn format_with_commas(n: u64) -> String {
838 let s = n.to_string();
839 let mut result = String::with_capacity(s.len() + s.len() / 3);
840 for (i, c) in s.chars().enumerate() {
841 if i > 0 && (s.len() - i).is_multiple_of(3) {
842 result.push(',');
843 }
844 result.push(c);
845 }
846 result
847}
848
849fn extract_currency_code(key: &str) -> Option<&str> {
851 let without_cents = key
852 .strip_suffix("_cents")
853 .or_else(|| key.strip_suffix("_CENTS"))?;
854 let last_underscore = without_cents.rfind('_')?;
855 let code = &without_cents[last_underscore + 1..];
856 if code.is_empty() {
857 return None;
858 }
859 Some(code)
860}
861
862fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
867 let prefix = " ".repeat(indent);
868 match value {
869 Value::Object(map) => {
870 let processed = process_object_fields(map);
871 for (display_key, v, formatted) in processed {
872 if let Some(fv) = formatted {
873 lines.push(format!(
874 "{}{}: \"{}\"",
875 prefix,
876 display_key,
877 escape_yaml_str(&fv)
878 ));
879 } else {
880 match v {
881 Value::Object(inner) if !inner.is_empty() => {
882 lines.push(format!("{}{}:", prefix, display_key));
883 render_yaml_processed(v, indent + 1, lines);
884 }
885 Value::Object(_) => {
886 lines.push(format!("{}{}: {{}}", prefix, display_key));
887 }
888 Value::Array(arr) => {
889 if arr.is_empty() {
890 lines.push(format!("{}{}: []", prefix, display_key));
891 } else {
892 lines.push(format!("{}{}:", prefix, display_key));
893 for item in arr {
894 if item.is_object() {
895 lines.push(format!("{} -", prefix));
896 render_yaml_processed(item, indent + 2, lines);
897 } else {
898 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
899 }
900 }
901 }
902 }
903 _ => {
904 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
905 }
906 }
907 }
908 }
909 }
910 _ => {
911 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
912 }
913 }
914}
915
916fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
917 let prefix = " ".repeat(indent);
918 match value {
919 Value::Object(map) => {
920 for (key, v) in map {
921 render_yaml_field_raw(&prefix, key, v, indent, lines);
922 }
923 }
924 Value::Array(arr) => {
925 render_yaml_array_raw(arr, indent, lines);
926 }
927 _ => {
928 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
929 }
930 }
931}
932
933fn render_yaml_field_raw(
934 prefix: &str,
935 key: &str,
936 value: &Value,
937 indent: usize,
938 lines: &mut Vec<String>,
939) {
940 match value {
941 Value::Object(inner) if !inner.is_empty() => {
942 lines.push(format!("{}{}:", prefix, key));
943 render_yaml_raw(value, indent + 1, lines);
944 }
945 Value::Object(_) => {
946 lines.push(format!("{}{}: {{}}", prefix, key));
947 }
948 Value::Array(arr) => {
949 if arr.is_empty() {
950 lines.push(format!("{}{}: []", prefix, key));
951 } else {
952 lines.push(format!("{}{}:", prefix, key));
953 render_yaml_array_raw(arr, indent + 1, lines);
954 }
955 }
956 _ => {
957 lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
958 }
959 }
960}
961
962fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
963 let prefix = " ".repeat(indent);
964 for item in arr {
965 match item {
966 Value::Object(inner) if !inner.is_empty() => {
967 lines.push(format!("{}-", prefix));
968 render_yaml_raw(item, indent + 1, lines);
969 }
970 Value::Array(nested) if !nested.is_empty() => {
971 lines.push(format!("{}-", prefix));
972 render_yaml_array_raw(nested, indent + 1, lines);
973 }
974 Value::Object(_) => {
975 lines.push(format!("{}- {{}}", prefix));
976 }
977 Value::Array(_) => {
978 lines.push(format!("{}- []", prefix));
979 }
980 _ => {
981 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
982 }
983 }
984 }
985}
986
987fn escape_yaml_str(s: &str) -> String {
988 s.replace('\\', "\\\\")
989 .replace('"', "\\\"")
990 .replace('\n', "\\n")
991 .replace('\r', "\\r")
992 .replace('\t', "\\t")
993}
994
995fn yaml_scalar(value: &Value) -> String {
996 match value {
997 Value::String(s) => {
998 format!("\"{}\"", escape_yaml_str(s))
999 }
1000 Value::Null => "null".to_string(),
1001 Value::Bool(b) => b.to_string(),
1002 Value::Number(n) => n.to_string(),
1003 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1004 }
1005}
1006
1007fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1012 if let Value::Object(map) = value {
1013 let processed = process_object_fields(map);
1014 for (display_key, v, formatted) in processed {
1015 let full_key = if prefix.is_empty() {
1016 display_key
1017 } else {
1018 format!("{}.{}", prefix, display_key)
1019 };
1020 if let Some(fv) = formatted {
1021 pairs.push((full_key, fv));
1022 } else {
1023 match v {
1024 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1025 Value::Array(arr) => {
1026 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1027 pairs.push((full_key, joined));
1028 }
1029 Value::Null => pairs.push((full_key, String::new())),
1030 _ => pairs.push((full_key, plain_scalar(v))),
1031 }
1032 }
1033 }
1034 }
1035}
1036
1037fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1038 if let Value::Object(map) = value {
1039 for (key, v) in map {
1040 let full_key = if prefix.is_empty() {
1041 key.clone()
1042 } else {
1043 format!("{}.{}", prefix, key)
1044 };
1045 match v {
1046 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1047 Value::Array(arr) => {
1048 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1049 pairs.push((full_key, joined));
1050 }
1051 Value::Null => pairs.push((full_key, String::new())),
1052 _ => pairs.push((full_key, plain_scalar(v))),
1053 }
1054 }
1055 }
1056}
1057
1058fn plain_scalar(value: &Value) -> String {
1059 match value {
1060 Value::String(s) => s.clone(),
1061 Value::Null => "null".to_string(),
1062 Value::Bool(b) => b.to_string(),
1063 Value::Number(n) => n.to_string(),
1064 other => other.to_string(),
1065 }
1066}
1067
1068fn quote_logfmt_value(value: &str) -> String {
1069 if value.is_empty() {
1070 return String::new();
1071 }
1072 if !value
1073 .chars()
1074 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1075 {
1076 return value.to_string();
1077 }
1078 let escaped = value
1079 .replace('\\', "\\\\")
1080 .replace('"', "\\\"")
1081 .replace('\n', "\\n")
1082 .replace('\r', "\\r")
1083 .replace('\t', "\\t");
1084 format!("\"{}\"", escaped)
1085}
1086
1087#[cfg(test)]
1088mod tests;