1#[cfg(feature = "tracing")]
29pub mod afdata_tracing;
30
31#[cfg(feature = "skill-admin")]
32pub mod skill;
33
34use serde_json::Value;
35use std::collections::HashSet;
36
37pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
43 match trace {
44 Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
45 None => serde_json::json!({"code": "ok", "result": result}),
46 }
47}
48
49pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
51 let mut obj = serde_json::Map::new();
52 obj.insert("code".to_string(), Value::String("error".to_string()));
53 obj.insert("error".to_string(), Value::String(message.to_string()));
54 if let Some(h) = hint {
55 obj.insert("hint".to_string(), Value::String(h.to_string()));
56 }
57 if let Some(t) = trace {
58 obj.insert("trace".to_string(), t);
59 }
60 Value::Object(obj)
61}
62
63pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
65 let mut obj = match fields {
66 Value::Object(map) => map,
67 _ => serde_json::Map::new(),
68 };
69 obj.insert("code".to_string(), Value::String(code.to_string()));
70 if let Some(t) = trace {
71 obj.insert("trace".to_string(), t);
72 }
73 Value::Object(obj)
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82pub enum RedactionPolicy {
83 RedactionTraceOnly,
85 RedactionNone,
87}
88
89#[derive(Clone, Debug, Default, PartialEq, Eq)]
91pub struct RedactionOptions {
92 pub policy: Option<RedactionPolicy>,
94 pub secret_names: Vec<String>,
100}
101
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
104pub enum OutputStyle {
105 #[default]
107 Readable,
108 Raw,
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq)]
114pub struct OutputOptions {
115 pub redaction: RedactionOptions,
117 pub style: OutputStyle,
119}
120
121pub fn output_json(value: &Value) -> String {
123 serialize_json_output(&redacted_value(value))
124}
125
126pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
128 serialize_json_output(&redacted_value_with(value, redaction_policy))
129}
130
131pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
136 serialize_json_output(&redacted_value_with_options(
137 value,
138 &output_options.redaction,
139 ))
140}
141
142fn serialize_json_output(value: &Value) -> String {
143 match serde_json::to_string(value) {
144 Ok(s) => s,
145 Err(err) => serde_json::json!({
146 "error": "output_json_failed",
147 "detail": err.to_string(),
148 })
149 .to_string(),
150 }
151}
152
153pub fn output_yaml(value: &Value) -> String {
155 output_yaml_with_options(value, &OutputOptions::default())
156}
157
158pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
160 let mut lines = vec!["---".to_string()];
161 let v = redacted_value_with_options(value, &output_options.redaction);
162 match output_options.style {
163 OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
164 OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
165 }
166 lines.join("\n")
167}
168
169pub fn output_plain(value: &Value) -> String {
171 output_plain_with_options(value, &OutputOptions::default())
172}
173
174pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
176 let mut pairs: Vec<(String, String)> = Vec::new();
177 let v = redacted_value_with_options(value, &output_options.redaction);
178 match output_options.style {
179 OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
180 OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
181 }
182 pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
183 pairs
184 .into_iter()
185 .map(|(k, v)| format!("{}={}", quote_logfmt_key(&k), quote_logfmt_value(&v)))
186 .collect::<Vec<_>>()
187 .join(" ")
188}
189
190pub fn redact_secrets_in_place(value: &mut Value) {
196 redact_secrets(value);
197}
198
199pub fn redact_secrets_in_place_with_options(
201 value: &mut Value,
202 redaction_options: &RedactionOptions,
203) {
204 apply_redaction_options(value, redaction_options);
205}
206
207pub fn redacted_value(value: &Value) -> Value {
209 let mut v = value.clone();
210 redact_secrets(&mut v);
211 v
212}
213
214pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
216 let mut v = value.clone();
217 apply_redaction_policy(&mut v, redaction_policy);
218 v
219}
220
221pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
223 let mut v = value.clone();
224 apply_redaction_options(&mut v, redaction_options);
225 v
226}
227
228pub fn redact_url_secrets(url: &str) -> String {
233 redact_url_secrets_with_options(url, &RedactionOptions::default())
234}
235
236pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
246 let context = RedactionContext::from_options(redaction_options);
247 redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
248}
249
250pub fn parse_size(s: &str) -> Option<u64> {
256 const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
257 let s = s.trim();
258 if s.is_empty() {
259 return None;
260 }
261 let last = *s.as_bytes().last()?;
262 let (num_str, mult) = match last {
263 b'B' | b'b' => (&s[..s.len() - 1], 1u64),
264 b'K' | b'k' => (&s[..s.len() - 1], 1024),
265 b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
266 b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
267 b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
268 b'0'..=b'9' | b'.' => (s, 1),
269 _ => return None,
270 };
271 if num_str.is_empty() || !is_decimal_number(num_str) {
272 return None;
273 }
274 if let Ok(n) = num_str.parse::<u64>() {
275 let result = n.checked_mul(mult)?;
276 return (result <= MAX_SAFE_INTEGER).then_some(result);
277 }
278 if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
280 return None;
281 }
282 let f: f64 = num_str.parse().ok()?;
283 if f < 0.0 || f.is_nan() || f.is_infinite() {
284 return None;
285 }
286 let result = f * mult as f64;
287 if result > MAX_SAFE_INTEGER as f64 {
288 return None;
289 }
290 Some(result as u64)
291}
292
293fn is_decimal_number(s: &str) -> bool {
294 let bytes = s.as_bytes();
295 let mut i = 0;
296 let mut digits = 0;
297 while i < bytes.len() && bytes[i].is_ascii_digit() {
298 i += 1;
299 digits += 1;
300 }
301 if i < bytes.len() && bytes[i] == b'.' {
302 i += 1;
303 while i < bytes.len() && bytes[i].is_ascii_digit() {
304 i += 1;
305 digits += 1;
306 }
307 }
308 if digits == 0 {
309 return false;
310 }
311 if i < bytes.len() && matches!(bytes[i], b'e' | b'E') {
312 i += 1;
313 if i < bytes.len() && matches!(bytes[i], b'+' | b'-') {
314 i += 1;
315 }
316 let exp_start = i;
317 while i < bytes.len() && bytes[i].is_ascii_digit() {
318 i += 1;
319 }
320 if i == exp_start {
321 return false;
322 }
323 }
324 i == bytes.len()
325}
326
327pub fn normalize_utc_offset(s: &str) -> Option<String> {
333 let s = s.trim();
334 if s.eq_ignore_ascii_case("utc") || s.eq_ignore_ascii_case("z") {
335 return Some("UTC".to_string());
336 }
337 let sign = match s.as_bytes().first()? {
338 b'+' => '+',
339 b'-' => '-',
340 _ => return None,
341 };
342 let body = &s[1..];
343 let (hours, minutes) = parse_utc_offset_body(body)?;
344 if hours > 23 || minutes > 59 {
345 return None;
346 }
347 if hours == 0 && minutes == 0 {
348 return Some("UTC".to_string());
349 }
350 Some(format!("{sign}{hours:02}:{minutes:02}"))
351}
352
353pub fn is_valid_rfc3339_date(s: &str) -> bool {
355 let bytes = s.as_bytes();
356 if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
357 return false;
358 }
359 let Some(year) = parse_ascii_u16_bytes(&bytes[0..4]) else {
360 return false;
361 };
362 let Some(month) = parse_ascii_u8_bytes(&bytes[5..7]) else {
363 return false;
364 };
365 let Some(day) = parse_ascii_u8_bytes(&bytes[8..10]) else {
366 return false;
367 };
368 (1..=12).contains(&month) && (1..=days_in_month(year, month)).contains(&day)
369}
370
371pub fn is_valid_rfc3339_time(s: &str) -> bool {
376 let bytes = s.as_bytes();
377 if bytes.len() < 8 || bytes[2] != b':' || bytes[5] != b':' {
378 return false;
379 }
380 let Some(hour) = parse_ascii_u8_bytes(&bytes[0..2]) else {
381 return false;
382 };
383 let Some(minute) = parse_ascii_u8_bytes(&bytes[3..5]) else {
384 return false;
385 };
386 let Some(second) = parse_ascii_u8_bytes(&bytes[6..8]) else {
387 return false;
388 };
389 if hour > 23 || minute > 59 || second > 59 {
390 return false;
391 }
392 if bytes.len() == 8 {
393 return true;
394 }
395 bytes[8] == b'.' && bytes.len() > 9 && bytes[9..].iter().all(u8::is_ascii_digit)
396}
397
398fn parse_utc_offset_body(body: &str) -> Option<(u8, u8)> {
399 if body.is_empty() {
400 return None;
401 }
402 if let Some((hours, minutes)) = body.split_once(':') {
403 if hours.is_empty() || hours.len() > 2 || minutes.len() != 2 {
404 return None;
405 }
406 return Some((parse_ascii_u8(hours)?, parse_ascii_u8(minutes)?));
407 }
408 if !body.bytes().all(|b| b.is_ascii_digit()) {
409 return None;
410 }
411 match body.len() {
412 1 | 2 => Some((parse_ascii_u8(body)?, 0)),
413 4 => Some((parse_ascii_u8(&body[..2])?, parse_ascii_u8(&body[2..])?)),
414 _ => None,
415 }
416}
417
418fn parse_ascii_u8(s: &str) -> Option<u8> {
419 if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
420 return None;
421 }
422 s.parse().ok()
423}
424
425fn parse_ascii_u8_bytes(bytes: &[u8]) -> Option<u8> {
426 let n = parse_ascii_u16_bytes(bytes)?;
427 u8::try_from(n).ok()
428}
429
430fn parse_ascii_u16_bytes(bytes: &[u8]) -> Option<u16> {
431 if bytes.is_empty() || !bytes.iter().all(u8::is_ascii_digit) {
432 return None;
433 }
434 let mut value = 0u16;
435 for byte in bytes {
436 value = value.checked_mul(10)?;
437 value = value.checked_add(u16::from(byte - b'0'))?;
438 }
439 Some(value)
440}
441
442fn days_in_month(year: u16, month: u8) -> u8 {
443 match month {
444 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
445 4 | 6 | 9 | 11 => 30,
446 2 if is_leap_year(year) => 29,
447 2 => 28,
448 _ => 0,
449 }
450}
451
452fn is_leap_year(year: u16) -> bool {
453 let year = u32::from(year);
454 year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
455}
456
457#[derive(Clone, Copy, Debug, PartialEq, Eq)]
463pub enum OutputFormat {
464 Json,
465 Yaml,
466 Plain,
467}
468
469#[derive(Clone, Debug, PartialEq, Eq)]
475pub struct VersionConfig {
476 pub default_output: Option<OutputFormat>,
481 pub output_flag: Option<&'static str>,
483 pub output_short: Option<char>,
485 pub allow_output_format: bool,
487}
488
489impl VersionConfig {
490 pub const fn new(default_output: Option<OutputFormat>) -> Self {
492 Self {
493 default_output,
494 output_flag: None,
495 output_short: None,
496 allow_output_format: false,
497 }
498 }
499
500 pub const fn agent_cli_default() -> Self {
506 Self {
507 default_output: Some(OutputFormat::Json),
508 output_flag: Some("--output"),
509 output_short: None,
510 allow_output_format: true,
511 }
512 }
513
514 pub const fn conventional_default() -> Self {
517 Self {
518 default_output: None,
519 output_flag: Some("--output"),
520 output_short: None,
521 allow_output_format: true,
522 }
523 }
524
525 pub const fn with_default_output(mut self, default_output: Option<OutputFormat>) -> Self {
527 self.default_output = default_output;
528 self
529 }
530
531 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
533 self.output_flag = flag;
534 self
535 }
536
537 pub const fn with_output_short(mut self, flag: Option<char>) -> Self {
539 self.output_short = flag;
540 self
541 }
542
543 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
545 self.allow_output_format = enabled;
546 self
547 }
548}
549
550pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
560 match s {
561 "json" => Ok(OutputFormat::Json),
562 "yaml" => Ok(OutputFormat::Yaml),
563 "plain" => Ok(OutputFormat::Plain),
564 _ => Err(format!(
565 "invalid --output format '{s}': expected json, yaml, or plain"
566 )),
567 }
568}
569
570pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
580 let mut out: Vec<String> = Vec::new();
581 for entry in entries {
582 let s = entry.as_ref().trim().to_ascii_lowercase();
583 if !s.is_empty() && !out.contains(&s) {
584 out.push(s);
585 }
586 }
587 out
588}
589
590pub fn cli_output(value: &Value, format: OutputFormat) -> String {
601 match format {
602 OutputFormat::Json => output_json(value),
603 OutputFormat::Yaml => output_yaml(value),
604 OutputFormat::Plain => output_plain(value),
605 }
606}
607
608pub fn cli_output_with_options(
613 value: &Value,
614 format: OutputFormat,
615 output_options: &OutputOptions,
616) -> String {
617 match format {
618 OutputFormat::Json => output_json_with_options(value, output_options),
619 OutputFormat::Yaml => output_yaml_with_options(value, output_options),
620 OutputFormat::Plain => output_plain_with_options(value, output_options),
621 }
622}
623
624pub fn build_cli_version(version: &str) -> Value {
626 build_json("version", serde_json::json!({ "version": version }), None)
627}
628
629pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
634 let mut rendered = match format {
635 Some(format) => cli_output(&build_cli_version(version), format),
636 None => format!("{name} {version}"),
637 };
638 while rendered.ends_with('\n') {
639 rendered.pop();
640 }
641 rendered.push('\n');
642 rendered
643}
644
645pub fn cli_handle_version_or_continue(
655 raw_args: &[String],
656 name: &str,
657 version: &str,
658 config: &VersionConfig,
659) -> Result<Option<String>, Value> {
660 let parsed = parse_version_request(raw_args, config);
661 if !parsed.version_requested {
662 return Ok(None);
663 }
664 if let Some(error) = parsed.output_error {
665 return Err(build_cli_error(
666 &error,
667 Some("valid version output formats: json, yaml, plain"),
668 ));
669 }
670 let format = if config.allow_output_format {
671 parsed.output_format.or(config.default_output)
672 } else {
673 config.default_output
674 };
675 Ok(Some(cli_render_version(name, version, format)))
676}
677
678struct ParsedVersionRequest {
679 version_requested: bool,
680 output_format: Option<OutputFormat>,
681 output_error: Option<String>,
682}
683
684fn parse_version_request(raw_args: &[String], config: &VersionConfig) -> ParsedVersionRequest {
685 let args = raw_args.get(1..).unwrap_or(&[]);
686 let mut version_requested = false;
687 let mut output_format = None;
688 let mut output_error = None;
689 let output_flag = config.output_flag.map(normalize_long_flag);
690
691 let mut i = 0usize;
692 while i < args.len() {
693 let arg = args[i].as_str();
694 if arg == "--" {
695 break;
696 }
697
698 let (flag_name, inline_value) = split_flag(arg);
699 if matches!(arg, "--version" | "-V") {
700 version_requested = true;
701 i += 1;
702 continue;
703 }
704
705 if config.allow_output_format
706 && version_output_flag_matches(flag_name, output_flag, config.output_short)
707 {
708 let value = inline_value.or_else(|| {
709 args.get(i + 1)
710 .map(String::as_str)
711 .filter(|next| !next.starts_with('-'))
712 });
713 if let Some(value) = value {
714 match cli_parse_output(value) {
715 Ok(format) => output_format = Some(format),
716 Err(err) => output_error = Some(err),
717 }
718 } else {
719 output_error = Some(format!(
720 "missing value for --{}: expected json, yaml, or plain",
721 output_flag.unwrap_or("output")
722 ));
723 }
724 i += if inline_value.is_some() || value.is_none() {
725 1
726 } else {
727 2
728 };
729 continue;
730 }
731 i += 1;
732 }
733
734 ParsedVersionRequest {
735 version_requested,
736 output_format,
737 output_error,
738 }
739}
740
741fn version_output_flag_matches(
742 flag_name: Option<&str>,
743 output_flag: Option<&str>,
744 output_short: Option<char>,
745) -> bool {
746 let Some(seen) = flag_name else {
747 return false;
748 };
749 output_flag.is_some_and(|expected| seen == expected)
750 || output_short.is_some_and(|short| {
751 let mut chars = seen.chars();
752 chars.next().is_some_and(|seen_short| seen_short == short) && chars.next().is_none()
753 })
754}
755
756pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
768 let mut obj = serde_json::Map::new();
769 obj.insert("code".to_string(), Value::String("error".to_string()));
770 obj.insert("error".to_string(), Value::String(message.to_string()));
771 if let Some(h) = hint {
772 obj.insert("hint".to_string(), Value::String(h.to_string()));
773 }
774 Value::Object(obj)
775}
776
777#[cfg(feature = "cli-help")]
785#[derive(Clone, Copy, Debug, PartialEq, Eq)]
786pub enum HelpScope {
787 OneLevel,
792 Recursive,
794}
795
796#[cfg(feature = "cli-help")]
800#[derive(Clone, Copy, Debug, PartialEq, Eq)]
801pub enum HelpFormat {
802 Plain,
803 Markdown,
804 Json,
805 Yaml,
806}
807
808#[cfg(feature = "cli-help")]
809impl HelpFormat {
810 fn parse(s: &str) -> Option<Self> {
811 match s {
812 "plain" => Some(Self::Plain),
813 "markdown" => Some(Self::Markdown),
814 "json" => Some(Self::Json),
815 "yaml" => Some(Self::Yaml),
816 _ => None,
817 }
818 }
819}
820
821#[cfg(feature = "cli-help")]
825#[derive(Clone, Copy, Debug, PartialEq, Eq)]
826pub struct HelpOptions {
827 pub scope: HelpScope,
828 pub format: HelpFormat,
829}
830
831#[cfg(feature = "cli-help")]
832impl HelpOptions {
833 pub const fn one_level_plain() -> Self {
835 Self {
836 scope: HelpScope::OneLevel,
837 format: HelpFormat::Plain,
838 }
839 }
840
841 pub const fn recursive_plain() -> Self {
843 Self {
844 scope: HelpScope::Recursive,
845 format: HelpFormat::Plain,
846 }
847 }
848}
849
850#[cfg(feature = "cli-help")]
858#[derive(Clone, Debug, PartialEq, Eq)]
859pub struct HelpConfig {
860 pub default_scope: HelpScope,
863 pub default_format: HelpFormat,
865 pub recursive_flag: Option<&'static str>,
872 pub output_flag: Option<&'static str>,
874 pub allow_output_format: bool,
876}
877
878#[cfg(feature = "cli-help")]
879impl HelpConfig {
880 pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
882 Self {
883 default_scope,
884 default_format,
885 recursive_flag: None,
886 output_flag: None,
887 allow_output_format: false,
888 }
889 }
890
891 pub const fn human_cli_default() -> Self {
899 Self {
900 default_scope: HelpScope::OneLevel,
901 default_format: HelpFormat::Plain,
902 recursive_flag: None,
903 output_flag: Some("--output"),
904 allow_output_format: true,
905 }
906 }
907
908 pub const fn agent_cli_default() -> Self {
910 Self {
911 default_scope: HelpScope::Recursive,
912 default_format: HelpFormat::Plain,
913 recursive_flag: None,
914 output_flag: Some("--output"),
915 allow_output_format: true,
916 }
917 }
918
919 pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
921 self.default_scope = scope;
922 self
923 }
924
925 pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
927 self.default_format = format;
928 self
929 }
930
931 pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
933 self.recursive_flag = flag;
934 self
935 }
936
937 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
939 self.output_flag = flag;
940 self
941 }
942
943 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
945 self.allow_output_format = enabled;
946 self
947 }
948}
949
950#[cfg(feature = "cli-help")]
958pub fn cli_render_help_with_options(
959 cmd: &clap::Command,
960 subcommand_path: &[&str],
961 options: &HelpOptions,
962) -> String {
963 let target = walk_to_subcommand(cmd, subcommand_path);
964 let mut rendered = match options.format {
965 HelpFormat::Plain => match options.scope {
966 HelpScope::OneLevel => render_help_one_level_plain(target),
967 HelpScope::Recursive => {
968 let mut buf = String::new();
969 render_help_recursive_plain(target, &[], &mut buf);
970 buf
971 }
972 },
973 HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
974 HelpFormat::Json => {
975 serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
976 }
977 HelpFormat::Yaml => output_yaml_with_options(
978 &build_help_schema(cmd, subcommand_path, options.scope),
979 &OutputOptions {
980 redaction: RedactionOptions {
981 policy: Some(RedactionPolicy::RedactionNone),
982 secret_names: Vec::new(),
983 },
984 style: OutputStyle::Raw,
985 },
986 ),
987 };
988 while rendered.ends_with('\n') {
992 rendered.pop();
993 }
994 rendered.push('\n');
995 rendered
996}
997
998#[cfg(feature = "cli-help")]
1005pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
1006 cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
1007}
1008
1009#[cfg(feature = "cli-help-markdown")]
1016pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
1017 cli_render_help_with_options(
1018 cmd,
1019 subcommand_path,
1020 &HelpOptions {
1021 scope: HelpScope::Recursive,
1022 format: HelpFormat::Markdown,
1023 },
1024 )
1025}
1026
1027#[cfg(feature = "cli-help")]
1043pub fn cli_handle_help_or_continue(
1044 raw_args: &[String],
1045 cmd: &clap::Command,
1046 config: &HelpConfig,
1047) -> Result<Option<String>, Value> {
1048 let parsed = parse_help_request(raw_args, cmd, config);
1049 if !parsed.help_requested {
1050 return Ok(None);
1051 }
1052 if let Some(error) = parsed.output_error {
1053 return Err(build_cli_error(
1054 &error,
1055 Some("valid help output formats: plain, markdown, json, yaml"),
1056 ));
1057 }
1058
1059 let (scope, format) = resolve_help_options(&parsed, config);
1060 let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
1061 Ok(Some(cli_render_help_with_options(
1062 cmd,
1063 &path,
1064 &HelpOptions { scope, format },
1065 )))
1066}
1067
1068#[cfg(feature = "cli-help")]
1069fn resolve_help_options(
1070 parsed: &ParsedHelpRequest,
1071 config: &HelpConfig,
1072) -> (HelpScope, HelpFormat) {
1073 let scope = if parsed.recursive_requested {
1077 HelpScope::Recursive
1078 } else {
1079 config.default_scope
1080 };
1081 let format = if config.allow_output_format {
1082 parsed.output_format.unwrap_or(config.default_format)
1083 } else {
1084 config.default_format
1085 };
1086 (scope, format)
1087}
1088
1089#[cfg(feature = "cli-help")]
1090fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
1091 let mut current = cmd;
1092 for name in path {
1093 current = current.find_subcommand(name).unwrap_or(current);
1094 }
1095 current
1096}
1097
1098#[cfg(feature = "cli-help")]
1099fn walk_to_subcommand_with_names<'a>(
1100 cmd: &'a clap::Command,
1101 path: &[&str],
1102) -> (&'a clap::Command, Vec<String>) {
1103 let mut current = cmd;
1104 let mut names = vec![cmd.get_name().to_string()];
1105 for name in path {
1106 if let Some(next) = current.find_subcommand(name) {
1107 current = next;
1108 names.push(next.get_name().to_string());
1109 } else {
1110 break;
1111 }
1112 }
1113 (current, names)
1114}
1115
1116#[cfg(feature = "cli-help")]
1117fn render_help_one_level_plain(cmd: &clap::Command) -> String {
1118 enriched_help_command(cmd).render_long_help().to_string()
1119}
1120
1121#[cfg(feature = "cli-help")]
1132fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
1133 let cmd = cmd.clone();
1134 let description = if visible_subcommands(&cmd).next().is_some() {
1135 HELP_FLAG_WITH_SUBCOMMANDS
1136 } else {
1137 HELP_FLAG_LEAF
1138 };
1139 cmd.disable_help_flag(true).arg(
1144 clap::Arg::new("help")
1145 .short('h')
1146 .long("help")
1147 .help(description)
1148 .long_help(description)
1149 .action(clap::ArgAction::Help),
1150 )
1151}
1152
1153#[cfg(feature = "cli-help")]
1155const HELP_FLAG_WITH_SUBCOMMANDS: &str =
1156 "Print help. Add --recursive to expand every nested subcommand; \
1157 add --output json|yaml|markdown to render this help in another format.";
1158
1159#[cfg(feature = "cli-help")]
1161const HELP_FLAG_LEAF: &str =
1162 "Print help. Add --output json|yaml|markdown to render this help in another format.";
1163
1164#[cfg(feature = "cli-help")]
1165fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
1166 use std::fmt::Write;
1167
1168 let mut cmd_path = parent_path.to_vec();
1170 cmd_path.push(cmd.get_name());
1171 let path_str = cmd_path.join(" ");
1172
1173 if !buf.is_empty() {
1175 let _ = writeln!(buf);
1176 let _ = writeln!(buf, "{}", "═".repeat(60));
1177 }
1178
1179 if let Some(about) = cmd.get_about() {
1181 let _ = writeln!(buf, "{path_str} — {about}");
1182 } else {
1183 let _ = writeln!(buf, "{path_str}");
1184 }
1185 let _ = writeln!(buf);
1186
1187 let is_target = parent_path.is_empty();
1191 let styled = if is_target {
1192 enriched_help_command(cmd).render_long_help()
1193 } else {
1194 cmd.clone().render_long_help()
1195 };
1196 let help_text = styled.to_string();
1197 let _ = write!(buf, "{help_text}");
1198
1199 for sub in cmd.get_subcommands() {
1201 if sub.get_name() == "help" || sub.is_hide_set() {
1202 continue; }
1204 render_help_recursive_plain(sub, &cmd_path, buf);
1205 }
1206}
1207
1208#[cfg(feature = "cli-help")]
1209fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
1210 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1211 let mut buf = String::new();
1212 render_markdown_command(target, &names, &mut buf, 1, true);
1213 if matches!(scope, HelpScope::Recursive) {
1214 render_markdown_descendants(target, &names, &mut buf, 2);
1215 }
1216 buf
1217}
1218
1219#[cfg(feature = "cli-help")]
1220fn render_markdown_descendants(
1221 cmd: &clap::Command,
1222 parent_names: &[String],
1223 buf: &mut String,
1224 level: usize,
1225) {
1226 for sub in cmd.get_subcommands() {
1227 if sub.get_name() == "help" || sub.is_hide_set() {
1228 continue;
1229 }
1230 let mut names = parent_names.to_vec();
1231 names.push(sub.get_name().to_string());
1232 render_markdown_command(sub, &names, buf, level, false);
1233 render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
1234 }
1235}
1236
1237#[cfg(feature = "cli-help")]
1238fn render_markdown_command(
1239 cmd: &clap::Command,
1240 names: &[String],
1241 buf: &mut String,
1242 level: usize,
1243 enrich: bool,
1244) {
1245 use std::fmt::Write;
1246
1247 if !buf.is_empty() {
1248 let _ = writeln!(buf);
1249 }
1250 let heading_level = "#".repeat(level.max(1));
1251 let path = names.join(" ");
1252 if let Some(about) = cmd.get_about() {
1253 let _ = writeln!(buf, "{heading_level} {path} - {about}");
1254 } else {
1255 let _ = writeln!(buf, "{heading_level} {path}");
1256 }
1257 if let Some(long_about) = cmd.get_long_about() {
1258 let _ = writeln!(buf);
1259 let _ = writeln!(buf, "{long_about}");
1260 }
1261 let _ = writeln!(buf);
1262 let _ = writeln!(buf, "```text");
1263 let help = if enrich {
1264 enriched_help_command(cmd).render_long_help()
1265 } else {
1266 cmd.clone().render_long_help()
1267 };
1268 write_trimmed_help(buf, &help.to_string());
1269 if !buf.ends_with('\n') {
1270 let _ = writeln!(buf);
1271 }
1272 let _ = writeln!(buf, "```");
1273}
1274
1275#[cfg(feature = "cli-help")]
1276fn write_trimmed_help(buf: &mut String, help: &str) {
1277 use std::fmt::Write;
1278
1279 for line in help.lines() {
1280 let _ = writeln!(buf, "{}", line.trim_end());
1281 }
1282}
1283
1284#[cfg(feature = "cli-help")]
1285struct ParsedHelpRequest {
1286 help_requested: bool,
1287 recursive_requested: bool,
1288 output_format: Option<HelpFormat>,
1289 output_error: Option<String>,
1290 subcommand_path: Vec<String>,
1291}
1292
1293#[cfg(feature = "cli-help")]
1294fn parse_help_request(
1295 raw_args: &[String],
1296 cmd: &clap::Command,
1297 config: &HelpConfig,
1298) -> ParsedHelpRequest {
1299 let args = match raw_args.first() {
1300 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
1301 _ => raw_args.get(1..).unwrap_or(&[]),
1302 };
1303 let mut help_requested = false;
1304 let mut recursive_requested = false;
1305 let mut output_format = None;
1306 let mut output_error = None;
1307 let mut subcommand_path = Vec::new();
1308 let mut current = cmd;
1309 let output_flag = config.output_flag.map(normalize_long_flag);
1310 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
1311
1312 let mut i = 0usize;
1313 while i < args.len() {
1314 let arg = args[i].as_str();
1315 if arg == "--" {
1316 break;
1317 }
1318
1319 let (flag_name, inline_value) = split_flag(arg);
1320 if matches!(arg, "--help" | "-h") {
1321 help_requested = true;
1322 i += 1;
1323 continue;
1324 }
1325 if arg == "--recursive"
1330 || flag_name
1331 .zip(recursive_flag)
1332 .is_some_and(|(seen, expected)| seen == expected)
1333 {
1334 recursive_requested = true;
1335 i += 1;
1336 continue;
1337 }
1338 if config.allow_output_format
1339 && flag_name
1340 .zip(output_flag)
1341 .is_some_and(|(seen, expected)| seen == expected)
1342 {
1343 let value = inline_value.or_else(|| {
1344 args.get(i + 1)
1345 .map(String::as_str)
1346 .filter(|next| !next.starts_with('-'))
1347 });
1348 if let Some(value) = value {
1349 match HelpFormat::parse(value) {
1350 Some(format) => output_format = Some(format),
1351 None => {
1352 output_error = Some(format!(
1353 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
1354 output_flag.unwrap_or("output"),
1355 value
1356 ));
1357 }
1358 }
1359 } else {
1360 output_error = Some(format!(
1361 "missing value for --{}: expected plain, json, yaml, or markdown",
1362 output_flag.unwrap_or("output")
1363 ));
1364 }
1365 i += if inline_value.is_some() || value.is_none() {
1366 1
1367 } else {
1368 2
1369 };
1370 continue;
1371 }
1372 if arg.starts_with('-') {
1373 i += if inline_value.is_none() && flag_takes_value(current, arg) {
1374 2
1375 } else {
1376 1
1377 };
1378 continue;
1379 }
1380 if let Some(sub) = current.find_subcommand(arg) {
1381 if sub.get_name() != "help" && !sub.is_hide_set() {
1382 subcommand_path.push(sub.get_name().to_string());
1383 current = sub;
1384 }
1385 }
1386 i += 1;
1387 }
1388
1389 ParsedHelpRequest {
1390 help_requested,
1391 recursive_requested,
1392 output_format,
1393 output_error,
1394 subcommand_path,
1395 }
1396}
1397
1398fn normalize_long_flag(flag: &str) -> &str {
1399 flag.trim_start_matches('-')
1400}
1401
1402fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1403 if let Some(stripped) = arg.strip_prefix("--") {
1404 if let Some((name, value)) = stripped.split_once('=') {
1405 (Some(name), Some(value))
1406 } else {
1407 (Some(stripped), None)
1408 }
1409 } else if let Some(stripped) = arg.strip_prefix('-') {
1410 (Some(stripped), None)
1411 } else {
1412 (None, None)
1413 }
1414}
1415
1416#[cfg(feature = "cli-help")]
1417fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1418 let Some(flag) = raw_flag.strip_prefix('-') else {
1419 return false;
1420 };
1421 let name = flag.trim_start_matches('-');
1422 cmd.get_arguments().any(|arg| {
1423 let long_matches = arg.get_long().is_some_and(|long| long == name);
1424 let short_matches =
1425 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1426 (long_matches || short_matches)
1427 && matches!(
1428 arg.get_action(),
1429 clap::ArgAction::Set | clap::ArgAction::Append
1430 )
1431 })
1432}
1433
1434#[cfg(feature = "cli-help")]
1435fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1436 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1437 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1438 if let Value::Object(map) = &mut schema {
1439 map.insert("code".to_string(), Value::String("help".to_string()));
1440 map.insert(
1441 "scope".to_string(),
1442 Value::String(help_scope_tag(scope).to_string()),
1443 );
1444 }
1445 schema
1446}
1447
1448#[cfg(feature = "cli-help")]
1449fn help_scope_tag(scope: HelpScope) -> &'static str {
1450 match scope {
1451 HelpScope::OneLevel => "one_level",
1452 HelpScope::Recursive => "recursive",
1453 }
1454}
1455
1456#[cfg(feature = "cli-help")]
1457fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1458 let subcommands: Vec<Value> = visible_subcommands(cmd)
1459 .map(|sub| {
1460 let mut child_names = names.to_vec();
1461 child_names.push(sub.get_name().to_string());
1462 if recursive {
1463 command_schema(sub, &child_names, true, false)
1465 } else {
1466 command_summary_schema(sub, &child_names)
1467 }
1468 })
1469 .collect();
1470
1471 serde_json::json!({
1472 "name": cmd.get_name(),
1473 "command_path": names.join(" "),
1474 "path": names,
1475 "about": styled_to_value(cmd.get_about()),
1476 "long_about": styled_to_value(cmd.get_long_about()),
1477 "usage": cmd.clone().render_usage().to_string(),
1478 "arguments": command_arguments_schema(cmd, enrich),
1479 "subcommands": subcommands,
1480 })
1481}
1482
1483#[cfg(feature = "cli-help")]
1484fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1485 serde_json::json!({
1486 "name": cmd.get_name(),
1487 "command_path": names.join(" "),
1488 "path": names,
1489 "about": styled_to_value(cmd.get_about()),
1490 "long_about": styled_to_value(cmd.get_long_about()),
1491 "usage": Value::Null,
1492 "arguments": [],
1493 "subcommands": [],
1494 })
1495}
1496
1497#[cfg(feature = "cli-help")]
1498fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1499 cmd.get_subcommands()
1500 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1501}
1502
1503#[cfg(feature = "cli-help")]
1504fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1505 let owned = enrich.then(|| enriched_help_command(cmd));
1511 let source = owned.as_ref().unwrap_or(cmd);
1512 source
1513 .get_arguments()
1514 .filter(|arg| !arg.is_hide_set())
1515 .map(argument_schema)
1516 .collect()
1517}
1518
1519#[cfg(feature = "cli-help")]
1520fn argument_schema(arg: &clap::Arg) -> Value {
1521 let value_names: Vec<String> = arg
1522 .get_value_names()
1523 .map(|names| names.iter().map(ToString::to_string).collect())
1524 .unwrap_or_default();
1525 let default_values: Vec<String> = arg
1526 .get_default_values()
1527 .iter()
1528 .map(|value| value.to_string_lossy().to_string())
1529 .collect();
1530 serde_json::json!({
1531 "id": arg.get_id().to_string(),
1532 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1533 "long": arg.get_long(),
1534 "short": arg.get_short().map(|c| c.to_string()),
1535 "help": styled_to_value(arg.get_help()),
1536 "long_help": styled_to_value(arg.get_long_help()),
1537 "required": arg.is_required_set(),
1538 "action": format!("{:?}", arg.get_action()),
1539 "value_names": value_names,
1540 "default_values": default_values,
1541 })
1542}
1543
1544#[cfg(feature = "cli-help")]
1545fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1546 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1547}
1548
1549#[derive(Default)]
1554struct RedactionContext {
1555 secret_names: HashSet<String>,
1556}
1557
1558impl RedactionContext {
1559 fn from_options(redaction_options: &RedactionOptions) -> Self {
1560 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1561 Self { secret_names }
1562 }
1563
1564 fn is_secret_key(&self, key: &str) -> bool {
1565 key_has_secret_suffix(key) || self.secret_names.contains(key)
1566 }
1567}
1568
1569fn key_has_secret_suffix(key: &str) -> bool {
1570 key.ends_with("_secret") || key.ends_with("_SECRET")
1571}
1572
1573fn key_has_url_suffix(key: &str) -> bool {
1574 key.ends_with("_url") || key.ends_with("_URL")
1575}
1576
1577const MAX_DEPTH: usize = 256;
1578
1579fn redact_secrets(value: &mut Value) {
1580 let context = RedactionContext::default();
1581 redact_secrets_with_context(value, &context);
1582}
1583
1584fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1585 redact_secrets_with_context_depth(value, context, 0);
1586}
1587
1588fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
1589 if depth >= MAX_DEPTH {
1590 *value = Value::String("***".into());
1591 return;
1592 }
1593 match value {
1594 Value::Object(map) => {
1595 let keys: Vec<String> = map.keys().cloned().collect();
1596 for key in keys {
1597 if context.is_secret_key(&key) {
1598 map.insert(key, Value::String("***".into()));
1599 } else if key_has_url_suffix(&key) {
1600 if let Some(Value::String(s)) = map.get_mut(&key) {
1601 *s = redact_url_field_value(s, context);
1602 } else if let Some(v) = map.get_mut(&key) {
1603 redact_secrets_with_context_depth(v, context, depth + 1);
1604 }
1605 } else if let Some(v) = map.get_mut(&key) {
1606 redact_secrets_with_context_depth(v, context, depth + 1);
1607 }
1608 }
1609 }
1610 Value::Array(arr) => {
1611 for v in arr {
1612 redact_secrets_with_context_depth(v, context, depth + 1);
1613 }
1614 }
1615 _ => {}
1616 }
1617}
1618
1619fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1623 if !s.contains("://") || !is_single_url(s) {
1630 return None;
1631 }
1632 let scheme_sep = s.find("://")?;
1633 let scheme = &s[..scheme_sep];
1634 let rest = &s[scheme_sep + 3..];
1635
1636 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1638 let authority = &rest[..auth_end];
1639 let remainder = &rest[auth_end..];
1640
1641 let new_authority = redact_userinfo_password(authority);
1642
1643 let new_remainder = match remainder.find('?') {
1645 Some(q) => {
1646 let (path, q_onwards) = remainder.split_at(q);
1647 let query_body = &q_onwards[1..];
1648 let (query, fragment) = match query_body.find('#') {
1649 Some(h) => (&query_body[..h], &query_body[h..]),
1650 None => (query_body, ""),
1651 };
1652 format!("{path}?{}{fragment}", redact_query(query, context))
1653 }
1654 None => remainder.to_string(),
1655 };
1656
1657 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1658}
1659
1660fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
1661 if let Some(redacted) = redact_url_in_str(s, context) {
1662 return redacted;
1663 }
1664 let trimmed = s.trim();
1665 if trimmed != s {
1666 if let Some(redacted) = redact_url_in_str(trimmed, context) {
1667 return redacted;
1668 }
1669 }
1670 if s.chars().any(char::is_whitespace) || s.contains('@') {
1676 return "***".to_string();
1677 }
1678 s.to_string()
1679}
1680
1681fn redact_userinfo_password(authority: &str) -> String {
1684 let Some(at) = authority.rfind('@') else {
1685 return authority.to_string();
1686 };
1687 let userinfo = &authority[..at];
1688 match userinfo.find(':') {
1689 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1690 None => authority.to_string(),
1691 }
1692}
1693
1694fn redact_query(query: &str, context: &RedactionContext) -> String {
1697 query
1698 .split('&')
1699 .map(|segment| {
1700 let Some(eq) = segment.find('=') else {
1701 return segment.to_string();
1702 };
1703 let raw_key = &segment[..eq];
1704 let name = url::form_urlencoded::parse(segment.as_bytes())
1706 .next()
1707 .map(|(k, _)| k.into_owned())
1708 .unwrap_or_default();
1709 if context.is_secret_key(&name) {
1710 format!("{raw_key}=***")
1711 } else {
1712 segment.to_string()
1713 }
1714 })
1715 .collect::<Vec<_>>()
1716 .join("&")
1717}
1718
1719fn is_single_url(s: &str) -> bool {
1723 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1724 return false;
1725 }
1726 let bytes = s.as_bytes();
1727 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1728 return false;
1729 }
1730 let mut i = 1;
1731 while i < bytes.len() {
1732 let c = bytes[i];
1733 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1734 i += 1;
1735 } else {
1736 break;
1737 }
1738 }
1739 s[i..].starts_with("://")
1740}
1741
1742fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1743 let context = RedactionContext::default();
1744 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1745}
1746
1747fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1748 let context = RedactionContext::from_options(redaction_options);
1749 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1750}
1751
1752fn apply_redaction_policy_with_context(
1753 value: &mut Value,
1754 redaction_policy: Option<RedactionPolicy>,
1755 context: &RedactionContext,
1756) {
1757 match redaction_policy {
1758 Some(RedactionPolicy::RedactionTraceOnly) => {
1759 if let Value::Object(map) = value {
1760 if let Some(trace) = map.get_mut("trace") {
1761 redact_secrets_with_context(trace, context);
1762 }
1763 }
1764 }
1765 Some(RedactionPolicy::RedactionNone) => {}
1766 None => redact_secrets_with_context(value, context),
1767 }
1768}
1769
1770fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1776 if let Some(s) = key.strip_suffix(suffix_lower) {
1777 return Some(s.to_string());
1778 }
1779 let suffix_upper: String = suffix_lower
1780 .chars()
1781 .map(|c| c.to_ascii_uppercase())
1782 .collect();
1783 if let Some(s) = key.strip_suffix(&suffix_upper) {
1784 return Some(s.to_string());
1785 }
1786 None
1787}
1788
1789fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1791 let code = extract_currency_code(key)?;
1792 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1794 if stripped.is_empty() {
1795 return None;
1796 }
1797 Some((stripped.to_string(), code.to_string()))
1798}
1799
1800fn as_int(value: &Value) -> Option<i64> {
1808 if let Some(i) = value.as_i64() {
1809 return Some(i);
1810 }
1811 let f = value.as_f64()?;
1812 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1813 return Some(f as i64);
1814 }
1815 None
1816}
1817
1818fn as_uint(value: &Value) -> Option<u64> {
1820 if let Some(u) = value.as_u64() {
1821 return Some(u);
1822 }
1823 let f = value.as_f64()?;
1824 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1825 return Some(f as u64);
1826 }
1827 None
1828}
1829
1830fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1831 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1833 return as_int(value)
1834 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1835 }
1836 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1837 return as_int(value)
1838 .and_then(|s| s.checked_mul(1000))
1839 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1840 }
1841 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1842 return as_int(value).and_then(|ns| {
1843 format_rfc3339_ms(ns.div_euclid(1_000_000)).map(|formatted| (stripped, formatted))
1844 });
1845 }
1846
1847 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1849 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1850 }
1851 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1852 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1853 }
1854 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1855 return as_uint(value).map(|n| {
1856 (
1857 stripped,
1858 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1859 )
1860 });
1861 }
1862
1863 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1865 return value.as_str().map(|s| (stripped, s.to_string()));
1866 }
1867 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1868 return value
1869 .is_number()
1870 .then(|| (stripped, format!("{} minutes", number_str(value))));
1871 }
1872 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1873 return value
1874 .is_number()
1875 .then(|| (stripped, format!("{} hours", number_str(value))));
1876 }
1877 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1878 return value
1879 .is_number()
1880 .then(|| (stripped, format!("{} days", number_str(value))));
1881 }
1882
1883 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1885 return value
1886 .is_number()
1887 .then(|| (stripped, format!("{}msats", number_str(value))));
1888 }
1889 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1890 return value
1891 .is_number()
1892 .then(|| (stripped, format!("{}sats", number_str(value))));
1893 }
1894 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1895 return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1896 }
1897 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1898 return value
1899 .is_number()
1900 .then(|| (stripped, format!("{}%", number_str(value))));
1901 }
1902 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1904 return value
1905 .is_number()
1906 .then(|| (stripped, format!("{} BTC", number_str(value))));
1907 }
1908 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1909 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1910 }
1911 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1912 return value
1913 .is_number()
1914 .then(|| (stripped, format!("{}ns", number_str(value))));
1915 }
1916 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1917 return value
1918 .is_number()
1919 .then(|| (stripped, format!("{}μs", number_str(value))));
1920 }
1921 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1922 return format_ms_value(value).map(|v| (stripped, v));
1923 }
1924 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1925 return value
1926 .is_number()
1927 .then(|| (stripped, format!("{}s", number_str(value))));
1928 }
1929
1930 None
1931}
1932
1933fn process_object_fields<'a>(
1935 map: &'a serde_json::Map<String, Value>,
1936) -> Vec<(String, &'a Value, Option<String>)> {
1937 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1938 for (key, value) in map {
1939 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1940 entries.push((stripped, key.as_str(), value, None));
1941 continue;
1942 }
1943 match try_process_field(key, value) {
1944 Some((stripped, formatted)) => {
1945 entries.push((stripped, key.as_str(), value, Some(formatted)));
1946 }
1947 None => {
1948 entries.push((key.clone(), key.as_str(), value, None));
1949 }
1950 }
1951 }
1952
1953 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1955 for (stripped, _, _, _) in &entries {
1956 *counts.entry(stripped.clone()).or_insert(0) += 1;
1957 }
1958
1959 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1961 .into_iter()
1962 .map(|(stripped, original, value, formatted)| {
1963 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1964 (original.to_string(), value, None)
1965 } else {
1966 (stripped, value, formatted)
1967 }
1968 })
1969 .collect();
1970
1971 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1972 result
1973}
1974
1975fn number_str(value: &Value) -> String {
1980 match value {
1981 Value::Number(n) => format_number(n),
1982 _ => String::new(),
1983 }
1984}
1985
1986fn format_number(n: &serde_json::Number) -> String {
1992 if n.is_f64() {
1993 if let Some(f) = n.as_f64() {
1994 if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 {
1995 return format!("{f:.0}");
1996 }
1997 }
1998 }
1999 normalize_exponent(&n.to_string())
2000}
2001
2002fn normalize_exponent(s: &str) -> String {
2003 let Some(e) = s.find(['e', 'E']) else {
2004 return s.to_string();
2005 };
2006 let mantissa = &s[..e];
2007 let mut exp = &s[e + 1..];
2008 let mut sign = "";
2009 if exp.starts_with(['+', '-']) {
2010 sign = &exp[..1];
2011 exp = &exp[1..];
2012 }
2013 let exp = exp.trim_start_matches('0');
2014 let exp = if exp.is_empty() { "0" } else { exp };
2015 format!("{mantissa}e{sign}{exp}")
2016}
2017
2018fn format_ms_as_seconds(ms: f64) -> String {
2020 let formatted = format!("{:.3}", ms / 1000.0);
2021 let trimmed = formatted.trim_end_matches('0');
2022 if trimmed.ends_with('.') {
2023 format!("{}0s", trimmed)
2024 } else {
2025 format!("{}s", trimmed)
2026 }
2027}
2028
2029fn format_ms_value(value: &Value) -> Option<String> {
2031 let n = value.as_f64()?;
2032 if n.abs() >= 1000.0 {
2033 Some(format_ms_as_seconds(n))
2034 } else if let Some(i) = value.as_i64() {
2035 Some(format!("{}ms", i))
2036 } else {
2037 Some(format!("{}ms", number_str(value)))
2038 }
2039}
2040
2041const MIN_RFC3339_MS: i64 = -62135596800000;
2043const MAX_RFC3339_MS: i64 = 253402300799999;
2044
2045fn format_rfc3339_ms(ms: i64) -> Option<String> {
2046 use chrono::{DateTime, Utc};
2047 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
2048 return None;
2049 }
2050 let secs = ms.div_euclid(1000);
2051 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
2052 DateTime::from_timestamp(secs, nanos).map(|dt| {
2053 dt.with_timezone(&Utc)
2054 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2055 })
2056}
2057
2058fn format_bytes_human(bytes: i64) -> String {
2060 const KB: f64 = 1024.0;
2061 const MB: f64 = KB * 1024.0;
2062 const GB: f64 = MB * 1024.0;
2063 const TB: f64 = GB * 1024.0;
2064
2065 let sign = if bytes < 0 { "-" } else { "" };
2066 let b = (bytes as f64).abs();
2067 if b >= TB {
2068 format!("{sign}{:.1}TB", b / TB)
2069 } else if b >= GB {
2070 format!("{sign}{:.1}GB", b / GB)
2071 } else if b >= MB {
2072 format!("{sign}{:.1}MB", b / MB)
2073 } else if b >= KB {
2074 format!("{sign}{:.1}KB", b / KB)
2075 } else {
2076 format!("{bytes}B")
2077 }
2078}
2079
2080fn format_with_commas(n: u64) -> String {
2082 let s = n.to_string();
2083 let mut result = String::with_capacity(s.len() + s.len() / 3);
2084 for (i, c) in s.chars().enumerate() {
2085 if i > 0 && (s.len() - i).is_multiple_of(3) {
2086 result.push(',');
2087 }
2088 result.push(c);
2089 }
2090 result
2091}
2092
2093fn extract_currency_code(key: &str) -> Option<&str> {
2095 let without_cents = key
2096 .strip_suffix("_cents")
2097 .or_else(|| key.strip_suffix("_CENTS"))?;
2098 let last_underscore = without_cents.rfind('_')?;
2099 let code = &without_cents[last_underscore + 1..];
2100 if code.is_empty()
2101 || !(3..=4).contains(&code.len())
2102 || !code.bytes().all(|b| b.is_ascii_alphabetic())
2103 {
2104 return None;
2105 }
2106 Some(code)
2107}
2108
2109fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
2114 let prefix = " ".repeat(indent);
2115 match value {
2116 Value::Object(map) => {
2117 let processed = process_object_fields(map);
2118 for (display_key, v, formatted) in processed {
2119 if let Some(fv) = formatted {
2120 lines.push(format!(
2121 "{}{}: \"{}\"",
2122 prefix,
2123 yaml_key(&display_key),
2124 escape_yaml_str(&fv)
2125 ));
2126 } else {
2127 match v {
2128 Value::Object(inner) if !inner.is_empty() => {
2129 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2130 render_yaml_processed(v, indent + 1, lines);
2131 }
2132 Value::Object(_) => {
2133 lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
2134 }
2135 Value::Array(arr) => {
2136 if arr.is_empty() {
2137 lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
2138 } else {
2139 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2140 for item in arr {
2141 if item.is_object() {
2142 lines.push(format!("{} -", prefix));
2143 render_yaml_processed(item, indent + 2, lines);
2144 } else {
2145 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
2146 }
2147 }
2148 }
2149 }
2150 _ => {
2151 lines.push(format!(
2152 "{}{}: {}",
2153 prefix,
2154 yaml_key(&display_key),
2155 yaml_scalar(v)
2156 ));
2157 }
2158 }
2159 }
2160 }
2161 }
2162 _ => {
2163 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2164 }
2165 }
2166}
2167
2168fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
2169 let prefix = " ".repeat(indent);
2170 match value {
2171 Value::Object(map) => {
2172 for key in sorted_value_keys(map) {
2173 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
2174 }
2175 }
2176 Value::Array(arr) => {
2177 render_yaml_array_raw(arr, indent, lines);
2178 }
2179 _ => {
2180 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2181 }
2182 }
2183}
2184
2185fn render_yaml_field_raw(
2186 prefix: &str,
2187 key: &str,
2188 value: &Value,
2189 indent: usize,
2190 lines: &mut Vec<String>,
2191) {
2192 match value {
2193 Value::Object(inner) if !inner.is_empty() => {
2194 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2195 render_yaml_raw(value, indent + 1, lines);
2196 }
2197 Value::Object(_) => {
2198 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
2199 }
2200 Value::Array(arr) => {
2201 if arr.is_empty() {
2202 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
2203 } else {
2204 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2205 render_yaml_array_raw(arr, indent + 1, lines);
2206 }
2207 }
2208 _ => {
2209 lines.push(format!(
2210 "{}{}: {}",
2211 prefix,
2212 yaml_key(key),
2213 yaml_scalar(value)
2214 ));
2215 }
2216 }
2217}
2218
2219fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
2220 let prefix = " ".repeat(indent);
2221 for item in arr {
2222 match item {
2223 Value::Object(inner) if !inner.is_empty() => {
2224 lines.push(format!("{}-", prefix));
2225 render_yaml_raw(item, indent + 1, lines);
2226 }
2227 Value::Array(nested) if !nested.is_empty() => {
2228 lines.push(format!("{}-", prefix));
2229 render_yaml_array_raw(nested, indent + 1, lines);
2230 }
2231 Value::Object(_) => {
2232 lines.push(format!("{}- {{}}", prefix));
2233 }
2234 Value::Array(_) => {
2235 lines.push(format!("{}- []", prefix));
2236 }
2237 _ => {
2238 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
2239 }
2240 }
2241 }
2242}
2243
2244fn escape_yaml_str(s: &str) -> String {
2245 s.replace('\\', "\\\\")
2246 .replace('"', "\\\"")
2247 .replace('\n', "\\n")
2248 .replace('\r', "\\r")
2249 .replace('\t', "\\t")
2250 .replace('\x0c', "\\f")
2251 .replace('\x0b', "\\v")
2252}
2253
2254fn yaml_key(key: &str) -> String {
2255 if is_safe_key(key) {
2256 key.to_string()
2257 } else {
2258 format!("\"{}\"", escape_yaml_str(key))
2259 }
2260}
2261
2262fn quote_logfmt_key(key: &str) -> String {
2263 if is_safe_key(key) {
2264 key.to_string()
2265 } else {
2266 quote_logfmt_value(key)
2267 }
2268}
2269
2270fn is_safe_key(key: &str) -> bool {
2271 !key.is_empty()
2272 && key
2273 .bytes()
2274 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
2275}
2276
2277fn yaml_scalar(value: &Value) -> String {
2278 match value {
2279 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
2280 Value::Null => "null".to_string(),
2281 Value::Bool(b) => b.to_string(),
2282 Value::Number(n) => format_number(n),
2283 Value::Object(_) | Value::Array(_) => {
2284 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
2285 }
2286 }
2287}
2288
2289fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2294 if let Value::Object(map) = value {
2295 let processed = process_object_fields(map);
2296 for (display_key, v, formatted) in processed {
2297 let full_key = if prefix.is_empty() {
2298 display_key
2299 } else {
2300 format!("{}.{}", prefix, display_key)
2301 };
2302 if let Some(fv) = formatted {
2303 pairs.push((full_key, fv));
2304 } else {
2305 match v {
2306 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
2307 Value::Array(arr) => {
2308 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2309 pairs.push((full_key, joined));
2310 }
2311 Value::Null => pairs.push((full_key, String::new())),
2312 _ => pairs.push((full_key, plain_scalar(v))),
2313 }
2314 }
2315 }
2316 }
2317}
2318
2319fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2320 if let Value::Object(map) = value {
2321 for key in sorted_value_keys(map) {
2322 let v = &map[&key];
2323 let full_key = if prefix.is_empty() {
2324 key.clone()
2325 } else {
2326 format!("{}.{}", prefix, key)
2327 };
2328 match v {
2329 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
2330 Value::Array(arr) => {
2331 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2332 pairs.push((full_key, joined));
2333 }
2334 Value::Null => pairs.push((full_key, String::new())),
2335 _ => pairs.push((full_key, plain_scalar(v))),
2336 }
2337 }
2338 }
2339}
2340
2341fn plain_scalar(value: &Value) -> String {
2342 match value {
2343 Value::String(s) => s.clone(),
2344 Value::Null => "null".to_string(),
2345 Value::Bool(b) => b.to_string(),
2346 Value::Number(n) => format_number(n),
2347 Value::Object(_) | Value::Array(_) => canonical_json(value),
2348 }
2349}
2350
2351fn quote_logfmt_value(value: &str) -> String {
2352 if value.is_empty() {
2353 return String::new();
2354 }
2355 if !value
2356 .chars()
2357 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
2358 {
2359 return value.to_string();
2360 }
2361 let escaped = value
2362 .replace('\\', "\\\\")
2363 .replace('"', "\\\"")
2364 .replace('\n', "\\n")
2365 .replace('\r', "\\r")
2366 .replace('\t', "\\t")
2367 .replace('\x0c', "\\f")
2368 .replace('\x0b', "\\v");
2369 format!("\"{}\"", escaped)
2370}
2371
2372fn canonical_json(value: &Value) -> String {
2373 serde_json::to_string(&sort_json_value(value))
2374 .unwrap_or_else(|_| "<unsupported:json>".to_string())
2375}
2376
2377fn sort_json_value(value: &Value) -> Value {
2378 match value {
2379 Value::Object(map) => {
2380 let mut out = serde_json::Map::new();
2381 for key in sorted_value_keys(map) {
2382 if let Some(v) = map.get(&key) {
2383 out.insert(key, sort_json_value(v));
2384 }
2385 }
2386 Value::Object(out)
2387 }
2388 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
2389 _ => value.clone(),
2390 }
2391}
2392
2393fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
2394 let mut keys: Vec<String> = map.keys().cloned().collect();
2395 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
2396 keys
2397}
2398
2399#[cfg(test)]
2400mod tests;