1#[cfg(feature = "tracing")]
27pub mod afdata_tracing;
28
29#[cfg(feature = "skill-admin")]
30pub mod skill;
31
32use serde_json::Value;
33use std::collections::HashSet;
34
35pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
41 match trace {
42 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
43 None => serde_json::json!({"code": "ok", "result": result}),
44 }
45}
46
47pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
49 let mut obj = serde_json::Map::new();
50 obj.insert("code".to_string(), Value::String("error".to_string()));
51 obj.insert("error".to_string(), Value::String(message.to_string()));
52 if let Some(h) = hint {
53 obj.insert("hint".to_string(), Value::String(h.to_string()));
54 }
55 if let Some(t) = trace {
56 obj.insert("trace".to_string(), t);
57 }
58 Value::Object(obj)
59}
60
61pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
63 let mut obj = match fields {
64 Value::Object(map) => map,
65 _ => serde_json::Map::new(),
66 };
67 obj.insert("code".to_string(), Value::String(code.to_string()));
68 if let Some(t) = trace {
69 obj.insert("trace".to_string(), t);
70 }
71 Value::Object(obj)
72}
73
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum RedactionPolicy {
81 RedactionTraceOnly,
83 RedactionNone,
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!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
184 .collect::<Vec<_>>()
185 .join(" ")
186}
187
188pub fn redact_secrets_in_place(value: &mut Value) {
194 redact_secrets(value);
195}
196
197pub fn redact_secrets_in_place_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 const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
255 let s = s.trim();
256 if s.is_empty() {
257 return None;
258 }
259 let last = *s.as_bytes().last()?;
260 let (num_str, mult) = match last {
261 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
262 b'K' | b'k' => (&s[..s.len() - 1], 1024),
263 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
264 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
265 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
266 b'0'..=b'9' | b'.' => (s, 1),
267 _ => return None,
268 };
269 if num_str.is_empty() || !is_decimal_number(num_str) {
270 return None;
271 }
272 if let Ok(n) = num_str.parse::<u64>() {
273 let result = n.checked_mul(mult)?;
274 return (result <= MAX_SAFE_INTEGER).then_some(result);
275 }
276 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
278 return None;
279 }
280 let f: f64 = num_str.parse().ok()?;
281 if f < 0.0 || f.is_nan() || f.is_infinite() {
282 return None;
283 }
284 let result = f * mult as f64;
285 if result > MAX_SAFE_INTEGER as f64 {
286 return None;
287 }
288 Some(result as u64)
289}
290
291fn is_decimal_number(s: &str) -> bool {
292 let bytes = s.as_bytes();
293 let mut i = 0;
294 let mut digits = 0;
295 while i < bytes.len() && bytes[i].is_ascii_digit() {
296 i += 1;
297 digits += 1;
298 }
299 if i < bytes.len() && bytes[i] == b'.' {
300 i += 1;
301 while i < bytes.len() && bytes[i].is_ascii_digit() {
302 i += 1;
303 digits += 1;
304 }
305 }
306 if digits == 0 {
307 return false;
308 }
309 if i < bytes.len() && matches!(bytes[i], b'e' | b'E') {
310 i += 1;
311 if i < bytes.len() && matches!(bytes[i], b'+' | b'-') {
312 i += 1;
313 }
314 let exp_start = i;
315 while i < bytes.len() && bytes[i].is_ascii_digit() {
316 i += 1;
317 }
318 if i == exp_start {
319 return false;
320 }
321 }
322 i == bytes.len()
323}
324
325pub fn normalize_utc_offset(s: &str) -> Option<String> {
331 let s = s.trim();
332 if s.eq_ignore_ascii_case("utc") || s.eq_ignore_ascii_case("z") {
333 return Some("UTC".to_string());
334 }
335 let sign = match s.as_bytes().first()? {
336 b'+' => '+',
337 b'-' => '-',
338 _ => return None,
339 };
340 let body = &s[1..];
341 let (hours, minutes) = parse_utc_offset_body(body)?;
342 if hours > 23 || minutes > 59 {
343 return None;
344 }
345 if hours == 0 && minutes == 0 {
346 return Some("UTC".to_string());
347 }
348 Some(format!("{sign}{hours:02}:{minutes:02}"))
349}
350
351fn parse_utc_offset_body(body: &str) -> Option<(u8, u8)> {
352 if body.is_empty() {
353 return None;
354 }
355 if let Some((hours, minutes)) = body.split_once(':') {
356 if hours.is_empty() || hours.len() > 2 || minutes.len() != 2 {
357 return None;
358 }
359 return Some((parse_ascii_u8(hours)?, parse_ascii_u8(minutes)?));
360 }
361 if !body.bytes().all(|b| b.is_ascii_digit()) {
362 return None;
363 }
364 match body.len() {
365 1 | 2 => Some((parse_ascii_u8(body)?, 0)),
366 4 => Some((parse_ascii_u8(&body[..2])?, parse_ascii_u8(&body[2..])?)),
367 _ => None,
368 }
369}
370
371fn parse_ascii_u8(s: &str) -> Option<u8> {
372 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
373 return None;
374 }
375 s.parse().ok()
376}
377
378#[derive(Clone, Copy, Debug, PartialEq, Eq)]
384pub enum OutputFormat {
385 Json,
386 Yaml,
387 Plain,
388}
389
390pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
400 match s {
401 "json" => Ok(OutputFormat::Json),
402 "yaml" => Ok(OutputFormat::Yaml),
403 "plain" => Ok(OutputFormat::Plain),
404 _ => Err(format!(
405 "invalid --output format '{s}': expected json, yaml, or plain"
406 )),
407 }
408}
409
410pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
420 let mut out: Vec<String> = Vec::new();
421 for entry in entries {
422 let s = entry.as_ref().trim().to_ascii_lowercase();
423 if !s.is_empty() && !out.contains(&s) {
424 out.push(s);
425 }
426 }
427 out
428}
429
430pub fn cli_output(value: &Value, format: OutputFormat) -> String {
441 match format {
442 OutputFormat::Json => output_json(value),
443 OutputFormat::Yaml => output_yaml(value),
444 OutputFormat::Plain => output_plain(value),
445 }
446}
447
448pub fn cli_output_with_options(
453 value: &Value,
454 format: OutputFormat,
455 output_options: &OutputOptions,
456) -> String {
457 match format {
458 OutputFormat::Json => output_json_with_options(value, output_options),
459 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
460 OutputFormat::Plain => output_plain_with_options(value, output_options),
461 }
462}
463
464pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
476 let mut obj = serde_json::Map::new();
477 obj.insert("code".to_string(), Value::String("error".to_string()));
478 obj.insert("error".to_string(), Value::String(message.to_string()));
479 if let Some(h) = hint {
480 obj.insert("hint".to_string(), Value::String(h.to_string()));
481 }
482 Value::Object(obj)
483}
484
485#[cfg(feature = "cli-help")]
493#[derive(Clone, Copy, Debug, PartialEq, Eq)]
494pub enum HelpScope {
495 OneLevel,
500 Recursive,
502}
503
504#[cfg(feature = "cli-help")]
508#[derive(Clone, Copy, Debug, PartialEq, Eq)]
509pub enum HelpFormat {
510 Plain,
511 Markdown,
512 Json,
513 Yaml,
514}
515
516#[cfg(feature = "cli-help")]
517impl HelpFormat {
518 fn parse(s: &str) -> Option<Self> {
519 match s {
520 "plain" => Some(Self::Plain),
521 "markdown" => Some(Self::Markdown),
522 "json" => Some(Self::Json),
523 "yaml" => Some(Self::Yaml),
524 _ => None,
525 }
526 }
527}
528
529#[cfg(feature = "cli-help")]
533#[derive(Clone, Copy, Debug, PartialEq, Eq)]
534pub struct HelpOptions {
535 pub scope: HelpScope,
536 pub format: HelpFormat,
537}
538
539#[cfg(feature = "cli-help")]
540impl HelpOptions {
541 pub const fn one_level_plain() -> Self {
543 Self {
544 scope: HelpScope::OneLevel,
545 format: HelpFormat::Plain,
546 }
547 }
548
549 pub const fn recursive_plain() -> Self {
551 Self {
552 scope: HelpScope::Recursive,
553 format: HelpFormat::Plain,
554 }
555 }
556}
557
558#[cfg(feature = "cli-help")]
566#[derive(Clone, Debug, PartialEq, Eq)]
567pub struct HelpConfig {
568 pub default_scope: HelpScope,
571 pub default_format: HelpFormat,
573 pub recursive_flag: Option<&'static str>,
580 pub output_flag: Option<&'static str>,
582 pub allow_output_format: bool,
584}
585
586#[cfg(feature = "cli-help")]
587impl HelpConfig {
588 pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
590 Self {
591 default_scope,
592 default_format,
593 recursive_flag: None,
594 output_flag: None,
595 allow_output_format: false,
596 }
597 }
598
599 pub const fn human_cli_default() -> Self {
607 Self {
608 default_scope: HelpScope::OneLevel,
609 default_format: HelpFormat::Plain,
610 recursive_flag: None,
611 output_flag: Some("--output"),
612 allow_output_format: true,
613 }
614 }
615
616 pub const fn agent_cli_default() -> Self {
618 Self {
619 default_scope: HelpScope::Recursive,
620 default_format: HelpFormat::Plain,
621 recursive_flag: None,
622 output_flag: Some("--output"),
623 allow_output_format: true,
624 }
625 }
626
627 pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
629 self.default_scope = scope;
630 self
631 }
632
633 pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
635 self.default_format = format;
636 self
637 }
638
639 pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
641 self.recursive_flag = flag;
642 self
643 }
644
645 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
647 self.output_flag = flag;
648 self
649 }
650
651 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
653 self.allow_output_format = enabled;
654 self
655 }
656}
657
658#[cfg(feature = "cli-help")]
666pub fn cli_render_help_with_options(
667 cmd: &clap::Command,
668 subcommand_path: &[&str],
669 options: &HelpOptions,
670) -> String {
671 let target = walk_to_subcommand(cmd, subcommand_path);
672 let mut rendered = match options.format {
673 HelpFormat::Plain => match options.scope {
674 HelpScope::OneLevel => render_help_one_level_plain(target),
675 HelpScope::Recursive => {
676 let mut buf = String::new();
677 render_help_recursive_plain(target, &[], &mut buf);
678 buf
679 }
680 },
681 HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
682 HelpFormat::Json => {
683 serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
684 }
685 HelpFormat::Yaml => output_yaml_with_options(
686 &build_help_schema(cmd, subcommand_path, options.scope),
687 &OutputOptions {
688 redaction: RedactionOptions {
689 policy: Some(RedactionPolicy::RedactionNone),
690 secret_names: Vec::new(),
691 },
692 style: OutputStyle::Raw,
693 },
694 ),
695 };
696 while rendered.ends_with('\n') {
700 rendered.pop();
701 }
702 rendered.push('\n');
703 rendered
704}
705
706#[cfg(feature = "cli-help")]
713pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
714 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
715}
716
717#[cfg(feature = "cli-help-markdown")]
724pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
725 cli_render_help_with_options(
726 cmd,
727 subcommand_path,
728 &HelpOptions {
729 scope: HelpScope::Recursive,
730 format: HelpFormat::Markdown,
731 },
732 )
733}
734
735#[cfg(feature = "cli-help")]
751pub fn cli_handle_help_or_continue(
752 raw_args: &[String],
753 cmd: &clap::Command,
754 config: &HelpConfig,
755) -> Result<Option<String>, Value> {
756 let parsed = parse_help_request(raw_args, cmd, config);
757 if !parsed.help_requested {
758 return Ok(None);
759 }
760 if let Some(error) = parsed.output_error {
761 return Err(build_cli_error(
762 &error,
763 Some("valid help output formats: plain, markdown, json, yaml"),
764 ));
765 }
766
767 let (scope, format) = resolve_help_options(&parsed, config);
768 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
769 Ok(Some(cli_render_help_with_options(
770 cmd,
771 &path,
772 &HelpOptions { scope, format },
773 )))
774}
775
776#[cfg(feature = "cli-help")]
777fn resolve_help_options(
778 parsed: &ParsedHelpRequest,
779 config: &HelpConfig,
780) -> (HelpScope, HelpFormat) {
781 let scope = if parsed.recursive_requested {
785 HelpScope::Recursive
786 } else {
787 config.default_scope
788 };
789 let format = if config.allow_output_format {
790 parsed.output_format.unwrap_or(config.default_format)
791 } else {
792 config.default_format
793 };
794 (scope, format)
795}
796
797#[cfg(feature = "cli-help")]
798fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
799 let mut current = cmd;
800 for name in path {
801 current = current.find_subcommand(name).unwrap_or(current);
802 }
803 current
804}
805
806#[cfg(feature = "cli-help")]
807fn walk_to_subcommand_with_names<'a>(
808 cmd: &'a clap::Command,
809 path: &[&str],
810) -> (&'a clap::Command, Vec<String>) {
811 let mut current = cmd;
812 let mut names = vec![cmd.get_name().to_string()];
813 for name in path {
814 if let Some(next) = current.find_subcommand(name) {
815 current = next;
816 names.push(next.get_name().to_string());
817 } else {
818 break;
819 }
820 }
821 (current, names)
822}
823
824#[cfg(feature = "cli-help")]
825fn render_help_one_level_plain(cmd: &clap::Command) -> String {
826 enriched_help_command(cmd).render_long_help().to_string()
827}
828
829#[cfg(feature = "cli-help")]
840fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
841 let cmd = cmd.clone();
842 let description = if visible_subcommands(&cmd).next().is_some() {
843 HELP_FLAG_WITH_SUBCOMMANDS
844 } else {
845 HELP_FLAG_LEAF
846 };
847 cmd.disable_help_flag(true).arg(
852 clap::Arg::new("help")
853 .short('h')
854 .long("help")
855 .help(description)
856 .long_help(description)
857 .action(clap::ArgAction::Help),
858 )
859}
860
861#[cfg(feature = "cli-help")]
863const HELP_FLAG_WITH_SUBCOMMANDS: &str =
864 "Print help. Add --recursive to expand every nested subcommand; \
865 add --output json|yaml|markdown to render this help in another format.";
866
867#[cfg(feature = "cli-help")]
869const HELP_FLAG_LEAF: &str =
870 "Print help. Add --output json|yaml|markdown to render this help in another format.";
871
872#[cfg(feature = "cli-help")]
873fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
874 use std::fmt::Write;
875
876 let mut cmd_path = parent_path.to_vec();
878 cmd_path.push(cmd.get_name());
879 let path_str = cmd_path.join(" ");
880
881 if !buf.is_empty() {
883 let _ = writeln!(buf);
884 let _ = writeln!(buf, "{}", "═".repeat(60));
885 }
886
887 if let Some(about) = cmd.get_about() {
889 let _ = writeln!(buf, "{path_str} — {about}");
890 } else {
891 let _ = writeln!(buf, "{path_str}");
892 }
893 let _ = writeln!(buf);
894
895 let is_target = parent_path.is_empty();
899 let styled = if is_target {
900 enriched_help_command(cmd).render_long_help()
901 } else {
902 cmd.clone().render_long_help()
903 };
904 let help_text = styled.to_string();
905 let _ = write!(buf, "{help_text}");
906
907 for sub in cmd.get_subcommands() {
909 if sub.get_name() == "help" || sub.is_hide_set() {
910 continue; }
912 render_help_recursive_plain(sub, &cmd_path, buf);
913 }
914}
915
916#[cfg(feature = "cli-help")]
917fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
918 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
919 let mut buf = String::new();
920 render_markdown_command(target, &names, &mut buf, 1, true);
921 if matches!(scope, HelpScope::Recursive) {
922 render_markdown_descendants(target, &names, &mut buf, 2);
923 }
924 buf
925}
926
927#[cfg(feature = "cli-help")]
928fn render_markdown_descendants(
929 cmd: &clap::Command,
930 parent_names: &[String],
931 buf: &mut String,
932 level: usize,
933) {
934 for sub in cmd.get_subcommands() {
935 if sub.get_name() == "help" || sub.is_hide_set() {
936 continue;
937 }
938 let mut names = parent_names.to_vec();
939 names.push(sub.get_name().to_string());
940 render_markdown_command(sub, &names, buf, level, false);
941 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
942 }
943}
944
945#[cfg(feature = "cli-help")]
946fn render_markdown_command(
947 cmd: &clap::Command,
948 names: &[String],
949 buf: &mut String,
950 level: usize,
951 enrich: bool,
952) {
953 use std::fmt::Write;
954
955 if !buf.is_empty() {
956 let _ = writeln!(buf);
957 }
958 let heading_level = "#".repeat(level.max(1));
959 let path = names.join(" ");
960 if let Some(about) = cmd.get_about() {
961 let _ = writeln!(buf, "{heading_level} {path} - {about}");
962 } else {
963 let _ = writeln!(buf, "{heading_level} {path}");
964 }
965 if let Some(long_about) = cmd.get_long_about() {
966 let _ = writeln!(buf);
967 let _ = writeln!(buf, "{long_about}");
968 }
969 let _ = writeln!(buf);
970 let _ = writeln!(buf, "```text");
971 let help = if enrich {
972 enriched_help_command(cmd).render_long_help()
973 } else {
974 cmd.clone().render_long_help()
975 };
976 write_trimmed_help(buf, &help.to_string());
977 if !buf.ends_with('\n') {
978 let _ = writeln!(buf);
979 }
980 let _ = writeln!(buf, "```");
981}
982
983#[cfg(feature = "cli-help")]
984fn write_trimmed_help(buf: &mut String, help: &str) {
985 use std::fmt::Write;
986
987 for line in help.lines() {
988 let _ = writeln!(buf, "{}", line.trim_end());
989 }
990}
991
992#[cfg(feature = "cli-help")]
993struct ParsedHelpRequest {
994 help_requested: bool,
995 recursive_requested: bool,
996 output_format: Option<HelpFormat>,
997 output_error: Option<String>,
998 subcommand_path: Vec<String>,
999}
1000
1001#[cfg(feature = "cli-help")]
1002fn parse_help_request(
1003 raw_args: &[String],
1004 cmd: &clap::Command,
1005 config: &HelpConfig,
1006) -> ParsedHelpRequest {
1007 let args = match raw_args.first() {
1008 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
1009 _ => raw_args.get(1..).unwrap_or(&[]),
1010 };
1011 let mut help_requested = false;
1012 let mut recursive_requested = false;
1013 let mut output_format = None;
1014 let mut output_error = None;
1015 let mut subcommand_path = Vec::new();
1016 let mut current = cmd;
1017 let output_flag = config.output_flag.map(normalize_long_flag);
1018 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
1019
1020 let mut i = 0usize;
1021 while i < args.len() {
1022 let arg = args[i].as_str();
1023 if arg == "--" {
1024 break;
1025 }
1026
1027 let (flag_name, inline_value) = split_flag(arg);
1028 if matches!(arg, "--help" | "-h") {
1029 help_requested = true;
1030 i += 1;
1031 continue;
1032 }
1033 if arg == "--recursive"
1038 || flag_name
1039 .zip(recursive_flag)
1040 .is_some_and(|(seen, expected)| seen == expected)
1041 {
1042 recursive_requested = true;
1043 i += 1;
1044 continue;
1045 }
1046 if config.allow_output_format
1047 && flag_name
1048 .zip(output_flag)
1049 .is_some_and(|(seen, expected)| seen == expected)
1050 {
1051 let value = inline_value.or_else(|| {
1052 args.get(i + 1)
1053 .map(String::as_str)
1054 .filter(|next| !next.starts_with('-'))
1055 });
1056 if let Some(value) = value {
1057 match HelpFormat::parse(value) {
1058 Some(format) => output_format = Some(format),
1059 None => {
1060 output_error = Some(format!(
1061 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
1062 output_flag.unwrap_or("output"),
1063 value
1064 ));
1065 }
1066 }
1067 } else {
1068 output_error = Some(format!(
1069 "missing value for --{}: expected plain, json, yaml, or markdown",
1070 output_flag.unwrap_or("output")
1071 ));
1072 }
1073 i += if inline_value.is_some() || value.is_none() {
1074 1
1075 } else {
1076 2
1077 };
1078 continue;
1079 }
1080 if arg.starts_with('-') {
1081 i += if inline_value.is_none() && flag_takes_value(current, arg) {
1082 2
1083 } else {
1084 1
1085 };
1086 continue;
1087 }
1088 if let Some(sub) = current.find_subcommand(arg) {
1089 if sub.get_name() != "help" && !sub.is_hide_set() {
1090 subcommand_path.push(sub.get_name().to_string());
1091 current = sub;
1092 }
1093 }
1094 i += 1;
1095 }
1096
1097 ParsedHelpRequest {
1098 help_requested,
1099 recursive_requested,
1100 output_format,
1101 output_error,
1102 subcommand_path,
1103 }
1104}
1105
1106#[cfg(feature = "cli-help")]
1107fn normalize_long_flag(flag: &str) -> &str {
1108 flag.trim_start_matches('-')
1109}
1110
1111#[cfg(feature = "cli-help")]
1112fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1113 if let Some(stripped) = arg.strip_prefix("--") {
1114 if let Some((name, value)) = stripped.split_once('=') {
1115 (Some(name), Some(value))
1116 } else {
1117 (Some(stripped), None)
1118 }
1119 } else if let Some(stripped) = arg.strip_prefix('-') {
1120 (Some(stripped), None)
1121 } else {
1122 (None, None)
1123 }
1124}
1125
1126#[cfg(feature = "cli-help")]
1127fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1128 let Some(flag) = raw_flag.strip_prefix('-') else {
1129 return false;
1130 };
1131 let name = flag.trim_start_matches('-');
1132 cmd.get_arguments().any(|arg| {
1133 let long_matches = arg.get_long().is_some_and(|long| long == name);
1134 let short_matches =
1135 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1136 (long_matches || short_matches)
1137 && matches!(
1138 arg.get_action(),
1139 clap::ArgAction::Set | clap::ArgAction::Append
1140 )
1141 })
1142}
1143
1144#[cfg(feature = "cli-help")]
1145fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1146 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1147 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1148 if let Value::Object(map) = &mut schema {
1149 map.insert("code".to_string(), Value::String("help".to_string()));
1150 map.insert(
1151 "scope".to_string(),
1152 Value::String(help_scope_tag(scope).to_string()),
1153 );
1154 }
1155 schema
1156}
1157
1158#[cfg(feature = "cli-help")]
1159fn help_scope_tag(scope: HelpScope) -> &'static str {
1160 match scope {
1161 HelpScope::OneLevel => "one_level",
1162 HelpScope::Recursive => "recursive",
1163 }
1164}
1165
1166#[cfg(feature = "cli-help")]
1167fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1168 let subcommands: Vec<Value> = visible_subcommands(cmd)
1169 .map(|sub| {
1170 let mut child_names = names.to_vec();
1171 child_names.push(sub.get_name().to_string());
1172 if recursive {
1173 command_schema(sub, &child_names, true, false)
1175 } else {
1176 command_summary_schema(sub, &child_names)
1177 }
1178 })
1179 .collect();
1180
1181 serde_json::json!({
1182 "name": cmd.get_name(),
1183 "command_path": names.join(" "),
1184 "path": names,
1185 "about": styled_to_value(cmd.get_about()),
1186 "long_about": styled_to_value(cmd.get_long_about()),
1187 "usage": cmd.clone().render_usage().to_string(),
1188 "arguments": command_arguments_schema(cmd, enrich),
1189 "subcommands": subcommands,
1190 })
1191}
1192
1193#[cfg(feature = "cli-help")]
1194fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1195 serde_json::json!({
1196 "name": cmd.get_name(),
1197 "command_path": names.join(" "),
1198 "path": names,
1199 "about": styled_to_value(cmd.get_about()),
1200 "long_about": styled_to_value(cmd.get_long_about()),
1201 "usage": Value::Null,
1202 "arguments": [],
1203 "subcommands": [],
1204 })
1205}
1206
1207#[cfg(feature = "cli-help")]
1208fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1209 cmd.get_subcommands()
1210 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1211}
1212
1213#[cfg(feature = "cli-help")]
1214fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1215 let owned = enrich.then(|| enriched_help_command(cmd));
1221 let source = owned.as_ref().unwrap_or(cmd);
1222 source
1223 .get_arguments()
1224 .filter(|arg| !arg.is_hide_set())
1225 .map(argument_schema)
1226 .collect()
1227}
1228
1229#[cfg(feature = "cli-help")]
1230fn argument_schema(arg: &clap::Arg) -> Value {
1231 let value_names: Vec<String> = arg
1232 .get_value_names()
1233 .map(|names| names.iter().map(ToString::to_string).collect())
1234 .unwrap_or_default();
1235 let default_values: Vec<String> = arg
1236 .get_default_values()
1237 .iter()
1238 .map(|value| value.to_string_lossy().to_string())
1239 .collect();
1240 serde_json::json!({
1241 "id": arg.get_id().to_string(),
1242 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1243 "long": arg.get_long(),
1244 "short": arg.get_short().map(|c| c.to_string()),
1245 "help": styled_to_value(arg.get_help()),
1246 "long_help": styled_to_value(arg.get_long_help()),
1247 "required": arg.is_required_set(),
1248 "action": format!("{:?}", arg.get_action()),
1249 "value_names": value_names,
1250 "default_values": default_values,
1251 })
1252}
1253
1254#[cfg(feature = "cli-help")]
1255fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1256 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1257}
1258
1259#[derive(Default)]
1264struct RedactionContext {
1265 secret_names: HashSet<String>,
1266}
1267
1268impl RedactionContext {
1269 fn from_options(redaction_options: &RedactionOptions) -> Self {
1270 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1271 Self { secret_names }
1272 }
1273
1274 fn is_secret_key(&self, key: &str) -> bool {
1275 key_has_secret_suffix(key) || self.secret_names.contains(key)
1276 }
1277}
1278
1279fn key_has_secret_suffix(key: &str) -> bool {
1280 key.ends_with("_secret") || key.ends_with("_SECRET")
1281}
1282
1283fn key_has_url_suffix(key: &str) -> bool {
1284 key.ends_with("_url") || key.ends_with("_URL")
1285}
1286
1287const MAX_DEPTH: usize = 256;
1288
1289fn redact_secrets(value: &mut Value) {
1290 let context = RedactionContext::default();
1291 redact_secrets_with_context(value, &context);
1292}
1293
1294fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1295 redact_secrets_with_context_depth(value, context, 0);
1296}
1297
1298fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
1299 if depth >= MAX_DEPTH {
1300 *value = Value::String("***".into());
1301 return;
1302 }
1303 match value {
1304 Value::Object(map) => {
1305 let keys: Vec<String> = map.keys().cloned().collect();
1306 for key in keys {
1307 if context.is_secret_key(&key) {
1308 map.insert(key, Value::String("***".into()));
1309 } else if key_has_url_suffix(&key) {
1310 if let Some(Value::String(s)) = map.get_mut(&key) {
1311 *s = redact_url_field_value(s, context);
1312 } else if let Some(v) = map.get_mut(&key) {
1313 redact_secrets_with_context_depth(v, context, depth + 1);
1314 }
1315 } else if let Some(v) = map.get_mut(&key) {
1316 redact_secrets_with_context_depth(v, context, depth + 1);
1317 }
1318 }
1319 }
1320 Value::Array(arr) => {
1321 for v in arr {
1322 redact_secrets_with_context_depth(v, context, depth + 1);
1323 }
1324 }
1325 _ => {}
1326 }
1327}
1328
1329fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1333 if !s.contains("://") || !is_single_url(s) {
1340 return None;
1341 }
1342 let scheme_sep = s.find("://")?;
1343 let scheme = &s[..scheme_sep];
1344 let rest = &s[scheme_sep + 3..];
1345
1346 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1348 let authority = &rest[..auth_end];
1349 let remainder = &rest[auth_end..];
1350
1351 let new_authority = redact_userinfo_password(authority);
1352
1353 let new_remainder = match remainder.find('?') {
1355 Some(q) => {
1356 let (path, q_onwards) = remainder.split_at(q);
1357 let query_body = &q_onwards[1..];
1358 let (query, fragment) = match query_body.find('#') {
1359 Some(h) => (&query_body[..h], &query_body[h..]),
1360 None => (query_body, ""),
1361 };
1362 format!("{path}?{}{fragment}", redact_query(query, context))
1363 }
1364 None => remainder.to_string(),
1365 };
1366
1367 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1368}
1369
1370fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
1371 if let Some(redacted) = redact_url_in_str(s, context) {
1372 return redacted;
1373 }
1374 let trimmed = s.trim();
1375 if trimmed != s {
1376 if let Some(redacted) = redact_url_in_str(trimmed, context) {
1377 return redacted;
1378 }
1379 }
1380 if s.chars().any(char::is_whitespace) || s.contains('@') {
1386 return "***".to_string();
1387 }
1388 s.to_string()
1389}
1390
1391fn redact_userinfo_password(authority: &str) -> String {
1394 let Some(at) = authority.rfind('@') else {
1395 return authority.to_string();
1396 };
1397 let userinfo = &authority[..at];
1398 match userinfo.find(':') {
1399 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1400 None => authority.to_string(),
1401 }
1402}
1403
1404fn redact_query(query: &str, context: &RedactionContext) -> String {
1407 query
1408 .split('&')
1409 .map(|segment| {
1410 let Some(eq) = segment.find('=') else {
1411 return segment.to_string();
1412 };
1413 let raw_key = &segment[..eq];
1414 let name = url::form_urlencoded::parse(segment.as_bytes())
1416 .next()
1417 .map(|(k, _)| k.into_owned())
1418 .unwrap_or_default();
1419 if context.is_secret_key(&name) {
1420 format!("{raw_key}=***")
1421 } else {
1422 segment.to_string()
1423 }
1424 })
1425 .collect::<Vec<_>>()
1426 .join("&")
1427}
1428
1429fn is_single_url(s: &str) -> bool {
1433 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1434 return false;
1435 }
1436 let bytes = s.as_bytes();
1437 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1438 return false;
1439 }
1440 let mut i = 1;
1441 while i < bytes.len() {
1442 let c = bytes[i];
1443 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1444 i += 1;
1445 } else {
1446 break;
1447 }
1448 }
1449 s[i..].starts_with("://")
1450}
1451
1452fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1453 let context = RedactionContext::default();
1454 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1455}
1456
1457fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1458 let context = RedactionContext::from_options(redaction_options);
1459 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1460}
1461
1462fn apply_redaction_policy_with_context(
1463 value: &mut Value,
1464 redaction_policy: Option<RedactionPolicy>,
1465 context: &RedactionContext,
1466) {
1467 match redaction_policy {
1468 Some(RedactionPolicy::RedactionTraceOnly) => {
1469 if let Value::Object(map) = value {
1470 if let Some(trace) = map.get_mut("trace") {
1471 redact_secrets_with_context(trace, context);
1472 }
1473 }
1474 }
1475 Some(RedactionPolicy::RedactionNone) => {}
1476 None => redact_secrets_with_context(value, context),
1477 }
1478}
1479
1480fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1486 if let Some(s) = key.strip_suffix(suffix_lower) {
1487 return Some(s.to_string());
1488 }
1489 let suffix_upper: String = suffix_lower
1490 .chars()
1491 .map(|c| c.to_ascii_uppercase())
1492 .collect();
1493 if let Some(s) = key.strip_suffix(&suffix_upper) {
1494 return Some(s.to_string());
1495 }
1496 None
1497}
1498
1499fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1501 let code = extract_currency_code(key)?;
1502 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1504 if stripped.is_empty() {
1505 return None;
1506 }
1507 Some((stripped.to_string(), code.to_string()))
1508}
1509
1510fn as_int(value: &Value) -> Option<i64> {
1518 if let Some(i) = value.as_i64() {
1519 return Some(i);
1520 }
1521 let f = value.as_f64()?;
1522 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1523 return Some(f as i64);
1524 }
1525 None
1526}
1527
1528fn as_uint(value: &Value) -> Option<u64> {
1530 if let Some(u) = value.as_u64() {
1531 return Some(u);
1532 }
1533 let f = value.as_f64()?;
1534 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1535 return Some(f as u64);
1536 }
1537 None
1538}
1539
1540fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1541 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1543 return as_int(value)
1544 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1545 }
1546 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1547 return as_int(value)
1548 .and_then(|s| s.checked_mul(1000))
1549 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1550 }
1551 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1552 return as_int(value).and_then(|ns| {
1553 format_rfc3339_ms(ns.div_euclid(1_000_000)).map(|formatted| (stripped, formatted))
1554 });
1555 }
1556
1557 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1559 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1560 }
1561 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1562 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1563 }
1564 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1565 return as_uint(value).map(|n| {
1566 (
1567 stripped,
1568 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1569 )
1570 });
1571 }
1572
1573 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1575 return value.as_str().map(|s| (stripped, s.to_string()));
1576 }
1577 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1578 return value
1579 .is_number()
1580 .then(|| (stripped, format!("{} minutes", number_str(value))));
1581 }
1582 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1583 return value
1584 .is_number()
1585 .then(|| (stripped, format!("{} hours", number_str(value))));
1586 }
1587 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1588 return value
1589 .is_number()
1590 .then(|| (stripped, format!("{} days", number_str(value))));
1591 }
1592
1593 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1595 return value
1596 .is_number()
1597 .then(|| (stripped, format!("{}msats", number_str(value))));
1598 }
1599 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1600 return value
1601 .is_number()
1602 .then(|| (stripped, format!("{}sats", number_str(value))));
1603 }
1604 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1605 return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1606 }
1607 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1608 return value
1609 .is_number()
1610 .then(|| (stripped, format!("{}%", number_str(value))));
1611 }
1612 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1614 return value
1615 .is_number()
1616 .then(|| (stripped, format!("{} BTC", number_str(value))));
1617 }
1618 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1619 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1620 }
1621 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1622 return value
1623 .is_number()
1624 .then(|| (stripped, format!("{}ns", number_str(value))));
1625 }
1626 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1627 return value
1628 .is_number()
1629 .then(|| (stripped, format!("{}μs", number_str(value))));
1630 }
1631 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1632 return format_ms_value(value).map(|v| (stripped, v));
1633 }
1634 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1635 return value
1636 .is_number()
1637 .then(|| (stripped, format!("{}s", number_str(value))));
1638 }
1639
1640 None
1641}
1642
1643fn process_object_fields<'a>(
1645 map: &'a serde_json::Map<String, Value>,
1646) -> Vec<(String, &'a Value, Option<String>)> {
1647 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1648 for (key, value) in map {
1649 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1650 entries.push((stripped, key.as_str(), value, None));
1651 continue;
1652 }
1653 match try_process_field(key, value) {
1654 Some((stripped, formatted)) => {
1655 entries.push((stripped, key.as_str(), value, Some(formatted)));
1656 }
1657 None => {
1658 entries.push((key.clone(), key.as_str(), value, None));
1659 }
1660 }
1661 }
1662
1663 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1665 for (stripped, _, _, _) in &entries {
1666 *counts.entry(stripped.clone()).or_insert(0) += 1;
1667 }
1668
1669 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1671 .into_iter()
1672 .map(|(stripped, original, value, formatted)| {
1673 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1674 (original.to_string(), value, None)
1675 } else {
1676 (stripped, value, formatted)
1677 }
1678 })
1679 .collect();
1680
1681 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1682 result
1683}
1684
1685fn number_str(value: &Value) -> String {
1690 match value {
1691 Value::Number(n) => format_number(n),
1692 _ => String::new(),
1693 }
1694}
1695
1696fn format_number(n: &serde_json::Number) -> String {
1702 if n.is_f64() {
1703 if let Some(f) = n.as_f64() {
1704 if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 {
1705 return format!("{f:.0}");
1706 }
1707 }
1708 }
1709 normalize_exponent(&n.to_string())
1710}
1711
1712fn normalize_exponent(s: &str) -> String {
1713 let Some(e) = s.find(['e', 'E']) else {
1714 return s.to_string();
1715 };
1716 let mantissa = &s[..e];
1717 let mut exp = &s[e + 1..];
1718 let mut sign = "";
1719 if exp.starts_with(['+', '-']) {
1720 sign = &exp[..1];
1721 exp = &exp[1..];
1722 }
1723 let exp = exp.trim_start_matches('0');
1724 let exp = if exp.is_empty() { "0" } else { exp };
1725 format!("{mantissa}e{sign}{exp}")
1726}
1727
1728fn format_ms_as_seconds(ms: f64) -> String {
1730 let formatted = format!("{:.3}", ms / 1000.0);
1731 let trimmed = formatted.trim_end_matches('0');
1732 if trimmed.ends_with('.') {
1733 format!("{}0s", trimmed)
1734 } else {
1735 format!("{}s", trimmed)
1736 }
1737}
1738
1739fn format_ms_value(value: &Value) -> Option<String> {
1741 let n = value.as_f64()?;
1742 if n.abs() >= 1000.0 {
1743 Some(format_ms_as_seconds(n))
1744 } else if let Some(i) = value.as_i64() {
1745 Some(format!("{}ms", i))
1746 } else {
1747 Some(format!("{}ms", number_str(value)))
1748 }
1749}
1750
1751const MIN_RFC3339_MS: i64 = -62135596800000;
1753const MAX_RFC3339_MS: i64 = 253402300799999;
1754
1755fn format_rfc3339_ms(ms: i64) -> Option<String> {
1756 use chrono::{DateTime, Utc};
1757 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
1758 return None;
1759 }
1760 let secs = ms.div_euclid(1000);
1761 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1762 DateTime::from_timestamp(secs, nanos).map(|dt| {
1763 dt.with_timezone(&Utc)
1764 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1765 })
1766}
1767
1768fn format_bytes_human(bytes: i64) -> String {
1770 const KB: f64 = 1024.0;
1771 const MB: f64 = KB * 1024.0;
1772 const GB: f64 = MB * 1024.0;
1773 const TB: f64 = GB * 1024.0;
1774
1775 let sign = if bytes < 0 { "-" } else { "" };
1776 let b = (bytes as f64).abs();
1777 if b >= TB {
1778 format!("{sign}{:.1}TB", b / TB)
1779 } else if b >= GB {
1780 format!("{sign}{:.1}GB", b / GB)
1781 } else if b >= MB {
1782 format!("{sign}{:.1}MB", b / MB)
1783 } else if b >= KB {
1784 format!("{sign}{:.1}KB", b / KB)
1785 } else {
1786 format!("{bytes}B")
1787 }
1788}
1789
1790fn format_with_commas(n: u64) -> String {
1792 let s = n.to_string();
1793 let mut result = String::with_capacity(s.len() + s.len() / 3);
1794 for (i, c) in s.chars().enumerate() {
1795 if i > 0 && (s.len() - i).is_multiple_of(3) {
1796 result.push(',');
1797 }
1798 result.push(c);
1799 }
1800 result
1801}
1802
1803fn extract_currency_code(key: &str) -> Option<&str> {
1805 let without_cents = key
1806 .strip_suffix("_cents")
1807 .or_else(|| key.strip_suffix("_CENTS"))?;
1808 let last_underscore = without_cents.rfind('_')?;
1809 let code = &without_cents[last_underscore + 1..];
1810 if code.is_empty()
1811 || !(3..=4).contains(&code.len())
1812 || !code.bytes().all(|b| b.is_ascii_alphabetic())
1813 {
1814 return None;
1815 }
1816 Some(code)
1817}
1818
1819fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1824 let prefix = " ".repeat(indent);
1825 match value {
1826 Value::Object(map) => {
1827 let processed = process_object_fields(map);
1828 for (display_key, v, formatted) in processed {
1829 if let Some(fv) = formatted {
1830 lines.push(format!(
1831 "{}{}: \"{}\"",
1832 prefix,
1833 yaml_key(&display_key),
1834 escape_yaml_str(&fv)
1835 ));
1836 } else {
1837 match v {
1838 Value::Object(inner) if !inner.is_empty() => {
1839 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
1840 render_yaml_processed(v, indent + 1, lines);
1841 }
1842 Value::Object(_) => {
1843 lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
1844 }
1845 Value::Array(arr) => {
1846 if arr.is_empty() {
1847 lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
1848 } else {
1849 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
1850 for item in arr {
1851 if item.is_object() {
1852 lines.push(format!("{} -", prefix));
1853 render_yaml_processed(item, indent + 2, lines);
1854 } else {
1855 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
1856 }
1857 }
1858 }
1859 }
1860 _ => {
1861 lines.push(format!(
1862 "{}{}: {}",
1863 prefix,
1864 yaml_key(&display_key),
1865 yaml_scalar(v)
1866 ));
1867 }
1868 }
1869 }
1870 }
1871 }
1872 _ => {
1873 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1874 }
1875 }
1876}
1877
1878fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1879 let prefix = " ".repeat(indent);
1880 match value {
1881 Value::Object(map) => {
1882 for key in sorted_value_keys(map) {
1883 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
1884 }
1885 }
1886 Value::Array(arr) => {
1887 render_yaml_array_raw(arr, indent, lines);
1888 }
1889 _ => {
1890 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1891 }
1892 }
1893}
1894
1895fn render_yaml_field_raw(
1896 prefix: &str,
1897 key: &str,
1898 value: &Value,
1899 indent: usize,
1900 lines: &mut Vec<String>,
1901) {
1902 match value {
1903 Value::Object(inner) if !inner.is_empty() => {
1904 lines.push(format!("{}{}:", prefix, yaml_key(key)));
1905 render_yaml_raw(value, indent + 1, lines);
1906 }
1907 Value::Object(_) => {
1908 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
1909 }
1910 Value::Array(arr) => {
1911 if arr.is_empty() {
1912 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
1913 } else {
1914 lines.push(format!("{}{}:", prefix, yaml_key(key)));
1915 render_yaml_array_raw(arr, indent + 1, lines);
1916 }
1917 }
1918 _ => {
1919 lines.push(format!(
1920 "{}{}: {}",
1921 prefix,
1922 yaml_key(key),
1923 yaml_scalar(value)
1924 ));
1925 }
1926 }
1927}
1928
1929fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1930 let prefix = " ".repeat(indent);
1931 for item in arr {
1932 match item {
1933 Value::Object(inner) if !inner.is_empty() => {
1934 lines.push(format!("{}-", prefix));
1935 render_yaml_raw(item, indent + 1, lines);
1936 }
1937 Value::Array(nested) if !nested.is_empty() => {
1938 lines.push(format!("{}-", prefix));
1939 render_yaml_array_raw(nested, indent + 1, lines);
1940 }
1941 Value::Object(_) => {
1942 lines.push(format!("{}- {{}}", prefix));
1943 }
1944 Value::Array(_) => {
1945 lines.push(format!("{}- []", prefix));
1946 }
1947 _ => {
1948 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1949 }
1950 }
1951 }
1952}
1953
1954fn escape_yaml_str(s: &str) -> String {
1955 s.replace('\\', "\\\\")
1956 .replace('"', "\\\"")
1957 .replace('\n', "\\n")
1958 .replace('\r', "\\r")
1959 .replace('\t', "\\t")
1960 .replace('\x0c', "\\f")
1961 .replace('\x0b', "\\v")
1962}
1963
1964fn yaml_key(key: &str) -> String {
1965 if is_safe_key(key) {
1966 key.to_string()
1967 } else {
1968 format!("\"{}\"", escape_yaml_str(key))
1969 }
1970}
1971
1972fn quote_logfmt_key(key: &str) -> String {
1973 if is_safe_key(key) {
1974 key.to_string()
1975 } else {
1976 quote_logfmt_value(key)
1977 }
1978}
1979
1980fn is_safe_key(key: &str) -> bool {
1981 !key.is_empty()
1982 && key
1983 .bytes()
1984 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
1985}
1986
1987fn yaml_scalar(value: &Value) -> String {
1988 match value {
1989 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
1990 Value::Null => "null".to_string(),
1991 Value::Bool(b) => b.to_string(),
1992 Value::Number(n) => format_number(n),
1993 Value::Object(_) | Value::Array(_) => {
1994 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
1995 }
1996 }
1997}
1998
1999fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2004 if let Value::Object(map) = value {
2005 let processed = process_object_fields(map);
2006 for (display_key, v, formatted) in processed {
2007 let full_key = if prefix.is_empty() {
2008 display_key
2009 } else {
2010 format!("{}.{}", prefix, display_key)
2011 };
2012 if let Some(fv) = formatted {
2013 pairs.push((full_key, fv));
2014 } else {
2015 match v {
2016 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
2017 Value::Array(arr) => {
2018 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2019 pairs.push((full_key, joined));
2020 }
2021 Value::Null => pairs.push((full_key, String::new())),
2022 _ => pairs.push((full_key, plain_scalar(v))),
2023 }
2024 }
2025 }
2026 }
2027}
2028
2029fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2030 if let Value::Object(map) = value {
2031 for key in sorted_value_keys(map) {
2032 let v = &map[&key];
2033 let full_key = if prefix.is_empty() {
2034 key.clone()
2035 } else {
2036 format!("{}.{}", prefix, key)
2037 };
2038 match v {
2039 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
2040 Value::Array(arr) => {
2041 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2042 pairs.push((full_key, joined));
2043 }
2044 Value::Null => pairs.push((full_key, String::new())),
2045 _ => pairs.push((full_key, plain_scalar(v))),
2046 }
2047 }
2048 }
2049}
2050
2051fn plain_scalar(value: &Value) -> String {
2052 match value {
2053 Value::String(s) => s.clone(),
2054 Value::Null => "null".to_string(),
2055 Value::Bool(b) => b.to_string(),
2056 Value::Number(n) => format_number(n),
2057 Value::Object(_) | Value::Array(_) => canonical_json(value),
2058 }
2059}
2060
2061fn quote_logfmt_value(value: &str) -> String {
2062 if value.is_empty() {
2063 return String::new();
2064 }
2065 if !value
2066 .chars()
2067 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
2068 {
2069 return value.to_string();
2070 }
2071 let escaped = value
2072 .replace('\\', "\\\\")
2073 .replace('"', "\\\"")
2074 .replace('\n', "\\n")
2075 .replace('\r', "\\r")
2076 .replace('\t', "\\t")
2077 .replace('\x0c', "\\f")
2078 .replace('\x0b', "\\v");
2079 format!("\"{}\"", escaped)
2080}
2081
2082fn canonical_json(value: &Value) -> String {
2083 serde_json::to_string(&sort_json_value(value))
2084 .unwrap_or_else(|_| "<unsupported:json>".to_string())
2085}
2086
2087fn sort_json_value(value: &Value) -> Value {
2088 match value {
2089 Value::Object(map) => {
2090 let mut out = serde_json::Map::new();
2091 for key in sorted_value_keys(map) {
2092 if let Some(v) = map.get(&key) {
2093 out.insert(key, sort_json_value(v));
2094 }
2095 }
2096 Value::Object(out)
2097 }
2098 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
2099 _ => value.clone(),
2100 }
2101}
2102
2103fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
2104 let mut keys: Vec<String> = map.keys().cloned().collect();
2105 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
2106 keys
2107}
2108
2109#[cfg(test)]
2110mod tests;