1#[cfg(feature = "tracing")]
25pub mod afdata_tracing;
26
27#[cfg(feature = "skill-admin")]
28pub mod skill;
29
30use serde_json::Value;
31use std::collections::HashSet;
32
33pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
39 match trace {
40 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
41 None => serde_json::json!({"code": "ok", "result": result}),
42 }
43}
44
45pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
47 let mut obj = serde_json::Map::new();
48 obj.insert("code".to_string(), Value::String("error".to_string()));
49 obj.insert("error".to_string(), Value::String(message.to_string()));
50 if let Some(h) = hint {
51 obj.insert("hint".to_string(), Value::String(h.to_string()));
52 }
53 if let Some(t) = trace {
54 obj.insert("trace".to_string(), t);
55 }
56 Value::Object(obj)
57}
58
59pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
61 let mut obj = match fields {
62 Value::Object(map) => map,
63 _ => serde_json::Map::new(),
64 };
65 obj.insert("code".to_string(), Value::String(code.to_string()));
66 if let Some(t) = trace {
67 obj.insert("trace".to_string(), t);
68 }
69 Value::Object(obj)
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum RedactionPolicy {
79 RedactionTraceOnly,
81 RedactionNone,
83 RedactionStrict,
85}
86
87#[derive(Clone, Debug, Default, PartialEq, Eq)]
89pub struct RedactionOptions {
90 pub policy: Option<RedactionPolicy>,
92 pub secret_names: Vec<String>,
98}
99
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum OutputStyle {
103 #[default]
105 Readable,
106 Raw,
108}
109
110#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct OutputOptions {
113 pub redaction: RedactionOptions,
115 pub style: OutputStyle,
117}
118
119pub fn output_json(value: &Value) -> String {
121 serialize_json_output(&redacted_value(value))
122}
123
124pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
126 serialize_json_output(&redacted_value_with(value, redaction_policy))
127}
128
129pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
134 serialize_json_output(&redacted_value_with_options(
135 value,
136 &output_options.redaction,
137 ))
138}
139
140fn serialize_json_output(value: &Value) -> String {
141 match serde_json::to_string(value) {
142 Ok(s) => s,
143 Err(err) => serde_json::json!({
144 "error": "output_json_failed",
145 "detail": err.to_string(),
146 })
147 .to_string(),
148 }
149}
150
151pub fn output_yaml(value: &Value) -> String {
153 output_yaml_with_options(value, &OutputOptions::default())
154}
155
156pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
158 let mut lines = vec!["---".to_string()];
159 let v = redacted_value_with_options(value, &output_options.redaction);
160 match output_options.style {
161 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
162 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
163 }
164 lines.join("\n")
165}
166
167pub fn output_plain(value: &Value) -> String {
169 output_plain_with_options(value, &OutputOptions::default())
170}
171
172pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
174 let mut pairs: Vec<(String, String)> = Vec::new();
175 let v = redacted_value_with_options(value, &output_options.redaction);
176 match output_options.style {
177 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
178 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
179 }
180 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
181 pairs
182 .into_iter()
183 .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
184 .collect::<Vec<_>>()
185 .join(" ")
186}
187
188pub fn internal_redact_secrets(value: &mut Value) {
194 redact_secrets(value);
195}
196
197pub fn internal_redact_secrets_with_options(
199 value: &mut Value,
200 redaction_options: &RedactionOptions,
201) {
202 apply_redaction_options(value, redaction_options);
203}
204
205pub fn redacted_value(value: &Value) -> Value {
207 let mut v = value.clone();
208 redact_secrets(&mut v);
209 v
210}
211
212pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
214 let mut v = value.clone();
215 apply_redaction_policy(&mut v, redaction_policy);
216 v
217}
218
219pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
221 let mut v = value.clone();
222 apply_redaction_options(&mut v, redaction_options);
223 v
224}
225
226pub fn redact_url_secrets(url: &str) -> String {
231 redact_url_secrets_with_options(url, &RedactionOptions::default())
232}
233
234pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
244 let context = RedactionContext::from_options(redaction_options);
245 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
246}
247
248pub fn parse_size(s: &str) -> Option<u64> {
254 let s = s.trim();
255 if s.is_empty() {
256 return None;
257 }
258 let last = *s.as_bytes().last()?;
259 let (num_str, mult) = match last {
260 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
261 b'K' | b'k' => (&s[..s.len() - 1], 1024),
262 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
263 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
264 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
265 b'0'..=b'9' | b'.' => (s, 1),
266 _ => return None,
267 };
268 if num_str.is_empty() {
269 return None;
270 }
271 if let Ok(n) = num_str.parse::<u64>() {
272 return n.checked_mul(mult);
273 }
274 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
276 return None;
277 }
278 let f: f64 = num_str.parse().ok()?;
279 if f < 0.0 || f.is_nan() || f.is_infinite() {
280 return None;
281 }
282 let result = f * mult as f64;
283 if result >= u64::MAX as f64 {
284 return None;
285 }
286 Some(result as u64)
287}
288
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295pub enum OutputFormat {
296 Json,
297 Yaml,
298 Plain,
299}
300
301pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
311 match s {
312 "json" => Ok(OutputFormat::Json),
313 "yaml" => Ok(OutputFormat::Yaml),
314 "plain" => Ok(OutputFormat::Plain),
315 _ => Err(format!(
316 "invalid --output format '{s}': expected json, yaml, or plain"
317 )),
318 }
319}
320
321pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
331 let mut out: Vec<String> = Vec::new();
332 for entry in entries {
333 let s = entry.as_ref().trim().to_ascii_lowercase();
334 if !s.is_empty() && !out.contains(&s) {
335 out.push(s);
336 }
337 }
338 out
339}
340
341pub fn cli_output(value: &Value, format: OutputFormat) -> String {
352 match format {
353 OutputFormat::Json => output_json(value),
354 OutputFormat::Yaml => output_yaml(value),
355 OutputFormat::Plain => output_plain(value),
356 }
357}
358
359pub fn cli_output_with_options(
364 value: &Value,
365 format: OutputFormat,
366 output_options: &OutputOptions,
367) -> String {
368 match format {
369 OutputFormat::Json => output_json_with_options(value, output_options),
370 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
371 OutputFormat::Plain => output_plain_with_options(value, output_options),
372 }
373}
374
375pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
387 let mut obj = serde_json::Map::new();
388 obj.insert("code".to_string(), Value::String("error".to_string()));
389 obj.insert(
390 "error_code".to_string(),
391 Value::String("invalid_request".to_string()),
392 );
393 obj.insert("error".to_string(), Value::String(message.to_string()));
394 if let Some(h) = hint {
395 obj.insert("hint".to_string(), Value::String(h.to_string()));
396 }
397 obj.insert("retryable".to_string(), Value::Bool(false));
398 obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
399 Value::Object(obj)
400}
401
402#[cfg(feature = "cli-help")]
410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub enum HelpScope {
412 OneLevel,
417 Recursive,
419}
420
421#[cfg(feature = "cli-help")]
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
426pub enum HelpFormat {
427 Plain,
428 Markdown,
429 Json,
430 Yaml,
431}
432
433#[cfg(feature = "cli-help")]
434impl HelpFormat {
435 fn parse(s: &str) -> Option<Self> {
436 match s {
437 "plain" => Some(Self::Plain),
438 "markdown" => Some(Self::Markdown),
439 "json" => Some(Self::Json),
440 "yaml" => Some(Self::Yaml),
441 _ => None,
442 }
443 }
444}
445
446#[cfg(feature = "cli-help")]
450#[derive(Clone, Copy, Debug, PartialEq, Eq)]
451pub struct HelpOptions {
452 pub scope: HelpScope,
453 pub format: HelpFormat,
454}
455
456#[cfg(feature = "cli-help")]
457impl HelpOptions {
458 pub const fn one_level_plain() -> Self {
460 Self {
461 scope: HelpScope::OneLevel,
462 format: HelpFormat::Plain,
463 }
464 }
465
466 pub const fn recursive_plain() -> Self {
468 Self {
469 scope: HelpScope::Recursive,
470 format: HelpFormat::Plain,
471 }
472 }
473}
474
475#[cfg(feature = "cli-help")]
483#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct HelpConfig {
485 pub default_scope: HelpScope,
488 pub default_format: HelpFormat,
490 pub recursive_flag: Option<&'static str>,
497 pub output_flag: Option<&'static str>,
499 pub allow_output_format: bool,
501}
502
503#[cfg(feature = "cli-help")]
504impl HelpConfig {
505 pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
507 Self {
508 default_scope,
509 default_format,
510 recursive_flag: None,
511 output_flag: None,
512 allow_output_format: false,
513 }
514 }
515
516 pub const fn human_cli_default() -> Self {
524 Self {
525 default_scope: HelpScope::OneLevel,
526 default_format: HelpFormat::Plain,
527 recursive_flag: None,
528 output_flag: Some("--output"),
529 allow_output_format: true,
530 }
531 }
532
533 pub const fn agent_cli_default() -> Self {
535 Self {
536 default_scope: HelpScope::Recursive,
537 default_format: HelpFormat::Plain,
538 recursive_flag: None,
539 output_flag: Some("--output"),
540 allow_output_format: true,
541 }
542 }
543
544 pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
546 self.default_scope = scope;
547 self
548 }
549
550 pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
552 self.default_format = format;
553 self
554 }
555
556 pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
558 self.recursive_flag = flag;
559 self
560 }
561
562 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
564 self.output_flag = flag;
565 self
566 }
567
568 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
570 self.allow_output_format = enabled;
571 self
572 }
573}
574
575#[cfg(feature = "cli-help")]
583pub fn cli_render_help_with_options(
584 cmd: &clap::Command,
585 subcommand_path: &[&str],
586 options: &HelpOptions,
587) -> String {
588 let target = walk_to_subcommand(cmd, subcommand_path);
589 let mut rendered = match options.format {
590 HelpFormat::Plain => match options.scope {
591 HelpScope::OneLevel => render_help_one_level_plain(target),
592 HelpScope::Recursive => {
593 let mut buf = String::new();
594 render_help_recursive_plain(target, &[], &mut buf);
595 buf
596 }
597 },
598 HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
599 HelpFormat::Json => {
600 serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
601 }
602 HelpFormat::Yaml => output_yaml_with_options(
603 &build_help_schema(cmd, subcommand_path, options.scope),
604 &OutputOptions {
605 redaction: RedactionOptions {
606 policy: Some(RedactionPolicy::RedactionNone),
607 secret_names: Vec::new(),
608 },
609 style: OutputStyle::Raw,
610 },
611 ),
612 };
613 while rendered.ends_with('\n') {
617 rendered.pop();
618 }
619 rendered.push('\n');
620 rendered
621}
622
623#[cfg(feature = "cli-help")]
630pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
631 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
632}
633
634#[cfg(feature = "cli-help-markdown")]
641pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
642 cli_render_help_with_options(
643 cmd,
644 subcommand_path,
645 &HelpOptions {
646 scope: HelpScope::Recursive,
647 format: HelpFormat::Markdown,
648 },
649 )
650}
651
652#[cfg(feature = "cli-help")]
668pub fn cli_handle_help_or_continue(
669 raw_args: &[String],
670 cmd: &clap::Command,
671 config: &HelpConfig,
672) -> Result<Option<String>, Value> {
673 let parsed = parse_help_request(raw_args, cmd, config);
674 if !parsed.help_requested {
675 return Ok(None);
676 }
677 if let Some(error) = parsed.output_error {
678 return Err(build_cli_error(
679 &error,
680 Some("valid help output formats: plain, markdown, json, yaml"),
681 ));
682 }
683
684 let (scope, format) = resolve_help_options(&parsed, config);
685 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
686 Ok(Some(cli_render_help_with_options(
687 cmd,
688 &path,
689 &HelpOptions { scope, format },
690 )))
691}
692
693#[cfg(feature = "cli-help")]
694fn resolve_help_options(
695 parsed: &ParsedHelpRequest,
696 config: &HelpConfig,
697) -> (HelpScope, HelpFormat) {
698 let scope = if parsed.recursive_requested {
702 HelpScope::Recursive
703 } else {
704 config.default_scope
705 };
706 let format = if config.allow_output_format {
707 parsed.output_format.unwrap_or(config.default_format)
708 } else {
709 config.default_format
710 };
711 (scope, format)
712}
713
714#[cfg(feature = "cli-help")]
715fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
716 let mut current = cmd;
717 for name in path {
718 current = current.find_subcommand(name).unwrap_or(current);
719 }
720 current
721}
722
723#[cfg(feature = "cli-help")]
724fn walk_to_subcommand_with_names<'a>(
725 cmd: &'a clap::Command,
726 path: &[&str],
727) -> (&'a clap::Command, Vec<String>) {
728 let mut current = cmd;
729 let mut names = vec![cmd.get_name().to_string()];
730 for name in path {
731 if let Some(next) = current.find_subcommand(name) {
732 current = next;
733 names.push(next.get_name().to_string());
734 } else {
735 break;
736 }
737 }
738 (current, names)
739}
740
741#[cfg(feature = "cli-help")]
742fn render_help_one_level_plain(cmd: &clap::Command) -> String {
743 enriched_help_command(cmd).render_long_help().to_string()
744}
745
746#[cfg(feature = "cli-help")]
757fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
758 let cmd = cmd.clone();
759 let description = if visible_subcommands(&cmd).next().is_some() {
760 HELP_FLAG_WITH_SUBCOMMANDS
761 } else {
762 HELP_FLAG_LEAF
763 };
764 cmd.disable_help_flag(true).arg(
769 clap::Arg::new("help")
770 .short('h')
771 .long("help")
772 .help(description)
773 .long_help(description)
774 .action(clap::ArgAction::Help),
775 )
776}
777
778#[cfg(feature = "cli-help")]
780const HELP_FLAG_WITH_SUBCOMMANDS: &str =
781 "Print help. Add --recursive to expand every nested subcommand; \
782 add --output json|yaml|markdown to render this help in another format.";
783
784#[cfg(feature = "cli-help")]
786const HELP_FLAG_LEAF: &str =
787 "Print help. Add --output json|yaml|markdown to render this help in another format.";
788
789#[cfg(feature = "cli-help")]
790fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
791 use std::fmt::Write;
792
793 let mut cmd_path = parent_path.to_vec();
795 cmd_path.push(cmd.get_name());
796 let path_str = cmd_path.join(" ");
797
798 if !buf.is_empty() {
800 let _ = writeln!(buf);
801 let _ = writeln!(buf, "{}", "═".repeat(60));
802 }
803
804 if let Some(about) = cmd.get_about() {
806 let _ = writeln!(buf, "{path_str} — {about}");
807 } else {
808 let _ = writeln!(buf, "{path_str}");
809 }
810 let _ = writeln!(buf);
811
812 let is_target = parent_path.is_empty();
816 let styled = if is_target {
817 enriched_help_command(cmd).render_long_help()
818 } else {
819 cmd.clone().render_long_help()
820 };
821 let help_text = styled.to_string();
822 let _ = write!(buf, "{help_text}");
823
824 for sub in cmd.get_subcommands() {
826 if sub.get_name() == "help" || sub.is_hide_set() {
827 continue; }
829 render_help_recursive_plain(sub, &cmd_path, buf);
830 }
831}
832
833#[cfg(feature = "cli-help")]
834fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
835 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
836 let mut buf = String::new();
837 render_markdown_command(target, &names, &mut buf, 1, true);
838 if matches!(scope, HelpScope::Recursive) {
839 render_markdown_descendants(target, &names, &mut buf, 2);
840 }
841 buf
842}
843
844#[cfg(feature = "cli-help")]
845fn render_markdown_descendants(
846 cmd: &clap::Command,
847 parent_names: &[String],
848 buf: &mut String,
849 level: usize,
850) {
851 for sub in cmd.get_subcommands() {
852 if sub.get_name() == "help" || sub.is_hide_set() {
853 continue;
854 }
855 let mut names = parent_names.to_vec();
856 names.push(sub.get_name().to_string());
857 render_markdown_command(sub, &names, buf, level, false);
858 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
859 }
860}
861
862#[cfg(feature = "cli-help")]
863fn render_markdown_command(
864 cmd: &clap::Command,
865 names: &[String],
866 buf: &mut String,
867 level: usize,
868 enrich: bool,
869) {
870 use std::fmt::Write;
871
872 if !buf.is_empty() {
873 let _ = writeln!(buf);
874 }
875 let heading_level = "#".repeat(level.max(1));
876 let path = names.join(" ");
877 if let Some(about) = cmd.get_about() {
878 let _ = writeln!(buf, "{heading_level} {path} - {about}");
879 } else {
880 let _ = writeln!(buf, "{heading_level} {path}");
881 }
882 if let Some(long_about) = cmd.get_long_about() {
883 let _ = writeln!(buf);
884 let _ = writeln!(buf, "{long_about}");
885 }
886 let _ = writeln!(buf);
887 let _ = writeln!(buf, "```text");
888 let help = if enrich {
889 enriched_help_command(cmd).render_long_help()
890 } else {
891 cmd.clone().render_long_help()
892 };
893 write_trimmed_help(buf, &help.to_string());
894 if !buf.ends_with('\n') {
895 let _ = writeln!(buf);
896 }
897 let _ = writeln!(buf, "```");
898}
899
900#[cfg(feature = "cli-help")]
901fn write_trimmed_help(buf: &mut String, help: &str) {
902 use std::fmt::Write;
903
904 for line in help.lines() {
905 let _ = writeln!(buf, "{}", line.trim_end());
906 }
907}
908
909#[cfg(feature = "cli-help")]
910struct ParsedHelpRequest {
911 help_requested: bool,
912 recursive_requested: bool,
913 output_format: Option<HelpFormat>,
914 output_error: Option<String>,
915 subcommand_path: Vec<String>,
916}
917
918#[cfg(feature = "cli-help")]
919fn parse_help_request(
920 raw_args: &[String],
921 cmd: &clap::Command,
922 config: &HelpConfig,
923) -> ParsedHelpRequest {
924 let args = match raw_args.first() {
925 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
926 _ => raw_args.get(1..).unwrap_or(&[]),
927 };
928 let mut help_requested = false;
929 let mut recursive_requested = false;
930 let mut output_format = None;
931 let mut output_error = None;
932 let mut subcommand_path = Vec::new();
933 let mut current = cmd;
934 let output_flag = config.output_flag.map(normalize_long_flag);
935 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
936
937 let mut i = 0usize;
938 while i < args.len() {
939 let arg = args[i].as_str();
940 if arg == "--" {
941 break;
942 }
943
944 let (flag_name, inline_value) = split_flag(arg);
945 if matches!(arg, "--help" | "-h") {
946 help_requested = true;
947 i += 1;
948 continue;
949 }
950 if arg == "--recursive"
955 || flag_name
956 .zip(recursive_flag)
957 .is_some_and(|(seen, expected)| seen == expected)
958 {
959 recursive_requested = true;
960 i += 1;
961 continue;
962 }
963 if config.allow_output_format
964 && flag_name
965 .zip(output_flag)
966 .is_some_and(|(seen, expected)| seen == expected)
967 {
968 let value = inline_value.or_else(|| {
969 args.get(i + 1)
970 .map(String::as_str)
971 .filter(|next| !next.starts_with('-'))
972 });
973 if let Some(value) = value {
974 match HelpFormat::parse(value) {
975 Some(format) => output_format = Some(format),
976 None => {
977 output_error = Some(format!(
978 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
979 output_flag.unwrap_or("output"),
980 value
981 ));
982 }
983 }
984 } else {
985 output_error = Some(format!(
986 "missing value for --{}: expected plain, json, yaml, or markdown",
987 output_flag.unwrap_or("output")
988 ));
989 }
990 i += if inline_value.is_some() || value.is_none() {
991 1
992 } else {
993 2
994 };
995 continue;
996 }
997 if arg.starts_with('-') {
998 i += if inline_value.is_none() && flag_takes_value(current, arg) {
999 2
1000 } else {
1001 1
1002 };
1003 continue;
1004 }
1005 if let Some(sub) = current.find_subcommand(arg) {
1006 if sub.get_name() != "help" && !sub.is_hide_set() {
1007 subcommand_path.push(sub.get_name().to_string());
1008 current = sub;
1009 }
1010 }
1011 i += 1;
1012 }
1013
1014 ParsedHelpRequest {
1015 help_requested,
1016 recursive_requested,
1017 output_format,
1018 output_error,
1019 subcommand_path,
1020 }
1021}
1022
1023#[cfg(feature = "cli-help")]
1024fn normalize_long_flag(flag: &str) -> &str {
1025 flag.trim_start_matches('-')
1026}
1027
1028#[cfg(feature = "cli-help")]
1029fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1030 if let Some(stripped) = arg.strip_prefix("--") {
1031 if let Some((name, value)) = stripped.split_once('=') {
1032 (Some(name), Some(value))
1033 } else {
1034 (Some(stripped), None)
1035 }
1036 } else if let Some(stripped) = arg.strip_prefix('-') {
1037 (Some(stripped), None)
1038 } else {
1039 (None, None)
1040 }
1041}
1042
1043#[cfg(feature = "cli-help")]
1044fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1045 let Some(flag) = raw_flag.strip_prefix('-') else {
1046 return false;
1047 };
1048 let name = flag.trim_start_matches('-');
1049 cmd.get_arguments().any(|arg| {
1050 let long_matches = arg.get_long().is_some_and(|long| long == name);
1051 let short_matches =
1052 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1053 (long_matches || short_matches)
1054 && matches!(
1055 arg.get_action(),
1056 clap::ArgAction::Set | clap::ArgAction::Append
1057 )
1058 })
1059}
1060
1061#[cfg(feature = "cli-help")]
1062fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1063 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1064 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1065 if let Value::Object(map) = &mut schema {
1066 map.insert("code".to_string(), Value::String("help".to_string()));
1067 map.insert(
1068 "scope".to_string(),
1069 Value::String(help_scope_tag(scope).to_string()),
1070 );
1071 }
1072 schema
1073}
1074
1075#[cfg(feature = "cli-help")]
1076fn help_scope_tag(scope: HelpScope) -> &'static str {
1077 match scope {
1078 HelpScope::OneLevel => "one_level",
1079 HelpScope::Recursive => "recursive",
1080 }
1081}
1082
1083#[cfg(feature = "cli-help")]
1084fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1085 let subcommands: Vec<Value> = visible_subcommands(cmd)
1086 .map(|sub| {
1087 let mut child_names = names.to_vec();
1088 child_names.push(sub.get_name().to_string());
1089 if recursive {
1090 command_schema(sub, &child_names, true, false)
1092 } else {
1093 command_summary_schema(sub, &child_names)
1094 }
1095 })
1096 .collect();
1097
1098 serde_json::json!({
1099 "name": cmd.get_name(),
1100 "command_path": names.join(" "),
1101 "path": names,
1102 "about": styled_to_value(cmd.get_about()),
1103 "long_about": styled_to_value(cmd.get_long_about()),
1104 "usage": cmd.clone().render_usage().to_string(),
1105 "arguments": command_arguments_schema(cmd, enrich),
1106 "subcommands": subcommands,
1107 })
1108}
1109
1110#[cfg(feature = "cli-help")]
1111fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1112 serde_json::json!({
1113 "name": cmd.get_name(),
1114 "command_path": names.join(" "),
1115 "path": names,
1116 "about": styled_to_value(cmd.get_about()),
1117 "long_about": styled_to_value(cmd.get_long_about()),
1118 "usage": Value::Null,
1119 "arguments": [],
1120 "subcommands": [],
1121 })
1122}
1123
1124#[cfg(feature = "cli-help")]
1125fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1126 cmd.get_subcommands()
1127 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1128}
1129
1130#[cfg(feature = "cli-help")]
1131fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1132 let owned = enrich.then(|| enriched_help_command(cmd));
1138 let source = owned.as_ref().unwrap_or(cmd);
1139 source
1140 .get_arguments()
1141 .filter(|arg| !arg.is_hide_set())
1142 .map(argument_schema)
1143 .collect()
1144}
1145
1146#[cfg(feature = "cli-help")]
1147fn argument_schema(arg: &clap::Arg) -> Value {
1148 let value_names: Vec<String> = arg
1149 .get_value_names()
1150 .map(|names| names.iter().map(ToString::to_string).collect())
1151 .unwrap_or_default();
1152 let default_values: Vec<String> = arg
1153 .get_default_values()
1154 .iter()
1155 .map(|value| value.to_string_lossy().to_string())
1156 .collect();
1157 serde_json::json!({
1158 "id": arg.get_id().to_string(),
1159 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1160 "long": arg.get_long(),
1161 "short": arg.get_short().map(|c| c.to_string()),
1162 "help": styled_to_value(arg.get_help()),
1163 "long_help": styled_to_value(arg.get_long_help()),
1164 "required": arg.is_required_set(),
1165 "action": format!("{:?}", arg.get_action()),
1166 "value_names": value_names,
1167 "default_values": default_values,
1168 })
1169}
1170
1171#[cfg(feature = "cli-help")]
1172fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1173 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1174}
1175
1176#[derive(Default)]
1181struct RedactionContext {
1182 secret_names: HashSet<String>,
1183}
1184
1185impl RedactionContext {
1186 fn from_options(redaction_options: &RedactionOptions) -> Self {
1187 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1188 Self { secret_names }
1189 }
1190
1191 fn is_secret_key(&self, key: &str) -> bool {
1192 key_has_secret_suffix(key) || self.secret_names.contains(key)
1193 }
1194}
1195
1196fn key_has_secret_suffix(key: &str) -> bool {
1197 key.ends_with("_secret") || key.ends_with("_SECRET")
1198}
1199
1200fn key_has_url_suffix(key: &str) -> bool {
1201 key.ends_with("_url") || key.ends_with("_URL")
1202}
1203
1204fn redact_secrets(value: &mut Value) {
1205 let context = RedactionContext::default();
1206 redact_secrets_with_context(value, &context);
1207}
1208
1209fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1210 match value {
1211 Value::Object(map) => {
1212 let keys: Vec<String> = map.keys().cloned().collect();
1213 for key in keys {
1214 if context.is_secret_key(&key) {
1215 match map.get(&key) {
1216 Some(Value::Object(_)) | Some(Value::Array(_)) => {
1217 }
1219 _ => {
1220 map.insert(key.clone(), Value::String("***".into()));
1221 continue;
1222 }
1223 }
1224 } else if key_has_url_suffix(&key) {
1225 if let Some(Value::String(s)) = map.get_mut(&key) {
1226 if let Some(redacted) = redact_url_in_str(s, context) {
1227 *s = redacted;
1228 }
1229 continue;
1230 }
1231 }
1232 if let Some(v) = map.get_mut(&key) {
1233 redact_secrets_with_context(v, context);
1234 }
1235 }
1236 }
1237 Value::Array(arr) => {
1238 for v in arr {
1239 redact_secrets_with_context(v, context);
1240 }
1241 }
1242 _ => {}
1243 }
1244}
1245
1246fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
1247 match value {
1248 Value::Object(map) => {
1249 let keys: Vec<String> = map.keys().cloned().collect();
1250 for key in keys {
1251 if context.is_secret_key(&key) {
1252 map.insert(key, Value::String("***".into()));
1253 } else if key_has_url_suffix(&key) {
1254 if let Some(Value::String(s)) = map.get_mut(&key) {
1255 if let Some(redacted) = redact_url_in_str(s, context) {
1256 *s = redacted;
1257 }
1258 } else if let Some(v) = map.get_mut(&key) {
1259 redact_secrets_strict_with_context(v, context);
1260 }
1261 } else if let Some(v) = map.get_mut(&key) {
1262 redact_secrets_strict_with_context(v, context);
1263 }
1264 }
1265 }
1266 Value::Array(arr) => {
1267 for v in arr {
1268 redact_secrets_strict_with_context(v, context);
1269 }
1270 }
1271 _ => {}
1272 }
1273}
1274
1275fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1279 if !s.contains("://") || !is_single_url(s) {
1286 return None;
1287 }
1288 let scheme_sep = s.find("://")?;
1289 let scheme = &s[..scheme_sep];
1290 let rest = &s[scheme_sep + 3..];
1291
1292 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1294 let authority = &rest[..auth_end];
1295 let remainder = &rest[auth_end..];
1296
1297 let new_authority = redact_userinfo_password(authority);
1298
1299 let new_remainder = match remainder.find('?') {
1301 Some(q) => {
1302 let (path, q_onwards) = remainder.split_at(q);
1303 let query_body = &q_onwards[1..];
1304 let (query, fragment) = match query_body.find('#') {
1305 Some(h) => (&query_body[..h], &query_body[h..]),
1306 None => (query_body, ""),
1307 };
1308 format!("{path}?{}{fragment}", redact_query(query, context))
1309 }
1310 None => remainder.to_string(),
1311 };
1312
1313 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1314}
1315
1316fn redact_userinfo_password(authority: &str) -> String {
1319 let Some(at) = authority.find('@') else {
1320 return authority.to_string();
1321 };
1322 let userinfo = &authority[..at];
1323 match userinfo.find(':') {
1324 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1325 None => authority.to_string(),
1326 }
1327}
1328
1329fn redact_query(query: &str, context: &RedactionContext) -> String {
1332 query
1333 .split('&')
1334 .map(|segment| {
1335 let Some(eq) = segment.find('=') else {
1336 return segment.to_string();
1337 };
1338 let raw_key = &segment[..eq];
1339 let name = url::form_urlencoded::parse(segment.as_bytes())
1341 .next()
1342 .map(|(k, _)| k.into_owned())
1343 .unwrap_or_default();
1344 if context.is_secret_key(&name) {
1345 format!("{raw_key}=***")
1346 } else {
1347 segment.to_string()
1348 }
1349 })
1350 .collect::<Vec<_>>()
1351 .join("&")
1352}
1353
1354fn is_single_url(s: &str) -> bool {
1358 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1359 return false;
1360 }
1361 let bytes = s.as_bytes();
1362 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1363 return false;
1364 }
1365 let mut i = 1;
1366 while i < bytes.len() {
1367 let c = bytes[i];
1368 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1369 i += 1;
1370 } else {
1371 break;
1372 }
1373 }
1374 s[i..].starts_with("://")
1375}
1376
1377fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1378 let context = RedactionContext::default();
1379 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1380}
1381
1382fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1383 let context = RedactionContext::from_options(redaction_options);
1384 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1385}
1386
1387fn apply_redaction_policy_with_context(
1388 value: &mut Value,
1389 redaction_policy: Option<RedactionPolicy>,
1390 context: &RedactionContext,
1391) {
1392 match redaction_policy {
1393 Some(RedactionPolicy::RedactionTraceOnly) => {
1394 if let Value::Object(map) = value {
1395 if let Some(trace) = map.get_mut("trace") {
1396 redact_secrets_with_context(trace, context);
1397 }
1398 }
1399 }
1400 Some(RedactionPolicy::RedactionNone) => {}
1401 Some(RedactionPolicy::RedactionStrict) => {
1402 redact_secrets_strict_with_context(value, context)
1403 }
1404 None => redact_secrets_with_context(value, context),
1405 }
1406}
1407
1408fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1414 if let Some(s) = key.strip_suffix(suffix_lower) {
1415 return Some(s.to_string());
1416 }
1417 let suffix_upper: String = suffix_lower
1418 .chars()
1419 .map(|c| c.to_ascii_uppercase())
1420 .collect();
1421 if let Some(s) = key.strip_suffix(&suffix_upper) {
1422 return Some(s.to_string());
1423 }
1424 None
1425}
1426
1427fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1429 let code = extract_currency_code(key)?;
1430 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1432 if stripped.is_empty() {
1433 return None;
1434 }
1435 Some((stripped.to_string(), code.to_string()))
1436}
1437
1438fn as_int(value: &Value) -> Option<i64> {
1446 if let Some(i) = value.as_i64() {
1447 return Some(i);
1448 }
1449 let f = value.as_f64()?;
1450 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1451 return Some(f as i64);
1452 }
1453 None
1454}
1455
1456fn as_uint(value: &Value) -> Option<u64> {
1458 if let Some(u) = value.as_u64() {
1459 return Some(u);
1460 }
1461 let f = value.as_f64()?;
1462 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1463 return Some(f as u64);
1464 }
1465 None
1466}
1467
1468fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1469 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1471 return as_int(value).map(|ms| (stripped, format_rfc3339_ms(ms)));
1472 }
1473 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1474 return as_int(value)
1475 .and_then(|s| s.checked_mul(1000))
1476 .map(|ms| (stripped, format_rfc3339_ms(ms)));
1477 }
1478 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1479 return as_int(value).map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
1480 }
1481
1482 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1484 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1485 }
1486 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1487 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1488 }
1489 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1490 return as_uint(value).map(|n| {
1491 (
1492 stripped,
1493 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1494 )
1495 });
1496 }
1497
1498 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1500 return value.as_str().map(|s| (stripped, s.to_string()));
1501 }
1502 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1503 return value
1504 .is_number()
1505 .then(|| (stripped, format!("{} minutes", number_str(value))));
1506 }
1507 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1508 return value
1509 .is_number()
1510 .then(|| (stripped, format!("{} hours", number_str(value))));
1511 }
1512 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1513 return value
1514 .is_number()
1515 .then(|| (stripped, format!("{} days", number_str(value))));
1516 }
1517
1518 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1520 return value
1521 .is_number()
1522 .then(|| (stripped, format!("{}msats", number_str(value))));
1523 }
1524 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1525 return value
1526 .is_number()
1527 .then(|| (stripped, format!("{}sats", number_str(value))));
1528 }
1529 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1530 return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1531 }
1532 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1533 return value
1534 .is_number()
1535 .then(|| (stripped, format!("{}%", number_str(value))));
1536 }
1537 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1538 return Some((stripped, "***".to_string()));
1539 }
1540
1541 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1543 return value
1544 .is_number()
1545 .then(|| (stripped, format!("{} BTC", number_str(value))));
1546 }
1547 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1548 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1549 }
1550 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1551 return value
1552 .is_number()
1553 .then(|| (stripped, format!("{}ns", number_str(value))));
1554 }
1555 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1556 return value
1557 .is_number()
1558 .then(|| (stripped, format!("{}μs", number_str(value))));
1559 }
1560 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1561 return format_ms_value(value).map(|v| (stripped, v));
1562 }
1563 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1564 return value
1565 .is_number()
1566 .then(|| (stripped, format!("{}s", number_str(value))));
1567 }
1568
1569 None
1570}
1571
1572fn process_object_fields<'a>(
1574 map: &'a serde_json::Map<String, Value>,
1575) -> Vec<(String, &'a Value, Option<String>)> {
1576 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1577 for (key, value) in map {
1578 match try_process_field(key, value) {
1579 Some((stripped, formatted)) => {
1580 entries.push((stripped, key.as_str(), value, Some(formatted)));
1581 }
1582 None => {
1583 entries.push((key.clone(), key.as_str(), value, None));
1584 }
1585 }
1586 }
1587
1588 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1590 for (stripped, _, _, _) in &entries {
1591 *counts.entry(stripped.clone()).or_insert(0) += 1;
1592 }
1593
1594 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1596 .into_iter()
1597 .map(|(stripped, original, value, formatted)| {
1598 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1599 (original.to_string(), value, None)
1600 } else {
1601 (stripped, value, formatted)
1602 }
1603 })
1604 .collect();
1605
1606 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1607 result
1608}
1609
1610fn number_str(value: &Value) -> String {
1615 match value {
1616 Value::Number(n) => format_number(n),
1617 _ => String::new(),
1618 }
1619}
1620
1621fn format_number(n: &serde_json::Number) -> String {
1627 if n.is_f64() {
1628 if let Some(f) = n.as_f64() {
1629 if f.is_finite() && f.fract() == 0.0 {
1630 return format!("{f:.0}");
1631 }
1632 }
1633 }
1634 n.to_string()
1635}
1636
1637fn format_ms_as_seconds(ms: f64) -> String {
1639 let formatted = format!("{:.3}", ms / 1000.0);
1640 let trimmed = formatted.trim_end_matches('0');
1641 if trimmed.ends_with('.') {
1642 format!("{}0s", trimmed)
1643 } else {
1644 format!("{}s", trimmed)
1645 }
1646}
1647
1648fn format_ms_value(value: &Value) -> Option<String> {
1650 let n = value.as_f64()?;
1651 if n.abs() >= 1000.0 {
1652 Some(format_ms_as_seconds(n))
1653 } else if let Some(i) = value.as_i64() {
1654 Some(format!("{}ms", i))
1655 } else {
1656 Some(format!("{}ms", number_str(value)))
1657 }
1658}
1659
1660fn format_rfc3339_ms(ms: i64) -> String {
1662 use chrono::{DateTime, Utc};
1663 let secs = ms.div_euclid(1000);
1664 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1665 match DateTime::from_timestamp(secs, nanos) {
1666 Some(dt) => dt
1667 .with_timezone(&Utc)
1668 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1669 None => ms.to_string(),
1670 }
1671}
1672
1673fn format_bytes_human(bytes: i64) -> String {
1675 const KB: f64 = 1024.0;
1676 const MB: f64 = KB * 1024.0;
1677 const GB: f64 = MB * 1024.0;
1678 const TB: f64 = GB * 1024.0;
1679
1680 let sign = if bytes < 0 { "-" } else { "" };
1681 let b = (bytes as f64).abs();
1682 if b >= TB {
1683 format!("{sign}{:.1}TB", b / TB)
1684 } else if b >= GB {
1685 format!("{sign}{:.1}GB", b / GB)
1686 } else if b >= MB {
1687 format!("{sign}{:.1}MB", b / MB)
1688 } else if b >= KB {
1689 format!("{sign}{:.1}KB", b / KB)
1690 } else {
1691 format!("{bytes}B")
1692 }
1693}
1694
1695fn format_with_commas(n: u64) -> String {
1697 let s = n.to_string();
1698 let mut result = String::with_capacity(s.len() + s.len() / 3);
1699 for (i, c) in s.chars().enumerate() {
1700 if i > 0 && (s.len() - i).is_multiple_of(3) {
1701 result.push(',');
1702 }
1703 result.push(c);
1704 }
1705 result
1706}
1707
1708fn extract_currency_code(key: &str) -> Option<&str> {
1710 let without_cents = key
1711 .strip_suffix("_cents")
1712 .or_else(|| key.strip_suffix("_CENTS"))?;
1713 let last_underscore = without_cents.rfind('_')?;
1714 let code = &without_cents[last_underscore + 1..];
1715 if code.is_empty() {
1716 return None;
1717 }
1718 Some(code)
1719}
1720
1721fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1726 let prefix = " ".repeat(indent);
1727 match value {
1728 Value::Object(map) => {
1729 let processed = process_object_fields(map);
1730 for (display_key, v, formatted) in processed {
1731 if let Some(fv) = formatted {
1732 lines.push(format!(
1733 "{}{}: \"{}\"",
1734 prefix,
1735 display_key,
1736 escape_yaml_str(&fv)
1737 ));
1738 } else {
1739 match v {
1740 Value::Object(inner) if !inner.is_empty() => {
1741 lines.push(format!("{}{}:", prefix, display_key));
1742 render_yaml_processed(v, indent + 1, lines);
1743 }
1744 Value::Object(_) => {
1745 lines.push(format!("{}{}: {{}}", prefix, display_key));
1746 }
1747 Value::Array(arr) => {
1748 if arr.is_empty() {
1749 lines.push(format!("{}{}: []", prefix, display_key));
1750 } else {
1751 lines.push(format!("{}{}:", prefix, display_key));
1752 for item in arr {
1753 if item.is_object() {
1754 lines.push(format!("{} -", prefix));
1755 render_yaml_processed(item, indent + 2, lines);
1756 } else {
1757 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
1758 }
1759 }
1760 }
1761 }
1762 _ => {
1763 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1764 }
1765 }
1766 }
1767 }
1768 }
1769 _ => {
1770 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1771 }
1772 }
1773}
1774
1775fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1776 let prefix = " ".repeat(indent);
1777 match value {
1778 Value::Object(map) => {
1779 for (key, v) in map {
1780 render_yaml_field_raw(&prefix, key, v, indent, lines);
1781 }
1782 }
1783 Value::Array(arr) => {
1784 render_yaml_array_raw(arr, indent, lines);
1785 }
1786 _ => {
1787 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1788 }
1789 }
1790}
1791
1792fn render_yaml_field_raw(
1793 prefix: &str,
1794 key: &str,
1795 value: &Value,
1796 indent: usize,
1797 lines: &mut Vec<String>,
1798) {
1799 match value {
1800 Value::Object(inner) if !inner.is_empty() => {
1801 lines.push(format!("{}{}:", prefix, key));
1802 render_yaml_raw(value, indent + 1, lines);
1803 }
1804 Value::Object(_) => {
1805 lines.push(format!("{}{}: {{}}", prefix, key));
1806 }
1807 Value::Array(arr) => {
1808 if arr.is_empty() {
1809 lines.push(format!("{}{}: []", prefix, key));
1810 } else {
1811 lines.push(format!("{}{}:", prefix, key));
1812 render_yaml_array_raw(arr, indent + 1, lines);
1813 }
1814 }
1815 _ => {
1816 lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1817 }
1818 }
1819}
1820
1821fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1822 let prefix = " ".repeat(indent);
1823 for item in arr {
1824 match item {
1825 Value::Object(inner) if !inner.is_empty() => {
1826 lines.push(format!("{}-", prefix));
1827 render_yaml_raw(item, indent + 1, lines);
1828 }
1829 Value::Array(nested) if !nested.is_empty() => {
1830 lines.push(format!("{}-", prefix));
1831 render_yaml_array_raw(nested, indent + 1, lines);
1832 }
1833 Value::Object(_) => {
1834 lines.push(format!("{}- {{}}", prefix));
1835 }
1836 Value::Array(_) => {
1837 lines.push(format!("{}- []", prefix));
1838 }
1839 _ => {
1840 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1841 }
1842 }
1843 }
1844}
1845
1846fn escape_yaml_str(s: &str) -> String {
1847 s.replace('\\', "\\\\")
1848 .replace('"', "\\\"")
1849 .replace('\n', "\\n")
1850 .replace('\r', "\\r")
1851 .replace('\t', "\\t")
1852}
1853
1854fn yaml_scalar(value: &Value) -> String {
1855 match value {
1856 Value::String(s) => {
1857 format!("\"{}\"", escape_yaml_str(s))
1858 }
1859 Value::Null => "null".to_string(),
1860 Value::Bool(b) => b.to_string(),
1861 Value::Number(n) => format_number(n),
1862 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1863 }
1864}
1865
1866fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1871 if let Value::Object(map) = value {
1872 let processed = process_object_fields(map);
1873 for (display_key, v, formatted) in processed {
1874 let full_key = if prefix.is_empty() {
1875 display_key
1876 } else {
1877 format!("{}.{}", prefix, display_key)
1878 };
1879 if let Some(fv) = formatted {
1880 pairs.push((full_key, fv));
1881 } else {
1882 match v {
1883 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1884 Value::Array(arr) => {
1885 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1886 pairs.push((full_key, joined));
1887 }
1888 Value::Null => pairs.push((full_key, String::new())),
1889 _ => pairs.push((full_key, plain_scalar(v))),
1890 }
1891 }
1892 }
1893 }
1894}
1895
1896fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1897 if let Value::Object(map) = value {
1898 for (key, v) in map {
1899 let full_key = if prefix.is_empty() {
1900 key.clone()
1901 } else {
1902 format!("{}.{}", prefix, key)
1903 };
1904 match v {
1905 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1906 Value::Array(arr) => {
1907 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1908 pairs.push((full_key, joined));
1909 }
1910 Value::Null => pairs.push((full_key, String::new())),
1911 _ => pairs.push((full_key, plain_scalar(v))),
1912 }
1913 }
1914 }
1915}
1916
1917fn plain_scalar(value: &Value) -> String {
1918 match value {
1919 Value::String(s) => s.clone(),
1920 Value::Null => "null".to_string(),
1921 Value::Bool(b) => b.to_string(),
1922 Value::Number(n) => format_number(n),
1923 other => other.to_string(),
1924 }
1925}
1926
1927fn quote_logfmt_value(value: &str) -> String {
1928 if value.is_empty() {
1929 return String::new();
1930 }
1931 if !value
1932 .chars()
1933 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1934 {
1935 return value.to_string();
1936 }
1937 let escaped = value
1938 .replace('\\', "\\\\")
1939 .replace('"', "\\\"")
1940 .replace('\n', "\\n")
1941 .replace('\r', "\\r")
1942 .replace('\t', "\\t");
1943 format!("\"{}\"", escaped)
1944}
1945
1946#[cfg(test)]
1947mod tests;