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 => {
592 let mut help = render_help_one_level_plain(target);
596 append_recursive_help_hint(&mut help, target);
597 help
598 }
599 HelpScope::Recursive => {
600 let mut buf = String::new();
601 render_help_recursive_plain(target, &[], &mut buf);
602 buf
603 }
604 },
605 HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
606 HelpFormat::Json => {
607 serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
608 }
609 HelpFormat::Yaml => output_yaml_with_options(
610 &build_help_schema(cmd, subcommand_path, options.scope),
611 &OutputOptions {
612 redaction: RedactionOptions {
613 policy: Some(RedactionPolicy::RedactionNone),
614 secret_names: Vec::new(),
615 },
616 style: OutputStyle::Raw,
617 },
618 ),
619 };
620 while rendered.ends_with('\n') {
624 rendered.pop();
625 }
626 rendered.push('\n');
627 rendered
628}
629
630#[cfg(feature = "cli-help")]
637pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
638 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
639}
640
641#[cfg(feature = "cli-help-markdown")]
648pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
649 cli_render_help_with_options(
650 cmd,
651 subcommand_path,
652 &HelpOptions {
653 scope: HelpScope::Recursive,
654 format: HelpFormat::Markdown,
655 },
656 )
657}
658
659#[cfg(feature = "cli-help")]
675pub fn cli_handle_help_or_continue(
676 raw_args: &[String],
677 cmd: &clap::Command,
678 config: &HelpConfig,
679) -> Result<Option<String>, Value> {
680 let parsed = parse_help_request(raw_args, cmd, config);
681 if !parsed.help_requested {
682 return Ok(None);
683 }
684 if let Some(error) = parsed.output_error {
685 return Err(build_cli_error(
686 &error,
687 Some("valid help output formats: plain, markdown, json, yaml"),
688 ));
689 }
690
691 let (scope, format) = resolve_help_options(&parsed, config);
692 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
693 Ok(Some(cli_render_help_with_options(
694 cmd,
695 &path,
696 &HelpOptions { scope, format },
697 )))
698}
699
700#[cfg(feature = "cli-help")]
701fn resolve_help_options(
702 parsed: &ParsedHelpRequest,
703 config: &HelpConfig,
704) -> (HelpScope, HelpFormat) {
705 let scope = if parsed.recursive_requested {
709 HelpScope::Recursive
710 } else {
711 config.default_scope
712 };
713 let format = if config.allow_output_format {
714 parsed.output_format.unwrap_or(config.default_format)
715 } else {
716 config.default_format
717 };
718 (scope, format)
719}
720
721#[cfg(feature = "cli-help")]
722fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
723 let mut current = cmd;
724 for name in path {
725 current = current.find_subcommand(name).unwrap_or(current);
726 }
727 current
728}
729
730#[cfg(feature = "cli-help")]
731fn walk_to_subcommand_with_names<'a>(
732 cmd: &'a clap::Command,
733 path: &[&str],
734) -> (&'a clap::Command, Vec<String>) {
735 let mut current = cmd;
736 let mut names = vec![cmd.get_name().to_string()];
737 for name in path {
738 if let Some(next) = current.find_subcommand(name) {
739 current = next;
740 names.push(next.get_name().to_string());
741 } else {
742 break;
743 }
744 }
745 (current, names)
746}
747
748#[cfg(feature = "cli-help")]
749fn render_help_one_level_plain(cmd: &clap::Command) -> String {
750 cmd.clone().render_long_help().to_string()
751}
752
753#[cfg(feature = "cli-help")]
759fn append_recursive_help_hint(help: &mut String, cmd: &clap::Command) {
760 use std::fmt::Write;
761 if visible_subcommands(cmd).next().is_none() {
762 return;
763 }
764 if !help.ends_with('\n') {
765 help.push('\n');
766 }
767 let _ = write!(
768 help,
769 "\nHelp scope:\n --recursive\n Show this help for every nested subcommand, not just the current\n level. Add --output json|yaml|markdown to export the whole tree.\n"
770 );
771}
772
773#[cfg(feature = "cli-help")]
774fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
775 use std::fmt::Write;
776
777 let mut cmd_path = parent_path.to_vec();
779 cmd_path.push(cmd.get_name());
780 let path_str = cmd_path.join(" ");
781
782 if !buf.is_empty() {
784 let _ = writeln!(buf);
785 let _ = writeln!(buf, "{}", "═".repeat(60));
786 }
787
788 if let Some(about) = cmd.get_about() {
790 let _ = writeln!(buf, "{path_str} — {about}");
791 } else {
792 let _ = writeln!(buf, "{path_str}");
793 }
794 let _ = writeln!(buf);
795
796 let styled = cmd.clone().render_long_help();
798 let help_text = styled.to_string();
799 let _ = write!(buf, "{help_text}");
800
801 for sub in cmd.get_subcommands() {
803 if sub.get_name() == "help" || sub.is_hide_set() {
804 continue; }
806 render_help_recursive_plain(sub, &cmd_path, buf);
807 }
808}
809
810#[cfg(feature = "cli-help")]
811fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
812 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
813 let mut buf = String::new();
814 render_markdown_command(target, &names, &mut buf, 1);
815 if matches!(scope, HelpScope::Recursive) {
816 render_markdown_descendants(target, &names, &mut buf, 2);
817 }
818 buf
819}
820
821#[cfg(feature = "cli-help")]
822fn render_markdown_descendants(
823 cmd: &clap::Command,
824 parent_names: &[String],
825 buf: &mut String,
826 level: usize,
827) {
828 for sub in cmd.get_subcommands() {
829 if sub.get_name() == "help" || sub.is_hide_set() {
830 continue;
831 }
832 let mut names = parent_names.to_vec();
833 names.push(sub.get_name().to_string());
834 render_markdown_command(sub, &names, buf, level);
835 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
836 }
837}
838
839#[cfg(feature = "cli-help")]
840fn render_markdown_command(cmd: &clap::Command, names: &[String], buf: &mut String, level: usize) {
841 use std::fmt::Write;
842
843 if !buf.is_empty() {
844 let _ = writeln!(buf);
845 }
846 let heading_level = "#".repeat(level.max(1));
847 let path = names.join(" ");
848 if let Some(about) = cmd.get_about() {
849 let _ = writeln!(buf, "{heading_level} {path} - {about}");
850 } else {
851 let _ = writeln!(buf, "{heading_level} {path}");
852 }
853 if let Some(long_about) = cmd.get_long_about() {
854 let _ = writeln!(buf);
855 let _ = writeln!(buf, "{long_about}");
856 }
857 let _ = writeln!(buf);
858 let _ = writeln!(buf, "```text");
859 write_trimmed_help(buf, &cmd.clone().render_long_help().to_string());
860 if !buf.ends_with('\n') {
861 let _ = writeln!(buf);
862 }
863 let _ = writeln!(buf, "```");
864}
865
866#[cfg(feature = "cli-help")]
867fn write_trimmed_help(buf: &mut String, help: &str) {
868 use std::fmt::Write;
869
870 for line in help.lines() {
871 let _ = writeln!(buf, "{}", line.trim_end());
872 }
873}
874
875#[cfg(feature = "cli-help")]
876struct ParsedHelpRequest {
877 help_requested: bool,
878 recursive_requested: bool,
879 output_format: Option<HelpFormat>,
880 output_error: Option<String>,
881 subcommand_path: Vec<String>,
882}
883
884#[cfg(feature = "cli-help")]
885fn parse_help_request(
886 raw_args: &[String],
887 cmd: &clap::Command,
888 config: &HelpConfig,
889) -> ParsedHelpRequest {
890 let args = match raw_args.first() {
891 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
892 _ => raw_args.get(1..).unwrap_or(&[]),
893 };
894 let mut help_requested = false;
895 let mut recursive_requested = false;
896 let mut output_format = None;
897 let mut output_error = None;
898 let mut subcommand_path = Vec::new();
899 let mut current = cmd;
900 let output_flag = config.output_flag.map(normalize_long_flag);
901 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
902
903 let mut i = 0usize;
904 while i < args.len() {
905 let arg = args[i].as_str();
906 if arg == "--" {
907 break;
908 }
909
910 let (flag_name, inline_value) = split_flag(arg);
911 if matches!(arg, "--help" | "-h") {
912 help_requested = true;
913 i += 1;
914 continue;
915 }
916 if arg == "--recursive"
921 || flag_name
922 .zip(recursive_flag)
923 .is_some_and(|(seen, expected)| seen == expected)
924 {
925 recursive_requested = true;
926 i += 1;
927 continue;
928 }
929 if config.allow_output_format
930 && flag_name
931 .zip(output_flag)
932 .is_some_and(|(seen, expected)| seen == expected)
933 {
934 let value = inline_value.or_else(|| {
935 args.get(i + 1)
936 .map(String::as_str)
937 .filter(|next| !next.starts_with('-'))
938 });
939 if let Some(value) = value {
940 match HelpFormat::parse(value) {
941 Some(format) => output_format = Some(format),
942 None => {
943 output_error = Some(format!(
944 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
945 output_flag.unwrap_or("output"),
946 value
947 ));
948 }
949 }
950 } else {
951 output_error = Some(format!(
952 "missing value for --{}: expected plain, json, yaml, or markdown",
953 output_flag.unwrap_or("output")
954 ));
955 }
956 i += if inline_value.is_some() || value.is_none() {
957 1
958 } else {
959 2
960 };
961 continue;
962 }
963 if arg.starts_with('-') {
964 i += if inline_value.is_none() && flag_takes_value(current, arg) {
965 2
966 } else {
967 1
968 };
969 continue;
970 }
971 if let Some(sub) = current.find_subcommand(arg) {
972 if sub.get_name() != "help" && !sub.is_hide_set() {
973 subcommand_path.push(sub.get_name().to_string());
974 current = sub;
975 }
976 }
977 i += 1;
978 }
979
980 ParsedHelpRequest {
981 help_requested,
982 recursive_requested,
983 output_format,
984 output_error,
985 subcommand_path,
986 }
987}
988
989#[cfg(feature = "cli-help")]
990fn normalize_long_flag(flag: &str) -> &str {
991 flag.trim_start_matches('-')
992}
993
994#[cfg(feature = "cli-help")]
995fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
996 if let Some(stripped) = arg.strip_prefix("--") {
997 if let Some((name, value)) = stripped.split_once('=') {
998 (Some(name), Some(value))
999 } else {
1000 (Some(stripped), None)
1001 }
1002 } else if let Some(stripped) = arg.strip_prefix('-') {
1003 (Some(stripped), None)
1004 } else {
1005 (None, None)
1006 }
1007}
1008
1009#[cfg(feature = "cli-help")]
1010fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1011 let Some(flag) = raw_flag.strip_prefix('-') else {
1012 return false;
1013 };
1014 let name = flag.trim_start_matches('-');
1015 cmd.get_arguments().any(|arg| {
1016 let long_matches = arg.get_long().is_some_and(|long| long == name);
1017 let short_matches =
1018 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1019 (long_matches || short_matches)
1020 && matches!(
1021 arg.get_action(),
1022 clap::ArgAction::Set | clap::ArgAction::Append
1023 )
1024 })
1025}
1026
1027#[cfg(feature = "cli-help")]
1028fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1029 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1030 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive));
1031 if let Value::Object(map) = &mut schema {
1032 map.insert("code".to_string(), Value::String("help".to_string()));
1033 map.insert(
1034 "scope".to_string(),
1035 Value::String(help_scope_tag(scope).to_string()),
1036 );
1037 }
1038 schema
1039}
1040
1041#[cfg(feature = "cli-help")]
1042fn help_scope_tag(scope: HelpScope) -> &'static str {
1043 match scope {
1044 HelpScope::OneLevel => "one_level",
1045 HelpScope::Recursive => "recursive",
1046 }
1047}
1048
1049#[cfg(feature = "cli-help")]
1050fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool) -> Value {
1051 let subcommands: Vec<Value> = visible_subcommands(cmd)
1052 .map(|sub| {
1053 let mut child_names = names.to_vec();
1054 child_names.push(sub.get_name().to_string());
1055 if recursive {
1056 command_schema(sub, &child_names, true)
1057 } else {
1058 command_summary_schema(sub, &child_names)
1059 }
1060 })
1061 .collect();
1062
1063 serde_json::json!({
1064 "name": cmd.get_name(),
1065 "command_path": names.join(" "),
1066 "path": names,
1067 "about": styled_to_value(cmd.get_about()),
1068 "long_about": styled_to_value(cmd.get_long_about()),
1069 "usage": cmd.clone().render_usage().to_string(),
1070 "arguments": command_arguments_schema(cmd),
1071 "subcommands": subcommands,
1072 })
1073}
1074
1075#[cfg(feature = "cli-help")]
1076fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1077 serde_json::json!({
1078 "name": cmd.get_name(),
1079 "command_path": names.join(" "),
1080 "path": names,
1081 "about": styled_to_value(cmd.get_about()),
1082 "long_about": styled_to_value(cmd.get_long_about()),
1083 "usage": Value::Null,
1084 "arguments": [],
1085 "subcommands": [],
1086 })
1087}
1088
1089#[cfg(feature = "cli-help")]
1090fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1091 cmd.get_subcommands()
1092 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1093}
1094
1095#[cfg(feature = "cli-help")]
1096fn command_arguments_schema(cmd: &clap::Command) -> Vec<Value> {
1097 cmd.get_arguments()
1098 .filter(|arg| !arg.is_hide_set())
1099 .map(argument_schema)
1100 .collect()
1101}
1102
1103#[cfg(feature = "cli-help")]
1104fn argument_schema(arg: &clap::Arg) -> Value {
1105 let value_names: Vec<String> = arg
1106 .get_value_names()
1107 .map(|names| names.iter().map(ToString::to_string).collect())
1108 .unwrap_or_default();
1109 let default_values: Vec<String> = arg
1110 .get_default_values()
1111 .iter()
1112 .map(|value| value.to_string_lossy().to_string())
1113 .collect();
1114 serde_json::json!({
1115 "id": arg.get_id().to_string(),
1116 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1117 "long": arg.get_long(),
1118 "short": arg.get_short().map(|c| c.to_string()),
1119 "help": styled_to_value(arg.get_help()),
1120 "long_help": styled_to_value(arg.get_long_help()),
1121 "required": arg.is_required_set(),
1122 "action": format!("{:?}", arg.get_action()),
1123 "value_names": value_names,
1124 "default_values": default_values,
1125 })
1126}
1127
1128#[cfg(feature = "cli-help")]
1129fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1130 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1131}
1132
1133#[derive(Default)]
1138struct RedactionContext {
1139 secret_names: HashSet<String>,
1140}
1141
1142impl RedactionContext {
1143 fn from_options(redaction_options: &RedactionOptions) -> Self {
1144 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1145 Self { secret_names }
1146 }
1147
1148 fn is_secret_key(&self, key: &str) -> bool {
1149 key_has_secret_suffix(key) || self.secret_names.contains(key)
1150 }
1151}
1152
1153fn key_has_secret_suffix(key: &str) -> bool {
1154 key.ends_with("_secret") || key.ends_with("_SECRET")
1155}
1156
1157fn key_has_url_suffix(key: &str) -> bool {
1158 key.ends_with("_url") || key.ends_with("_URL")
1159}
1160
1161fn redact_secrets(value: &mut Value) {
1162 let context = RedactionContext::default();
1163 redact_secrets_with_context(value, &context);
1164}
1165
1166fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1167 match value {
1168 Value::Object(map) => {
1169 let keys: Vec<String> = map.keys().cloned().collect();
1170 for key in keys {
1171 if context.is_secret_key(&key) {
1172 match map.get(&key) {
1173 Some(Value::Object(_)) | Some(Value::Array(_)) => {
1174 }
1176 _ => {
1177 map.insert(key.clone(), Value::String("***".into()));
1178 continue;
1179 }
1180 }
1181 } else if key_has_url_suffix(&key) {
1182 if let Some(Value::String(s)) = map.get_mut(&key) {
1183 if let Some(redacted) = redact_url_in_str(s, context) {
1184 *s = redacted;
1185 }
1186 continue;
1187 }
1188 }
1189 if let Some(v) = map.get_mut(&key) {
1190 redact_secrets_with_context(v, context);
1191 }
1192 }
1193 }
1194 Value::Array(arr) => {
1195 for v in arr {
1196 redact_secrets_with_context(v, context);
1197 }
1198 }
1199 _ => {}
1200 }
1201}
1202
1203fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
1204 match value {
1205 Value::Object(map) => {
1206 let keys: Vec<String> = map.keys().cloned().collect();
1207 for key in keys {
1208 if context.is_secret_key(&key) {
1209 map.insert(key, Value::String("***".into()));
1210 } else if key_has_url_suffix(&key) {
1211 if let Some(Value::String(s)) = map.get_mut(&key) {
1212 if let Some(redacted) = redact_url_in_str(s, context) {
1213 *s = redacted;
1214 }
1215 } else if let Some(v) = map.get_mut(&key) {
1216 redact_secrets_strict_with_context(v, context);
1217 }
1218 } else if let Some(v) = map.get_mut(&key) {
1219 redact_secrets_strict_with_context(v, context);
1220 }
1221 }
1222 }
1223 Value::Array(arr) => {
1224 for v in arr {
1225 redact_secrets_strict_with_context(v, context);
1226 }
1227 }
1228 _ => {}
1229 }
1230}
1231
1232fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1236 if !s.contains("://") || !is_single_url(s) || url::Url::parse(s).is_err() {
1238 return None;
1239 }
1240 let scheme_sep = s.find("://")?;
1241 let scheme = &s[..scheme_sep];
1242 let rest = &s[scheme_sep + 3..];
1243
1244 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1246 let authority = &rest[..auth_end];
1247 let remainder = &rest[auth_end..];
1248
1249 let new_authority = redact_userinfo_password(authority);
1250
1251 let new_remainder = match remainder.find('?') {
1253 Some(q) => {
1254 let (path, q_onwards) = remainder.split_at(q);
1255 let query_body = &q_onwards[1..];
1256 let (query, fragment) = match query_body.find('#') {
1257 Some(h) => (&query_body[..h], &query_body[h..]),
1258 None => (query_body, ""),
1259 };
1260 format!("{path}?{}{fragment}", redact_query(query, context))
1261 }
1262 None => remainder.to_string(),
1263 };
1264
1265 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1266}
1267
1268fn redact_userinfo_password(authority: &str) -> String {
1271 let Some(at) = authority.find('@') else {
1272 return authority.to_string();
1273 };
1274 let userinfo = &authority[..at];
1275 match userinfo.find(':') {
1276 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1277 None => authority.to_string(),
1278 }
1279}
1280
1281fn redact_query(query: &str, context: &RedactionContext) -> String {
1284 query
1285 .split('&')
1286 .map(|segment| {
1287 let Some(eq) = segment.find('=') else {
1288 return segment.to_string();
1289 };
1290 let raw_key = &segment[..eq];
1291 let name = url::form_urlencoded::parse(segment.as_bytes())
1293 .next()
1294 .map(|(k, _)| k.into_owned())
1295 .unwrap_or_default();
1296 if context.is_secret_key(&name) {
1297 format!("{raw_key}=***")
1298 } else {
1299 segment.to_string()
1300 }
1301 })
1302 .collect::<Vec<_>>()
1303 .join("&")
1304}
1305
1306fn is_single_url(s: &str) -> bool {
1310 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1311 return false;
1312 }
1313 let bytes = s.as_bytes();
1314 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1315 return false;
1316 }
1317 let mut i = 1;
1318 while i < bytes.len() {
1319 let c = bytes[i];
1320 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1321 i += 1;
1322 } else {
1323 break;
1324 }
1325 }
1326 s[i..].starts_with("://")
1327}
1328
1329fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1330 let context = RedactionContext::default();
1331 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1332}
1333
1334fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1335 let context = RedactionContext::from_options(redaction_options);
1336 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1337}
1338
1339fn apply_redaction_policy_with_context(
1340 value: &mut Value,
1341 redaction_policy: Option<RedactionPolicy>,
1342 context: &RedactionContext,
1343) {
1344 match redaction_policy {
1345 Some(RedactionPolicy::RedactionTraceOnly) => {
1346 if let Value::Object(map) = value {
1347 if let Some(trace) = map.get_mut("trace") {
1348 redact_secrets_with_context(trace, context);
1349 }
1350 }
1351 }
1352 Some(RedactionPolicy::RedactionNone) => {}
1353 Some(RedactionPolicy::RedactionStrict) => {
1354 redact_secrets_strict_with_context(value, context)
1355 }
1356 None => redact_secrets_with_context(value, context),
1357 }
1358}
1359
1360fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1366 if let Some(s) = key.strip_suffix(suffix_lower) {
1367 return Some(s.to_string());
1368 }
1369 let suffix_upper: String = suffix_lower
1370 .chars()
1371 .map(|c| c.to_ascii_uppercase())
1372 .collect();
1373 if let Some(s) = key.strip_suffix(&suffix_upper) {
1374 return Some(s.to_string());
1375 }
1376 None
1377}
1378
1379fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1381 let code = extract_currency_code(key)?;
1382 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1384 if stripped.is_empty() {
1385 return None;
1386 }
1387 Some((stripped.to_string(), code.to_string()))
1388}
1389
1390fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1393 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1395 return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
1396 }
1397 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1398 return value
1399 .as_i64()
1400 .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
1401 }
1402 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1403 return value
1404 .as_i64()
1405 .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
1406 }
1407
1408 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1410 return value
1411 .as_u64()
1412 .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1413 }
1414 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1415 return value
1416 .as_u64()
1417 .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1418 }
1419 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1420 return value.as_u64().map(|n| {
1421 (
1422 stripped,
1423 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1424 )
1425 });
1426 }
1427
1428 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1430 return value.as_str().map(|s| (stripped, s.to_string()));
1431 }
1432 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1433 return value
1434 .is_number()
1435 .then(|| (stripped, format!("{} minutes", number_str(value))));
1436 }
1437 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1438 return value
1439 .is_number()
1440 .then(|| (stripped, format!("{} hours", number_str(value))));
1441 }
1442 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1443 return value
1444 .is_number()
1445 .then(|| (stripped, format!("{} days", number_str(value))));
1446 }
1447
1448 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1450 return value
1451 .is_number()
1452 .then(|| (stripped, format!("{}msats", number_str(value))));
1453 }
1454 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1455 return value
1456 .is_number()
1457 .then(|| (stripped, format!("{}sats", number_str(value))));
1458 }
1459 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1460 return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
1461 }
1462 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1463 return value
1464 .is_number()
1465 .then(|| (stripped, format!("{}%", number_str(value))));
1466 }
1467 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1468 return Some((stripped, "***".to_string()));
1469 }
1470
1471 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1473 return value
1474 .is_number()
1475 .then(|| (stripped, format!("{} BTC", number_str(value))));
1476 }
1477 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1478 return value
1479 .as_u64()
1480 .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1481 }
1482 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1483 return value
1484 .is_number()
1485 .then(|| (stripped, format!("{}ns", number_str(value))));
1486 }
1487 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1488 return value
1489 .is_number()
1490 .then(|| (stripped, format!("{}μs", number_str(value))));
1491 }
1492 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1493 return format_ms_value(value).map(|v| (stripped, v));
1494 }
1495 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1496 return value
1497 .is_number()
1498 .then(|| (stripped, format!("{}s", number_str(value))));
1499 }
1500
1501 None
1502}
1503
1504fn process_object_fields<'a>(
1506 map: &'a serde_json::Map<String, Value>,
1507) -> Vec<(String, &'a Value, Option<String>)> {
1508 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1509 for (key, value) in map {
1510 match try_process_field(key, value) {
1511 Some((stripped, formatted)) => {
1512 entries.push((stripped, key.as_str(), value, Some(formatted)));
1513 }
1514 None => {
1515 entries.push((key.clone(), key.as_str(), value, None));
1516 }
1517 }
1518 }
1519
1520 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1522 for (stripped, _, _, _) in &entries {
1523 *counts.entry(stripped.clone()).or_insert(0) += 1;
1524 }
1525
1526 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1528 .into_iter()
1529 .map(|(stripped, original, value, formatted)| {
1530 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1531 (original.to_string(), value, None)
1532 } else {
1533 (stripped, value, formatted)
1534 }
1535 })
1536 .collect();
1537
1538 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1539 result
1540}
1541
1542fn number_str(value: &Value) -> String {
1547 match value {
1548 Value::Number(n) => n.to_string(),
1549 _ => String::new(),
1550 }
1551}
1552
1553fn format_ms_as_seconds(ms: f64) -> String {
1555 let formatted = format!("{:.3}", ms / 1000.0);
1556 let trimmed = formatted.trim_end_matches('0');
1557 if trimmed.ends_with('.') {
1558 format!("{}0s", trimmed)
1559 } else {
1560 format!("{}s", trimmed)
1561 }
1562}
1563
1564fn format_ms_value(value: &Value) -> Option<String> {
1566 let n = value.as_f64()?;
1567 if n.abs() >= 1000.0 {
1568 Some(format_ms_as_seconds(n))
1569 } else if let Some(i) = value.as_i64() {
1570 Some(format!("{}ms", i))
1571 } else {
1572 Some(format!("{}ms", number_str(value)))
1573 }
1574}
1575
1576fn format_rfc3339_ms(ms: i64) -> String {
1578 use chrono::{DateTime, Utc};
1579 let secs = ms.div_euclid(1000);
1580 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1581 match DateTime::from_timestamp(secs, nanos) {
1582 Some(dt) => dt
1583 .with_timezone(&Utc)
1584 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1585 None => ms.to_string(),
1586 }
1587}
1588
1589fn format_bytes_human(bytes: i64) -> String {
1591 const KB: f64 = 1024.0;
1592 const MB: f64 = KB * 1024.0;
1593 const GB: f64 = MB * 1024.0;
1594 const TB: f64 = GB * 1024.0;
1595
1596 let sign = if bytes < 0 { "-" } else { "" };
1597 let b = (bytes as f64).abs();
1598 if b >= TB {
1599 format!("{sign}{:.1}TB", b / TB)
1600 } else if b >= GB {
1601 format!("{sign}{:.1}GB", b / GB)
1602 } else if b >= MB {
1603 format!("{sign}{:.1}MB", b / MB)
1604 } else if b >= KB {
1605 format!("{sign}{:.1}KB", b / KB)
1606 } else {
1607 format!("{bytes}B")
1608 }
1609}
1610
1611fn format_with_commas(n: u64) -> String {
1613 let s = n.to_string();
1614 let mut result = String::with_capacity(s.len() + s.len() / 3);
1615 for (i, c) in s.chars().enumerate() {
1616 if i > 0 && (s.len() - i).is_multiple_of(3) {
1617 result.push(',');
1618 }
1619 result.push(c);
1620 }
1621 result
1622}
1623
1624fn extract_currency_code(key: &str) -> Option<&str> {
1626 let without_cents = key
1627 .strip_suffix("_cents")
1628 .or_else(|| key.strip_suffix("_CENTS"))?;
1629 let last_underscore = without_cents.rfind('_')?;
1630 let code = &without_cents[last_underscore + 1..];
1631 if code.is_empty() {
1632 return None;
1633 }
1634 Some(code)
1635}
1636
1637fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1642 let prefix = " ".repeat(indent);
1643 match value {
1644 Value::Object(map) => {
1645 let processed = process_object_fields(map);
1646 for (display_key, v, formatted) in processed {
1647 if let Some(fv) = formatted {
1648 lines.push(format!(
1649 "{}{}: \"{}\"",
1650 prefix,
1651 display_key,
1652 escape_yaml_str(&fv)
1653 ));
1654 } else {
1655 match v {
1656 Value::Object(inner) if !inner.is_empty() => {
1657 lines.push(format!("{}{}:", prefix, display_key));
1658 render_yaml_processed(v, indent + 1, lines);
1659 }
1660 Value::Object(_) => {
1661 lines.push(format!("{}{}: {{}}", prefix, display_key));
1662 }
1663 Value::Array(arr) => {
1664 if arr.is_empty() {
1665 lines.push(format!("{}{}: []", prefix, display_key));
1666 } else {
1667 lines.push(format!("{}{}:", prefix, display_key));
1668 for item in arr {
1669 if item.is_object() {
1670 lines.push(format!("{} -", prefix));
1671 render_yaml_processed(item, indent + 2, lines);
1672 } else {
1673 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
1674 }
1675 }
1676 }
1677 }
1678 _ => {
1679 lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1680 }
1681 }
1682 }
1683 }
1684 }
1685 _ => {
1686 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1687 }
1688 }
1689}
1690
1691fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1692 let prefix = " ".repeat(indent);
1693 match value {
1694 Value::Object(map) => {
1695 for (key, v) in map {
1696 render_yaml_field_raw(&prefix, key, v, indent, lines);
1697 }
1698 }
1699 Value::Array(arr) => {
1700 render_yaml_array_raw(arr, indent, lines);
1701 }
1702 _ => {
1703 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1704 }
1705 }
1706}
1707
1708fn render_yaml_field_raw(
1709 prefix: &str,
1710 key: &str,
1711 value: &Value,
1712 indent: usize,
1713 lines: &mut Vec<String>,
1714) {
1715 match value {
1716 Value::Object(inner) if !inner.is_empty() => {
1717 lines.push(format!("{}{}:", prefix, key));
1718 render_yaml_raw(value, indent + 1, lines);
1719 }
1720 Value::Object(_) => {
1721 lines.push(format!("{}{}: {{}}", prefix, key));
1722 }
1723 Value::Array(arr) => {
1724 if arr.is_empty() {
1725 lines.push(format!("{}{}: []", prefix, key));
1726 } else {
1727 lines.push(format!("{}{}:", prefix, key));
1728 render_yaml_array_raw(arr, indent + 1, lines);
1729 }
1730 }
1731 _ => {
1732 lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1733 }
1734 }
1735}
1736
1737fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1738 let prefix = " ".repeat(indent);
1739 for item in arr {
1740 match item {
1741 Value::Object(inner) if !inner.is_empty() => {
1742 lines.push(format!("{}-", prefix));
1743 render_yaml_raw(item, indent + 1, lines);
1744 }
1745 Value::Array(nested) if !nested.is_empty() => {
1746 lines.push(format!("{}-", prefix));
1747 render_yaml_array_raw(nested, indent + 1, lines);
1748 }
1749 Value::Object(_) => {
1750 lines.push(format!("{}- {{}}", prefix));
1751 }
1752 Value::Array(_) => {
1753 lines.push(format!("{}- []", prefix));
1754 }
1755 _ => {
1756 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1757 }
1758 }
1759 }
1760}
1761
1762fn escape_yaml_str(s: &str) -> String {
1763 s.replace('\\', "\\\\")
1764 .replace('"', "\\\"")
1765 .replace('\n', "\\n")
1766 .replace('\r', "\\r")
1767 .replace('\t', "\\t")
1768}
1769
1770fn yaml_scalar(value: &Value) -> String {
1771 match value {
1772 Value::String(s) => {
1773 format!("\"{}\"", escape_yaml_str(s))
1774 }
1775 Value::Null => "null".to_string(),
1776 Value::Bool(b) => b.to_string(),
1777 Value::Number(n) => n.to_string(),
1778 other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1779 }
1780}
1781
1782fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1787 if let Value::Object(map) = value {
1788 let processed = process_object_fields(map);
1789 for (display_key, v, formatted) in processed {
1790 let full_key = if prefix.is_empty() {
1791 display_key
1792 } else {
1793 format!("{}.{}", prefix, display_key)
1794 };
1795 if let Some(fv) = formatted {
1796 pairs.push((full_key, fv));
1797 } else {
1798 match v {
1799 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1800 Value::Array(arr) => {
1801 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1802 pairs.push((full_key, joined));
1803 }
1804 Value::Null => pairs.push((full_key, String::new())),
1805 _ => pairs.push((full_key, plain_scalar(v))),
1806 }
1807 }
1808 }
1809 }
1810}
1811
1812fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1813 if let Value::Object(map) = value {
1814 for (key, v) in map {
1815 let full_key = if prefix.is_empty() {
1816 key.clone()
1817 } else {
1818 format!("{}.{}", prefix, key)
1819 };
1820 match v {
1821 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1822 Value::Array(arr) => {
1823 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1824 pairs.push((full_key, joined));
1825 }
1826 Value::Null => pairs.push((full_key, String::new())),
1827 _ => pairs.push((full_key, plain_scalar(v))),
1828 }
1829 }
1830 }
1831}
1832
1833fn plain_scalar(value: &Value) -> String {
1834 match value {
1835 Value::String(s) => s.clone(),
1836 Value::Null => "null".to_string(),
1837 Value::Bool(b) => b.to_string(),
1838 Value::Number(n) => n.to_string(),
1839 other => other.to_string(),
1840 }
1841}
1842
1843fn quote_logfmt_value(value: &str) -> String {
1844 if value.is_empty() {
1845 return String::new();
1846 }
1847 if !value
1848 .chars()
1849 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1850 {
1851 return value.to_string();
1852 }
1853 let escaped = value
1854 .replace('\\', "\\\\")
1855 .replace('"', "\\\"")
1856 .replace('\n', "\\n")
1857 .replace('\r', "\\r")
1858 .replace('\t', "\\t");
1859 format!("\"{}\"", escaped)
1860}
1861
1862#[cfg(test)]
1863mod tests;