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) = markdown_long_about(cmd) {
1258 let _ = writeln!(buf);
1259 write_trimmed_help(buf, &long_about);
1260 }
1261 let _ = writeln!(buf);
1262 let _ = writeln!(buf, "```text");
1263 let help = markdown_help_block_command(cmd, enrich).render_long_help();
1264 write_trimmed_help(buf, &help.to_string());
1265 if !buf.ends_with('\n') {
1266 let _ = writeln!(buf);
1267 }
1268 let _ = writeln!(buf, "```");
1269}
1270
1271#[cfg(feature = "cli-help")]
1272fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
1273 let long_about = cmd.get_long_about()?.to_string();
1274 let rendered = match cmd.get_about() {
1275 Some(about) => strip_leading_about_paragraph(&long_about, &about.to_string()),
1276 None => long_about.as_str(),
1277 };
1278 let rendered = rendered.trim_matches(['\r', '\n']);
1279 if rendered.is_empty() {
1280 None
1281 } else {
1282 Some(rendered.to_string())
1283 }
1284}
1285
1286#[cfg(feature = "cli-help")]
1287fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
1288 let long_about = long_about.trim_start_matches(['\r', '\n']);
1289 let Some(rest) = long_about.strip_prefix(about) else {
1290 return long_about;
1291 };
1292 if rest.is_empty() {
1293 return "";
1294 }
1295 rest.strip_prefix("\r\n\r\n")
1296 .or_else(|| rest.strip_prefix("\n\n"))
1297 .unwrap_or(long_about)
1298}
1299
1300#[cfg(feature = "cli-help")]
1301fn markdown_help_block_command(cmd: &clap::Command, enrich: bool) -> clap::Command {
1302 let cmd = if enrich {
1303 enriched_help_command(cmd)
1304 } else {
1305 cmd.clone()
1306 };
1307 cmd.about(None::<&str>).long_about(None::<&str>)
1308}
1309
1310#[cfg(feature = "cli-help")]
1311fn write_trimmed_help(buf: &mut String, help: &str) {
1312 use std::fmt::Write;
1313
1314 for line in help.lines() {
1315 let _ = writeln!(buf, "{}", line.trim_end());
1316 }
1317}
1318
1319#[cfg(feature = "cli-help")]
1320struct ParsedHelpRequest {
1321 help_requested: bool,
1322 recursive_requested: bool,
1323 output_format: Option<HelpFormat>,
1324 output_error: Option<String>,
1325 subcommand_path: Vec<String>,
1326}
1327
1328#[cfg(feature = "cli-help")]
1329fn parse_help_request(
1330 raw_args: &[String],
1331 cmd: &clap::Command,
1332 config: &HelpConfig,
1333) -> ParsedHelpRequest {
1334 let args = match raw_args.first() {
1335 Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
1336 _ => raw_args.get(1..).unwrap_or(&[]),
1337 };
1338 let mut help_requested = false;
1339 let mut recursive_requested = false;
1340 let mut output_format = None;
1341 let mut output_error = None;
1342 let mut subcommand_path = Vec::new();
1343 let mut current = cmd;
1344 let output_flag = config.output_flag.map(normalize_long_flag);
1345 let recursive_flag = config.recursive_flag.map(normalize_long_flag);
1346
1347 let mut i = 0usize;
1348 while i < args.len() {
1349 let arg = args[i].as_str();
1350 if arg == "--" {
1351 break;
1352 }
1353
1354 let (flag_name, inline_value) = split_flag(arg);
1355 if matches!(arg, "--help" | "-h") {
1356 help_requested = true;
1357 i += 1;
1358 continue;
1359 }
1360 if arg == "--recursive"
1365 || flag_name
1366 .zip(recursive_flag)
1367 .is_some_and(|(seen, expected)| seen == expected)
1368 {
1369 recursive_requested = true;
1370 i += 1;
1371 continue;
1372 }
1373 if config.allow_output_format
1374 && flag_name
1375 .zip(output_flag)
1376 .is_some_and(|(seen, expected)| seen == expected)
1377 {
1378 let value = inline_value.or_else(|| {
1379 args.get(i + 1)
1380 .map(String::as_str)
1381 .filter(|next| !next.starts_with('-'))
1382 });
1383 if let Some(value) = value {
1384 match HelpFormat::parse(value) {
1385 Some(format) => output_format = Some(format),
1386 None => {
1387 output_error = Some(format!(
1388 "invalid --{} format '{}': expected plain, json, yaml, or markdown",
1389 output_flag.unwrap_or("output"),
1390 value
1391 ));
1392 }
1393 }
1394 } else {
1395 output_error = Some(format!(
1396 "missing value for --{}: expected plain, json, yaml, or markdown",
1397 output_flag.unwrap_or("output")
1398 ));
1399 }
1400 i += if inline_value.is_some() || value.is_none() {
1401 1
1402 } else {
1403 2
1404 };
1405 continue;
1406 }
1407 if arg.starts_with('-') {
1408 i += if inline_value.is_none() && flag_takes_value(current, arg) {
1409 2
1410 } else {
1411 1
1412 };
1413 continue;
1414 }
1415 if let Some(sub) = current.find_subcommand(arg) {
1416 if sub.get_name() != "help" && !sub.is_hide_set() {
1417 subcommand_path.push(sub.get_name().to_string());
1418 current = sub;
1419 }
1420 }
1421 i += 1;
1422 }
1423
1424 ParsedHelpRequest {
1425 help_requested,
1426 recursive_requested,
1427 output_format,
1428 output_error,
1429 subcommand_path,
1430 }
1431}
1432
1433fn normalize_long_flag(flag: &str) -> &str {
1434 flag.trim_start_matches('-')
1435}
1436
1437fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1438 if let Some(stripped) = arg.strip_prefix("--") {
1439 if let Some((name, value)) = stripped.split_once('=') {
1440 (Some(name), Some(value))
1441 } else {
1442 (Some(stripped), None)
1443 }
1444 } else if let Some(stripped) = arg.strip_prefix('-') {
1445 (Some(stripped), None)
1446 } else {
1447 (None, None)
1448 }
1449}
1450
1451#[cfg(feature = "cli-help")]
1452fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1453 let Some(flag) = raw_flag.strip_prefix('-') else {
1454 return false;
1455 };
1456 let name = flag.trim_start_matches('-');
1457 cmd.get_arguments().any(|arg| {
1458 let long_matches = arg.get_long().is_some_and(|long| long == name);
1459 let short_matches =
1460 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1461 (long_matches || short_matches)
1462 && matches!(
1463 arg.get_action(),
1464 clap::ArgAction::Set | clap::ArgAction::Append
1465 )
1466 })
1467}
1468
1469#[cfg(feature = "cli-help")]
1470fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1471 let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1472 let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1473 if let Value::Object(map) = &mut schema {
1474 map.insert("code".to_string(), Value::String("help".to_string()));
1475 map.insert(
1476 "scope".to_string(),
1477 Value::String(help_scope_tag(scope).to_string()),
1478 );
1479 }
1480 schema
1481}
1482
1483#[cfg(feature = "cli-help")]
1484fn help_scope_tag(scope: HelpScope) -> &'static str {
1485 match scope {
1486 HelpScope::OneLevel => "one_level",
1487 HelpScope::Recursive => "recursive",
1488 }
1489}
1490
1491#[cfg(feature = "cli-help")]
1492fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1493 let subcommands: Vec<Value> = visible_subcommands(cmd)
1494 .map(|sub| {
1495 let mut child_names = names.to_vec();
1496 child_names.push(sub.get_name().to_string());
1497 if recursive {
1498 command_schema(sub, &child_names, true, false)
1500 } else {
1501 command_summary_schema(sub, &child_names)
1502 }
1503 })
1504 .collect();
1505
1506 serde_json::json!({
1507 "name": cmd.get_name(),
1508 "command_path": names.join(" "),
1509 "path": names,
1510 "about": styled_to_value(cmd.get_about()),
1511 "long_about": styled_to_value(cmd.get_long_about()),
1512 "usage": cmd.clone().render_usage().to_string(),
1513 "arguments": command_arguments_schema(cmd, enrich),
1514 "subcommands": subcommands,
1515 })
1516}
1517
1518#[cfg(feature = "cli-help")]
1519fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1520 serde_json::json!({
1521 "name": cmd.get_name(),
1522 "command_path": names.join(" "),
1523 "path": names,
1524 "about": styled_to_value(cmd.get_about()),
1525 "long_about": styled_to_value(cmd.get_long_about()),
1526 "usage": Value::Null,
1527 "arguments": [],
1528 "subcommands": [],
1529 })
1530}
1531
1532#[cfg(feature = "cli-help")]
1533fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1534 cmd.get_subcommands()
1535 .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1536}
1537
1538#[cfg(feature = "cli-help")]
1539fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1540 let owned = enrich.then(|| enriched_help_command(cmd));
1546 let source = owned.as_ref().unwrap_or(cmd);
1547 source
1548 .get_arguments()
1549 .filter(|arg| !arg.is_hide_set())
1550 .map(argument_schema)
1551 .collect()
1552}
1553
1554#[cfg(feature = "cli-help")]
1555fn argument_schema(arg: &clap::Arg) -> Value {
1556 let value_names: Vec<String> = arg
1557 .get_value_names()
1558 .map(|names| names.iter().map(ToString::to_string).collect())
1559 .unwrap_or_default();
1560 let default_values: Vec<String> = arg
1561 .get_default_values()
1562 .iter()
1563 .map(|value| value.to_string_lossy().to_string())
1564 .collect();
1565 serde_json::json!({
1566 "id": arg.get_id().to_string(),
1567 "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1568 "long": arg.get_long(),
1569 "short": arg.get_short().map(|c| c.to_string()),
1570 "help": styled_to_value(arg.get_help()),
1571 "long_help": styled_to_value(arg.get_long_help()),
1572 "required": arg.is_required_set(),
1573 "action": format!("{:?}", arg.get_action()),
1574 "value_names": value_names,
1575 "default_values": default_values,
1576 })
1577}
1578
1579#[cfg(feature = "cli-help")]
1580fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1581 value.map_or(Value::Null, |s| Value::String(s.to_string()))
1582}
1583
1584#[derive(Default)]
1589struct RedactionContext {
1590 secret_names: HashSet<String>,
1591}
1592
1593impl RedactionContext {
1594 fn from_options(redaction_options: &RedactionOptions) -> Self {
1595 let secret_names = redaction_options.secret_names.iter().cloned().collect();
1596 Self { secret_names }
1597 }
1598
1599 fn is_secret_key(&self, key: &str) -> bool {
1600 key_has_secret_suffix(key) || self.secret_names.contains(key)
1601 }
1602}
1603
1604fn key_has_secret_suffix(key: &str) -> bool {
1605 key.ends_with("_secret") || key.ends_with("_SECRET")
1606}
1607
1608fn key_has_url_suffix(key: &str) -> bool {
1609 key.ends_with("_url") || key.ends_with("_URL")
1610}
1611
1612const MAX_DEPTH: usize = 256;
1613
1614fn redact_secrets(value: &mut Value) {
1615 let context = RedactionContext::default();
1616 redact_secrets_with_context(value, &context);
1617}
1618
1619fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1620 redact_secrets_with_context_depth(value, context, 0);
1621}
1622
1623fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
1624 if depth >= MAX_DEPTH {
1625 *value = Value::String("***".into());
1626 return;
1627 }
1628 match value {
1629 Value::Object(map) => {
1630 let keys: Vec<String> = map.keys().cloned().collect();
1631 for key in keys {
1632 if context.is_secret_key(&key) {
1633 map.insert(key, Value::String("***".into()));
1634 } else if key_has_url_suffix(&key) {
1635 if let Some(Value::String(s)) = map.get_mut(&key) {
1636 *s = redact_url_field_value(s, context);
1637 } else if let Some(v) = map.get_mut(&key) {
1638 redact_secrets_with_context_depth(v, context, depth + 1);
1639 }
1640 } else if let Some(v) = map.get_mut(&key) {
1641 redact_secrets_with_context_depth(v, context, depth + 1);
1642 }
1643 }
1644 }
1645 Value::Array(arr) => {
1646 for v in arr {
1647 redact_secrets_with_context_depth(v, context, depth + 1);
1648 }
1649 }
1650 _ => {}
1651 }
1652}
1653
1654fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1658 if !s.contains("://") || !is_single_url(s) {
1665 return None;
1666 }
1667 let scheme_sep = s.find("://")?;
1668 let scheme = &s[..scheme_sep];
1669 let rest = &s[scheme_sep + 3..];
1670
1671 let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1673 let authority = &rest[..auth_end];
1674 let remainder = &rest[auth_end..];
1675
1676 let new_authority = redact_userinfo_password(authority);
1677
1678 let new_remainder = match remainder.find('?') {
1680 Some(q) => {
1681 let (path, q_onwards) = remainder.split_at(q);
1682 let query_body = &q_onwards[1..];
1683 let (query, fragment) = match query_body.find('#') {
1684 Some(h) => (&query_body[..h], &query_body[h..]),
1685 None => (query_body, ""),
1686 };
1687 format!("{path}?{}{fragment}", redact_query(query, context))
1688 }
1689 None => remainder.to_string(),
1690 };
1691
1692 Some(format!("{scheme}://{new_authority}{new_remainder}"))
1693}
1694
1695fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
1696 if let Some(redacted) = redact_url_in_str(s, context) {
1697 return redacted;
1698 }
1699 let trimmed = s.trim();
1700 if trimmed != s {
1701 if let Some(redacted) = redact_url_in_str(trimmed, context) {
1702 return redacted;
1703 }
1704 }
1705 if s.chars().any(char::is_whitespace) || s.contains('@') {
1711 return "***".to_string();
1712 }
1713 s.to_string()
1714}
1715
1716fn redact_userinfo_password(authority: &str) -> String {
1719 let Some(at) = authority.rfind('@') else {
1720 return authority.to_string();
1721 };
1722 let userinfo = &authority[..at];
1723 match userinfo.find(':') {
1724 Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1725 None => authority.to_string(),
1726 }
1727}
1728
1729fn redact_query(query: &str, context: &RedactionContext) -> String {
1732 query
1733 .split('&')
1734 .map(|segment| {
1735 let Some(eq) = segment.find('=') else {
1736 return segment.to_string();
1737 };
1738 let raw_key = &segment[..eq];
1739 let name = url::form_urlencoded::parse(segment.as_bytes())
1741 .next()
1742 .map(|(k, _)| k.into_owned())
1743 .unwrap_or_default();
1744 if context.is_secret_key(&name) {
1745 format!("{raw_key}=***")
1746 } else {
1747 segment.to_string()
1748 }
1749 })
1750 .collect::<Vec<_>>()
1751 .join("&")
1752}
1753
1754fn is_single_url(s: &str) -> bool {
1758 if s.bytes().any(|b| b.is_ascii_whitespace()) {
1759 return false;
1760 }
1761 let bytes = s.as_bytes();
1762 if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1763 return false;
1764 }
1765 let mut i = 1;
1766 while i < bytes.len() {
1767 let c = bytes[i];
1768 if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1769 i += 1;
1770 } else {
1771 break;
1772 }
1773 }
1774 s[i..].starts_with("://")
1775}
1776
1777fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1778 let context = RedactionContext::default();
1779 apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1780}
1781
1782fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1783 let context = RedactionContext::from_options(redaction_options);
1784 apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1785}
1786
1787fn apply_redaction_policy_with_context(
1788 value: &mut Value,
1789 redaction_policy: Option<RedactionPolicy>,
1790 context: &RedactionContext,
1791) {
1792 match redaction_policy {
1793 Some(RedactionPolicy::RedactionTraceOnly) => {
1794 if let Value::Object(map) = value {
1795 if let Some(trace) = map.get_mut("trace") {
1796 redact_secrets_with_context(trace, context);
1797 }
1798 }
1799 }
1800 Some(RedactionPolicy::RedactionNone) => {}
1801 None => redact_secrets_with_context(value, context),
1802 }
1803}
1804
1805fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1811 if let Some(s) = key.strip_suffix(suffix_lower) {
1812 return Some(s.to_string());
1813 }
1814 let suffix_upper: String = suffix_lower
1815 .chars()
1816 .map(|c| c.to_ascii_uppercase())
1817 .collect();
1818 if let Some(s) = key.strip_suffix(&suffix_upper) {
1819 return Some(s.to_string());
1820 }
1821 None
1822}
1823
1824fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1826 let code = extract_currency_code(key)?;
1827 let suffix_len = code.len() + "_cents".len() + 1; let stripped = &key[..key.len() - suffix_len];
1829 if stripped.is_empty() {
1830 return None;
1831 }
1832 Some((stripped.to_string(), code.to_string()))
1833}
1834
1835fn as_int(value: &Value) -> Option<i64> {
1843 if let Some(i) = value.as_i64() {
1844 return Some(i);
1845 }
1846 let f = value.as_f64()?;
1847 if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1848 return Some(f as i64);
1849 }
1850 None
1851}
1852
1853fn as_uint(value: &Value) -> Option<u64> {
1855 if let Some(u) = value.as_u64() {
1856 return Some(u);
1857 }
1858 let f = value.as_f64()?;
1859 if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1860 return Some(f as u64);
1861 }
1862 None
1863}
1864
1865fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1866 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1868 return as_int(value)
1869 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1870 }
1871 if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1872 return as_int(value)
1873 .and_then(|s| s.checked_mul(1000))
1874 .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1875 }
1876 if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1877 return as_int(value).and_then(|ns| {
1878 format_rfc3339_ms(ns.div_euclid(1_000_000)).map(|formatted| (stripped, formatted))
1879 });
1880 }
1881
1882 if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1884 return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1885 }
1886 if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1887 return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1888 }
1889 if let Some((stripped, code)) = try_strip_generic_cents(key) {
1890 return as_uint(value).map(|n| {
1891 (
1892 stripped,
1893 format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1894 )
1895 });
1896 }
1897
1898 if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1900 return value.as_str().map(|s| (stripped, s.to_string()));
1901 }
1902 if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1903 return value
1904 .is_number()
1905 .then(|| (stripped, format!("{} minutes", number_str(value))));
1906 }
1907 if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1908 return value
1909 .is_number()
1910 .then(|| (stripped, format!("{} hours", number_str(value))));
1911 }
1912 if let Some(stripped) = strip_suffix_ci(key, "_days") {
1913 return value
1914 .is_number()
1915 .then(|| (stripped, format!("{} days", number_str(value))));
1916 }
1917
1918 if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1920 return value
1921 .is_number()
1922 .then(|| (stripped, format!("{}msats", number_str(value))));
1923 }
1924 if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1925 return value
1926 .is_number()
1927 .then(|| (stripped, format!("{}sats", number_str(value))));
1928 }
1929 if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1930 return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1931 }
1932 if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1933 return value
1934 .is_number()
1935 .then(|| (stripped, format!("{}%", number_str(value))));
1936 }
1937 if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1939 return value
1940 .is_number()
1941 .then(|| (stripped, format!("{} BTC", number_str(value))));
1942 }
1943 if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1944 return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1945 }
1946 if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1947 return value
1948 .is_number()
1949 .then(|| (stripped, format!("{}ns", number_str(value))));
1950 }
1951 if let Some(stripped) = strip_suffix_ci(key, "_us") {
1952 return value
1953 .is_number()
1954 .then(|| (stripped, format!("{}μs", number_str(value))));
1955 }
1956 if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1957 return format_ms_value(value).map(|v| (stripped, v));
1958 }
1959 if let Some(stripped) = strip_suffix_ci(key, "_s") {
1960 return value
1961 .is_number()
1962 .then(|| (stripped, format!("{}s", number_str(value))));
1963 }
1964
1965 None
1966}
1967
1968fn process_object_fields<'a>(
1970 map: &'a serde_json::Map<String, Value>,
1971) -> Vec<(String, &'a Value, Option<String>)> {
1972 let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1973 for (key, value) in map {
1974 if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1975 entries.push((stripped, key.as_str(), value, None));
1976 continue;
1977 }
1978 match try_process_field(key, value) {
1979 Some((stripped, formatted)) => {
1980 entries.push((stripped, key.as_str(), value, Some(formatted)));
1981 }
1982 None => {
1983 entries.push((key.clone(), key.as_str(), value, None));
1984 }
1985 }
1986 }
1987
1988 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1990 for (stripped, _, _, _) in &entries {
1991 *counts.entry(stripped.clone()).or_insert(0) += 1;
1992 }
1993
1994 let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1996 .into_iter()
1997 .map(|(stripped, original, value, formatted)| {
1998 if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1999 (original.to_string(), value, None)
2000 } else {
2001 (stripped, value, formatted)
2002 }
2003 })
2004 .collect();
2005
2006 result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
2007 result
2008}
2009
2010fn number_str(value: &Value) -> String {
2015 match value {
2016 Value::Number(n) => format_number(n),
2017 _ => String::new(),
2018 }
2019}
2020
2021fn format_number(n: &serde_json::Number) -> String {
2027 if n.is_f64() {
2028 if let Some(f) = n.as_f64() {
2029 if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 {
2030 return format!("{f:.0}");
2031 }
2032 }
2033 }
2034 normalize_exponent(&n.to_string())
2035}
2036
2037fn normalize_exponent(s: &str) -> String {
2038 let Some(e) = s.find(['e', 'E']) else {
2039 return s.to_string();
2040 };
2041 let mantissa = &s[..e];
2042 let mut exp = &s[e + 1..];
2043 let mut sign = "";
2044 if exp.starts_with(['+', '-']) {
2045 sign = &exp[..1];
2046 exp = &exp[1..];
2047 }
2048 let exp = exp.trim_start_matches('0');
2049 let exp = if exp.is_empty() { "0" } else { exp };
2050 format!("{mantissa}e{sign}{exp}")
2051}
2052
2053fn format_ms_as_seconds(ms: f64) -> String {
2055 let formatted = format!("{:.3}", ms / 1000.0);
2056 let trimmed = formatted.trim_end_matches('0');
2057 if trimmed.ends_with('.') {
2058 format!("{}0s", trimmed)
2059 } else {
2060 format!("{}s", trimmed)
2061 }
2062}
2063
2064fn format_ms_value(value: &Value) -> Option<String> {
2066 let n = value.as_f64()?;
2067 if n.abs() >= 1000.0 {
2068 Some(format_ms_as_seconds(n))
2069 } else if let Some(i) = value.as_i64() {
2070 Some(format!("{}ms", i))
2071 } else {
2072 Some(format!("{}ms", number_str(value)))
2073 }
2074}
2075
2076const MIN_RFC3339_MS: i64 = -62135596800000;
2078const MAX_RFC3339_MS: i64 = 253402300799999;
2079
2080fn format_rfc3339_ms(ms: i64) -> Option<String> {
2081 use chrono::{DateTime, Utc};
2082 if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
2083 return None;
2084 }
2085 let secs = ms.div_euclid(1000);
2086 let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
2087 DateTime::from_timestamp(secs, nanos).map(|dt| {
2088 dt.with_timezone(&Utc)
2089 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2090 })
2091}
2092
2093fn format_bytes_human(bytes: i64) -> String {
2095 const KB: f64 = 1024.0;
2096 const MB: f64 = KB * 1024.0;
2097 const GB: f64 = MB * 1024.0;
2098 const TB: f64 = GB * 1024.0;
2099
2100 let sign = if bytes < 0 { "-" } else { "" };
2101 let b = (bytes as f64).abs();
2102 if b >= TB {
2103 format!("{sign}{:.1}TB", b / TB)
2104 } else if b >= GB {
2105 format!("{sign}{:.1}GB", b / GB)
2106 } else if b >= MB {
2107 format!("{sign}{:.1}MB", b / MB)
2108 } else if b >= KB {
2109 format!("{sign}{:.1}KB", b / KB)
2110 } else {
2111 format!("{bytes}B")
2112 }
2113}
2114
2115fn format_with_commas(n: u64) -> String {
2117 let s = n.to_string();
2118 let mut result = String::with_capacity(s.len() + s.len() / 3);
2119 for (i, c) in s.chars().enumerate() {
2120 if i > 0 && (s.len() - i).is_multiple_of(3) {
2121 result.push(',');
2122 }
2123 result.push(c);
2124 }
2125 result
2126}
2127
2128fn extract_currency_code(key: &str) -> Option<&str> {
2130 let without_cents = key
2131 .strip_suffix("_cents")
2132 .or_else(|| key.strip_suffix("_CENTS"))?;
2133 let last_underscore = without_cents.rfind('_')?;
2134 let code = &without_cents[last_underscore + 1..];
2135 if code.is_empty()
2136 || !(3..=4).contains(&code.len())
2137 || !code.bytes().all(|b| b.is_ascii_alphabetic())
2138 {
2139 return None;
2140 }
2141 Some(code)
2142}
2143
2144fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
2149 let prefix = " ".repeat(indent);
2150 match value {
2151 Value::Object(map) => {
2152 let processed = process_object_fields(map);
2153 for (display_key, v, formatted) in processed {
2154 if let Some(fv) = formatted {
2155 lines.push(format!(
2156 "{}{}: \"{}\"",
2157 prefix,
2158 yaml_key(&display_key),
2159 escape_yaml_str(&fv)
2160 ));
2161 } else {
2162 match v {
2163 Value::Object(inner) if !inner.is_empty() => {
2164 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2165 render_yaml_processed(v, indent + 1, lines);
2166 }
2167 Value::Object(_) => {
2168 lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
2169 }
2170 Value::Array(arr) => {
2171 if arr.is_empty() {
2172 lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
2173 } else {
2174 lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2175 for item in arr {
2176 if item.is_object() {
2177 lines.push(format!("{} -", prefix));
2178 render_yaml_processed(item, indent + 2, lines);
2179 } else {
2180 lines.push(format!("{} - {}", prefix, yaml_scalar(item)));
2181 }
2182 }
2183 }
2184 }
2185 _ => {
2186 lines.push(format!(
2187 "{}{}: {}",
2188 prefix,
2189 yaml_key(&display_key),
2190 yaml_scalar(v)
2191 ));
2192 }
2193 }
2194 }
2195 }
2196 }
2197 _ => {
2198 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2199 }
2200 }
2201}
2202
2203fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
2204 let prefix = " ".repeat(indent);
2205 match value {
2206 Value::Object(map) => {
2207 for key in sorted_value_keys(map) {
2208 render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
2209 }
2210 }
2211 Value::Array(arr) => {
2212 render_yaml_array_raw(arr, indent, lines);
2213 }
2214 _ => {
2215 lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2216 }
2217 }
2218}
2219
2220fn render_yaml_field_raw(
2221 prefix: &str,
2222 key: &str,
2223 value: &Value,
2224 indent: usize,
2225 lines: &mut Vec<String>,
2226) {
2227 match value {
2228 Value::Object(inner) if !inner.is_empty() => {
2229 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2230 render_yaml_raw(value, indent + 1, lines);
2231 }
2232 Value::Object(_) => {
2233 lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
2234 }
2235 Value::Array(arr) => {
2236 if arr.is_empty() {
2237 lines.push(format!("{}{}: []", prefix, yaml_key(key)));
2238 } else {
2239 lines.push(format!("{}{}:", prefix, yaml_key(key)));
2240 render_yaml_array_raw(arr, indent + 1, lines);
2241 }
2242 }
2243 _ => {
2244 lines.push(format!(
2245 "{}{}: {}",
2246 prefix,
2247 yaml_key(key),
2248 yaml_scalar(value)
2249 ));
2250 }
2251 }
2252}
2253
2254fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
2255 let prefix = " ".repeat(indent);
2256 for item in arr {
2257 match item {
2258 Value::Object(inner) if !inner.is_empty() => {
2259 lines.push(format!("{}-", prefix));
2260 render_yaml_raw(item, indent + 1, lines);
2261 }
2262 Value::Array(nested) if !nested.is_empty() => {
2263 lines.push(format!("{}-", prefix));
2264 render_yaml_array_raw(nested, indent + 1, lines);
2265 }
2266 Value::Object(_) => {
2267 lines.push(format!("{}- {{}}", prefix));
2268 }
2269 Value::Array(_) => {
2270 lines.push(format!("{}- []", prefix));
2271 }
2272 _ => {
2273 lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
2274 }
2275 }
2276 }
2277}
2278
2279fn escape_yaml_str(s: &str) -> String {
2280 s.replace('\\', "\\\\")
2281 .replace('"', "\\\"")
2282 .replace('\n', "\\n")
2283 .replace('\r', "\\r")
2284 .replace('\t', "\\t")
2285 .replace('\x0c', "\\f")
2286 .replace('\x0b', "\\v")
2287}
2288
2289fn yaml_key(key: &str) -> String {
2290 if is_safe_key(key) {
2291 key.to_string()
2292 } else {
2293 format!("\"{}\"", escape_yaml_str(key))
2294 }
2295}
2296
2297fn quote_logfmt_key(key: &str) -> String {
2298 if is_safe_key(key) {
2299 key.to_string()
2300 } else {
2301 quote_logfmt_value(key)
2302 }
2303}
2304
2305fn is_safe_key(key: &str) -> bool {
2306 !key.is_empty()
2307 && key
2308 .bytes()
2309 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
2310}
2311
2312fn yaml_scalar(value: &Value) -> String {
2313 match value {
2314 Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
2315 Value::Null => "null".to_string(),
2316 Value::Bool(b) => b.to_string(),
2317 Value::Number(n) => format_number(n),
2318 Value::Object(_) | Value::Array(_) => {
2319 format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
2320 }
2321 }
2322}
2323
2324fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2329 if let Value::Object(map) = value {
2330 let processed = process_object_fields(map);
2331 for (display_key, v, formatted) in processed {
2332 let full_key = if prefix.is_empty() {
2333 display_key
2334 } else {
2335 format!("{}.{}", prefix, display_key)
2336 };
2337 if let Some(fv) = formatted {
2338 pairs.push((full_key, fv));
2339 } else {
2340 match v {
2341 Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
2342 Value::Array(arr) => {
2343 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2344 pairs.push((full_key, joined));
2345 }
2346 Value::Null => pairs.push((full_key, String::new())),
2347 _ => pairs.push((full_key, plain_scalar(v))),
2348 }
2349 }
2350 }
2351 }
2352}
2353
2354fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2355 if let Value::Object(map) = value {
2356 for key in sorted_value_keys(map) {
2357 let v = &map[&key];
2358 let full_key = if prefix.is_empty() {
2359 key.clone()
2360 } else {
2361 format!("{}.{}", prefix, key)
2362 };
2363 match v {
2364 Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
2365 Value::Array(arr) => {
2366 let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2367 pairs.push((full_key, joined));
2368 }
2369 Value::Null => pairs.push((full_key, String::new())),
2370 _ => pairs.push((full_key, plain_scalar(v))),
2371 }
2372 }
2373 }
2374}
2375
2376fn plain_scalar(value: &Value) -> String {
2377 match value {
2378 Value::String(s) => s.clone(),
2379 Value::Null => "null".to_string(),
2380 Value::Bool(b) => b.to_string(),
2381 Value::Number(n) => format_number(n),
2382 Value::Object(_) | Value::Array(_) => canonical_json(value),
2383 }
2384}
2385
2386fn quote_logfmt_value(value: &str) -> String {
2387 if value.is_empty() {
2388 return String::new();
2389 }
2390 if !value
2391 .chars()
2392 .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
2393 {
2394 return value.to_string();
2395 }
2396 let escaped = value
2397 .replace('\\', "\\\\")
2398 .replace('"', "\\\"")
2399 .replace('\n', "\\n")
2400 .replace('\r', "\\r")
2401 .replace('\t', "\\t")
2402 .replace('\x0c', "\\f")
2403 .replace('\x0b', "\\v");
2404 format!("\"{}\"", escaped)
2405}
2406
2407fn canonical_json(value: &Value) -> String {
2408 serde_json::to_string(&sort_json_value(value))
2409 .unwrap_or_else(|_| "<unsupported:json>".to_string())
2410}
2411
2412fn sort_json_value(value: &Value) -> Value {
2413 match value {
2414 Value::Object(map) => {
2415 let mut out = serde_json::Map::new();
2416 for key in sorted_value_keys(map) {
2417 if let Some(v) = map.get(&key) {
2418 out.insert(key, sort_json_value(v));
2419 }
2420 }
2421 Value::Object(out)
2422 }
2423 Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
2424 _ => value.clone(),
2425 }
2426}
2427
2428fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
2429 let mut keys: Vec<String> = map.keys().cloned().collect();
2430 keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
2431 keys
2432}
2433
2434#[cfg(test)]
2435mod tests;