Skip to main content

agent_first_data/
lib.rs

1//! Agent-First Data (AFDATA) output formatting and protocol templates.
2//!
3//! Public APIs include:
4//! - 3 protocol builders: [`build_json_ok`], [`build_json_error`], [`build_json`]
5//! - 3 value-copy redactors: [`redacted_value`], [`redacted_value_with`], [`redacted_value_with_options`]
6//! - 7 output formatters: [`output_json`], [`output_json_with`], [`output_json_with_options`],
7//!   [`output_yaml`], [`output_yaml_with_options`], [`output_plain`], [`output_plain_with_options`]
8//! - 2 in-place value redactors: [`internal_redact_secrets`], [`internal_redact_secrets_with_options`]
9//!   (these redact `_secret` and `_url` fields in a JSON value)
10//! - 2 URL-string redactors: [`redact_url_secrets`], [`redact_url_secrets_with_options`]
11//!   (operate on one URL string; the value redactors above apply these to `_url` fields)
12//! - 1 parse utility: [`parse_size`]
13//! - 5 CLI helpers: [`cli_parse_output`], [`cli_parse_log_filters`], [`cli_output`],
14//!   [`cli_output_with_options`], [`build_cli_error`]
15//! - 5 types: [`OutputFormat`], [`RedactionPolicy`], [`RedactionOptions`],
16//!   [`OutputStyle`], [`OutputOptions`]
17//! - (feature `cli-help`): configurable clap help rendering via [`cli_render_help_with_options`]
18//!   and [`cli_handle_help_or_continue`]
19//! - (feature `cli-help-markdown`): [`cli_render_help_markdown`] — recursive Markdown help
20//! - (feature `skill-admin`): [`skill::run_skill_admin`] — install/uninstall/status a spore's
21//!   embedded Agent Skill across Codex, Claude Code, and opencode; returns a typed
22//!   [`skill::SkillReport`]
23
24#[cfg(feature = "tracing")]
25pub mod afdata_tracing;
26
27#[cfg(feature = "skill-admin")]
28pub mod skill;
29
30use serde_json::Value;
31use std::collections::HashSet;
32
33// ═══════════════════════════════════════════
34// Public API: Protocol Builders
35// ═══════════════════════════════════════════
36
37/// Build `{code: "ok", result: ..., trace?: ...}`.
38pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
39    match trace {
40        Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
41        None => serde_json::json!({"code": "ok", "result": result}),
42    }
43}
44
45/// Build `{code: "error", error: message, hint?: ..., trace?: ...}`.
46pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
47    let mut obj = serde_json::Map::new();
48    obj.insert("code".to_string(), Value::String("error".to_string()));
49    obj.insert("error".to_string(), Value::String(message.to_string()));
50    if let Some(h) = hint {
51        obj.insert("hint".to_string(), Value::String(h.to_string()));
52    }
53    if let Some(t) = trace {
54        obj.insert("trace".to_string(), t);
55    }
56    Value::Object(obj)
57}
58
59/// Build `{code: "<custom>", ...fields, trace?: ...}`.
60pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
61    let mut obj = match fields {
62        Value::Object(map) => map,
63        _ => serde_json::Map::new(),
64    };
65    obj.insert("code".to_string(), Value::String(code.to_string()));
66    if let Some(t) = trace {
67        obj.insert("trace".to_string(), t);
68    }
69    Value::Object(obj)
70}
71
72// ═══════════════════════════════════════════
73// Public API: Output Formatters
74// ═══════════════════════════════════════════
75
76/// Redaction policy for [`output_json_with`].
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum RedactionPolicy {
79    /// Redact only inside top-level `trace`.
80    RedactionTraceOnly,
81    /// Do not redact any fields.
82    RedactionNone,
83    /// Replace every `_secret` subtree with `"***"`.
84    RedactionStrict,
85}
86
87/// Redaction options for legacy secret field names.
88#[derive(Clone, Debug, Default, PartialEq, Eq)]
89pub struct RedactionOptions {
90    /// Optional scoped policy. `None` means default full redaction.
91    pub policy: Option<RedactionPolicy>,
92    /// Field names to treat as secrets in addition to `_secret` suffixes.
93    ///
94    /// Matching is exact field-name equality at any nesting level. The same
95    /// list also matches URL query-parameter names inside `_url` fields (see
96    /// [`redact_url_secrets`]).
97    pub secret_names: Vec<String>,
98}
99
100/// Rendering style for YAML and plain output.
101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
102pub enum OutputStyle {
103    /// Human-readable AFDATA rendering: strip suffixes and format values.
104    #[default]
105    Readable,
106    /// Schema-preserving rendering: keep keys and values unchanged after redaction.
107    Raw,
108}
109
110/// Output options combining redaction and rendering style.
111#[derive(Clone, Debug, Default, PartialEq, Eq)]
112pub struct OutputOptions {
113    /// Redaction options applied before rendering.
114    pub redaction: RedactionOptions,
115    /// Rendering style for YAML and plain output.
116    pub style: OutputStyle,
117}
118
119/// Format as single-line JSON with full `_secret` redaction.
120pub fn output_json(value: &Value) -> String {
121    serialize_json_output(&redacted_value(value))
122}
123
124/// Format as single-line JSON with configurable redaction policy.
125pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
126    serialize_json_output(&redacted_value_with(value, redaction_policy))
127}
128
129/// Format as single-line JSON with configurable output options.
130///
131/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
132/// redaction.
133pub fn output_json_with_options(value: &Value, output_options: &OutputOptions) -> String {
134    serialize_json_output(&redacted_value_with_options(
135        value,
136        &output_options.redaction,
137    ))
138}
139
140fn serialize_json_output(value: &Value) -> String {
141    match serde_json::to_string(value) {
142        Ok(s) => s,
143        Err(err) => serde_json::json!({
144            "error": "output_json_failed",
145            "detail": err.to_string(),
146        })
147        .to_string(),
148    }
149}
150
151/// Format as multi-line YAML. Keys stripped, values formatted, secrets redacted.
152pub fn output_yaml(value: &Value) -> String {
153    output_yaml_with_options(value, &OutputOptions::default())
154}
155
156/// Format as multi-line YAML with configurable output options.
157pub fn output_yaml_with_options(value: &Value, output_options: &OutputOptions) -> String {
158    let mut lines = vec!["---".to_string()];
159    let v = redacted_value_with_options(value, &output_options.redaction);
160    match output_options.style {
161        OutputStyle::Readable => render_yaml_processed(&v, 0, &mut lines),
162        OutputStyle::Raw => render_yaml_raw(&v, 0, &mut lines),
163    }
164    lines.join("\n")
165}
166
167/// Format as single-line logfmt. Keys stripped, values formatted, secrets redacted.
168pub fn output_plain(value: &Value) -> String {
169    output_plain_with_options(value, &OutputOptions::default())
170}
171
172/// Format as single-line logfmt with configurable output options.
173pub fn output_plain_with_options(value: &Value, output_options: &OutputOptions) -> String {
174    let mut pairs: Vec<(String, String)> = Vec::new();
175    let v = redacted_value_with_options(value, &output_options.redaction);
176    match output_options.style {
177        OutputStyle::Readable => collect_plain_pairs(&v, "", &mut pairs),
178        OutputStyle::Raw => collect_plain_pairs_raw(&v, "", &mut pairs),
179    }
180    pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
181    pairs
182        .into_iter()
183        .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
184        .collect::<Vec<_>>()
185        .join(" ")
186}
187
188// ═══════════════════════════════════════════
189// Public API: Redaction & Utility
190// ═══════════════════════════════════════════
191
192/// Redact `_secret` fields in-place.
193pub fn internal_redact_secrets(value: &mut Value) {
194    redact_secrets(value);
195}
196
197/// Redact secret fields in-place using configurable redaction options.
198pub fn internal_redact_secrets_with_options(
199    value: &mut Value,
200    redaction_options: &RedactionOptions,
201) {
202    apply_redaction_options(value, redaction_options);
203}
204
205/// Return a JSON value copy with default `_secret` redaction applied.
206pub fn redacted_value(value: &Value) -> Value {
207    let mut v = value.clone();
208    redact_secrets(&mut v);
209    v
210}
211
212/// Return a JSON value copy with an explicit redaction policy applied.
213pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
214    let mut v = value.clone();
215    apply_redaction_policy(&mut v, redaction_policy);
216    v
217}
218
219/// Return a JSON value copy with configurable redaction options applied.
220pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
221    let mut v = value.clone();
222    apply_redaction_options(&mut v, redaction_options);
223    v
224}
225
226/// Redact secret components of a single URL string, using default options.
227///
228/// Returns `url` with its userinfo password and any `_secret`-suffixed query
229/// parameter values replaced by `***`. See [`redact_url_secrets_with_options`].
230pub fn redact_url_secrets(url: &str) -> String {
231    redact_url_secrets_with_options(url, &RedactionOptions::default())
232}
233
234/// Redact secret components of a single URL string.
235///
236/// A query parameter is redacted iff its (form-decoded) name ends in
237/// `_secret`/`_SECRET` or matches an exact entry in `secret_names`. The
238/// userinfo password (`scheme://user:pass@host`) is always redacted as a
239/// structural rule. Only the secret spans are replaced with `***`; every other
240/// byte is preserved. A string that is not a single, whitespace-free,
241/// scheme-prefixed URL (including a URL embedded in surrounding prose) is
242/// returned unchanged.
243pub fn redact_url_secrets_with_options(url: &str, redaction_options: &RedactionOptions) -> String {
244    let context = RedactionContext::from_options(redaction_options);
245    redact_url_in_str(url, &context).unwrap_or_else(|| url.to_string())
246}
247
248/// Parse a human-readable size string into bytes.
249///
250/// Accepts bare number, or number followed by unit letter
251/// (`B`, `K`, `M`, `G`, `T`). Case-insensitive. Trims whitespace.
252/// Returns `None` for invalid or negative input.
253pub fn parse_size(s: &str) -> Option<u64> {
254    let s = s.trim();
255    if s.is_empty() {
256        return None;
257    }
258    let last = *s.as_bytes().last()?;
259    let (num_str, mult) = match last {
260        b'B' | b'b' => (&s[..s.len() - 1], 1u64),
261        b'K' | b'k' => (&s[..s.len() - 1], 1024),
262        b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
263        b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
264        b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
265        b'0'..=b'9' | b'.' => (s, 1),
266        _ => return None,
267    };
268    if num_str.is_empty() {
269        return None;
270    }
271    if let Ok(n) = num_str.parse::<u64>() {
272        return n.checked_mul(mult);
273    }
274    // Integer overflow must not silently fall back to float parsing.
275    if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
276        return None;
277    }
278    let f: f64 = num_str.parse().ok()?;
279    if f < 0.0 || f.is_nan() || f.is_infinite() {
280        return None;
281    }
282    let result = f * mult as f64;
283    if result >= u64::MAX as f64 {
284        return None;
285    }
286    Some(result as u64)
287}
288
289// ═══════════════════════════════════════════
290// Public API: CLI Helpers
291// ═══════════════════════════════════════════
292
293/// Output format for CLI and pipe/MCP modes.
294#[derive(Clone, Copy, Debug, PartialEq, Eq)]
295pub enum OutputFormat {
296    Json,
297    Yaml,
298    Plain,
299}
300
301/// Parse `--output` flag value into [`OutputFormat`].
302///
303/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
304///
305/// ```
306/// use agent_first_data::{cli_parse_output, OutputFormat};
307/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
308/// assert!(cli_parse_output("xml").is_err());
309/// ```
310pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
311    match s {
312        "json" => Ok(OutputFormat::Json),
313        "yaml" => Ok(OutputFormat::Yaml),
314        "plain" => Ok(OutputFormat::Plain),
315        _ => Err(format!(
316            "invalid --output format '{s}': expected json, yaml, or plain"
317        )),
318    }
319}
320
321/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
322///
323/// Accepts pre-split entries as produced by clap's `value_delimiter = ','`.
324///
325/// ```
326/// use agent_first_data::cli_parse_log_filters;
327/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
328/// assert_eq!(f, vec!["query", "error"]);
329/// ```
330pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
331    let mut out: Vec<String> = Vec::new();
332    for entry in entries {
333        let s = entry.as_ref().trim().to_ascii_lowercase();
334        if !s.is_empty() && !out.contains(&s) {
335            out.push(s);
336        }
337    }
338    out
339}
340
341/// Dispatch output formatting by [`OutputFormat`].
342///
343/// Equivalent to calling [`output_json`], [`output_yaml`], or [`output_plain`] directly.
344///
345/// ```
346/// use agent_first_data::{cli_output, OutputFormat};
347/// let v = serde_json::json!({"code": "ok"});
348/// let s = cli_output(&v, OutputFormat::Plain);
349/// assert!(s.contains("code=ok"));
350/// ```
351pub fn cli_output(value: &Value, format: OutputFormat) -> String {
352    match format {
353        OutputFormat::Json => output_json(value),
354        OutputFormat::Yaml => output_yaml(value),
355        OutputFormat::Plain => output_plain(value),
356    }
357}
358
359/// Dispatch output formatting by [`OutputFormat`] with configurable output options.
360///
361/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
362/// redaction. YAML and plain output use the requested style.
363pub fn cli_output_with_options(
364    value: &Value,
365    format: OutputFormat,
366    output_options: &OutputOptions,
367) -> String {
368    match format {
369        OutputFormat::Json => output_json_with_options(value, output_options),
370        OutputFormat::Yaml => output_yaml_with_options(value, output_options),
371        OutputFormat::Plain => output_plain_with_options(value, output_options),
372    }
373}
374
375/// Build a standard CLI parse error value.
376///
377/// Use when `Cli::try_parse()` fails or a flag value is invalid.
378/// Print with [`output_json`] and exit with code 2.
379///
380/// ```
381/// let err = agent_first_data::build_cli_error("--output: invalid value 'xml'", None);
382/// assert_eq!(err["code"], "error");
383/// assert_eq!(err["error_code"], "invalid_request");
384/// assert_eq!(err["retryable"], false);
385/// ```
386pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
387    let mut obj = serde_json::Map::new();
388    obj.insert("code".to_string(), Value::String("error".to_string()));
389    obj.insert(
390        "error_code".to_string(),
391        Value::String("invalid_request".to_string()),
392    );
393    obj.insert("error".to_string(), Value::String(message.to_string()));
394    if let Some(h) = hint {
395        obj.insert("hint".to_string(), Value::String(h.to_string()));
396    }
397    obj.insert("retryable".to_string(), Value::Bool(false));
398    obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
399    Value::Object(obj)
400}
401
402// ═══════════════════════════════════════════
403// Public API: CLI Help Rendering (optional)
404// ═══════════════════════════════════════════
405
406/// How much of a command tree a help request should render.
407///
408/// Requires the `cli-help` feature.
409#[cfg(feature = "cli-help")]
410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub enum HelpScope {
412    /// Render only the selected command's own clap-style help.
413    ///
414    /// Clap's normal help still lists direct subcommands in the "Commands"
415    /// section, but descendant command detail is not expanded.
416    OneLevel,
417    /// Render the selected command and all visible descendant subcommands.
418    Recursive,
419}
420
421/// Output format for help rendering.
422///
423/// Requires the `cli-help` feature.
424#[cfg(feature = "cli-help")]
425#[derive(Clone, Copy, Debug, PartialEq, Eq)]
426pub enum HelpFormat {
427    Plain,
428    Markdown,
429    Json,
430    Yaml,
431}
432
433#[cfg(feature = "cli-help")]
434impl HelpFormat {
435    fn parse(s: &str) -> Option<Self> {
436        match s {
437            "plain" => Some(Self::Plain),
438            "markdown" => Some(Self::Markdown),
439            "json" => Some(Self::Json),
440            "yaml" => Some(Self::Yaml),
441            _ => None,
442        }
443    }
444}
445
446/// Options for rendering CLI help.
447///
448/// Requires the `cli-help` feature.
449#[cfg(feature = "cli-help")]
450#[derive(Clone, Copy, Debug, PartialEq, Eq)]
451pub struct HelpOptions {
452    pub scope: HelpScope,
453    pub format: HelpFormat,
454}
455
456#[cfg(feature = "cli-help")]
457impl HelpOptions {
458    /// Human-friendly current-level plain help.
459    pub const fn one_level_plain() -> Self {
460        Self {
461            scope: HelpScope::OneLevel,
462            format: HelpFormat::Plain,
463        }
464    }
465
466    /// Agent/doc-friendly recursive plain help.
467    pub const fn recursive_plain() -> Self {
468        Self {
469            scope: HelpScope::Recursive,
470            format: HelpFormat::Plain,
471        }
472    }
473}
474
475/// Configuration for pre-clap help handling.
476///
477/// The handler scans raw argv before `Cli::try_parse()` so applications can
478/// support requests such as `--help --output markdown` without clap exiting
479/// early with `DisplayHelp`.
480///
481/// Requires the `cli-help` feature.
482#[cfg(feature = "cli-help")]
483#[derive(Clone, Debug, PartialEq, Eq)]
484pub struct HelpConfig {
485    /// Scope used for `--help` / `-h` when neither `--recursive` nor a
486    /// configured `recursive_flag` is present.
487    pub default_scope: HelpScope,
488    /// Format used for help when no explicit output flag is present.
489    pub default_format: HelpFormat,
490    /// Optional extra alias for the built-in `--recursive` scope modifier.
491    ///
492    /// `--recursive` is always recognized; set this only to accept an
493    /// additional custom flag name (for example `--full`). Like `--recursive`,
494    /// the alias is a *modifier* that selects recursive scope when `--help` is
495    /// present; on its own it does not trigger help.
496    pub recursive_flag: Option<&'static str>,
497    /// Optional output flag to read help format from, for example `--output`.
498    pub output_flag: Option<&'static str>,
499    /// Whether an explicit output flag can override `default_format`.
500    pub allow_output_format: bool,
501}
502
503#[cfg(feature = "cli-help")]
504impl HelpConfig {
505    /// Construct a custom help handler configuration.
506    pub const fn new(default_scope: HelpScope, default_format: HelpFormat) -> Self {
507        Self {
508            default_scope,
509            default_format,
510            recursive_flag: None,
511            output_flag: None,
512            allow_output_format: false,
513        }
514    }
515
516    /// Recommended preset for human-facing CLIs.
517    ///
518    /// `--help` renders one-level plain help by default. Scope and format are
519    /// orthogonal: `--recursive` expands the selected command subtree, while
520    /// `--output json|yaml|markdown` picks the format. So `--help --recursive`
521    /// is recursive plain text and `--help --recursive --output markdown` is a
522    /// recursive Markdown export.
523    pub const fn human_cli_default() -> Self {
524        Self {
525            default_scope: HelpScope::OneLevel,
526            default_format: HelpFormat::Plain,
527            recursive_flag: None,
528            output_flag: Some("--output"),
529            allow_output_format: true,
530        }
531    }
532
533    /// Recommended preset for agent-first CLIs that want full surface help by default.
534    pub const fn agent_cli_default() -> Self {
535        Self {
536            default_scope: HelpScope::Recursive,
537            default_format: HelpFormat::Plain,
538            recursive_flag: None,
539            output_flag: Some("--output"),
540            allow_output_format: true,
541        }
542    }
543
544    /// Return a copy with a different default scope.
545    pub const fn with_default_scope(mut self, scope: HelpScope) -> Self {
546        self.default_scope = scope;
547        self
548    }
549
550    /// Return a copy with a different default format.
551    pub const fn with_default_format(mut self, format: HelpFormat) -> Self {
552        self.default_format = format;
553        self
554    }
555
556    /// Return a copy with a different recursive-help flag.
557    pub const fn with_recursive_flag(mut self, flag: Option<&'static str>) -> Self {
558        self.recursive_flag = flag;
559        self
560    }
561
562    /// Return a copy with a different output flag.
563    pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
564        self.output_flag = flag;
565        self
566    }
567
568    /// Return a copy that enables or disables help format overrides.
569    pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
570        self.allow_output_format = enabled;
571        self
572    }
573}
574
575/// Render help for a clap command tree with explicit scope and format.
576///
577/// Walks to the subcommand identified by `subcommand_path` (empty = root),
578/// then renders either the selected command only (`OneLevel`) or the selected
579/// command and all descendants (`Recursive`).
580///
581/// Requires the `cli-help` feature.
582#[cfg(feature = "cli-help")]
583pub fn cli_render_help_with_options(
584    cmd: &clap::Command,
585    subcommand_path: &[&str],
586    options: &HelpOptions,
587) -> String {
588    let target = walk_to_subcommand(cmd, subcommand_path);
589    let mut rendered = match options.format {
590        HelpFormat::Plain => match options.scope {
591            HelpScope::OneLevel => render_help_one_level_plain(target),
592            HelpScope::Recursive => {
593                let mut buf = String::new();
594                render_help_recursive_plain(target, &[], &mut buf);
595                buf
596            }
597        },
598        HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
599        HelpFormat::Json => {
600            serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
601        }
602        HelpFormat::Yaml => output_yaml_with_options(
603            &build_help_schema(cmd, subcommand_path, options.scope),
604            &OutputOptions {
605                redaction: RedactionOptions {
606                    policy: Some(RedactionPolicy::RedactionNone),
607                    secret_names: Vec::new(),
608                },
609                style: OutputStyle::Raw,
610            },
611        ),
612    };
613    // Every format ends with exactly one trailing newline so `print!`-ing the
614    // result is clean across plain/markdown/json/yaml (JSON and raw YAML would
615    // otherwise have none).
616    while rendered.ends_with('\n') {
617        rendered.pop();
618    }
619    rendered.push('\n');
620    rendered
621}
622
623/// Render recursive plain-text help for a clap command tree.
624///
625/// Walks to the subcommand identified by `subcommand_path` (empty = root),
626/// then recursively expands all descendant subcommands into a single output.
627///
628/// Requires the `cli-help` feature.
629#[cfg(feature = "cli-help")]
630pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
631    cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
632}
633
634/// Render recursive Markdown help for a clap command tree.
635///
636/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
637/// documentation generation (`myapp --help --recursive --output markdown > docs/cli.md`).
638///
639/// Requires the `cli-help-markdown` feature.
640#[cfg(feature = "cli-help-markdown")]
641pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
642    cli_render_help_with_options(
643        cmd,
644        subcommand_path,
645        &HelpOptions {
646            scope: HelpScope::Recursive,
647            format: HelpFormat::Markdown,
648        },
649    )
650}
651
652/// Render help from raw argv if a help flag is present; otherwise return `None`.
653///
654/// `raw_args` should be the full argv vector, including argv[0], as produced by
655/// `std::env::args()`. The helper intentionally runs before clap parsing so
656/// `--help --recursive` and `--help --output markdown` can select scope and
657/// format instead of being consumed by clap's built-in help handling. Scope
658/// (`--recursive`) and format (`--output`) are orthogonal.
659///
660/// A bare `--recursive` without `--help` is treated as a non-help request
661/// (`Ok(None)`), leaving the flag for the application's own parser.
662///
663/// Returns a standard [`build_cli_error`] value when the help request is
664/// malformed, for example `--help --output xml`.
665///
666/// Requires the `cli-help` feature.
667#[cfg(feature = "cli-help")]
668pub fn cli_handle_help_or_continue(
669    raw_args: &[String],
670    cmd: &clap::Command,
671    config: &HelpConfig,
672) -> Result<Option<String>, Value> {
673    let parsed = parse_help_request(raw_args, cmd, config);
674    if !parsed.help_requested {
675        return Ok(None);
676    }
677    if let Some(error) = parsed.output_error {
678        return Err(build_cli_error(
679            &error,
680            Some("valid help output formats: plain, markdown, json, yaml"),
681        ));
682    }
683
684    let (scope, format) = resolve_help_options(&parsed, config);
685    let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
686    Ok(Some(cli_render_help_with_options(
687        cmd,
688        &path,
689        &HelpOptions { scope, format },
690    )))
691}
692
693#[cfg(feature = "cli-help")]
694fn resolve_help_options(
695    parsed: &ParsedHelpRequest,
696    config: &HelpConfig,
697) -> (HelpScope, HelpFormat) {
698    // Scope and format are orthogonal: `--recursive` (or the configured
699    // recursive flag, or a recursive default_scope) decides one-level vs
700    // recursive, while `--output` independently decides the format.
701    let scope = if parsed.recursive_requested {
702        HelpScope::Recursive
703    } else {
704        config.default_scope
705    };
706    let format = if config.allow_output_format {
707        parsed.output_format.unwrap_or(config.default_format)
708    } else {
709        config.default_format
710    };
711    (scope, format)
712}
713
714#[cfg(feature = "cli-help")]
715fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
716    let mut current = cmd;
717    for name in path {
718        current = current.find_subcommand(name).unwrap_or(current);
719    }
720    current
721}
722
723#[cfg(feature = "cli-help")]
724fn walk_to_subcommand_with_names<'a>(
725    cmd: &'a clap::Command,
726    path: &[&str],
727) -> (&'a clap::Command, Vec<String>) {
728    let mut current = cmd;
729    let mut names = vec![cmd.get_name().to_string()];
730    for name in path {
731        if let Some(next) = current.find_subcommand(name) {
732            current = next;
733            names.push(next.get_name().to_string());
734        } else {
735            break;
736        }
737    }
738    (current, names)
739}
740
741#[cfg(feature = "cli-help")]
742fn render_help_one_level_plain(cmd: &clap::Command) -> String {
743    enriched_help_command(cmd).render_long_help().to_string()
744}
745
746/// Clone `cmd` and fold the afdata-handled help modifiers into clap's own
747/// `-h, --help` description.
748///
749/// Help is rendered by clap, which has no knowledge of the `--recursive` scope
750/// modifier or the `--output` help formats (afdata consumes both before clap
751/// parses). Rather than appending a separate section, we patch the description
752/// of the existing help flag so the help surface is documented in place — in
753/// every format, since plain/markdown render this flag and the JSON/YAML schema
754/// reads it. Commands with subcommands advertise `--recursive`; leaf commands
755/// only advertise the `--output` formats (they have nothing to expand).
756#[cfg(feature = "cli-help")]
757fn enriched_help_command(cmd: &clap::Command) -> clap::Command {
758    let cmd = cmd.clone();
759    let description = if visible_subcommands(&cmd).next().is_some() {
760        HELP_FLAG_WITH_SUBCOMMANDS
761    } else {
762        HELP_FLAG_LEAF
763    };
764    // clap auto-generates `-h, --help` lazily during build, so `mut_arg` cannot
765    // reach it yet. Replace it with an explicit flag carrying the enriched
766    // description. This command is only rendered, never parsed (afdata handles
767    // `--help` before clap), so the action is immaterial.
768    cmd.disable_help_flag(true).arg(
769        clap::Arg::new("help")
770            .short('h')
771            .long("help")
772            .help(description)
773            .long_help(description)
774            .action(clap::ArgAction::Help),
775    )
776}
777
778/// Description for the `-h, --help` flag on commands that have subcommands.
779#[cfg(feature = "cli-help")]
780const HELP_FLAG_WITH_SUBCOMMANDS: &str =
781    "Print help. Add --recursive to expand every nested subcommand; \
782     add --output json|yaml|markdown to render this help in another format.";
783
784/// Description for the `-h, --help` flag on leaf commands (no subcommands).
785#[cfg(feature = "cli-help")]
786const HELP_FLAG_LEAF: &str =
787    "Print help. Add --output json|yaml|markdown to render this help in another format.";
788
789#[cfg(feature = "cli-help")]
790fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
791    use std::fmt::Write;
792
793    // Build the full command path (e.g. "myapp service start")
794    let mut cmd_path = parent_path.to_vec();
795    cmd_path.push(cmd.get_name());
796    let path_str = cmd_path.join(" ");
797
798    // Separator between commands (skip for the first one)
799    if !buf.is_empty() {
800        let _ = writeln!(buf);
801        let _ = writeln!(buf, "{}", "═".repeat(60));
802    }
803
804    // Header: "myapp service start — description"
805    if let Some(about) = cmd.get_about() {
806        let _ = writeln!(buf, "{path_str} — {about}");
807    } else {
808        let _ = writeln!(buf, "{path_str}");
809    }
810    let _ = writeln!(buf);
811
812    // Render clap's built-in help for this command (usage, args, options).
813    // Only the target command (top of the recursion) advertises the help
814    // modifiers; repeating them on every descendant block would be pure noise.
815    let is_target = parent_path.is_empty();
816    let styled = if is_target {
817        enriched_help_command(cmd).render_long_help()
818    } else {
819        cmd.clone().render_long_help()
820    };
821    let help_text = styled.to_string();
822    let _ = write!(buf, "{help_text}");
823
824    // Recurse into visible subcommands
825    for sub in cmd.get_subcommands() {
826        if sub.get_name() == "help" || sub.is_hide_set() {
827            continue; // skip clap's auto-generated "help" subcommand
828        }
829        render_help_recursive_plain(sub, &cmd_path, buf);
830    }
831}
832
833#[cfg(feature = "cli-help")]
834fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
835    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
836    let mut buf = String::new();
837    render_markdown_command(target, &names, &mut buf, 1, true);
838    if matches!(scope, HelpScope::Recursive) {
839        render_markdown_descendants(target, &names, &mut buf, 2);
840    }
841    buf
842}
843
844#[cfg(feature = "cli-help")]
845fn render_markdown_descendants(
846    cmd: &clap::Command,
847    parent_names: &[String],
848    buf: &mut String,
849    level: usize,
850) {
851    for sub in cmd.get_subcommands() {
852        if sub.get_name() == "help" || sub.is_hide_set() {
853            continue;
854        }
855        let mut names = parent_names.to_vec();
856        names.push(sub.get_name().to_string());
857        render_markdown_command(sub, &names, buf, level, false);
858        render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
859    }
860}
861
862#[cfg(feature = "cli-help")]
863fn render_markdown_command(
864    cmd: &clap::Command,
865    names: &[String],
866    buf: &mut String,
867    level: usize,
868    enrich: bool,
869) {
870    use std::fmt::Write;
871
872    if !buf.is_empty() {
873        let _ = writeln!(buf);
874    }
875    let heading_level = "#".repeat(level.max(1));
876    let path = names.join(" ");
877    if let Some(about) = cmd.get_about() {
878        let _ = writeln!(buf, "{heading_level} {path} - {about}");
879    } else {
880        let _ = writeln!(buf, "{heading_level} {path}");
881    }
882    if let Some(long_about) = cmd.get_long_about() {
883        let _ = writeln!(buf);
884        let _ = writeln!(buf, "{long_about}");
885    }
886    let _ = writeln!(buf);
887    let _ = writeln!(buf, "```text");
888    let help = if enrich {
889        enriched_help_command(cmd).render_long_help()
890    } else {
891        cmd.clone().render_long_help()
892    };
893    write_trimmed_help(buf, &help.to_string());
894    if !buf.ends_with('\n') {
895        let _ = writeln!(buf);
896    }
897    let _ = writeln!(buf, "```");
898}
899
900#[cfg(feature = "cli-help")]
901fn write_trimmed_help(buf: &mut String, help: &str) {
902    use std::fmt::Write;
903
904    for line in help.lines() {
905        let _ = writeln!(buf, "{}", line.trim_end());
906    }
907}
908
909#[cfg(feature = "cli-help")]
910struct ParsedHelpRequest {
911    help_requested: bool,
912    recursive_requested: bool,
913    output_format: Option<HelpFormat>,
914    output_error: Option<String>,
915    subcommand_path: Vec<String>,
916}
917
918#[cfg(feature = "cli-help")]
919fn parse_help_request(
920    raw_args: &[String],
921    cmd: &clap::Command,
922    config: &HelpConfig,
923) -> ParsedHelpRequest {
924    let args = match raw_args.first() {
925        Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
926        _ => raw_args.get(1..).unwrap_or(&[]),
927    };
928    let mut help_requested = false;
929    let mut recursive_requested = false;
930    let mut output_format = None;
931    let mut output_error = None;
932    let mut subcommand_path = Vec::new();
933    let mut current = cmd;
934    let output_flag = config.output_flag.map(normalize_long_flag);
935    let recursive_flag = config.recursive_flag.map(normalize_long_flag);
936
937    let mut i = 0usize;
938    while i < args.len() {
939        let arg = args[i].as_str();
940        if arg == "--" {
941            break;
942        }
943
944        let (flag_name, inline_value) = split_flag(arg);
945        if matches!(arg, "--help" | "-h") {
946            help_requested = true;
947            i += 1;
948            continue;
949        }
950        // `--recursive` is a help *modifier*, not a help trigger: it only
951        // selects recursive scope when `--help` is also present. A bare
952        // `--recursive` leaves help_requested false so the full argv falls
953        // through to the application's own parser untouched.
954        if arg == "--recursive"
955            || flag_name
956                .zip(recursive_flag)
957                .is_some_and(|(seen, expected)| seen == expected)
958        {
959            recursive_requested = true;
960            i += 1;
961            continue;
962        }
963        if config.allow_output_format
964            && flag_name
965                .zip(output_flag)
966                .is_some_and(|(seen, expected)| seen == expected)
967        {
968            let value = inline_value.or_else(|| {
969                args.get(i + 1)
970                    .map(String::as_str)
971                    .filter(|next| !next.starts_with('-'))
972            });
973            if let Some(value) = value {
974                match HelpFormat::parse(value) {
975                    Some(format) => output_format = Some(format),
976                    None => {
977                        output_error = Some(format!(
978                            "invalid --{} format '{}': expected plain, json, yaml, or markdown",
979                            output_flag.unwrap_or("output"),
980                            value
981                        ));
982                    }
983                }
984            } else {
985                output_error = Some(format!(
986                    "missing value for --{}: expected plain, json, yaml, or markdown",
987                    output_flag.unwrap_or("output")
988                ));
989            }
990            i += if inline_value.is_some() || value.is_none() {
991                1
992            } else {
993                2
994            };
995            continue;
996        }
997        if arg.starts_with('-') {
998            i += if inline_value.is_none() && flag_takes_value(current, arg) {
999                2
1000            } else {
1001                1
1002            };
1003            continue;
1004        }
1005        if let Some(sub) = current.find_subcommand(arg) {
1006            if sub.get_name() != "help" && !sub.is_hide_set() {
1007                subcommand_path.push(sub.get_name().to_string());
1008                current = sub;
1009            }
1010        }
1011        i += 1;
1012    }
1013
1014    ParsedHelpRequest {
1015        help_requested,
1016        recursive_requested,
1017        output_format,
1018        output_error,
1019        subcommand_path,
1020    }
1021}
1022
1023#[cfg(feature = "cli-help")]
1024fn normalize_long_flag(flag: &str) -> &str {
1025    flag.trim_start_matches('-')
1026}
1027
1028#[cfg(feature = "cli-help")]
1029fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1030    if let Some(stripped) = arg.strip_prefix("--") {
1031        if let Some((name, value)) = stripped.split_once('=') {
1032            (Some(name), Some(value))
1033        } else {
1034            (Some(stripped), None)
1035        }
1036    } else if let Some(stripped) = arg.strip_prefix('-') {
1037        (Some(stripped), None)
1038    } else {
1039        (None, None)
1040    }
1041}
1042
1043#[cfg(feature = "cli-help")]
1044fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1045    let Some(flag) = raw_flag.strip_prefix('-') else {
1046        return false;
1047    };
1048    let name = flag.trim_start_matches('-');
1049    cmd.get_arguments().any(|arg| {
1050        let long_matches = arg.get_long().is_some_and(|long| long == name);
1051        let short_matches =
1052            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1053        (long_matches || short_matches)
1054            && matches!(
1055                arg.get_action(),
1056                clap::ArgAction::Set | clap::ArgAction::Append
1057            )
1058    })
1059}
1060
1061#[cfg(feature = "cli-help")]
1062fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1063    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1064    let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1065    if let Value::Object(map) = &mut schema {
1066        map.insert("code".to_string(), Value::String("help".to_string()));
1067        map.insert(
1068            "scope".to_string(),
1069            Value::String(help_scope_tag(scope).to_string()),
1070        );
1071    }
1072    schema
1073}
1074
1075#[cfg(feature = "cli-help")]
1076fn help_scope_tag(scope: HelpScope) -> &'static str {
1077    match scope {
1078        HelpScope::OneLevel => "one_level",
1079        HelpScope::Recursive => "recursive",
1080    }
1081}
1082
1083#[cfg(feature = "cli-help")]
1084fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1085    let subcommands: Vec<Value> = visible_subcommands(cmd)
1086        .map(|sub| {
1087            let mut child_names = names.to_vec();
1088            child_names.push(sub.get_name().to_string());
1089            if recursive {
1090                // Descendants never re-advertise the help modifiers (enrich=false).
1091                command_schema(sub, &child_names, true, false)
1092            } else {
1093                command_summary_schema(sub, &child_names)
1094            }
1095        })
1096        .collect();
1097
1098    serde_json::json!({
1099        "name": cmd.get_name(),
1100        "command_path": names.join(" "),
1101        "path": names,
1102        "about": styled_to_value(cmd.get_about()),
1103        "long_about": styled_to_value(cmd.get_long_about()),
1104        "usage": cmd.clone().render_usage().to_string(),
1105        "arguments": command_arguments_schema(cmd, enrich),
1106        "subcommands": subcommands,
1107    })
1108}
1109
1110#[cfg(feature = "cli-help")]
1111fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1112    serde_json::json!({
1113        "name": cmd.get_name(),
1114        "command_path": names.join(" "),
1115        "path": names,
1116        "about": styled_to_value(cmd.get_about()),
1117        "long_about": styled_to_value(cmd.get_long_about()),
1118        "usage": Value::Null,
1119        "arguments": [],
1120        "subcommands": [],
1121    })
1122}
1123
1124#[cfg(feature = "cli-help")]
1125fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1126    cmd.get_subcommands()
1127        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1128}
1129
1130#[cfg(feature = "cli-help")]
1131fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1132    // For the target command, render through the enriched clone so the schema
1133    // documents the `-h, --help` modifiers (`--recursive`, `--output`) just like
1134    // the plain and markdown formats do (clap adds `--help` lazily during build,
1135    // so the raw command would omit it). Descendants stay un-enriched to avoid
1136    // repeating the same modifier doc on every command in a recursive dump.
1137    let owned = enrich.then(|| enriched_help_command(cmd));
1138    let source = owned.as_ref().unwrap_or(cmd);
1139    source
1140        .get_arguments()
1141        .filter(|arg| !arg.is_hide_set())
1142        .map(argument_schema)
1143        .collect()
1144}
1145
1146#[cfg(feature = "cli-help")]
1147fn argument_schema(arg: &clap::Arg) -> Value {
1148    let value_names: Vec<String> = arg
1149        .get_value_names()
1150        .map(|names| names.iter().map(ToString::to_string).collect())
1151        .unwrap_or_default();
1152    let default_values: Vec<String> = arg
1153        .get_default_values()
1154        .iter()
1155        .map(|value| value.to_string_lossy().to_string())
1156        .collect();
1157    serde_json::json!({
1158        "id": arg.get_id().to_string(),
1159        "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1160        "long": arg.get_long(),
1161        "short": arg.get_short().map(|c| c.to_string()),
1162        "help": styled_to_value(arg.get_help()),
1163        "long_help": styled_to_value(arg.get_long_help()),
1164        "required": arg.is_required_set(),
1165        "action": format!("{:?}", arg.get_action()),
1166        "value_names": value_names,
1167        "default_values": default_values,
1168    })
1169}
1170
1171#[cfg(feature = "cli-help")]
1172fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1173    value.map_or(Value::Null, |s| Value::String(s.to_string()))
1174}
1175
1176// ═══════════════════════════════════════════
1177// Secret Redaction
1178// ═══════════════════════════════════════════
1179
1180#[derive(Default)]
1181struct RedactionContext {
1182    secret_names: HashSet<String>,
1183}
1184
1185impl RedactionContext {
1186    fn from_options(redaction_options: &RedactionOptions) -> Self {
1187        let secret_names = redaction_options.secret_names.iter().cloned().collect();
1188        Self { secret_names }
1189    }
1190
1191    fn is_secret_key(&self, key: &str) -> bool {
1192        key_has_secret_suffix(key) || self.secret_names.contains(key)
1193    }
1194}
1195
1196fn key_has_secret_suffix(key: &str) -> bool {
1197    key.ends_with("_secret") || key.ends_with("_SECRET")
1198}
1199
1200fn key_has_url_suffix(key: &str) -> bool {
1201    key.ends_with("_url") || key.ends_with("_URL")
1202}
1203
1204fn redact_secrets(value: &mut Value) {
1205    let context = RedactionContext::default();
1206    redact_secrets_with_context(value, &context);
1207}
1208
1209fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1210    match value {
1211        Value::Object(map) => {
1212            let keys: Vec<String> = map.keys().cloned().collect();
1213            for key in keys {
1214                if context.is_secret_key(&key) {
1215                    match map.get(&key) {
1216                        Some(Value::Object(_)) | Some(Value::Array(_)) => {
1217                            // Traverse containers, don't replace
1218                        }
1219                        _ => {
1220                            map.insert(key.clone(), Value::String("***".into()));
1221                            continue;
1222                        }
1223                    }
1224                } else if key_has_url_suffix(&key) {
1225                    if let Some(Value::String(s)) = map.get_mut(&key) {
1226                        if let Some(redacted) = redact_url_in_str(s, context) {
1227                            *s = redacted;
1228                        }
1229                        continue;
1230                    }
1231                }
1232                if let Some(v) = map.get_mut(&key) {
1233                    redact_secrets_with_context(v, context);
1234                }
1235            }
1236        }
1237        Value::Array(arr) => {
1238            for v in arr {
1239                redact_secrets_with_context(v, context);
1240            }
1241        }
1242        _ => {}
1243    }
1244}
1245
1246fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
1247    match value {
1248        Value::Object(map) => {
1249            let keys: Vec<String> = map.keys().cloned().collect();
1250            for key in keys {
1251                if context.is_secret_key(&key) {
1252                    map.insert(key, Value::String("***".into()));
1253                } else if key_has_url_suffix(&key) {
1254                    if let Some(Value::String(s)) = map.get_mut(&key) {
1255                        if let Some(redacted) = redact_url_in_str(s, context) {
1256                            *s = redacted;
1257                        }
1258                    } else if let Some(v) = map.get_mut(&key) {
1259                        redact_secrets_strict_with_context(v, context);
1260                    }
1261                } else if let Some(v) = map.get_mut(&key) {
1262                    redact_secrets_strict_with_context(v, context);
1263                }
1264            }
1265        }
1266        Value::Array(arr) => {
1267            for v in arr {
1268                redact_secrets_strict_with_context(v, context);
1269            }
1270        }
1271        _ => {}
1272    }
1273}
1274
1275/// Redact secret components of a single URL string, returning `Some(redacted)`
1276/// when `s` is a processable URL, or `None` when it is not (so callers can keep
1277/// the original). Only secret spans change; all other bytes are preserved.
1278fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1279    // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
1280    // The gate is scheme + no-whitespace only — NOT "parses as a URL library
1281    // object". Span location below is purely byte-wise, so we never re-serialize
1282    // the URL; adding a `url::Url::parse` gate here would diverge across
1283    // languages (e.g. ports > 65535 or empty hosts that one library rejects and
1284    // another accepts) and silently leak secrets in the values it rejects.
1285    if !s.contains("://") || !is_single_url(s) {
1286        return None;
1287    }
1288    let scheme_sep = s.find("://")?;
1289    let scheme = &s[..scheme_sep];
1290    let rest = &s[scheme_sep + 3..];
1291
1292    // Authority runs from after "://" to the first '/', '?', or '#'.
1293    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1294    let authority = &rest[..auth_end];
1295    let remainder = &rest[auth_end..];
1296
1297    let new_authority = redact_userinfo_password(authority);
1298
1299    // Query runs from the first '?' to the first '#' (or end).
1300    let new_remainder = match remainder.find('?') {
1301        Some(q) => {
1302            let (path, q_onwards) = remainder.split_at(q);
1303            let query_body = &q_onwards[1..];
1304            let (query, fragment) = match query_body.find('#') {
1305                Some(h) => (&query_body[..h], &query_body[h..]),
1306                None => (query_body, ""),
1307            };
1308            format!("{path}?{}{fragment}", redact_query(query, context))
1309        }
1310        None => remainder.to_string(),
1311    };
1312
1313    Some(format!("{scheme}://{new_authority}{new_remainder}"))
1314}
1315
1316/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
1317/// username. Authority without `@`, or userinfo without `:`, is unchanged.
1318fn redact_userinfo_password(authority: &str) -> String {
1319    let Some(at) = authority.find('@') else {
1320        return authority.to_string();
1321    };
1322    let userinfo = &authority[..at];
1323    match userinfo.find(':') {
1324        Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1325        None => authority.to_string(),
1326    }
1327}
1328
1329/// Redact the values of secret-named query parameters, preserving raw bytes of
1330/// every other segment (keys, benign values, encoding, ordering, separators).
1331fn redact_query(query: &str, context: &RedactionContext) -> String {
1332    query
1333        .split('&')
1334        .map(|segment| {
1335            let Some(eq) = segment.find('=') else {
1336                return segment.to_string();
1337            };
1338            let raw_key = &segment[..eq];
1339            // Form-decode the name (`+` → space, percent-decode) for the check.
1340            let name = url::form_urlencoded::parse(segment.as_bytes())
1341                .next()
1342                .map(|(k, _)| k.into_owned())
1343                .unwrap_or_default();
1344            if context.is_secret_key(&name) {
1345                format!("{raw_key}=***")
1346            } else {
1347                segment.to_string()
1348            }
1349        })
1350        .collect::<Vec<_>>()
1351        .join("&")
1352}
1353
1354/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
1355/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
1356/// a URL embedded in prose.
1357fn is_single_url(s: &str) -> bool {
1358    if s.bytes().any(|b| b.is_ascii_whitespace()) {
1359        return false;
1360    }
1361    let bytes = s.as_bytes();
1362    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1363        return false;
1364    }
1365    let mut i = 1;
1366    while i < bytes.len() {
1367        let c = bytes[i];
1368        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1369            i += 1;
1370        } else {
1371            break;
1372        }
1373    }
1374    s[i..].starts_with("://")
1375}
1376
1377fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1378    let context = RedactionContext::default();
1379    apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1380}
1381
1382fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1383    let context = RedactionContext::from_options(redaction_options);
1384    apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1385}
1386
1387fn apply_redaction_policy_with_context(
1388    value: &mut Value,
1389    redaction_policy: Option<RedactionPolicy>,
1390    context: &RedactionContext,
1391) {
1392    match redaction_policy {
1393        Some(RedactionPolicy::RedactionTraceOnly) => {
1394            if let Value::Object(map) = value {
1395                if let Some(trace) = map.get_mut("trace") {
1396                    redact_secrets_with_context(trace, context);
1397                }
1398            }
1399        }
1400        Some(RedactionPolicy::RedactionNone) => {}
1401        Some(RedactionPolicy::RedactionStrict) => {
1402            redact_secrets_strict_with_context(value, context)
1403        }
1404        None => redact_secrets_with_context(value, context),
1405    }
1406}
1407
1408// ═══════════════════════════════════════════
1409// Suffix Processing
1410// ═══════════════════════════════════════════
1411
1412/// Strip a suffix matching exact lowercase or exact uppercase only.
1413fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1414    if let Some(s) = key.strip_suffix(suffix_lower) {
1415        return Some(s.to_string());
1416    }
1417    let suffix_upper: String = suffix_lower
1418        .chars()
1419        .map(|c| c.to_ascii_uppercase())
1420        .collect();
1421    if let Some(s) = key.strip_suffix(&suffix_upper) {
1422        return Some(s.to_string());
1423    }
1424    None
1425}
1426
1427/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
1428fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1429    let code = extract_currency_code(key)?;
1430    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
1431    let stripped = &key[..key.len() - suffix_len];
1432    if stripped.is_empty() {
1433        return None;
1434    }
1435    Some((stripped.to_string(), code.to_string()))
1436}
1437
1438/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
1439/// when suffix matches and type is valid. None for no match or type mismatch.
1440/// Accept an integer value, including an integral-valued float (`3.0` → `3`).
1441/// Non-integral floats and out-of-range values return `None`. This keeps the
1442/// four language implementations consistent: JS/TS cannot distinguish `3` from
1443/// `3.0` after JSON parsing, so the value's integrality — not its lexical form —
1444/// decides whether an integer-required suffix applies.
1445fn as_int(value: &Value) -> Option<i64> {
1446    if let Some(i) = value.as_i64() {
1447        return Some(i);
1448    }
1449    let f = value.as_f64()?;
1450    if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1451        return Some(f as i64);
1452    }
1453    None
1454}
1455
1456/// Like [`as_int`] but for non-negative integers (rejects negatives).
1457fn as_uint(value: &Value) -> Option<u64> {
1458    if let Some(u) = value.as_u64() {
1459        return Some(u);
1460    }
1461    let f = value.as_f64()?;
1462    if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1463        return Some(f as u64);
1464    }
1465    None
1466}
1467
1468fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1469    // Group 1: compound timestamp suffixes
1470    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1471        return as_int(value).map(|ms| (stripped, format_rfc3339_ms(ms)));
1472    }
1473    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1474        return as_int(value)
1475            .and_then(|s| s.checked_mul(1000))
1476            .map(|ms| (stripped, format_rfc3339_ms(ms)));
1477    }
1478    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1479        return as_int(value).map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
1480    }
1481
1482    // Group 2: compound currency suffixes
1483    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1484        return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1485    }
1486    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1487        return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1488    }
1489    if let Some((stripped, code)) = try_strip_generic_cents(key) {
1490        return as_uint(value).map(|n| {
1491            (
1492                stripped,
1493                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1494            )
1495        });
1496    }
1497
1498    // Group 3: multi-char suffixes
1499    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1500        return value.as_str().map(|s| (stripped, s.to_string()));
1501    }
1502    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1503        return value
1504            .is_number()
1505            .then(|| (stripped, format!("{} minutes", number_str(value))));
1506    }
1507    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1508        return value
1509            .is_number()
1510            .then(|| (stripped, format!("{} hours", number_str(value))));
1511    }
1512    if let Some(stripped) = strip_suffix_ci(key, "_days") {
1513        return value
1514            .is_number()
1515            .then(|| (stripped, format!("{} days", number_str(value))));
1516    }
1517
1518    // Group 4: single-unit suffixes
1519    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1520        return value
1521            .is_number()
1522            .then(|| (stripped, format!("{}msats", number_str(value))));
1523    }
1524    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1525        return value
1526            .is_number()
1527            .then(|| (stripped, format!("{}sats", number_str(value))));
1528    }
1529    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1530        return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1531    }
1532    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1533        return value
1534            .is_number()
1535            .then(|| (stripped, format!("{}%", number_str(value))));
1536    }
1537    if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1538        return Some((stripped, "***".to_string()));
1539    }
1540
1541    // Group 5: short suffixes (last to avoid false positives)
1542    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1543        return value
1544            .is_number()
1545            .then(|| (stripped, format!("{} BTC", number_str(value))));
1546    }
1547    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1548        return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1549    }
1550    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1551        return value
1552            .is_number()
1553            .then(|| (stripped, format!("{}ns", number_str(value))));
1554    }
1555    if let Some(stripped) = strip_suffix_ci(key, "_us") {
1556        return value
1557            .is_number()
1558            .then(|| (stripped, format!("{}μs", number_str(value))));
1559    }
1560    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1561        return format_ms_value(value).map(|v| (stripped, v));
1562    }
1563    if let Some(stripped) = strip_suffix_ci(key, "_s") {
1564        return value
1565            .is_number()
1566            .then(|| (stripped, format!("{}s", number_str(value))));
1567    }
1568
1569    None
1570}
1571
1572/// Process object fields: strip keys, format values, detect collisions.
1573fn process_object_fields<'a>(
1574    map: &'a serde_json::Map<String, Value>,
1575) -> Vec<(String, &'a Value, Option<String>)> {
1576    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1577    for (key, value) in map {
1578        match try_process_field(key, value) {
1579            Some((stripped, formatted)) => {
1580                entries.push((stripped, key.as_str(), value, Some(formatted)));
1581            }
1582            None => {
1583                entries.push((key.clone(), key.as_str(), value, None));
1584            }
1585        }
1586    }
1587
1588    // Detect collisions
1589    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1590    for (stripped, _, _, _) in &entries {
1591        *counts.entry(stripped.clone()).or_insert(0) += 1;
1592    }
1593
1594    // Resolve collisions: revert both key and formatted value
1595    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1596        .into_iter()
1597        .map(|(stripped, original, value, formatted)| {
1598            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1599                (original.to_string(), value, None)
1600            } else {
1601                (stripped, value, formatted)
1602            }
1603        })
1604        .collect();
1605
1606    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1607    result
1608}
1609
1610// ═══════════════════════════════════════════
1611// Formatting Helpers
1612// ═══════════════════════════════════════════
1613
1614fn number_str(value: &Value) -> String {
1615    match value {
1616        Value::Number(n) => format_number(n),
1617        _ => String::new(),
1618    }
1619}
1620
1621/// Render a JSON number canonically for YAML/plain output: an integral-valued
1622/// float drops its trailing `.0` so `3.0` and `3` both render as `3`. This
1623/// matches Go (`strconv.FormatFloat(_, 'f', -1, 64)`), TypeScript
1624/// (`Number.prototype.toString`), and Python (`int(v)` for integral floats),
1625/// keeping the four implementations byte-identical.
1626fn format_number(n: &serde_json::Number) -> String {
1627    if n.is_f64() {
1628        if let Some(f) = n.as_f64() {
1629            if f.is_finite() && f.fract() == 0.0 {
1630                return format!("{f:.0}");
1631            }
1632        }
1633    }
1634    n.to_string()
1635}
1636
1637/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
1638fn format_ms_as_seconds(ms: f64) -> String {
1639    let formatted = format!("{:.3}", ms / 1000.0);
1640    let trimmed = formatted.trim_end_matches('0');
1641    if trimmed.ends_with('.') {
1642        format!("{}0s", trimmed)
1643    } else {
1644        format!("{}s", trimmed)
1645    }
1646}
1647
1648/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
1649fn format_ms_value(value: &Value) -> Option<String> {
1650    let n = value.as_f64()?;
1651    if n.abs() >= 1000.0 {
1652        Some(format_ms_as_seconds(n))
1653    } else if let Some(i) = value.as_i64() {
1654        Some(format!("{}ms", i))
1655    } else {
1656        Some(format!("{}ms", number_str(value)))
1657    }
1658}
1659
1660/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
1661fn format_rfc3339_ms(ms: i64) -> String {
1662    use chrono::{DateTime, Utc};
1663    let secs = ms.div_euclid(1000);
1664    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1665    match DateTime::from_timestamp(secs, nanos) {
1666        Some(dt) => dt
1667            .with_timezone(&Utc)
1668            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1669        None => ms.to_string(),
1670    }
1671}
1672
1673/// Format bytes as human-readable size (binary units). Handles negative values.
1674fn format_bytes_human(bytes: i64) -> String {
1675    const KB: f64 = 1024.0;
1676    const MB: f64 = KB * 1024.0;
1677    const GB: f64 = MB * 1024.0;
1678    const TB: f64 = GB * 1024.0;
1679
1680    let sign = if bytes < 0 { "-" } else { "" };
1681    let b = (bytes as f64).abs();
1682    if b >= TB {
1683        format!("{sign}{:.1}TB", b / TB)
1684    } else if b >= GB {
1685        format!("{sign}{:.1}GB", b / GB)
1686    } else if b >= MB {
1687        format!("{sign}{:.1}MB", b / MB)
1688    } else if b >= KB {
1689        format!("{sign}{:.1}KB", b / KB)
1690    } else {
1691        format!("{bytes}B")
1692    }
1693}
1694
1695/// Format a number with thousands separators.
1696fn format_with_commas(n: u64) -> String {
1697    let s = n.to_string();
1698    let mut result = String::with_capacity(s.len() + s.len() / 3);
1699    for (i, c) in s.chars().enumerate() {
1700        if i > 0 && (s.len() - i).is_multiple_of(3) {
1701            result.push(',');
1702        }
1703        result.push(c);
1704    }
1705    result
1706}
1707
1708/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
1709fn extract_currency_code(key: &str) -> Option<&str> {
1710    let without_cents = key
1711        .strip_suffix("_cents")
1712        .or_else(|| key.strip_suffix("_CENTS"))?;
1713    let last_underscore = without_cents.rfind('_')?;
1714    let code = &without_cents[last_underscore + 1..];
1715    if code.is_empty() {
1716        return None;
1717    }
1718    Some(code)
1719}
1720
1721// ═══════════════════════════════════════════
1722// YAML Rendering
1723// ═══════════════════════════════════════════
1724
1725fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1726    let prefix = "  ".repeat(indent);
1727    match value {
1728        Value::Object(map) => {
1729            let processed = process_object_fields(map);
1730            for (display_key, v, formatted) in processed {
1731                if let Some(fv) = formatted {
1732                    lines.push(format!(
1733                        "{}{}: \"{}\"",
1734                        prefix,
1735                        display_key,
1736                        escape_yaml_str(&fv)
1737                    ));
1738                } else {
1739                    match v {
1740                        Value::Object(inner) if !inner.is_empty() => {
1741                            lines.push(format!("{}{}:", prefix, display_key));
1742                            render_yaml_processed(v, indent + 1, lines);
1743                        }
1744                        Value::Object(_) => {
1745                            lines.push(format!("{}{}: {{}}", prefix, display_key));
1746                        }
1747                        Value::Array(arr) => {
1748                            if arr.is_empty() {
1749                                lines.push(format!("{}{}: []", prefix, display_key));
1750                            } else {
1751                                lines.push(format!("{}{}:", prefix, display_key));
1752                                for item in arr {
1753                                    if item.is_object() {
1754                                        lines.push(format!("{}  -", prefix));
1755                                        render_yaml_processed(item, indent + 2, lines);
1756                                    } else {
1757                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
1758                                    }
1759                                }
1760                            }
1761                        }
1762                        _ => {
1763                            lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1764                        }
1765                    }
1766                }
1767            }
1768        }
1769        _ => {
1770            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1771        }
1772    }
1773}
1774
1775fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1776    let prefix = "  ".repeat(indent);
1777    match value {
1778        Value::Object(map) => {
1779            for (key, v) in map {
1780                render_yaml_field_raw(&prefix, key, v, indent, lines);
1781            }
1782        }
1783        Value::Array(arr) => {
1784            render_yaml_array_raw(arr, indent, lines);
1785        }
1786        _ => {
1787            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1788        }
1789    }
1790}
1791
1792fn render_yaml_field_raw(
1793    prefix: &str,
1794    key: &str,
1795    value: &Value,
1796    indent: usize,
1797    lines: &mut Vec<String>,
1798) {
1799    match value {
1800        Value::Object(inner) if !inner.is_empty() => {
1801            lines.push(format!("{}{}:", prefix, key));
1802            render_yaml_raw(value, indent + 1, lines);
1803        }
1804        Value::Object(_) => {
1805            lines.push(format!("{}{}: {{}}", prefix, key));
1806        }
1807        Value::Array(arr) => {
1808            if arr.is_empty() {
1809                lines.push(format!("{}{}: []", prefix, key));
1810            } else {
1811                lines.push(format!("{}{}:", prefix, key));
1812                render_yaml_array_raw(arr, indent + 1, lines);
1813            }
1814        }
1815        _ => {
1816            lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1817        }
1818    }
1819}
1820
1821fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1822    let prefix = "  ".repeat(indent);
1823    for item in arr {
1824        match item {
1825            Value::Object(inner) if !inner.is_empty() => {
1826                lines.push(format!("{}-", prefix));
1827                render_yaml_raw(item, indent + 1, lines);
1828            }
1829            Value::Array(nested) if !nested.is_empty() => {
1830                lines.push(format!("{}-", prefix));
1831                render_yaml_array_raw(nested, indent + 1, lines);
1832            }
1833            Value::Object(_) => {
1834                lines.push(format!("{}- {{}}", prefix));
1835            }
1836            Value::Array(_) => {
1837                lines.push(format!("{}- []", prefix));
1838            }
1839            _ => {
1840                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1841            }
1842        }
1843    }
1844}
1845
1846fn escape_yaml_str(s: &str) -> String {
1847    s.replace('\\', "\\\\")
1848        .replace('"', "\\\"")
1849        .replace('\n', "\\n")
1850        .replace('\r', "\\r")
1851        .replace('\t', "\\t")
1852}
1853
1854fn yaml_scalar(value: &Value) -> String {
1855    match value {
1856        Value::String(s) => {
1857            format!("\"{}\"", escape_yaml_str(s))
1858        }
1859        Value::Null => "null".to_string(),
1860        Value::Bool(b) => b.to_string(),
1861        Value::Number(n) => format_number(n),
1862        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1863    }
1864}
1865
1866// ═══════════════════════════════════════════
1867// Plain Rendering (logfmt)
1868// ═══════════════════════════════════════════
1869
1870fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1871    if let Value::Object(map) = value {
1872        let processed = process_object_fields(map);
1873        for (display_key, v, formatted) in processed {
1874            let full_key = if prefix.is_empty() {
1875                display_key
1876            } else {
1877                format!("{}.{}", prefix, display_key)
1878            };
1879            if let Some(fv) = formatted {
1880                pairs.push((full_key, fv));
1881            } else {
1882                match v {
1883                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1884                    Value::Array(arr) => {
1885                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1886                        pairs.push((full_key, joined));
1887                    }
1888                    Value::Null => pairs.push((full_key, String::new())),
1889                    _ => pairs.push((full_key, plain_scalar(v))),
1890                }
1891            }
1892        }
1893    }
1894}
1895
1896fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1897    if let Value::Object(map) = value {
1898        for (key, v) in map {
1899            let full_key = if prefix.is_empty() {
1900                key.clone()
1901            } else {
1902                format!("{}.{}", prefix, key)
1903            };
1904            match v {
1905                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1906                Value::Array(arr) => {
1907                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1908                    pairs.push((full_key, joined));
1909                }
1910                Value::Null => pairs.push((full_key, String::new())),
1911                _ => pairs.push((full_key, plain_scalar(v))),
1912            }
1913        }
1914    }
1915}
1916
1917fn plain_scalar(value: &Value) -> String {
1918    match value {
1919        Value::String(s) => s.clone(),
1920        Value::Null => "null".to_string(),
1921        Value::Bool(b) => b.to_string(),
1922        Value::Number(n) => format_number(n),
1923        other => other.to_string(),
1924    }
1925}
1926
1927fn quote_logfmt_value(value: &str) -> String {
1928    if value.is_empty() {
1929        return String::new();
1930    }
1931    if !value
1932        .chars()
1933        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1934    {
1935        return value.to_string();
1936    }
1937    let escaped = value
1938        .replace('\\', "\\\\")
1939        .replace('"', "\\\"")
1940        .replace('\n', "\\n")
1941        .replace('\r', "\\r")
1942        .replace('\t', "\\t");
1943    format!("\"{}\"", escaped)
1944}
1945
1946#[cfg(test)]
1947mod tests;