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) || url::Url::parse(s).is_err() {
1281 return None;
1282 }
1283 let scheme_sep = s.find("://")?;
1284 let scheme = &s[..scheme_sep];
1285 let rest = &s[scheme_sep + 3..];
1286
1287 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1289 let authority = &rest[..auth_end];
1290 let remainder = &rest[auth_end..];
1291
1292 let new_authority = redact_userinfo_password(authority);
1293
1294 let new_remainder = match remainder.find('?') {
1296 Some(q) => {
1297 let (path, q_onwards) = remainder.split_at(q);
1298 let query_body = &q_onwards[1..];
1299 let (query, fragment) = match query_body.find('#') {
1300 Some(h) => (&query_body[..h], &query_body[h..]),
1301 None => (query_body, ""),
1302 };
1303 format!("{path}?{}{fragment}", redact_query(query, context))
1304 }
1305 None => remainder.to_string(),
1306 };
1307
1308 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1309}
1310
1311fn redact_userinfo_password(authority: &str) -> String {
1314 let Some(at) = authority.find('@') else {
1315 return authority.to_string();
1316 };
1317 let userinfo = &authority[..at];
1318 match userinfo.find(':') {
1319 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1320 None => authority.to_string(),
1321 }
1322}
1323
1324fn redact_query(query: &str, context: &RedactionContext) -> String {
1327 query
1328 .split('&')
1329 .map(|segment| {
1330 let Some(eq) = segment.find('=') else {
1331 return segment.to_string();
1332 };
1333 let raw_key = &segment[..eq];
1334 let name = url::form_urlencoded::parse(segment.as_bytes())
1336 .next()
1337 .map(|(k, _)| k.into_owned())
1338 .unwrap_or_default();
1339 if context.is_secret_key(&name) {
1340 format!("{raw_key}=***")
1341 } else {
1342 segment.to_string()
1343 }
1344 })
1345 .collect::<Vec<_>>()
1346 .join("&")
1347}
1348
1349fn is_single_url(s: &str) -> bool {
1353 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1354 return false;
1355 }
1356 let bytes = s.as_bytes();
1357 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1358 return false;
1359 }
1360 let mut i = 1;
1361 while i < bytes.len() {
1362 let c = bytes[i];
1363 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1364 i += 1;
1365 } else {
1366 break;
1367 }
1368 }
1369 s[i..].starts_with("://")
1370}
1371
1372fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1373 let context = RedactionContext::default();
1374 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1375}
1376
1377fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1378 let context = RedactionContext::from_options(redaction_options);
1379 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1380}
1381
1382fn apply_redaction_policy_with_context(
1383 value: &mut Value,
1384 redaction_policy: Option<RedactionPolicy>,
1385 context: &RedactionContext,
1386) {
1387 match redaction_policy {
1388 Some(RedactionPolicy::RedactionTraceOnly) => {
1389 if let Value::Object(map) = value {
1390 if let Some(trace) = map.get_mut("trace") {
1391 redact_secrets_with_context(trace, context);
1392 }
1393 }
1394 }
1395 Some(RedactionPolicy::RedactionNone) => {}
1396 Some(RedactionPolicy::RedactionStrict) => {
1397 redact_secrets_strict_with_context(value, context)
1398 }
1399 None => redact_secrets_with_context(value, context),
1400 }
1401}
1402
1403fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1409 if let Some(s) = key.strip_suffix(suffix_lower) {
1410 return Some(s.to_string());
1411 }
1412 let suffix_upper: String = suffix_lower
1413 .chars()
1414 .map(|c| c.to_ascii_uppercase())
1415 .collect();
1416 if let Some(s) = key.strip_suffix(&suffix_upper) {
1417 return Some(s.to_string());
1418 }
1419 None
1420}
1421
1422fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1424 let code = extract_currency_code(key)?;
1425 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1427 if stripped.is_empty() {
1428 return None;
1429 }
1430 Some((stripped.to_string(), code.to_string()))
1431}
1432
1433fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1436 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1438 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
1439 }
1440 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1441 return value
1442 .as_i64()
1443 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
1444 }
1445 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1446 return value
1447 .as_i64()
1448 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
1449 }
1450
1451 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1453 return value
1454 .as_u64()
1455 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1456 }
1457 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1458 return value
1459 .as_u64()
1460 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1461 }
1462 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1463 return value.as_u64().map(|n| {
1464 (
1465 stripped,
1466 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1467 )
1468 });
1469 }
1470
1471 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1473 return value.as_str().map(|s| (stripped, s.to_string()));
1474 }
1475 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1476 return value
1477 .is_number()
1478 .then(|| (stripped, format!("{} minutes", number_str(value))));
1479 }
1480 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1481 return value
1482 .is_number()
1483 .then(|| (stripped, format!("{} hours", number_str(value))));
1484 }
1485 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1486 return value
1487 .is_number()
1488 .then(|| (stripped, format!("{} days", number_str(value))));
1489 }
1490
1491 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1493 return value
1494 .is_number()
1495 .then(|| (stripped, format!("{}msats", number_str(value))));
1496 }
1497 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1498 return value
1499 .is_number()
1500 .then(|| (stripped, format!("{}sats", number_str(value))));
1501 }
1502 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1503 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
1504 }
1505 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1506 return value
1507 .is_number()
1508 .then(|| (stripped, format!("{}%", number_str(value))));
1509 }
1510 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1511 return Some((stripped, "***".to_string()));
1512 }
1513
1514 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1516 return value
1517 .is_number()
1518 .then(|| (stripped, format!("{} BTC", number_str(value))));
1519 }
1520 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1521 return value
1522 .as_u64()
1523 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1524 }
1525 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1526 return value
1527 .is_number()
1528 .then(|| (stripped, format!("{}ns", number_str(value))));
1529 }
1530 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1531 return value
1532 .is_number()
1533 .then(|| (stripped, format!("{}μs", number_str(value))));
1534 }
1535 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1536 return format_ms_value(value).map(|v| (stripped, v));
1537 }
1538 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1539 return value
1540 .is_number()
1541 .then(|| (stripped, format!("{}s", number_str(value))));
1542 }
1543
1544 None
1545}
1546
1547fn process_object_fields<'a>(
1549 map: &'a serde_json::Map<String, Value>,
1550) -> Vec<(String, &'a Value, Option<String>)> {
1551 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1552 for (key, value) in map {
1553 match try_process_field(key, value) {
1554 Some((stripped, formatted)) => {
1555 entries.push((stripped, key.as_str(), value, Some(formatted)));
1556 }
1557 None => {
1558 entries.push((key.clone(), key.as_str(), value, None));
1559 }
1560 }
1561 }
1562
1563 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1565 for (stripped, _, _, _) in &entries {
1566 *counts.entry(stripped.clone()).or_insert(0) += 1;
1567 }
1568
1569 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1571 .into_iter()
1572 .map(|(stripped, original, value, formatted)| {
1573 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1574 (original.to_string(), value, None)
1575 } else {
1576 (stripped, value, formatted)
1577 }
1578 })
1579 .collect();
1580
1581 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1582 result
1583}
1584
1585fn number_str(value: &Value) -> String {
1590 match value {
1591 Value::Number(n) => n.to_string(),
1592 _ => String::new(),
1593 }
1594}
1595
1596fn format_ms_as_seconds(ms: f64) -> String {
1598 let formatted = format!("{:.3}", ms / 1000.0);
1599 let trimmed = formatted.trim_end_matches('0');
1600 if trimmed.ends_with('.') {
1601 format!("{}0s", trimmed)
1602 } else {
1603 format!("{}s", trimmed)
1604 }
1605}
1606
1607fn format_ms_value(value: &Value) -> Option<String> {
1609 let n = value.as_f64()?;
1610 if n.abs() >= 1000.0 {
1611 Some(format_ms_as_seconds(n))
1612 } else if let Some(i) = value.as_i64() {
1613 Some(format!("{}ms", i))
1614 } else {
1615 Some(format!("{}ms", number_str(value)))
1616 }
1617}
1618
1619fn format_rfc3339_ms(ms: i64) -> String {
1621 use chrono::{DateTime, Utc};
1622 let secs = ms.div_euclid(1000);
1623 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1624 match DateTime::from_timestamp(secs, nanos) {
1625 Some(dt) => dt
1626 .with_timezone(&Utc)
1627 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1628 None => ms.to_string(),
1629 }
1630}
1631
1632fn format_bytes_human(bytes: i64) -> String {
1634 const KB: f64 = 1024.0;
1635 const MB: f64 = KB * 1024.0;
1636 const GB: f64 = MB * 1024.0;
1637 const TB: f64 = GB * 1024.0;
1638
1639 let sign = if bytes < 0 { "-" } else { "" };
1640 let b = (bytes as f64).abs();
1641 if b >= TB {
1642 format!("{sign}{:.1}TB", b / TB)
1643 } else if b >= GB {
1644 format!("{sign}{:.1}GB", b / GB)
1645 } else if b >= MB {
1646 format!("{sign}{:.1}MB", b / MB)
1647 } else if b >= KB {
1648 format!("{sign}{:.1}KB", b / KB)
1649 } else {
1650 format!("{bytes}B")
1651 }
1652}
1653
1654fn format_with_commas(n: u64) -> String {
1656 let s = n.to_string();
1657 let mut result = String::with_capacity(s.len() + s.len() / 3);
1658 for (i, c) in s.chars().enumerate() {
1659 if i > 0 && (s.len() - i).is_multiple_of(3) {
1660 result.push(',');
1661 }
1662 result.push(c);
1663 }
1664 result
1665}
1666
1667fn extract_currency_code(key: &str) -> Option<&str> {
1669 let without_cents = key
1670 .strip_suffix("_cents")
1671 .or_else(|| key.strip_suffix("_CENTS"))?;
1672 let last_underscore = without_cents.rfind('_')?;
1673 let code = &without_cents[last_underscore + 1..];
1674 if code.is_empty() {
1675 return None;
1676 }
1677 Some(code)
1678}
1679
1680fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1685 let prefix = " ".repeat(indent);
1686 match value {
1687 Value::Object(map) => {
1688 let processed = process_object_fields(map);
1689 for (display_key, v, formatted) in processed {
1690 if let Some(fv) = formatted {
1691 lines.push(format!(
1692 "{}{}: \"{}\"",
1693 prefix,
1694 display_key,
1695 escape_yaml_str(&fv)
1696 ));
1697 } else {
1698 match v {
1699 Value::Object(inner) if !inner.is_empty() => {
1700 lines.push(format!("{}{}:", prefix, display_key));
1701 render_yaml_processed(v, indent + 1, lines);
1702 }
1703 Value::Object(_) => {
1704 lines.push(format!("{}{}: {{}}", prefix, display_key));
1705 }
1706 Value::Array(arr) => {
1707 if arr.is_empty() {
1708 lines.push(format!("{}{}: []", prefix, display_key));
1709 } else {
1710 lines.push(format!("{}{}:", prefix, display_key));
1711 for item in arr {
1712 if item.is_object() {
1713 lines.push(format!("{} -", prefix));
1714 render_yaml_processed(item, indent + 2, lines);
1715 } else {
1716 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
1717 }
1718 }
1719 }
1720 }
1721 _ => {
1722 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1723 }
1724 }
1725 }
1726 }
1727 }
1728 _ => {
1729 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1730 }
1731 }
1732}
1733
1734fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1735 let prefix = " ".repeat(indent);
1736 match value {
1737 Value::Object(map) => {
1738 for (key, v) in map {
1739 render_yaml_field_raw(&prefix, key, v, indent, lines);
1740 }
1741 }
1742 Value::Array(arr) => {
1743 render_yaml_array_raw(arr, indent, lines);
1744 }
1745 _ => {
1746 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1747 }
1748 }
1749}
1750
1751fn render_yaml_field_raw(
1752 prefix: &str,
1753 key: &str,
1754 value: &Value,
1755 indent: usize,
1756 lines: &mut Vec<String>,
1757) {
1758 match value {
1759 Value::Object(inner) if !inner.is_empty() => {
1760 lines.push(format!("{}{}:", prefix, key));
1761 render_yaml_raw(value, indent + 1, lines);
1762 }
1763 Value::Object(_) => {
1764 lines.push(format!("{}{}: {{}}", prefix, key));
1765 }
1766 Value::Array(arr) => {
1767 if arr.is_empty() {
1768 lines.push(format!("{}{}: []", prefix, key));
1769 } else {
1770 lines.push(format!("{}{}:", prefix, key));
1771 render_yaml_array_raw(arr, indent + 1, lines);
1772 }
1773 }
1774 _ => {
1775 lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1776 }
1777 }
1778}
1779
1780fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1781 let prefix = " ".repeat(indent);
1782 for item in arr {
1783 match item {
1784 Value::Object(inner) if !inner.is_empty() => {
1785 lines.push(format!("{}-", prefix));
1786 render_yaml_raw(item, indent + 1, lines);
1787 }
1788 Value::Array(nested) if !nested.is_empty() => {
1789 lines.push(format!("{}-", prefix));
1790 render_yaml_array_raw(nested, indent + 1, lines);
1791 }
1792 Value::Object(_) => {
1793 lines.push(format!("{}- {{}}", prefix));
1794 }
1795 Value::Array(_) => {
1796 lines.push(format!("{}- []", prefix));
1797 }
1798 _ => {
1799 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1800 }
1801 }
1802 }
1803}
1804
1805fn escape_yaml_str(s: &str) -> String {
1806 s.replace('\\', "\\\\")
1807 .replace('"', "\\\"")
1808 .replace('\n', "\\n")
1809 .replace('\r', "\\r")
1810 .replace('\t', "\\t")
1811}
1812
1813fn yaml_scalar(value: &Value) -> String {
1814 match value {
1815 Value::String(s) => {
1816 format!("\"{}\"", escape_yaml_str(s))
1817 }
1818 Value::Null => "null".to_string(),
1819 Value::Bool(b) => b.to_string(),
1820 Value::Number(n) => n.to_string(),
1821 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1822 }
1823}
1824
1825fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1830 if let Value::Object(map) = value {
1831 let processed = process_object_fields(map);
1832 for (display_key, v, formatted) in processed {
1833 let full_key = if prefix.is_empty() {
1834 display_key
1835 } else {
1836 format!("{}.{}", prefix, display_key)
1837 };
1838 if let Some(fv) = formatted {
1839 pairs.push((full_key, fv));
1840 } else {
1841 match v {
1842 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1843 Value::Array(arr) => {
1844 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1845 pairs.push((full_key, joined));
1846 }
1847 Value::Null => pairs.push((full_key, String::new())),
1848 _ => pairs.push((full_key, plain_scalar(v))),
1849 }
1850 }
1851 }
1852 }
1853}
1854
1855fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1856 if let Value::Object(map) = value {
1857 for (key, v) in map {
1858 let full_key = if prefix.is_empty() {
1859 key.clone()
1860 } else {
1861 format!("{}.{}", prefix, key)
1862 };
1863 match v {
1864 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1865 Value::Array(arr) => {
1866 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1867 pairs.push((full_key, joined));
1868 }
1869 Value::Null => pairs.push((full_key, String::new())),
1870 _ => pairs.push((full_key, plain_scalar(v))),
1871 }
1872 }
1873 }
1874}
1875
1876fn plain_scalar(value: &Value) -> String {
1877 match value {
1878 Value::String(s) => s.clone(),
1879 Value::Null => "null".to_string(),
1880 Value::Bool(b) => b.to_string(),
1881 Value::Number(n) => n.to_string(),
1882 other => other.to_string(),
1883 }
1884}
1885
1886fn quote_logfmt_value(value: &str) -> String {
1887 if value.is_empty() {
1888 return String::new();
1889 }
1890 if !value
1891 .chars()
1892 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1893 {
1894 return value.to_string();
1895 }
1896 let escaped = value
1897 .replace('\\', "\\\\")
1898 .replace('"', "\\\"")
1899 .replace('\n', "\\n")
1900 .replace('\r', "\\r")
1901 .replace('\t', "\\t");
1902 format!("\"{}\"", escaped)
1903}
1904
1905#[cfg(test)]
1906mod tests;