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