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