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 => {
592                // One-level help is clap-generated and so cannot list the
593                // afdata-handled `--recursive` modifier; advertise it here so a
594                // plain `--help` is self-documenting when subcommands exist.
595                let mut help = render_help_one_level_plain(target);
596                append_recursive_help_hint(&mut help, target);
597                help
598            }
599            HelpScope::Recursive => {
600                let mut buf = String::new();
601                render_help_recursive_plain(target, &[], &mut buf);
602                buf
603            }
604        },
605        HelpFormat::Markdown => render_help_markdown(cmd, subcommand_path, options.scope),
606        HelpFormat::Json => {
607            serialize_json_output(&build_help_schema(cmd, subcommand_path, options.scope))
608        }
609        HelpFormat::Yaml => output_yaml_with_options(
610            &build_help_schema(cmd, subcommand_path, options.scope),
611            &OutputOptions {
612                redaction: RedactionOptions {
613                    policy: Some(RedactionPolicy::RedactionNone),
614                    secret_names: Vec::new(),
615                },
616                style: OutputStyle::Raw,
617            },
618        ),
619    };
620    // Every format ends with exactly one trailing newline so `print!`-ing the
621    // result is clean across plain/markdown/json/yaml (JSON and raw YAML would
622    // otherwise have none).
623    while rendered.ends_with('\n') {
624        rendered.pop();
625    }
626    rendered.push('\n');
627    rendered
628}
629
630/// Render recursive plain-text help for a clap command tree.
631///
632/// Walks to the subcommand identified by `subcommand_path` (empty = root),
633/// then recursively expands all descendant subcommands into a single output.
634///
635/// Requires the `cli-help` feature.
636#[cfg(feature = "cli-help")]
637pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
638    cli_render_help_with_options(cmd, subcommand_path, &HelpOptions::recursive_plain())
639}
640
641/// Render recursive Markdown help for a clap command tree.
642///
643/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
644/// documentation generation (`myapp --help --recursive --output markdown > docs/cli.md`).
645///
646/// Requires the `cli-help-markdown` feature.
647#[cfg(feature = "cli-help-markdown")]
648pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
649    cli_render_help_with_options(
650        cmd,
651        subcommand_path,
652        &HelpOptions {
653            scope: HelpScope::Recursive,
654            format: HelpFormat::Markdown,
655        },
656    )
657}
658
659/// Render help from raw argv if a help flag is present; otherwise return `None`.
660///
661/// `raw_args` should be the full argv vector, including argv[0], as produced by
662/// `std::env::args()`. The helper intentionally runs before clap parsing so
663/// `--help --recursive` and `--help --output markdown` can select scope and
664/// format instead of being consumed by clap's built-in help handling. Scope
665/// (`--recursive`) and format (`--output`) are orthogonal.
666///
667/// A bare `--recursive` without `--help` is treated as a non-help request
668/// (`Ok(None)`), leaving the flag for the application's own parser.
669///
670/// Returns a standard [`build_cli_error`] value when the help request is
671/// malformed, for example `--help --output xml`.
672///
673/// Requires the `cli-help` feature.
674#[cfg(feature = "cli-help")]
675pub fn cli_handle_help_or_continue(
676    raw_args: &[String],
677    cmd: &clap::Command,
678    config: &HelpConfig,
679) -> Result<Option<String>, Value> {
680    let parsed = parse_help_request(raw_args, cmd, config);
681    if !parsed.help_requested {
682        return Ok(None);
683    }
684    if let Some(error) = parsed.output_error {
685        return Err(build_cli_error(
686            &error,
687            Some("valid help output formats: plain, markdown, json, yaml"),
688        ));
689    }
690
691    let (scope, format) = resolve_help_options(&parsed, config);
692    let path: Vec<&str> = parsed.subcommand_path.iter().map(String::as_str).collect();
693    Ok(Some(cli_render_help_with_options(
694        cmd,
695        &path,
696        &HelpOptions { scope, format },
697    )))
698}
699
700#[cfg(feature = "cli-help")]
701fn resolve_help_options(
702    parsed: &ParsedHelpRequest,
703    config: &HelpConfig,
704) -> (HelpScope, HelpFormat) {
705    // Scope and format are orthogonal: `--recursive` (or the configured
706    // recursive flag, or a recursive default_scope) decides one-level vs
707    // recursive, while `--output` independently decides the format.
708    let scope = if parsed.recursive_requested {
709        HelpScope::Recursive
710    } else {
711        config.default_scope
712    };
713    let format = if config.allow_output_format {
714        parsed.output_format.unwrap_or(config.default_format)
715    } else {
716        config.default_format
717    };
718    (scope, format)
719}
720
721#[cfg(feature = "cli-help")]
722fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
723    let mut current = cmd;
724    for name in path {
725        current = current.find_subcommand(name).unwrap_or(current);
726    }
727    current
728}
729
730#[cfg(feature = "cli-help")]
731fn walk_to_subcommand_with_names<'a>(
732    cmd: &'a clap::Command,
733    path: &[&str],
734) -> (&'a clap::Command, Vec<String>) {
735    let mut current = cmd;
736    let mut names = vec![cmd.get_name().to_string()];
737    for name in path {
738        if let Some(next) = current.find_subcommand(name) {
739            current = next;
740            names.push(next.get_name().to_string());
741        } else {
742            break;
743        }
744    }
745    (current, names)
746}
747
748#[cfg(feature = "cli-help")]
749fn render_help_one_level_plain(cmd: &clap::Command) -> String {
750    cmd.clone().render_long_help().to_string()
751}
752
753/// Append a short note documenting the built-in `--recursive` help modifier.
754///
755/// Only emitted when the command actually has visible subcommands (a leaf
756/// command has nothing to expand). Plain one-level help is rendered by clap and
757/// cannot otherwise mention a flag the afdata handler consumes before clap.
758#[cfg(feature = "cli-help")]
759fn append_recursive_help_hint(help: &mut String, cmd: &clap::Command) {
760    use std::fmt::Write;
761    if visible_subcommands(cmd).next().is_none() {
762        return;
763    }
764    if !help.ends_with('\n') {
765        help.push('\n');
766    }
767    let _ = write!(
768        help,
769        "\nHelp scope:\n      --recursive\n          Show this help for every nested subcommand, not just the current\n          level. Add --output json|yaml|markdown to export the whole tree.\n"
770    );
771}
772
773#[cfg(feature = "cli-help")]
774fn render_help_recursive_plain(cmd: &clap::Command, parent_path: &[&str], buf: &mut String) {
775    use std::fmt::Write;
776
777    // Build the full command path (e.g. "myapp service start")
778    let mut cmd_path = parent_path.to_vec();
779    cmd_path.push(cmd.get_name());
780    let path_str = cmd_path.join(" ");
781
782    // Separator between commands (skip for the first one)
783    if !buf.is_empty() {
784        let _ = writeln!(buf);
785        let _ = writeln!(buf, "{}", "═".repeat(60));
786    }
787
788    // Header: "myapp service start — description"
789    if let Some(about) = cmd.get_about() {
790        let _ = writeln!(buf, "{path_str} — {about}");
791    } else {
792        let _ = writeln!(buf, "{path_str}");
793    }
794    let _ = writeln!(buf);
795
796    // Render clap's built-in help for this command (usage, args, options)
797    let styled = cmd.clone().render_long_help();
798    let help_text = styled.to_string();
799    let _ = write!(buf, "{help_text}");
800
801    // Recurse into visible subcommands
802    for sub in cmd.get_subcommands() {
803        if sub.get_name() == "help" || sub.is_hide_set() {
804            continue; // skip clap's auto-generated "help" subcommand
805        }
806        render_help_recursive_plain(sub, &cmd_path, buf);
807    }
808}
809
810#[cfg(feature = "cli-help")]
811fn render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> String {
812    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
813    let mut buf = String::new();
814    render_markdown_command(target, &names, &mut buf, 1);
815    if matches!(scope, HelpScope::Recursive) {
816        render_markdown_descendants(target, &names, &mut buf, 2);
817    }
818    buf
819}
820
821#[cfg(feature = "cli-help")]
822fn render_markdown_descendants(
823    cmd: &clap::Command,
824    parent_names: &[String],
825    buf: &mut String,
826    level: usize,
827) {
828    for sub in cmd.get_subcommands() {
829        if sub.get_name() == "help" || sub.is_hide_set() {
830            continue;
831        }
832        let mut names = parent_names.to_vec();
833        names.push(sub.get_name().to_string());
834        render_markdown_command(sub, &names, buf, level);
835        render_markdown_descendants(sub, &names, buf, level.saturating_add(1));
836    }
837}
838
839#[cfg(feature = "cli-help")]
840fn render_markdown_command(cmd: &clap::Command, names: &[String], buf: &mut String, level: usize) {
841    use std::fmt::Write;
842
843    if !buf.is_empty() {
844        let _ = writeln!(buf);
845    }
846    let heading_level = "#".repeat(level.max(1));
847    let path = names.join(" ");
848    if let Some(about) = cmd.get_about() {
849        let _ = writeln!(buf, "{heading_level} {path} - {about}");
850    } else {
851        let _ = writeln!(buf, "{heading_level} {path}");
852    }
853    if let Some(long_about) = cmd.get_long_about() {
854        let _ = writeln!(buf);
855        let _ = writeln!(buf, "{long_about}");
856    }
857    let _ = writeln!(buf);
858    let _ = writeln!(buf, "```text");
859    write_trimmed_help(buf, &cmd.clone().render_long_help().to_string());
860    if !buf.ends_with('\n') {
861        let _ = writeln!(buf);
862    }
863    let _ = writeln!(buf, "```");
864}
865
866#[cfg(feature = "cli-help")]
867fn write_trimmed_help(buf: &mut String, help: &str) {
868    use std::fmt::Write;
869
870    for line in help.lines() {
871        let _ = writeln!(buf, "{}", line.trim_end());
872    }
873}
874
875#[cfg(feature = "cli-help")]
876struct ParsedHelpRequest {
877    help_requested: bool,
878    recursive_requested: bool,
879    output_format: Option<HelpFormat>,
880    output_error: Option<String>,
881    subcommand_path: Vec<String>,
882}
883
884#[cfg(feature = "cli-help")]
885fn parse_help_request(
886    raw_args: &[String],
887    cmd: &clap::Command,
888    config: &HelpConfig,
889) -> ParsedHelpRequest {
890    let args = match raw_args.first() {
891        Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
892        _ => raw_args.get(1..).unwrap_or(&[]),
893    };
894    let mut help_requested = false;
895    let mut recursive_requested = false;
896    let mut output_format = None;
897    let mut output_error = None;
898    let mut subcommand_path = Vec::new();
899    let mut current = cmd;
900    let output_flag = config.output_flag.map(normalize_long_flag);
901    let recursive_flag = config.recursive_flag.map(normalize_long_flag);
902
903    let mut i = 0usize;
904    while i < args.len() {
905        let arg = args[i].as_str();
906        if arg == "--" {
907            break;
908        }
909
910        let (flag_name, inline_value) = split_flag(arg);
911        if matches!(arg, "--help" | "-h") {
912            help_requested = true;
913            i += 1;
914            continue;
915        }
916        // `--recursive` is a help *modifier*, not a help trigger: it only
917        // selects recursive scope when `--help` is also present. A bare
918        // `--recursive` leaves help_requested false so the full argv falls
919        // through to the application's own parser untouched.
920        if arg == "--recursive"
921            || flag_name
922                .zip(recursive_flag)
923                .is_some_and(|(seen, expected)| seen == expected)
924        {
925            recursive_requested = true;
926            i += 1;
927            continue;
928        }
929        if config.allow_output_format
930            && flag_name
931                .zip(output_flag)
932                .is_some_and(|(seen, expected)| seen == expected)
933        {
934            let value = inline_value.or_else(|| {
935                args.get(i + 1)
936                    .map(String::as_str)
937                    .filter(|next| !next.starts_with('-'))
938            });
939            if let Some(value) = value {
940                match HelpFormat::parse(value) {
941                    Some(format) => output_format = Some(format),
942                    None => {
943                        output_error = Some(format!(
944                            "invalid --{} format '{}': expected plain, json, yaml, or markdown",
945                            output_flag.unwrap_or("output"),
946                            value
947                        ));
948                    }
949                }
950            } else {
951                output_error = Some(format!(
952                    "missing value for --{}: expected plain, json, yaml, or markdown",
953                    output_flag.unwrap_or("output")
954                ));
955            }
956            i += if inline_value.is_some() || value.is_none() {
957                1
958            } else {
959                2
960            };
961            continue;
962        }
963        if arg.starts_with('-') {
964            i += if inline_value.is_none() && flag_takes_value(current, arg) {
965                2
966            } else {
967                1
968            };
969            continue;
970        }
971        if let Some(sub) = current.find_subcommand(arg) {
972            if sub.get_name() != "help" && !sub.is_hide_set() {
973                subcommand_path.push(sub.get_name().to_string());
974                current = sub;
975            }
976        }
977        i += 1;
978    }
979
980    ParsedHelpRequest {
981        help_requested,
982        recursive_requested,
983        output_format,
984        output_error,
985        subcommand_path,
986    }
987}
988
989#[cfg(feature = "cli-help")]
990fn normalize_long_flag(flag: &str) -> &str {
991    flag.trim_start_matches('-')
992}
993
994#[cfg(feature = "cli-help")]
995fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
996    if let Some(stripped) = arg.strip_prefix("--") {
997        if let Some((name, value)) = stripped.split_once('=') {
998            (Some(name), Some(value))
999        } else {
1000            (Some(stripped), None)
1001        }
1002    } else if let Some(stripped) = arg.strip_prefix('-') {
1003        (Some(stripped), None)
1004    } else {
1005        (None, None)
1006    }
1007}
1008
1009#[cfg(feature = "cli-help")]
1010fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1011    let Some(flag) = raw_flag.strip_prefix('-') else {
1012        return false;
1013    };
1014    let name = flag.trim_start_matches('-');
1015    cmd.get_arguments().any(|arg| {
1016        let long_matches = arg.get_long().is_some_and(|long| long == name);
1017        let short_matches =
1018            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1019        (long_matches || short_matches)
1020            && matches!(
1021                arg.get_action(),
1022                clap::ArgAction::Set | clap::ArgAction::Append
1023            )
1024    })
1025}
1026
1027#[cfg(feature = "cli-help")]
1028fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1029    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1030    let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive));
1031    if let Value::Object(map) = &mut schema {
1032        map.insert("code".to_string(), Value::String("help".to_string()));
1033        map.insert(
1034            "scope".to_string(),
1035            Value::String(help_scope_tag(scope).to_string()),
1036        );
1037    }
1038    schema
1039}
1040
1041#[cfg(feature = "cli-help")]
1042fn help_scope_tag(scope: HelpScope) -> &'static str {
1043    match scope {
1044        HelpScope::OneLevel => "one_level",
1045        HelpScope::Recursive => "recursive",
1046    }
1047}
1048
1049#[cfg(feature = "cli-help")]
1050fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool) -> Value {
1051    let subcommands: Vec<Value> = visible_subcommands(cmd)
1052        .map(|sub| {
1053            let mut child_names = names.to_vec();
1054            child_names.push(sub.get_name().to_string());
1055            if recursive {
1056                command_schema(sub, &child_names, true)
1057            } else {
1058                command_summary_schema(sub, &child_names)
1059            }
1060        })
1061        .collect();
1062
1063    serde_json::json!({
1064        "name": cmd.get_name(),
1065        "command_path": names.join(" "),
1066        "path": names,
1067        "about": styled_to_value(cmd.get_about()),
1068        "long_about": styled_to_value(cmd.get_long_about()),
1069        "usage": cmd.clone().render_usage().to_string(),
1070        "arguments": command_arguments_schema(cmd),
1071        "subcommands": subcommands,
1072    })
1073}
1074
1075#[cfg(feature = "cli-help")]
1076fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1077    serde_json::json!({
1078        "name": cmd.get_name(),
1079        "command_path": names.join(" "),
1080        "path": names,
1081        "about": styled_to_value(cmd.get_about()),
1082        "long_about": styled_to_value(cmd.get_long_about()),
1083        "usage": Value::Null,
1084        "arguments": [],
1085        "subcommands": [],
1086    })
1087}
1088
1089#[cfg(feature = "cli-help")]
1090fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1091    cmd.get_subcommands()
1092        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1093}
1094
1095#[cfg(feature = "cli-help")]
1096fn command_arguments_schema(cmd: &clap::Command) -> Vec<Value> {
1097    cmd.get_arguments()
1098        .filter(|arg| !arg.is_hide_set())
1099        .map(argument_schema)
1100        .collect()
1101}
1102
1103#[cfg(feature = "cli-help")]
1104fn argument_schema(arg: &clap::Arg) -> Value {
1105    let value_names: Vec<String> = arg
1106        .get_value_names()
1107        .map(|names| names.iter().map(ToString::to_string).collect())
1108        .unwrap_or_default();
1109    let default_values: Vec<String> = arg
1110        .get_default_values()
1111        .iter()
1112        .map(|value| value.to_string_lossy().to_string())
1113        .collect();
1114    serde_json::json!({
1115        "id": arg.get_id().to_string(),
1116        "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1117        "long": arg.get_long(),
1118        "short": arg.get_short().map(|c| c.to_string()),
1119        "help": styled_to_value(arg.get_help()),
1120        "long_help": styled_to_value(arg.get_long_help()),
1121        "required": arg.is_required_set(),
1122        "action": format!("{:?}", arg.get_action()),
1123        "value_names": value_names,
1124        "default_values": default_values,
1125    })
1126}
1127
1128#[cfg(feature = "cli-help")]
1129fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1130    value.map_or(Value::Null, |s| Value::String(s.to_string()))
1131}
1132
1133// ═══════════════════════════════════════════
1134// Secret Redaction
1135// ═══════════════════════════════════════════
1136
1137#[derive(Default)]
1138struct RedactionContext {
1139    secret_names: HashSet<String>,
1140}
1141
1142impl RedactionContext {
1143    fn from_options(redaction_options: &RedactionOptions) -> Self {
1144        let secret_names = redaction_options.secret_names.iter().cloned().collect();
1145        Self { secret_names }
1146    }
1147
1148    fn is_secret_key(&self, key: &str) -> bool {
1149        key_has_secret_suffix(key) || self.secret_names.contains(key)
1150    }
1151}
1152
1153fn key_has_secret_suffix(key: &str) -> bool {
1154    key.ends_with("_secret") || key.ends_with("_SECRET")
1155}
1156
1157fn key_has_url_suffix(key: &str) -> bool {
1158    key.ends_with("_url") || key.ends_with("_URL")
1159}
1160
1161fn redact_secrets(value: &mut Value) {
1162    let context = RedactionContext::default();
1163    redact_secrets_with_context(value, &context);
1164}
1165
1166fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1167    match value {
1168        Value::Object(map) => {
1169            let keys: Vec<String> = map.keys().cloned().collect();
1170            for key in keys {
1171                if context.is_secret_key(&key) {
1172                    match map.get(&key) {
1173                        Some(Value::Object(_)) | Some(Value::Array(_)) => {
1174                            // Traverse containers, don't replace
1175                        }
1176                        _ => {
1177                            map.insert(key.clone(), Value::String("***".into()));
1178                            continue;
1179                        }
1180                    }
1181                } else if key_has_url_suffix(&key) {
1182                    if let Some(Value::String(s)) = map.get_mut(&key) {
1183                        if let Some(redacted) = redact_url_in_str(s, context) {
1184                            *s = redacted;
1185                        }
1186                        continue;
1187                    }
1188                }
1189                if let Some(v) = map.get_mut(&key) {
1190                    redact_secrets_with_context(v, context);
1191                }
1192            }
1193        }
1194        Value::Array(arr) => {
1195            for v in arr {
1196                redact_secrets_with_context(v, context);
1197            }
1198        }
1199        _ => {}
1200    }
1201}
1202
1203fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
1204    match value {
1205        Value::Object(map) => {
1206            let keys: Vec<String> = map.keys().cloned().collect();
1207            for key in keys {
1208                if context.is_secret_key(&key) {
1209                    map.insert(key, Value::String("***".into()));
1210                } else if key_has_url_suffix(&key) {
1211                    if let Some(Value::String(s)) = map.get_mut(&key) {
1212                        if let Some(redacted) = redact_url_in_str(s, context) {
1213                            *s = redacted;
1214                        }
1215                    } else if let Some(v) = map.get_mut(&key) {
1216                        redact_secrets_strict_with_context(v, context);
1217                    }
1218                } else if let Some(v) = map.get_mut(&key) {
1219                    redact_secrets_strict_with_context(v, context);
1220                }
1221            }
1222        }
1223        Value::Array(arr) => {
1224            for v in arr {
1225                redact_secrets_strict_with_context(v, context);
1226            }
1227        }
1228        _ => {}
1229    }
1230}
1231
1232/// Redact secret components of a single URL string, returning `Some(redacted)`
1233/// when `s` is a processable URL, or `None` when it is not (so callers can keep
1234/// the original). Only secret spans change; all other bytes are preserved.
1235fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1236    // Fast path + precondition: a single, whitespace-free, scheme-prefixed URL.
1237    if !s.contains("://") || !is_single_url(s) || url::Url::parse(s).is_err() {
1238        return None;
1239    }
1240    let scheme_sep = s.find("://")?;
1241    let scheme = &s[..scheme_sep];
1242    let rest = &s[scheme_sep + 3..];
1243
1244    // Authority runs from after "://" to the first '/', '?', or '#'.
1245    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1246    let authority = &rest[..auth_end];
1247    let remainder = &rest[auth_end..];
1248
1249    let new_authority = redact_userinfo_password(authority);
1250
1251    // Query runs from the first '?' to the first '#' (or end).
1252    let new_remainder = match remainder.find('?') {
1253        Some(q) => {
1254            let (path, q_onwards) = remainder.split_at(q);
1255            let query_body = &q_onwards[1..];
1256            let (query, fragment) = match query_body.find('#') {
1257                Some(h) => (&query_body[..h], &query_body[h..]),
1258                None => (query_body, ""),
1259            };
1260            format!("{path}?{}{fragment}", redact_query(query, context))
1261        }
1262        None => remainder.to_string(),
1263    };
1264
1265    Some(format!("{scheme}://{new_authority}{new_remainder}"))
1266}
1267
1268/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
1269/// username. Authority without `@`, or userinfo without `:`, is unchanged.
1270fn redact_userinfo_password(authority: &str) -> String {
1271    let Some(at) = authority.find('@') else {
1272        return authority.to_string();
1273    };
1274    let userinfo = &authority[..at];
1275    match userinfo.find(':') {
1276        Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1277        None => authority.to_string(),
1278    }
1279}
1280
1281/// Redact the values of secret-named query parameters, preserving raw bytes of
1282/// every other segment (keys, benign values, encoding, ordering, separators).
1283fn redact_query(query: &str, context: &RedactionContext) -> String {
1284    query
1285        .split('&')
1286        .map(|segment| {
1287            let Some(eq) = segment.find('=') else {
1288                return segment.to_string();
1289            };
1290            let raw_key = &segment[..eq];
1291            // Form-decode the name (`+` → space, percent-decode) for the check.
1292            let name = url::form_urlencoded::parse(segment.as_bytes())
1293                .next()
1294                .map(|(k, _)| k.into_owned())
1295                .unwrap_or_default();
1296            if context.is_secret_key(&name) {
1297                format!("{raw_key}=***")
1298            } else {
1299                segment.to_string()
1300            }
1301        })
1302        .collect::<Vec<_>>()
1303        .join("&")
1304}
1305
1306/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
1307/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
1308/// a URL embedded in prose.
1309fn is_single_url(s: &str) -> bool {
1310    if s.bytes().any(|b| b.is_ascii_whitespace()) {
1311        return false;
1312    }
1313    let bytes = s.as_bytes();
1314    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1315        return false;
1316    }
1317    let mut i = 1;
1318    while i < bytes.len() {
1319        let c = bytes[i];
1320        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1321            i += 1;
1322        } else {
1323            break;
1324        }
1325    }
1326    s[i..].starts_with("://")
1327}
1328
1329fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1330    let context = RedactionContext::default();
1331    apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1332}
1333
1334fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1335    let context = RedactionContext::from_options(redaction_options);
1336    apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1337}
1338
1339fn apply_redaction_policy_with_context(
1340    value: &mut Value,
1341    redaction_policy: Option<RedactionPolicy>,
1342    context: &RedactionContext,
1343) {
1344    match redaction_policy {
1345        Some(RedactionPolicy::RedactionTraceOnly) => {
1346            if let Value::Object(map) = value {
1347                if let Some(trace) = map.get_mut("trace") {
1348                    redact_secrets_with_context(trace, context);
1349                }
1350            }
1351        }
1352        Some(RedactionPolicy::RedactionNone) => {}
1353        Some(RedactionPolicy::RedactionStrict) => {
1354            redact_secrets_strict_with_context(value, context)
1355        }
1356        None => redact_secrets_with_context(value, context),
1357    }
1358}
1359
1360// ═══════════════════════════════════════════
1361// Suffix Processing
1362// ═══════════════════════════════════════════
1363
1364/// Strip a suffix matching exact lowercase or exact uppercase only.
1365fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1366    if let Some(s) = key.strip_suffix(suffix_lower) {
1367        return Some(s.to_string());
1368    }
1369    let suffix_upper: String = suffix_lower
1370        .chars()
1371        .map(|c| c.to_ascii_uppercase())
1372        .collect();
1373    if let Some(s) = key.strip_suffix(&suffix_upper) {
1374        return Some(s.to_string());
1375    }
1376    None
1377}
1378
1379/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
1380fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1381    let code = extract_currency_code(key)?;
1382    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
1383    let stripped = &key[..key.len() - suffix_len];
1384    if stripped.is_empty() {
1385        return None;
1386    }
1387    Some((stripped.to_string(), code.to_string()))
1388}
1389
1390/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
1391/// when suffix matches and type is valid. None for no match or type mismatch.
1392fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1393    // Group 1: compound timestamp suffixes
1394    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1395        return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
1396    }
1397    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1398        return value
1399            .as_i64()
1400            .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
1401    }
1402    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1403        return value
1404            .as_i64()
1405            .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
1406    }
1407
1408    // Group 2: compound currency suffixes
1409    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1410        return value
1411            .as_u64()
1412            .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1413    }
1414    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1415        return value
1416            .as_u64()
1417            .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1418    }
1419    if let Some((stripped, code)) = try_strip_generic_cents(key) {
1420        return value.as_u64().map(|n| {
1421            (
1422                stripped,
1423                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1424            )
1425        });
1426    }
1427
1428    // Group 3: multi-char suffixes
1429    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1430        return value.as_str().map(|s| (stripped, s.to_string()));
1431    }
1432    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1433        return value
1434            .is_number()
1435            .then(|| (stripped, format!("{} minutes", number_str(value))));
1436    }
1437    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1438        return value
1439            .is_number()
1440            .then(|| (stripped, format!("{} hours", number_str(value))));
1441    }
1442    if let Some(stripped) = strip_suffix_ci(key, "_days") {
1443        return value
1444            .is_number()
1445            .then(|| (stripped, format!("{} days", number_str(value))));
1446    }
1447
1448    // Group 4: single-unit suffixes
1449    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1450        return value
1451            .is_number()
1452            .then(|| (stripped, format!("{}msats", number_str(value))));
1453    }
1454    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1455        return value
1456            .is_number()
1457            .then(|| (stripped, format!("{}sats", number_str(value))));
1458    }
1459    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1460        return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
1461    }
1462    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1463        return value
1464            .is_number()
1465            .then(|| (stripped, format!("{}%", number_str(value))));
1466    }
1467    if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1468        return Some((stripped, "***".to_string()));
1469    }
1470
1471    // Group 5: short suffixes (last to avoid false positives)
1472    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1473        return value
1474            .is_number()
1475            .then(|| (stripped, format!("{} BTC", number_str(value))));
1476    }
1477    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1478        return value
1479            .as_u64()
1480            .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1481    }
1482    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1483        return value
1484            .is_number()
1485            .then(|| (stripped, format!("{}ns", number_str(value))));
1486    }
1487    if let Some(stripped) = strip_suffix_ci(key, "_us") {
1488        return value
1489            .is_number()
1490            .then(|| (stripped, format!("{}μs", number_str(value))));
1491    }
1492    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1493        return format_ms_value(value).map(|v| (stripped, v));
1494    }
1495    if let Some(stripped) = strip_suffix_ci(key, "_s") {
1496        return value
1497            .is_number()
1498            .then(|| (stripped, format!("{}s", number_str(value))));
1499    }
1500
1501    None
1502}
1503
1504/// Process object fields: strip keys, format values, detect collisions.
1505fn process_object_fields<'a>(
1506    map: &'a serde_json::Map<String, Value>,
1507) -> Vec<(String, &'a Value, Option<String>)> {
1508    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1509    for (key, value) in map {
1510        match try_process_field(key, value) {
1511            Some((stripped, formatted)) => {
1512                entries.push((stripped, key.as_str(), value, Some(formatted)));
1513            }
1514            None => {
1515                entries.push((key.clone(), key.as_str(), value, None));
1516            }
1517        }
1518    }
1519
1520    // Detect collisions
1521    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1522    for (stripped, _, _, _) in &entries {
1523        *counts.entry(stripped.clone()).or_insert(0) += 1;
1524    }
1525
1526    // Resolve collisions: revert both key and formatted value
1527    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1528        .into_iter()
1529        .map(|(stripped, original, value, formatted)| {
1530            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1531                (original.to_string(), value, None)
1532            } else {
1533                (stripped, value, formatted)
1534            }
1535        })
1536        .collect();
1537
1538    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
1539    result
1540}
1541
1542// ═══════════════════════════════════════════
1543// Formatting Helpers
1544// ═══════════════════════════════════════════
1545
1546fn number_str(value: &Value) -> String {
1547    match value {
1548        Value::Number(n) => n.to_string(),
1549        _ => String::new(),
1550    }
1551}
1552
1553/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
1554fn format_ms_as_seconds(ms: f64) -> String {
1555    let formatted = format!("{:.3}", ms / 1000.0);
1556    let trimmed = formatted.trim_end_matches('0');
1557    if trimmed.ends_with('.') {
1558        format!("{}0s", trimmed)
1559    } else {
1560        format!("{}s", trimmed)
1561    }
1562}
1563
1564/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
1565fn format_ms_value(value: &Value) -> Option<String> {
1566    let n = value.as_f64()?;
1567    if n.abs() >= 1000.0 {
1568        Some(format_ms_as_seconds(n))
1569    } else if let Some(i) = value.as_i64() {
1570        Some(format!("{}ms", i))
1571    } else {
1572        Some(format!("{}ms", number_str(value)))
1573    }
1574}
1575
1576/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
1577fn format_rfc3339_ms(ms: i64) -> String {
1578    use chrono::{DateTime, Utc};
1579    let secs = ms.div_euclid(1000);
1580    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
1581    match DateTime::from_timestamp(secs, nanos) {
1582        Some(dt) => dt
1583            .with_timezone(&Utc)
1584            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
1585        None => ms.to_string(),
1586    }
1587}
1588
1589/// Format bytes as human-readable size (binary units). Handles negative values.
1590fn format_bytes_human(bytes: i64) -> String {
1591    const KB: f64 = 1024.0;
1592    const MB: f64 = KB * 1024.0;
1593    const GB: f64 = MB * 1024.0;
1594    const TB: f64 = GB * 1024.0;
1595
1596    let sign = if bytes < 0 { "-" } else { "" };
1597    let b = (bytes as f64).abs();
1598    if b >= TB {
1599        format!("{sign}{:.1}TB", b / TB)
1600    } else if b >= GB {
1601        format!("{sign}{:.1}GB", b / GB)
1602    } else if b >= MB {
1603        format!("{sign}{:.1}MB", b / MB)
1604    } else if b >= KB {
1605        format!("{sign}{:.1}KB", b / KB)
1606    } else {
1607        format!("{bytes}B")
1608    }
1609}
1610
1611/// Format a number with thousands separators.
1612fn format_with_commas(n: u64) -> String {
1613    let s = n.to_string();
1614    let mut result = String::with_capacity(s.len() + s.len() / 3);
1615    for (i, c) in s.chars().enumerate() {
1616        if i > 0 && (s.len() - i).is_multiple_of(3) {
1617            result.push(',');
1618        }
1619        result.push(c);
1620    }
1621    result
1622}
1623
1624/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
1625fn extract_currency_code(key: &str) -> Option<&str> {
1626    let without_cents = key
1627        .strip_suffix("_cents")
1628        .or_else(|| key.strip_suffix("_CENTS"))?;
1629    let last_underscore = without_cents.rfind('_')?;
1630    let code = &without_cents[last_underscore + 1..];
1631    if code.is_empty() {
1632        return None;
1633    }
1634    Some(code)
1635}
1636
1637// ═══════════════════════════════════════════
1638// YAML Rendering
1639// ═══════════════════════════════════════════
1640
1641fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
1642    let prefix = "  ".repeat(indent);
1643    match value {
1644        Value::Object(map) => {
1645            let processed = process_object_fields(map);
1646            for (display_key, v, formatted) in processed {
1647                if let Some(fv) = formatted {
1648                    lines.push(format!(
1649                        "{}{}: \"{}\"",
1650                        prefix,
1651                        display_key,
1652                        escape_yaml_str(&fv)
1653                    ));
1654                } else {
1655                    match v {
1656                        Value::Object(inner) if !inner.is_empty() => {
1657                            lines.push(format!("{}{}:", prefix, display_key));
1658                            render_yaml_processed(v, indent + 1, lines);
1659                        }
1660                        Value::Object(_) => {
1661                            lines.push(format!("{}{}: {{}}", prefix, display_key));
1662                        }
1663                        Value::Array(arr) => {
1664                            if arr.is_empty() {
1665                                lines.push(format!("{}{}: []", prefix, display_key));
1666                            } else {
1667                                lines.push(format!("{}{}:", prefix, display_key));
1668                                for item in arr {
1669                                    if item.is_object() {
1670                                        lines.push(format!("{}  -", prefix));
1671                                        render_yaml_processed(item, indent + 2, lines);
1672                                    } else {
1673                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
1674                                    }
1675                                }
1676                            }
1677                        }
1678                        _ => {
1679                            lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
1680                        }
1681                    }
1682                }
1683            }
1684        }
1685        _ => {
1686            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1687        }
1688    }
1689}
1690
1691fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
1692    let prefix = "  ".repeat(indent);
1693    match value {
1694        Value::Object(map) => {
1695            for (key, v) in map {
1696                render_yaml_field_raw(&prefix, key, v, indent, lines);
1697            }
1698        }
1699        Value::Array(arr) => {
1700            render_yaml_array_raw(arr, indent, lines);
1701        }
1702        _ => {
1703            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
1704        }
1705    }
1706}
1707
1708fn render_yaml_field_raw(
1709    prefix: &str,
1710    key: &str,
1711    value: &Value,
1712    indent: usize,
1713    lines: &mut Vec<String>,
1714) {
1715    match value {
1716        Value::Object(inner) if !inner.is_empty() => {
1717            lines.push(format!("{}{}:", prefix, key));
1718            render_yaml_raw(value, indent + 1, lines);
1719        }
1720        Value::Object(_) => {
1721            lines.push(format!("{}{}: {{}}", prefix, key));
1722        }
1723        Value::Array(arr) => {
1724            if arr.is_empty() {
1725                lines.push(format!("{}{}: []", prefix, key));
1726            } else {
1727                lines.push(format!("{}{}:", prefix, key));
1728                render_yaml_array_raw(arr, indent + 1, lines);
1729            }
1730        }
1731        _ => {
1732            lines.push(format!("{}{}: {}", prefix, key, yaml_scalar(value)));
1733        }
1734    }
1735}
1736
1737fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
1738    let prefix = "  ".repeat(indent);
1739    for item in arr {
1740        match item {
1741            Value::Object(inner) if !inner.is_empty() => {
1742                lines.push(format!("{}-", prefix));
1743                render_yaml_raw(item, indent + 1, lines);
1744            }
1745            Value::Array(nested) if !nested.is_empty() => {
1746                lines.push(format!("{}-", prefix));
1747                render_yaml_array_raw(nested, indent + 1, lines);
1748            }
1749            Value::Object(_) => {
1750                lines.push(format!("{}- {{}}", prefix));
1751            }
1752            Value::Array(_) => {
1753                lines.push(format!("{}- []", prefix));
1754            }
1755            _ => {
1756                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
1757            }
1758        }
1759    }
1760}
1761
1762fn escape_yaml_str(s: &str) -> String {
1763    s.replace('\\', "\\\\")
1764        .replace('"', "\\\"")
1765        .replace('\n', "\\n")
1766        .replace('\r', "\\r")
1767        .replace('\t', "\\t")
1768}
1769
1770fn yaml_scalar(value: &Value) -> String {
1771    match value {
1772        Value::String(s) => {
1773            format!("\"{}\"", escape_yaml_str(s))
1774        }
1775        Value::Null => "null".to_string(),
1776        Value::Bool(b) => b.to_string(),
1777        Value::Number(n) => n.to_string(),
1778        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
1779    }
1780}
1781
1782// ═══════════════════════════════════════════
1783// Plain Rendering (logfmt)
1784// ═══════════════════════════════════════════
1785
1786fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1787    if let Value::Object(map) = value {
1788        let processed = process_object_fields(map);
1789        for (display_key, v, formatted) in processed {
1790            let full_key = if prefix.is_empty() {
1791                display_key
1792            } else {
1793                format!("{}.{}", prefix, display_key)
1794            };
1795            if let Some(fv) = formatted {
1796                pairs.push((full_key, fv));
1797            } else {
1798                match v {
1799                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
1800                    Value::Array(arr) => {
1801                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1802                        pairs.push((full_key, joined));
1803                    }
1804                    Value::Null => pairs.push((full_key, String::new())),
1805                    _ => pairs.push((full_key, plain_scalar(v))),
1806                }
1807            }
1808        }
1809    }
1810}
1811
1812fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
1813    if let Value::Object(map) = value {
1814        for (key, v) in map {
1815            let full_key = if prefix.is_empty() {
1816                key.clone()
1817            } else {
1818                format!("{}.{}", prefix, key)
1819            };
1820            match v {
1821                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
1822                Value::Array(arr) => {
1823                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
1824                    pairs.push((full_key, joined));
1825                }
1826                Value::Null => pairs.push((full_key, String::new())),
1827                _ => pairs.push((full_key, plain_scalar(v))),
1828            }
1829        }
1830    }
1831}
1832
1833fn plain_scalar(value: &Value) -> String {
1834    match value {
1835        Value::String(s) => s.clone(),
1836        Value::Null => "null".to_string(),
1837        Value::Bool(b) => b.to_string(),
1838        Value::Number(n) => n.to_string(),
1839        other => other.to_string(),
1840    }
1841}
1842
1843fn quote_logfmt_value(value: &str) -> String {
1844    if value.is_empty() {
1845        return String::new();
1846    }
1847    if !value
1848        .chars()
1849        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
1850    {
1851        return value.to_string();
1852    }
1853    let escaped = value
1854        .replace('\\', "\\\\")
1855        .replace('"', "\\\"")
1856        .replace('\n', "\\n")
1857        .replace('\r', "\\r")
1858        .replace('\t', "\\t");
1859    format!("\"{}\"", escaped)
1860}
1861
1862#[cfg(test)]
1863mod tests;