1#[cfg(feature = "tracing")]
28pub mod afdata_tracing;
29
30#[cfg(feature = "skill-admin")]
31pub mod skill;
32
33use serde_json::Value;
34use std::collections::HashSet;
35
36pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
42 match trace {
43 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
44 None => serde_json::json!({"code": "ok", "result": result}),
45 }
46}
47
48pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
50 let mut obj = serde_json::Map::new();
51 obj.insert("code".to_string(), Value::String("error".to_string()));
52 obj.insert("error".to_string(), Value::String(message.to_string()));
53 if let Some(h) = hint {
54 obj.insert("hint".to_string(), Value::String(h.to_string()));
55 }
56 if let Some(t) = trace {
57 obj.insert("trace".to_string(), t);
58 }
59 Value::Object(obj)
60}
61
62pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
64 let mut obj = match fields {
65 Value::Object(map) => map,
66 _ => serde_json::Map::new(),
67 };
68 obj.insert("code".to_string(), Value::String(code.to_string()));
69 if let Some(t) = trace {
70 obj.insert("trace".to_string(), t);
71 }
72 Value::Object(obj)
73}
74
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub enum RedactionPolicy {
82 RedactionTraceOnly,
84 RedactionNone,
86}
87
88#[derive(Clone, Debug, Default, PartialEq, Eq)]
90pub struct RedactionOptions {
91 pub policy: Option<RedactionPolicy>,
93 pub secret_names: Vec<String>,
99}
100
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
103pub enum OutputStyle {
104 #[default]
106 Readable,
107 Raw,
109}
110
111#[derive(Clone, Debug, Default, PartialEq, Eq)]
113pub struct OutputOptions {
114 pub redaction: RedactionOptions,
116 pub style: OutputStyle,
118}
119
120pub fn output_json(value: &Value) -> String {
122 serialize_json_output(&redacted_value(value))
123}
124
125pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
127 serialize_json_output(&redacted_value_with(value, redaction_policy))
128}
129
130pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
135 serialize_json_output(&redacted_value_with_options(
136 value,
137 &output_options.redaction,
138 ))
139}
140
141fn serialize_json_output(value: &Value) -> String {
142 match serde_json::to_string(value) {
143 Ok(s) => s,
144 Err(err) => serde_json::json!({
145 "error": "output_json_failed",
146 "detail": err.to_string(),
147 })
148 .to_string(),
149 }
150}
151
152pub fn output_yaml(value: &Value) -> String {
154 output_yaml_with_options(value, &OutputOptions::default())
155}
156
157pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
159 let mut lines = vec!["---".to_string()];
160 let v = redacted_value_with_options(value, &output_options.redaction);
161 match output_options.style {
162 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
163 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
164 }
165 lines.join("\n")
166}
167
168pub fn output_plain(value: &Value) -> String {
170 output_plain_with_options(value, &OutputOptions::default())
171}
172
173pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
175 let mut pairs: Vec<(String, String)> = Vec::new();
176 let v = redacted_value_with_options(value, &output_options.redaction);
177 match output_options.style {
178 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
179 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
180 }
181 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
182 pairs
183 .into_iter()
184 .map(|(k, v)| format!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
185 .collect::<Vec<_>>()
186 .join(" ")
187}
188
189pub fn redact_secrets_in_place(value: &mut Value) {
195 redact_secrets(value);
196}
197
198pub fn redact_secrets_in_place_with_options(
200 value: &mut Value,
201 redaction_options: &RedactionOptions,
202) {
203 apply_redaction_options(value, redaction_options);
204}
205
206pub fn redacted_value(value: &Value) -> Value {
208 let mut v = value.clone();
209 redact_secrets(&mut v);
210 v
211}
212
213pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
215 let mut v = value.clone();
216 apply_redaction_policy(&mut v, redaction_policy);
217 v
218}
219
220pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
222 let mut v = value.clone();
223 apply_redaction_options(&mut v, redaction_options);
224 v
225}
226
227pub fn redact_url_secrets(url: &str) -> String {
232 redact_url_secrets_with_options(url, &RedactionOptions::default())
233}
234
235pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
245 let context = RedactionContext::from_options(redaction_options);
246 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
247}
248
249pub fn parse_size(s: &str) -> Option<u64> {
255 const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
256 let s = s.trim();
257 if s.is_empty() {
258 return None;
259 }
260 let last = *s.as_bytes().last()?;
261 let (num_str, mult) = match last {
262 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
263 b'K' | b'k' => (&s[..s.len() - 1], 1024),
264 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
265 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
266 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
267 b'0'..=b'9' | b'.' => (s, 1),
268 _ => return None,
269 };
270 if num_str.is_empty() || !is_decimal_number(num_str) {
271 return None;
272 }
273 if let Ok(n) = num_str.parse::<u64>() {
274 let result = n.checked_mul(mult)?;
275 return (result <= MAX_SAFE_INTEGER).then_some(result);
276 }
277 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
279 return None;
280 }
281 let f: f64 = num_str.parse().ok()?;
282 if f < 0.0 || f.is_nan() || f.is_infinite() {
283 return None;
284 }
285 let result = f * mult as f64;
286 if result > MAX_SAFE_INTEGER as f64 {
287 return None;
288 }
289 Some(result as u64)
290}
291
292fn is_decimal_number(s: &str) -> bool {
293 let bytes = s.as_bytes();
294 let mut i = 0;
295 let mut digits = 0;
296 while i < bytes.len() && bytes[i].is_ascii_digit() {
297 i += 1;
298 digits += 1;
299 }
300 if i < bytes.len() && bytes[i] == b'.' {
301 i += 1;
302 while i < bytes.len() && bytes[i].is_ascii_digit() {
303 i += 1;
304 digits += 1;
305 }
306 }
307 if digits == 0 {
308 return false;
309 }
310 if i < bytes.len() && matches!(bytes[i], b'e' | b'E') {
311 i += 1;
312 if i < bytes.len() && matches!(bytes[i], b'+' | b'-') {
313 i += 1;
314 }
315 let exp_start = i;
316 while i < bytes.len() && bytes[i].is_ascii_digit() {
317 i += 1;
318 }
319 if i == exp_start {
320 return false;
321 }
322 }
323 i == bytes.len()
324}
325
326pub fn normalize_utc_offset(s: &str) -> Option<String> {
332 let s = s.trim();
333 if s.eq_ignore_ascii_case("utc") || s.eq_ignore_ascii_case("z") {
334 return Some("UTC".to_string());
335 }
336 let sign = match s.as_bytes().first()? {
337 b'+' => '+',
338 b'-' => '-',
339 _ => return None,
340 };
341 let body = &s[1..];
342 let (hours, minutes) = parse_utc_offset_body(body)?;
343 if hours > 23 || minutes > 59 {
344 return None;
345 }
346 if hours == 0 && minutes == 0 {
347 return Some("UTC".to_string());
348 }
349 Some(format!("{sign}{hours:02}:{minutes:02}"))
350}
351
352fn parse_utc_offset_body(body: &str) -> Option<(u8, u8)> {
353 if body.is_empty() {
354 return None;
355 }
356 if let Some((hours, minutes)) = body.split_once(':') {
357 if hours.is_empty() || hours.len() > 2 || minutes.len() != 2 {
358 return None;
359 }
360 return Some((parse_ascii_u8(hours)?, parse_ascii_u8(minutes)?));
361 }
362 if !body.bytes().all(|b| b.is_ascii_digit()) {
363 return None;
364 }
365 match body.len() {
366 1 | 2 => Some((parse_ascii_u8(body)?, 0)),
367 4 => Some((parse_ascii_u8(&body[..2])?, parse_ascii_u8(&body[2..])?)),
368 _ => None,
369 }
370}
371
372fn parse_ascii_u8(s: &str) -> Option<u8> {
373 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
374 return None;
375 }
376 s.parse().ok()
377}
378
379#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub enum OutputFormat {
386 Json,
387 Yaml,
388 Plain,
389}
390
391#[derive(Clone, Debug, PartialEq, Eq)]
397pub struct VersionConfig {
398 pub default_output: Option<OutputFormat>,
403 pub output_flag: Option<&'static str>,
405 pub output_short: Option<char>,
407 pub allow_output_format: bool,
409}
410
411impl VersionConfig {
412 pub const fn new(default_output: Option<OutputFormat>) -> Self {
414 Self {
415 default_output,
416 output_flag: None,
417 output_short: None,
418 allow_output_format: false,
419 }
420 }
421
422 pub const fn agent_cli_default() -> Self {
428 Self {
429 default_output: Some(OutputFormat::Json),
430 output_flag: Some("--output"),
431 output_short: None,
432 allow_output_format: true,
433 }
434 }
435
436 pub const fn conventional_default() -> Self {
439 Self {
440 default_output: None,
441 output_flag: Some("--output"),
442 output_short: None,
443 allow_output_format: true,
444 }
445 }
446
447 pub const fn with_default_output(mut self, default_output: Option<OutputFormat>) -> Self {
449 self.default_output = default_output;
450 self
451 }
452
453 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
455 self.output_flag = flag;
456 self
457 }
458
459 pub const fn with_output_short(mut self, flag: Option<char>) -> Self {
461 self.output_short = flag;
462 self
463 }
464
465 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
467 self.allow_output_format = enabled;
468 self
469 }
470}
471
472pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
482 match s {
483 "json" => Ok(OutputFormat::Json),
484 "yaml" => Ok(OutputFormat::Yaml),
485 "plain" => Ok(OutputFormat::Plain),
486 _ => Err(format!(
487 "invalid --output format '{s}': expected json, yaml, or plain"
488 )),
489 }
490}
491
492pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
502 let mut out: Vec<String> = Vec::new();
503 for entry in entries {
504 let s = entry.as_ref().trim().to_ascii_lowercase();
505 if !s.is_empty() && !out.contains(&s) {
506 out.push(s);
507 }
508 }
509 out
510}
511
512pub fn cli_output(value: &Value, format: OutputFormat) -> String {
523 match format {
524 OutputFormat::Json => output_json(value),
525 OutputFormat::Yaml => output_yaml(value),
526 OutputFormat::Plain => output_plain(value),
527 }
528}
529
530pub fn cli_output_with_options(
535 value: &Value,
536 format: OutputFormat,
537 output_options: &OutputOptions,
538) -> String {
539 match format {
540 OutputFormat::Json => output_json_with_options(value, output_options),
541 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
542 OutputFormat::Plain => output_plain_with_options(value, output_options),
543 }
544}
545
546pub fn build_cli_version(version: &str) -> Value {
548 build_json("version", serde_json::json!({ "version": version }), None)
549}
550
551pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
556 let mut rendered = match format {
557 Some(format) => cli_output(&build_cli_version(version), format),
558 None => format!("{name} {version}"),
559 };
560 while rendered.ends_with('\n') {
561 rendered.pop();
562 }
563 rendered.push('\n');
564 rendered
565}
566
567pub fn cli_handle_version_or_continue(
577 raw_args: &[String],
578 name: &str,
579 version: &str,
580 config: &VersionConfig,
581) -> Result<Option<String>, Value> {
582 let parsed = parse_version_request(raw_args, config);
583 if !parsed.version_requested {
584 return Ok(None);
585 }
586 if let Some(error) = parsed.output_error {
587 return Err(build_cli_error(
588 &error,
589 Some("valid version output formats: json, yaml, plain"),
590 ));
591 }
592 let format = if config.allow_output_format {
593 parsed.output_format.or(config.default_output)
594 } else {
595 config.default_output
596 };
597 Ok(Some(cli_render_version(name, version, format)))
598}
599
600struct ParsedVersionRequest {
601 version_requested: bool,
602 output_format: Option<OutputFormat>,
603 output_error: Option<String>,
604}
605
606fn parse_version_request(raw_args: &[String], config: &VersionConfig) -> ParsedVersionRequest {
607 let args = raw_args.get(1..).unwrap_or(&[]);
608 let mut version_requested = false;
609 let mut output_format = None;
610 let mut output_error = None;
611 let output_flag = config.output_flag.map(normalize_long_flag);
612
613 let mut i = 0usize;
614 while i < args.len() {
615 let arg = args[i].as_str();
616 if arg == "--" {
617 break;
618 }
619
620 let (flag_name, inline_value) = split_flag(arg);
621 if matches!(arg, "--version" | "-V") {
622 version_requested = true;
623 i += 1;
624 continue;
625 }
626
627 if config.allow_output_format
628 && version_output_flag_matches(flag_name, output_flag, config.output_short)
629 {
630 let value = inline_value.or_else(|| {
631 args.get(i + 1)
632 .map(String::as_str)
633 .filter(|next| !next.starts_with('-'))
634 });
635 if let Some(value) = value {
636 match cli_parse_output(value) {
637 Ok(format) => output_format = Some(format),
638 Err(err) => output_error = Some(err),
639 }
640 } else {
641 output_error = Some(format!(
642 "missing value for --{}: expected json, yaml, or plain",
643 output_flag.unwrap_or("output")
644 ));
645 }
646 i += if inline_value.is_some() || value.is_none() {
647 1
648 } else {
649 2
650 };
651 continue;
652 }
653 i += 1;
654 }
655
656 ParsedVersionRequest {
657 version_requested,
658 output_format,
659 output_error,
660 }
661}
662
663fn version_output_flag_matches(
664 flag_name: Option<&str>,
665 output_flag: Option<&str>,
666 output_short: Option<char>,
667) -> bool {
668 let Some(seen) = flag_name else {
669 return false;
670 };
671 output_flag.is_some_and(|expected| seen == expected)
672 || output_short.is_some_and(|short| {
673 let mut chars = seen.chars();
674 chars.next().is_some_and(|seen_short| seen_short == short) && chars.next().is_none()
675 })
676}
677
678pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
690 let mut obj = serde_json::Map::new();
691 obj.insert("code".to_string(), Value::String("error".to_string()));
692 obj.insert("error".to_string(), Value::String(message.to_string()));
693 if let Some(h) = hint {
694 obj.insert("hint".to_string(), Value::String(h.to_string()));
695 }
696 Value::Object(obj)
697}
698
699#[cfg(feature = "cli-help")]
707#[derive(Clone, Copy, Debug, PartialEq, Eq)]
708pub enum HelpScope {
709 OneLevel,
714 Recursive,
716}
717
718#[cfg(feature = "cli-help")]
722#[derive(Clone, Copy, Debug, PartialEq, Eq)]
723pub enum HelpFormat {
724 Plain,
725 Markdown,
726 Json,
727 Yaml,
728}
729
730#[cfg(feature = "cli-help")]
731impl HelpFormat {
732 fn parse(s: &str) -> Option<Self> {
733 match s {
734 "plain" => Some(Self::Plain),
735 "markdown" => Some(Self::Markdown),
736 "json" => Some(Self::Json),
737 "yaml" => Some(Self::Yaml),
738 _ => None,
739 }
740 }
741}
742
743#[cfg(feature = "cli-help")]
747#[derive(Clone, Copy, Debug, PartialEq, Eq)]
748pub struct HelpOptions {
749 pub scope: HelpScope,
750 pub format: HelpFormat,
751}
752
753#[cfg(feature = "cli-help")]
754impl HelpOptions {
755 pub const fn one_level_plain() -> Self {
757 Self {
758 scope: HelpScope::OneLevel,
759 format: HelpFormat::Plain,
760 }
761 }
762
763 pub const fn recursive_plain() -> Self {
765 Self {
766 scope: HelpScope::Recursive,
767 format: HelpFormat::Plain,
768 }
769 }
770}
771
772#[cfg(feature = "cli-help")]
780#[derive(Clone, Debug, PartialEq, Eq)]
781pub struct HelpConfig {
782 pub default_scope: HelpScope,
785 pub default_format: HelpFormat,
787 pub recursive_flag: Option<&'static str>,
794 pub output_flag: Option<&'static str>,
796 pub allow_output_format: bool,
798}
799
800#[cfg(feature = "cli-help")]
801impl HelpConfig {
802 pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
804 Self {
805 default_scope,
806 default_format,
807 recursive_flag: None,
808 output_flag: None,
809 allow_output_format: false,
810 }
811 }
812
813 pub const fn human_cli_default() -> Self {
821 Self {
822 default_scope: HelpScope::OneLevel,
823 default_format: HelpFormat::Plain,
824 recursive_flag: None,
825 output_flag: Some("--output"),
826 allow_output_format: true,
827 }
828 }
829
830 pub const fn agent_cli_default() -> Self {
832 Self {
833 default_scope: HelpScope::Recursive,
834 default_format: HelpFormat::Plain,
835 recursive_flag: None,
836 output_flag: Some("--output"),
837 allow_output_format: true,
838 }
839 }
840
841 pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
843 self.default_scope = scope;
844 self
845 }
846
847 pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
849 self.default_format = format;
850 self
851 }
852
853 pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
855 self.recursive_flag = flag;
856 self
857 }
858
859 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
861 self.output_flag = flag;
862 self
863 }
864
865 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
867 self.allow_output_format = enabled;
868 self
869 }
870}
871
872#[cfg(feature = "cli-help")]
880pub fn cli_render_help_with_options(
881 cmd: &clap::Command,
882 subcommand_path: &[&str],
883 options: &HelpOptions,
884) -> String {
885 let target = walk_to_subcommand(cmd, subcommand_path);
886 let mut rendered = match options.format {
887 HelpFormat::Plain => match options.scope {
888 HelpScope::OneLevel => render_help_one_level_plain(target),
889 HelpScope::Recursive => {
890 let mut buf = String::new();
891 render_help_recursive_plain(target, &[], &mut buf);
892 buf
893 }
894 },
895 HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
896 HelpFormat::Json => {
897 serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
898 }
899 HelpFormat::Yaml => output_yaml_with_options(
900 &build_help_schema(cmd, subcommand_path, options.scope),
901 &OutputOptions {
902 redaction: RedactionOptions {
903 policy: Some(RedactionPolicy::RedactionNone),
904 secret_names: Vec::new(),
905 },
906 style: OutputStyle::Raw,
907 },
908 ),
909 };
910 while rendered.ends_with('\n') {
914 rendered.pop();
915 }
916 rendered.push('\n');
917 rendered
918}
919
920#[cfg(feature = "cli-help")]
927pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
928 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
929}
930
931#[cfg(feature = "cli-help-markdown")]
938pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
939 cli_render_help_with_options(
940 cmd,
941 subcommand_path,
942 &HelpOptions {
943 scope: HelpScope::Recursive,
944 format: HelpFormat::Markdown,
945 },
946 )
947}
948
949#[cfg(feature = "cli-help")]
965pub fn cli_handle_help_or_continue(
966 raw_args: &[String],
967 cmd: &clap::Command,
968 config: &HelpConfig,
969) -> Result<Option<String>, Value> {
970 let parsed = parse_help_request(raw_args, cmd, config);
971 if !parsed.help_requested {
972 return Ok(None);
973 }
974 if let Some(error) = parsed.output_error {
975 return Err(build_cli_error(
976 &error,
977 Some("valid help output formats: plain, markdown, json, yaml"),
978 ));
979 }
980
981 let (scope, format) = resolve_help_options(&parsed, config);
982 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
983 Ok(Some(cli_render_help_with_options(
984 cmd,
985 &path,
986 &HelpOptions { scope, format },
987 )))
988}
989
990#[cfg(feature = "cli-help")]
991fn resolve_help_options(
992 parsed: &ParsedHelpRequest,
993 config: &HelpConfig,
994) -> (HelpScope, HelpFormat) {
995 let scope = if parsed.recursive_requested {
999 HelpScope::Recursive
1000 } else {
1001 config.default_scope
1002 };
1003 let format = if config.allow_output_format {
1004 parsed.output_format.unwrap_or(config.default_format)
1005 } else {
1006 config.default_format
1007 };
1008 (scope, format)
1009}
1010
1011#[cfg(feature = "cli-help")]
1012fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
1013 let mut current = cmd;
1014 for name in path {
1015 current = current.find_subcommand(name).unwrap_or(current);
1016 }
1017 current
1018}
1019
1020#[cfg(feature = "cli-help")]
1021fn walk_to_subcommand_with_names<'a>(
1022 cmd: &'a clap::Command,
1023 path: &[&str],
1024) -> (&'a clap::Command, Vec<String>) {
1025 let mut current = cmd;
1026 let mut names = vec![cmd.get_name().to_string()];
1027 for name in path {
1028 if let Some(next) = current.find_subcommand(name) {
1029 current = next;
1030 names.push(next.get_name().to_string());
1031 } else {
1032 break;
1033 }
1034 }
1035 (current, names)
1036}
1037
1038#[cfg(feature = "cli-help")]
1039fn render_help_one_level_plain(cmd: &clap::Command) -> String {
1040 enriched_help_command(cmd).render_long_help().to_string()
1041}
1042
1043#[cfg(feature = "cli-help")]
1054fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
1055 let cmd = cmd.clone();
1056 let description = if visible_subcommands(&cmd).next().is_some() {
1057 HELP_FLAG_WITH_SUBCOMMANDS
1058 } else {
1059 HELP_FLAG_LEAF
1060 };
1061 cmd.disable_help_flag(true).arg(
1066 clap::Arg::new("help")
1067 .short('h')
1068 .long("help")
1069 .help(description)
1070 .long_help(description)
1071 .action(clap::ArgAction::Help),
1072 )
1073}
1074
1075#[cfg(feature = "cli-help")]
1077const HELP_FLAG_WITH_SUBCOMMANDS: &str =
1078 "Print help. Add --recursive to expand every nested subcommand; \
1079 add --output json|yaml|markdown to render this help in another format.";
1080
1081#[cfg(feature = "cli-help")]
1083const HELP_FLAG_LEAF: &str =
1084 "Print help. Add --output json|yaml|markdown to render this help in another format.";
1085
1086#[cfg(feature = "cli-help")]
1087fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
1088 use std::fmt::Write;
1089
1090 let mut cmd_path = parent_path.to_vec();
1092 cmd_path.push(cmd.get_name());
1093 let path_str = cmd_path.join(" ");
1094
1095 if !buf.is_empty() {
1097 let _ = writeln!(buf);
1098 let _ = writeln!(buf, "{}", "═".repeat(60));
1099 }
1100
1101 if let Some(about) = cmd.get_about() {
1103 let _ = writeln!(buf, "{path_str} — {about}");
1104 } else {
1105 let _ = writeln!(buf, "{path_str}");
1106 }
1107 let _ = writeln!(buf);
1108
1109 let is_target = parent_path.is_empty();
1113 let styled = if is_target {
1114 enriched_help_command(cmd).render_long_help()
1115 } else {
1116 cmd.clone().render_long_help()
1117 };
1118 let help_text = styled.to_string();
1119 let _ = write!(buf, "{help_text}");
1120
1121 for sub in cmd.get_subcommands() {
1123 if sub.get_name() == "help" || sub.is_hide_set() {
1124 continue; }
1126 render_help_recursive_plain(sub, &cmd_path, buf);
1127 }
1128}
1129
1130#[cfg(feature = "cli-help")]
1131fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
1132 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1133 let mut buf = String::new();
1134 render_markdown_command(target, &names, &mut buf, 1, true);
1135 if matches!(scope, HelpScope::Recursive) {
1136 render_markdown_descendants(target, &names, &mut buf, 2);
1137 }
1138 buf
1139}
1140
1141#[cfg(feature = "cli-help")]
1142fn render_markdown_descendants(
1143 cmd: &clap::Command,
1144 parent_names: &[String],
1145 buf: &mut String,
1146 level: usize,
1147) {
1148 for sub in cmd.get_subcommands() {
1149 if sub.get_name() == "help" || sub.is_hide_set() {
1150 continue;
1151 }
1152 let mut names = parent_names.to_vec();
1153 names.push(sub.get_name().to_string());
1154 render_markdown_command(sub, &names, buf, level, false);
1155 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
1156 }
1157}
1158
1159#[cfg(feature = "cli-help")]
1160fn render_markdown_command(
1161 cmd: &clap::Command,
1162 names: &[String],
1163 buf: &mut String,
1164 level: usize,
1165 enrich: bool,
1166) {
1167 use std::fmt::Write;
1168
1169 if !buf.is_empty() {
1170 let _ = writeln!(buf);
1171 }
1172 let heading_level = "#".repeat(level.max(1));
1173 let path = names.join(" ");
1174 if let Some(about) = cmd.get_about() {
1175 let _ = writeln!(buf, "{heading_level} {path} - {about}");
1176 } else {
1177 let _ = writeln!(buf, "{heading_level} {path}");
1178 }
1179 if let Some(long_about) = cmd.get_long_about() {
1180 let _ = writeln!(buf);
1181 let _ = writeln!(buf, "{long_about}");
1182 }
1183 let _ = writeln!(buf);
1184 let _ = writeln!(buf, "```text");
1185 let help = if enrich {
1186 enriched_help_command(cmd).render_long_help()
1187 } else {
1188 cmd.clone().render_long_help()
1189 };
1190 write_trimmed_help(buf, &help.to_string());
1191 if !buf.ends_with('\n') {
1192 let _ = writeln!(buf);
1193 }
1194 let _ = writeln!(buf, "```");
1195}
1196
1197#[cfg(feature = "cli-help")]
1198fn write_trimmed_help(buf: &mut String, help: &str) {
1199 use std::fmt::Write;
1200
1201 for line in help.lines() {
1202 let _ = writeln!(buf, "{}", line.trim_end());
1203 }
1204}
1205
1206#[cfg(feature = "cli-help")]
1207struct ParsedHelpRequest {
1208 help_requested: bool,
1209 recursive_requested: bool,
1210 output_format: Option<HelpFormat>,
1211 output_error: Option<String>,
1212 subcommand_path: Vec<String>,
1213}
1214
1215#[cfg(feature = "cli-help")]
1216fn parse_help_request(
1217 raw_args: &[String],
1218 cmd: &clap::Command,
1219 config: &HelpConfig,
1220) -> ParsedHelpRequest {
1221 let args = match raw_args.first() {
1222 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
1223 _ => raw_args.get(1..).unwrap_or(&[]),
1224 };
1225 let mut help_requested = false;
1226 let mut recursive_requested = false;
1227 let mut output_format = None;
1228 let mut output_error = None;
1229 let mut subcommand_path = Vec::new();
1230 let mut current = cmd;
1231 let output_flag = config.output_flag.map(normalize_long_flag);
1232 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
1233
1234 let mut i = 0usize;
1235 while i < args.len() {
1236 let arg = args[i].as_str();
1237 if arg == "--" {
1238 break;
1239 }
1240
1241 let (flag_name, inline_value) = split_flag(arg);
1242 if matches!(arg, "--help" | "-h") {
1243 help_requested = true;
1244 i += 1;
1245 continue;
1246 }
1247 if arg == "--recursive"
1252 || flag_name
1253 .zip(recursive_flag)
1254 .is_some_and(|(seen, expected)| seen == expected)
1255 {
1256 recursive_requested = true;
1257 i += 1;
1258 continue;
1259 }
1260 if config.allow_output_format
1261 && flag_name
1262 .zip(output_flag)
1263 .is_some_and(|(seen, expected)| seen == expected)
1264 {
1265 let value = inline_value.or_else(|| {
1266 args.get(i + 1)
1267 .map(String::as_str)
1268 .filter(|next| !next.starts_with('-'))
1269 });
1270 if let Some(value) = value {
1271 match HelpFormat::parse(value) {
1272 Some(format) => output_format = Some(format),
1273 None => {
1274 output_error = Some(format!(
1275 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
1276 output_flag.unwrap_or("output"),
1277 value
1278 ));
1279 }
1280 }
1281 } else {
1282 output_error = Some(format!(
1283 "missing value for --{}: expected plain, json, yaml, or markdown",
1284 output_flag.unwrap_or("output")
1285 ));
1286 }
1287 i += if inline_value.is_some() || value.is_none() {
1288 1
1289 } else {
1290 2
1291 };
1292 continue;
1293 }
1294 if arg.starts_with('-') {
1295 i += if inline_value.is_none() && flag_takes_value(current, arg) {
1296 2
1297 } else {
1298 1
1299 };
1300 continue;
1301 }
1302 if let Some(sub) = current.find_subcommand(arg) {
1303 if sub.get_name() != "help" && !sub.is_hide_set() {
1304 subcommand_path.push(sub.get_name().to_string());
1305 current = sub;
1306 }
1307 }
1308 i += 1;
1309 }
1310
1311 ParsedHelpRequest {
1312 help_requested,
1313 recursive_requested,
1314 output_format,
1315 output_error,
1316 subcommand_path,
1317 }
1318}
1319
1320fn normalize_long_flag(flag: &str) -> &str {
1321 flag.trim_start_matches('-')
1322}
1323
1324fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1325 if let Some(stripped) = arg.strip_prefix("--") {
1326 if let Some((name, value)) = stripped.split_once('=') {
1327 (Some(name), Some(value))
1328 } else {
1329 (Some(stripped), None)
1330 }
1331 } else if let Some(stripped) = arg.strip_prefix('-') {
1332 (Some(stripped), None)
1333 } else {
1334 (None, None)
1335 }
1336}
1337
1338#[cfg(feature = "cli-help")]
1339fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1340 let Some(flag) = raw_flag.strip_prefix('-') else {
1341 return false;
1342 };
1343 let name = flag.trim_start_matches('-');
1344 cmd.get_arguments().any(|arg| {
1345 let long_matches = arg.get_long().is_some_and(|long| long == name);
1346 let short_matches =
1347 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1348 (long_matches || short_matches)
1349 && matches!(
1350 arg.get_action(),
1351 clap::ArgAction::Set | clap::ArgAction::Append
1352 )
1353 })
1354}
1355
1356#[cfg(feature = "cli-help")]
1357fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1358 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1359 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1360 if let Value::Object(map) = &mut schema {
1361 map.insert("code".to_string(), Value::String("help".to_string()));
1362 map.insert(
1363 "scope".to_string(),
1364 Value::String(help_scope_tag(scope).to_string()),
1365 );
1366 }
1367 schema
1368}
1369
1370#[cfg(feature = "cli-help")]
1371fn help_scope_tag(scope: HelpScope) -> &'static str {
1372 match scope {
1373 HelpScope::OneLevel => "one_level",
1374 HelpScope::Recursive => "recursive",
1375 }
1376}
1377
1378#[cfg(feature = "cli-help")]
1379fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1380 let subcommands: Vec<Value> = visible_subcommands(cmd)
1381 .map(|sub| {
1382 let mut child_names = names.to_vec();
1383 child_names.push(sub.get_name().to_string());
1384 if recursive {
1385 command_schema(sub, &child_names, true, false)
1387 } else {
1388 command_summary_schema(sub, &child_names)
1389 }
1390 })
1391 .collect();
1392
1393 serde_json::json!({
1394 "name": cmd.get_name(),
1395 "command_path": names.join(" "),
1396 "path": names,
1397 "about": styled_to_value(cmd.get_about()),
1398 "long_about": styled_to_value(cmd.get_long_about()),
1399 "usage": cmd.clone().render_usage().to_string(),
1400 "arguments": command_arguments_schema(cmd, enrich),
1401 "subcommands": subcommands,
1402 })
1403}
1404
1405#[cfg(feature = "cli-help")]
1406fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1407 serde_json::json!({
1408 "name": cmd.get_name(),
1409 "command_path": names.join(" "),
1410 "path": names,
1411 "about": styled_to_value(cmd.get_about()),
1412 "long_about": styled_to_value(cmd.get_long_about()),
1413 "usage": Value::Null,
1414 "arguments": [],
1415 "subcommands": [],
1416 })
1417}
1418
1419#[cfg(feature = "cli-help")]
1420fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1421 cmd.get_subcommands()
1422 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1423}
1424
1425#[cfg(feature = "cli-help")]
1426fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1427 let owned = enrich.then(|| enriched_help_command(cmd));
1433 let source = owned.as_ref().unwrap_or(cmd);
1434 source
1435 .get_arguments()
1436 .filter(|arg| !arg.is_hide_set())
1437 .map(argument_schema)
1438 .collect()
1439}
1440
1441#[cfg(feature = "cli-help")]
1442fn argument_schema(arg: &clap::Arg) -> Value {
1443 let value_names: Vec<String> = arg
1444 .get_value_names()
1445 .map(|names| names.iter().map(ToString::to_string).collect())
1446 .unwrap_or_default();
1447 let default_values: Vec<String> = arg
1448 .get_default_values()
1449 .iter()
1450 .map(|value| value.to_string_lossy().to_string())
1451 .collect();
1452 serde_json::json!({
1453 "id": arg.get_id().to_string(),
1454 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1455 "long": arg.get_long(),
1456 "short": arg.get_short().map(|c| c.to_string()),
1457 "help": styled_to_value(arg.get_help()),
1458 "long_help": styled_to_value(arg.get_long_help()),
1459 "required": arg.is_required_set(),
1460 "action": format!("{:?}", arg.get_action()),
1461 "value_names": value_names,
1462 "default_values": default_values,
1463 })
1464}
1465
1466#[cfg(feature = "cli-help")]
1467fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1468 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1469}
1470
1471#[derive(Default)]
1476struct RedactionContext {
1477 secret_names: HashSet<String>,
1478}
1479
1480impl RedactionContext {
1481 fn from_options(redaction_options: &RedactionOptions) -> Self {
1482 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1483 Self { secret_names }
1484 }
1485
1486 fn is_secret_key(&self, key: &str) -> bool {
1487 key_has_secret_suffix(key) || self.secret_names.contains(key)
1488 }
1489}
1490
1491fn key_has_secret_suffix(key: &str) -> bool {
1492 key.ends_with("_secret") || key.ends_with("_SECRET")
1493}
1494
1495fn key_has_url_suffix(key: &str) -> bool {
1496 key.ends_with("_url") || key.ends_with("_URL")
1497}
1498
1499const MAX_DEPTH: usize = 256;
1500
1501fn redact_secrets(value: &mut Value) {
1502 let context = RedactionContext::default();
1503 redact_secrets_with_context(value, &context);
1504}
1505
1506fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1507 redact_secrets_with_context_depth(value, context, 0);
1508}
1509
1510fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
1511 if depth >= MAX_DEPTH {
1512 *value = Value::String("***".into());
1513 return;
1514 }
1515 match value {
1516 Value::Object(map) => {
1517 let keys: Vec<String> = map.keys().cloned().collect();
1518 for key in keys {
1519 if context.is_secret_key(&key) {
1520 map.insert(key, Value::String("***".into()));
1521 } else if key_has_url_suffix(&key) {
1522 if let Some(Value::String(s)) = map.get_mut(&key) {
1523 *s = redact_url_field_value(s, context);
1524 } else if let Some(v) = map.get_mut(&key) {
1525 redact_secrets_with_context_depth(v, context, depth + 1);
1526 }
1527 } else if let Some(v) = map.get_mut(&key) {
1528 redact_secrets_with_context_depth(v, context, depth + 1);
1529 }
1530 }
1531 }
1532 Value::Array(arr) => {
1533 for v in arr {
1534 redact_secrets_with_context_depth(v, context, depth + 1);
1535 }
1536 }
1537 _ => {}
1538 }
1539}
1540
1541fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1545 if !s.contains("://") || !is_single_url(s) {
1552 return None;
1553 }
1554 let scheme_sep = s.find("://")?;
1555 let scheme = &s[..scheme_sep];
1556 let rest = &s[scheme_sep + 3..];
1557
1558 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1560 let authority = &rest[..auth_end];
1561 let remainder = &rest[auth_end..];
1562
1563 let new_authority = redact_userinfo_password(authority);
1564
1565 let new_remainder = match remainder.find('?') {
1567 Some(q) => {
1568 let (path, q_onwards) = remainder.split_at(q);
1569 let query_body = &q_onwards[1..];
1570 let (query, fragment) = match query_body.find('#') {
1571 Some(h) => (&query_body[..h], &query_body[h..]),
1572 None => (query_body, ""),
1573 };
1574 format!("{path}?{}{fragment}", redact_query(query, context))
1575 }
1576 None => remainder.to_string(),
1577 };
1578
1579 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1580}
1581
1582fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
1583 if let Some(redacted) = redact_url_in_str(s, context) {
1584 return redacted;
1585 }
1586 let trimmed = s.trim();
1587 if trimmed != s {
1588 if let Some(redacted) = redact_url_in_str(trimmed, context) {
1589 return redacted;
1590 }
1591 }
1592 if s.chars().any(char::is_whitespace) || s.contains('@') {
1598 return "***".to_string();
1599 }
1600 s.to_string()
1601}
1602
1603fn redact_userinfo_password(authority: &str) -> String {
1606 let Some(at) = authority.rfind('@') else {
1607 return authority.to_string();
1608 };
1609 let userinfo = &authority[..at];
1610 match userinfo.find(':') {
1611 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1612 None => authority.to_string(),
1613 }
1614}
1615
1616fn redact_query(query: &str, context: &RedactionContext) -> String {
1619 query
1620 .split('&')
1621 .map(|segment| {
1622 let Some(eq) = segment.find('=') else {
1623 return segment.to_string();
1624 };
1625 let raw_key = &segment[..eq];
1626 let name = url::form_urlencoded::parse(segment.as_bytes())
1628 .next()
1629 .map(|(k, _)| k.into_owned())
1630 .unwrap_or_default();
1631 if context.is_secret_key(&name) {
1632 format!("{raw_key}=***")
1633 } else {
1634 segment.to_string()
1635 }
1636 })
1637 .collect::<Vec<_>>()
1638 .join("&")
1639}
1640
1641fn is_single_url(s: &str) -> bool {
1645 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1646 return false;
1647 }
1648 let bytes = s.as_bytes();
1649 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1650 return false;
1651 }
1652 let mut i = 1;
1653 while i < bytes.len() {
1654 let c = bytes[i];
1655 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1656 i += 1;
1657 } else {
1658 break;
1659 }
1660 }
1661 s[i..].starts_with("://")
1662}
1663
1664fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1665 let context = RedactionContext::default();
1666 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1667}
1668
1669fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1670 let context = RedactionContext::from_options(redaction_options);
1671 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1672}
1673
1674fn apply_redaction_policy_with_context(
1675 value: &mut Value,
1676 redaction_policy: Option<RedactionPolicy>,
1677 context: &RedactionContext,
1678) {
1679 match redaction_policy {
1680 Some(RedactionPolicy::RedactionTraceOnly) => {
1681 if let Value::Object(map) = value {
1682 if let Some(trace) = map.get_mut("trace") {
1683 redact_secrets_with_context(trace, context);
1684 }
1685 }
1686 }
1687 Some(RedactionPolicy::RedactionNone) => {}
1688 None => redact_secrets_with_context(value, context),
1689 }
1690}
1691
1692fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1698 if let Some(s) = key.strip_suffix(suffix_lower) {
1699 return Some(s.to_string());
1700 }
1701 let suffix_upper: String = suffix_lower
1702 .chars()
1703 .map(|c| c.to_ascii_uppercase())
1704 .collect();
1705 if let Some(s) = key.strip_suffix(&suffix_upper) {
1706 return Some(s.to_string());
1707 }
1708 None
1709}
1710
1711fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1713 let code = extract_currency_code(key)?;
1714 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1716 if stripped.is_empty() {
1717 return None;
1718 }
1719 Some((stripped.to_string(), code.to_string()))
1720}
1721
1722fn as_int(value: &Value) -> Option<i64> {
1730 if let Some(i) = value.as_i64() {
1731 return Some(i);
1732 }
1733 let f = value.as_f64()?;
1734 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1735 return Some(f as i64);
1736 }
1737 None
1738}
1739
1740fn as_uint(value: &Value) -> Option<u64> {
1742 if let Some(u) = value.as_u64() {
1743 return Some(u);
1744 }
1745 let f = value.as_f64()?;
1746 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1747 return Some(f as u64);
1748 }
1749 None
1750}
1751
1752fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1753 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1755 return as_int(value)
1756 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1757 }
1758 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1759 return as_int(value)
1760 .and_then(|s| s.checked_mul(1000))
1761 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1762 }
1763 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1764 return as_int(value).and_then(|ns| {
1765 format_rfc3339_ms(ns.div_euclid(1_000_000)).map(|formatted| (stripped, formatted))
1766 });
1767 }
1768
1769 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1771 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1772 }
1773 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1774 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1775 }
1776 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1777 return as_uint(value).map(|n| {
1778 (
1779 stripped,
1780 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1781 )
1782 });
1783 }
1784
1785 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1787 return value.as_str().map(|s| (stripped, s.to_string()));
1788 }
1789 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1790 return value
1791 .is_number()
1792 .then(|| (stripped, format!("{} minutes", number_str(value))));
1793 }
1794 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1795 return value
1796 .is_number()
1797 .then(|| (stripped, format!("{} hours", number_str(value))));
1798 }
1799 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1800 return value
1801 .is_number()
1802 .then(|| (stripped, format!("{} days", number_str(value))));
1803 }
1804
1805 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1807 return value
1808 .is_number()
1809 .then(|| (stripped, format!("{}msats", number_str(value))));
1810 }
1811 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1812 return value
1813 .is_number()
1814 .then(|| (stripped, format!("{}sats", number_str(value))));
1815 }
1816 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1817 return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1818 }
1819 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1820 return value
1821 .is_number()
1822 .then(|| (stripped, format!("{}%", number_str(value))));
1823 }
1824 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1826 return value
1827 .is_number()
1828 .then(|| (stripped, format!("{} BTC", number_str(value))));
1829 }
1830 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1831 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1832 }
1833 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1834 return value
1835 .is_number()
1836 .then(|| (stripped, format!("{}ns", number_str(value))));
1837 }
1838 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1839 return value
1840 .is_number()
1841 .then(|| (stripped, format!("{}μs", number_str(value))));
1842 }
1843 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1844 return format_ms_value(value).map(|v| (stripped, v));
1845 }
1846 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1847 return value
1848 .is_number()
1849 .then(|| (stripped, format!("{}s", number_str(value))));
1850 }
1851
1852 None
1853}
1854
1855fn process_object_fields<'a>(
1857 map: &'a serde_json::Map<String, Value>,
1858) -> Vec<(String, &'a Value, Option<String>)> {
1859 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1860 for (key, value) in map {
1861 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1862 entries.push((stripped, key.as_str(), value, None));
1863 continue;
1864 }
1865 match try_process_field(key, value) {
1866 Some((stripped, formatted)) => {
1867 entries.push((stripped, key.as_str(), value, Some(formatted)));
1868 }
1869 None => {
1870 entries.push((key.clone(), key.as_str(), value, None));
1871 }
1872 }
1873 }
1874
1875 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1877 for (stripped, _, _, _) in &entries {
1878 *counts.entry(stripped.clone()).or_insert(0) += 1;
1879 }
1880
1881 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1883 .into_iter()
1884 .map(|(stripped, original, value, formatted)| {
1885 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1886 (original.to_string(), value, None)
1887 } else {
1888 (stripped, value, formatted)
1889 }
1890 })
1891 .collect();
1892
1893 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1894 result
1895}
1896
1897fn number_str(value: &Value) -> String {
1902 match value {
1903 Value::Number(n) => format_number(n),
1904 _ => String::new(),
1905 }
1906}
1907
1908fn format_number(n: &serde_json::Number) -> String {
1914 if n.is_f64() {
1915 if let Some(f) = n.as_f64() {
1916 if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 {
1917 return format!("{f:.0}");
1918 }
1919 }
1920 }
1921 normalize_exponent(&n.to_string())
1922}
1923
1924fn normalize_exponent(s: &str) -> String {
1925 let Some(e) = s.find(['e', 'E']) else {
1926 return s.to_string();
1927 };
1928 let mantissa = &s[..e];
1929 let mut exp = &s[e + 1..];
1930 let mut sign = "";
1931 if exp.starts_with(['+', '-']) {
1932 sign = &exp[..1];
1933 exp = &exp[1..];
1934 }
1935 let exp = exp.trim_start_matches('0');
1936 let exp = if exp.is_empty() { "0" } else { exp };
1937 format!("{mantissa}e{sign}{exp}")
1938}
1939
1940fn format_ms_as_seconds(ms: f64) -> String {
1942 let formatted = format!("{:.3}", ms / 1000.0);
1943 let trimmed = formatted.trim_end_matches('0');
1944 if trimmed.ends_with('.') {
1945 format!("{}0s", trimmed)
1946 } else {
1947 format!("{}s", trimmed)
1948 }
1949}
1950
1951fn format_ms_value(value: &Value) -> Option<String> {
1953 let n = value.as_f64()?;
1954 if n.abs() >= 1000.0 {
1955 Some(format_ms_as_seconds(n))
1956 } else if let Some(i) = value.as_i64() {
1957 Some(format!("{}ms", i))
1958 } else {
1959 Some(format!("{}ms", number_str(value)))
1960 }
1961}
1962
1963const MIN_RFC3339_MS: i64 = -62135596800000;
1965const MAX_RFC3339_MS: i64 = 253402300799999;
1966
1967fn format_rfc3339_ms(ms: i64) -> Option<String> {
1968 use chrono::{DateTime, Utc};
1969 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
1970 return None;
1971 }
1972 let secs = ms.div_euclid(1000);
1973 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1974 DateTime::from_timestamp(secs, nanos).map(|dt| {
1975 dt.with_timezone(&Utc)
1976 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1977 })
1978}
1979
1980fn format_bytes_human(bytes: i64) -> String {
1982 const KB: f64 = 1024.0;
1983 const MB: f64 = KB * 1024.0;
1984 const GB: f64 = MB * 1024.0;
1985 const TB: f64 = GB * 1024.0;
1986
1987 let sign = if bytes < 0 { "-" } else { "" };
1988 let b = (bytes as f64).abs();
1989 if b >= TB {
1990 format!("{sign}{:.1}TB", b / TB)
1991 } else if b >= GB {
1992 format!("{sign}{:.1}GB", b / GB)
1993 } else if b >= MB {
1994 format!("{sign}{:.1}MB", b / MB)
1995 } else if b >= KB {
1996 format!("{sign}{:.1}KB", b / KB)
1997 } else {
1998 format!("{bytes}B")
1999 }
2000}
2001
2002fn format_with_commas(n: u64) -> String {
2004 let s = n.to_string();
2005 let mut result = String::with_capacity(s.len() + s.len() / 3);
2006 for (i, c) in s.chars().enumerate() {
2007 if i > 0 && (s.len() - i).is_multiple_of(3) {
2008 result.push(',');
2009 }
2010 result.push(c);
2011 }
2012 result
2013}
2014
2015fn extract_currency_code(key: &str) -> Option<&str> {
2017 let without_cents = key
2018 .strip_suffix("_cents")
2019 .or_else(|| key.strip_suffix("_CENTS"))?;
2020 let last_underscore = without_cents.rfind('_')?;
2021 let code = &without_cents[last_underscore + 1..];
2022 if code.is_empty()
2023 || !(3..=4).contains(&code.len())
2024 || !code.bytes().all(|b| b.is_ascii_alphabetic())
2025 {
2026 return None;
2027 }
2028 Some(code)
2029}
2030
2031fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
2036 let prefix = " ".repeat(indent);
2037 match value {
2038 Value::Object(map) => {
2039 let processed = process_object_fields(map);
2040 for (display_key, v, formatted) in processed {
2041 if let Some(fv) = formatted {
2042 lines.push(format!(
2043 "{}{}: \"{}\"",
2044 prefix,
2045 yaml_key(&display_key),
2046 escape_yaml_str(&fv)
2047 ));
2048 } else {
2049 match v {
2050 Value::Object(inner) if !inner.is_empty() => {
2051 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2052 render_yaml_processed(v, indent + 1, lines);
2053 }
2054 Value::Object(_) => {
2055 lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
2056 }
2057 Value::Array(arr) => {
2058 if arr.is_empty() {
2059 lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
2060 } else {
2061 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2062 for item in arr {
2063 if item.is_object() {
2064 lines.push(format!("{} -", prefix));
2065 render_yaml_processed(item, indent + 2, lines);
2066 } else {
2067 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
2068 }
2069 }
2070 }
2071 }
2072 _ => {
2073 lines.push(format!(
2074 "{}{}: {}",
2075 prefix,
2076 yaml_key(&display_key),
2077 yaml_scalar(v)
2078 ));
2079 }
2080 }
2081 }
2082 }
2083 }
2084 _ => {
2085 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2086 }
2087 }
2088}
2089
2090fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
2091 let prefix = " ".repeat(indent);
2092 match value {
2093 Value::Object(map) => {
2094 for key in sorted_value_keys(map) {
2095 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
2096 }
2097 }
2098 Value::Array(arr) => {
2099 render_yaml_array_raw(arr, indent, lines);
2100 }
2101 _ => {
2102 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2103 }
2104 }
2105}
2106
2107fn render_yaml_field_raw(
2108 prefix: &str,
2109 key: &str,
2110 value: &Value,
2111 indent: usize,
2112 lines: &mut Vec<String>,
2113) {
2114 match value {
2115 Value::Object(inner) if !inner.is_empty() => {
2116 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2117 render_yaml_raw(value, indent + 1, lines);
2118 }
2119 Value::Object(_) => {
2120 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
2121 }
2122 Value::Array(arr) => {
2123 if arr.is_empty() {
2124 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
2125 } else {
2126 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2127 render_yaml_array_raw(arr, indent + 1, lines);
2128 }
2129 }
2130 _ => {
2131 lines.push(format!(
2132 "{}{}: {}",
2133 prefix,
2134 yaml_key(key),
2135 yaml_scalar(value)
2136 ));
2137 }
2138 }
2139}
2140
2141fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
2142 let prefix = " ".repeat(indent);
2143 for item in arr {
2144 match item {
2145 Value::Object(inner) if !inner.is_empty() => {
2146 lines.push(format!("{}-", prefix));
2147 render_yaml_raw(item, indent + 1, lines);
2148 }
2149 Value::Array(nested) if !nested.is_empty() => {
2150 lines.push(format!("{}-", prefix));
2151 render_yaml_array_raw(nested, indent + 1, lines);
2152 }
2153 Value::Object(_) => {
2154 lines.push(format!("{}- {{}}", prefix));
2155 }
2156 Value::Array(_) => {
2157 lines.push(format!("{}- []", prefix));
2158 }
2159 _ => {
2160 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
2161 }
2162 }
2163 }
2164}
2165
2166fn escape_yaml_str(s: &str) -> String {
2167 s.replace('\\', "\\\\")
2168 .replace('"', "\\\"")
2169 .replace('\n', "\\n")
2170 .replace('\r', "\\r")
2171 .replace('\t', "\\t")
2172 .replace('\x0c', "\\f")
2173 .replace('\x0b', "\\v")
2174}
2175
2176fn yaml_key(key: &str) -> String {
2177 if is_safe_key(key) {
2178 key.to_string()
2179 } else {
2180 format!("\"{}\"", escape_yaml_str(key))
2181 }
2182}
2183
2184fn quote_logfmt_key(key: &str) -> String {
2185 if is_safe_key(key) {
2186 key.to_string()
2187 } else {
2188 quote_logfmt_value(key)
2189 }
2190}
2191
2192fn is_safe_key(key: &str) -> bool {
2193 !key.is_empty()
2194 && key
2195 .bytes()
2196 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
2197}
2198
2199fn yaml_scalar(value: &Value) -> String {
2200 match value {
2201 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
2202 Value::Null => "null".to_string(),
2203 Value::Bool(b) => b.to_string(),
2204 Value::Number(n) => format_number(n),
2205 Value::Object(_) | Value::Array(_) => {
2206 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
2207 }
2208 }
2209}
2210
2211fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2216 if let Value::Object(map) = value {
2217 let processed = process_object_fields(map);
2218 for (display_key, v, formatted) in processed {
2219 let full_key = if prefix.is_empty() {
2220 display_key
2221 } else {
2222 format!("{}.{}", prefix, display_key)
2223 };
2224 if let Some(fv) = formatted {
2225 pairs.push((full_key, fv));
2226 } else {
2227 match v {
2228 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
2229 Value::Array(arr) => {
2230 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2231 pairs.push((full_key, joined));
2232 }
2233 Value::Null => pairs.push((full_key, String::new())),
2234 _ => pairs.push((full_key, plain_scalar(v))),
2235 }
2236 }
2237 }
2238 }
2239}
2240
2241fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2242 if let Value::Object(map) = value {
2243 for key in sorted_value_keys(map) {
2244 let v = &map[&key];
2245 let full_key = if prefix.is_empty() {
2246 key.clone()
2247 } else {
2248 format!("{}.{}", prefix, key)
2249 };
2250 match v {
2251 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
2252 Value::Array(arr) => {
2253 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2254 pairs.push((full_key, joined));
2255 }
2256 Value::Null => pairs.push((full_key, String::new())),
2257 _ => pairs.push((full_key, plain_scalar(v))),
2258 }
2259 }
2260 }
2261}
2262
2263fn plain_scalar(value: &Value) -> String {
2264 match value {
2265 Value::String(s) => s.clone(),
2266 Value::Null => "null".to_string(),
2267 Value::Bool(b) => b.to_string(),
2268 Value::Number(n) => format_number(n),
2269 Value::Object(_) | Value::Array(_) => canonical_json(value),
2270 }
2271}
2272
2273fn quote_logfmt_value(value: &str) -> String {
2274 if value.is_empty() {
2275 return String::new();
2276 }
2277 if !value
2278 .chars()
2279 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
2280 {
2281 return value.to_string();
2282 }
2283 let escaped = value
2284 .replace('\\', "\\\\")
2285 .replace('"', "\\\"")
2286 .replace('\n', "\\n")
2287 .replace('\r', "\\r")
2288 .replace('\t', "\\t")
2289 .replace('\x0c', "\\f")
2290 .replace('\x0b', "\\v");
2291 format!("\"{}\"", escaped)
2292}
2293
2294fn canonical_json(value: &Value) -> String {
2295 serde_json::to_string(&sort_json_value(value))
2296 .unwrap_or_else(|_| "<unsupported:json>".to_string())
2297}
2298
2299fn sort_json_value(value: &Value) -> Value {
2300 match value {
2301 Value::Object(map) => {
2302 let mut out = serde_json::Map::new();
2303 for key in sorted_value_keys(map) {
2304 if let Some(v) = map.get(&key) {
2305 out.insert(key, sort_json_value(v));
2306 }
2307 }
2308 Value::Object(out)
2309 }
2310 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
2311 _ => value.clone(),
2312 }
2313}
2314
2315fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
2316 let mut keys: Vec<String> = map.keys().cloned().collect();
2317 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
2318 keys
2319}
2320
2321#[cfg(test)]
2322mod tests;