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