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, opencode, and Hermes; 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) = markdown_long_about(cmd) {
1258        let _ = writeln!(buf);
1259        write_trimmed_help(buf, &long_about);
1260    }
1261    let _ = writeln!(buf);
1262    let _ = writeln!(buf, "```text");
1263    let help = markdown_help_block_command(cmd, enrich).render_long_help();
1264    write_trimmed_help(buf, &help.to_string());
1265    if !buf.ends_with('\n') {
1266        let _ = writeln!(buf);
1267    }
1268    let _ = writeln!(buf, "```");
1269}
1270
1271#[cfg(feature = "cli-help")]
1272fn markdown_long_about(cmd: &clap::Command) -> Option<String> {
1273    let long_about = cmd.get_long_about()?.to_string();
1274    let rendered = match cmd.get_about() {
1275        Some(about) => strip_leading_about_paragraph(&long_about, &about.to_string()),
1276        None => long_about.as_str(),
1277    };
1278    let rendered = rendered.trim_matches(['\r', '\n']);
1279    if rendered.is_empty() {
1280        None
1281    } else {
1282        Some(rendered.to_string())
1283    }
1284}
1285
1286#[cfg(feature = "cli-help")]
1287fn strip_leading_about_paragraph<'a>(long_about: &'a str, about: &str) -> &'a str {
1288    let long_about = long_about.trim_start_matches(['\r', '\n']);
1289    let Some(rest) = long_about.strip_prefix(about) else {
1290        return long_about;
1291    };
1292    if rest.is_empty() {
1293        return "";
1294    }
1295    rest.strip_prefix("\r\n\r\n")
1296        .or_else(|| rest.strip_prefix("\n\n"))
1297        .unwrap_or(long_about)
1298}
1299
1300#[cfg(feature = "cli-help")]
1301fn markdown_help_block_command(cmd: &clap::Command, enrich: bool) -> clap::Command {
1302    let cmd = if enrich {
1303        enriched_help_command(cmd)
1304    } else {
1305        cmd.clone()
1306    };
1307    cmd.about(None::<&str>).long_about(None::<&str>)
1308}
1309
1310#[cfg(feature = "cli-help")]
1311fn write_trimmed_help(buf: &mut String, help: &str) {
1312    use std::fmt::Write;
1313
1314    for line in help.lines() {
1315        let _ = writeln!(buf, "{}", line.trim_end());
1316    }
1317}
1318
1319#[cfg(feature = "cli-help")]
1320struct ParsedHelpRequest {
1321    help_requested: bool,
1322    recursive_requested: bool,
1323    output_format: Option<HelpFormat>,
1324    output_error: Option<String>,
1325    subcommand_path: Vec<String>,
1326}
1327
1328#[cfg(feature = "cli-help")]
1329fn parse_help_request(
1330    raw_args: &[String],
1331    cmd: &clap::Command,
1332    config: &HelpConfig,
1333) -> ParsedHelpRequest {
1334    let args = match raw_args.first() {
1335        Some(first) if first.starts_with('-') || cmd.find_subcommand(first).is_some() => raw_args,
1336        _ => raw_args.get(1..).unwrap_or(&[]),
1337    };
1338    let mut help_requested = false;
1339    let mut recursive_requested = false;
1340    let mut output_format = None;
1341    let mut output_error = None;
1342    let mut subcommand_path = Vec::new();
1343    let mut current = cmd;
1344    let output_flag = config.output_flag.map(normalize_long_flag);
1345    let recursive_flag = config.recursive_flag.map(normalize_long_flag);
1346
1347    let mut i = 0usize;
1348    while i < args.len() {
1349        let arg = args[i].as_str();
1350        if arg == "--" {
1351            break;
1352        }
1353
1354        let (flag_name, inline_value) = split_flag(arg);
1355        if matches!(arg, "--help" | "-h") {
1356            help_requested = true;
1357            i += 1;
1358            continue;
1359        }
1360        // `--recursive` is a help *modifier*, not a help trigger: it only
1361        // selects recursive scope when `--help` is also present. A bare
1362        // `--recursive` leaves help_requested false so the full argv falls
1363        // through to the application's own parser untouched.
1364        if arg == "--recursive"
1365            || flag_name
1366                .zip(recursive_flag)
1367                .is_some_and(|(seen, expected)| seen == expected)
1368        {
1369            recursive_requested = true;
1370            i += 1;
1371            continue;
1372        }
1373        if config.allow_output_format
1374            && flag_name
1375                .zip(output_flag)
1376                .is_some_and(|(seen, expected)| seen == expected)
1377        {
1378            let value = inline_value.or_else(|| {
1379                args.get(i + 1)
1380                    .map(String::as_str)
1381                    .filter(|next| !next.starts_with('-'))
1382            });
1383            if let Some(value) = value {
1384                match HelpFormat::parse(value) {
1385                    Some(format) => output_format = Some(format),
1386                    None => {
1387                        output_error = Some(format!(
1388                            "invalid --{} format '{}': expected plain, json, yaml, or markdown",
1389                            output_flag.unwrap_or("output"),
1390                            value
1391                        ));
1392                    }
1393                }
1394            } else {
1395                output_error = Some(format!(
1396                    "missing value for --{}: expected plain, json, yaml, or markdown",
1397                    output_flag.unwrap_or("output")
1398                ));
1399            }
1400            i += if inline_value.is_some() || value.is_none() {
1401                1
1402            } else {
1403                2
1404            };
1405            continue;
1406        }
1407        if arg.starts_with('-') {
1408            i += if inline_value.is_none() && flag_takes_value(current, arg) {
1409                2
1410            } else {
1411                1
1412            };
1413            continue;
1414        }
1415        if let Some(sub) = current.find_subcommand(arg) {
1416            if sub.get_name() != "help" && !sub.is_hide_set() {
1417                subcommand_path.push(sub.get_name().to_string());
1418                current = sub;
1419            }
1420        }
1421        i += 1;
1422    }
1423
1424    ParsedHelpRequest {
1425        help_requested,
1426        recursive_requested,
1427        output_format,
1428        output_error,
1429        subcommand_path,
1430    }
1431}
1432
1433fn normalize_long_flag(flag: &str) -> &str {
1434    flag.trim_start_matches('-')
1435}
1436
1437fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
1438    if let Some(stripped) = arg.strip_prefix("--") {
1439        if let Some((name, value)) = stripped.split_once('=') {
1440            (Some(name), Some(value))
1441        } else {
1442            (Some(stripped), None)
1443        }
1444    } else if let Some(stripped) = arg.strip_prefix('-') {
1445        (Some(stripped), None)
1446    } else {
1447        (None, None)
1448    }
1449}
1450
1451#[cfg(feature = "cli-help")]
1452fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
1453    let Some(flag) = raw_flag.strip_prefix('-') else {
1454        return false;
1455    };
1456    let name = flag.trim_start_matches('-');
1457    cmd.get_arguments().any(|arg| {
1458        let long_matches = arg.get_long().is_some_and(|long| long == name);
1459        let short_matches =
1460            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
1461        (long_matches || short_matches)
1462            && matches!(
1463                arg.get_action(),
1464                clap::ArgAction::Set | clap::ArgAction::Append
1465            )
1466    })
1467}
1468
1469#[cfg(feature = "cli-help")]
1470fn build_help_schema(cmd: &clap::Command, subcommand_path: &[&str], scope: HelpScope) -> Value {
1471    let (target, names) = walk_to_subcommand_with_names(cmd, subcommand_path);
1472    let mut schema = command_schema(target, &names, matches!(scope, HelpScope::Recursive), true);
1473    if let Value::Object(map) = &mut schema {
1474        map.insert("code".to_string(), Value::String("help".to_string()));
1475        map.insert(
1476            "scope".to_string(),
1477            Value::String(help_scope_tag(scope).to_string()),
1478        );
1479    }
1480    schema
1481}
1482
1483#[cfg(feature = "cli-help")]
1484fn help_scope_tag(scope: HelpScope) -> &'static str {
1485    match scope {
1486        HelpScope::OneLevel => "one_level",
1487        HelpScope::Recursive => "recursive",
1488    }
1489}
1490
1491#[cfg(feature = "cli-help")]
1492fn command_schema(cmd: &clap::Command, names: &[String], recursive: bool, enrich: bool) -> Value {
1493    let subcommands: Vec<Value> = visible_subcommands(cmd)
1494        .map(|sub| {
1495            let mut child_names = names.to_vec();
1496            child_names.push(sub.get_name().to_string());
1497            if recursive {
1498                // Descendants never re-advertise the help modifiers (enrich=false).
1499                command_schema(sub, &child_names, true, false)
1500            } else {
1501                command_summary_schema(sub, &child_names)
1502            }
1503        })
1504        .collect();
1505
1506    serde_json::json!({
1507        "name": cmd.get_name(),
1508        "command_path": names.join(" "),
1509        "path": names,
1510        "about": styled_to_value(cmd.get_about()),
1511        "long_about": styled_to_value(cmd.get_long_about()),
1512        "usage": cmd.clone().render_usage().to_string(),
1513        "arguments": command_arguments_schema(cmd, enrich),
1514        "subcommands": subcommands,
1515    })
1516}
1517
1518#[cfg(feature = "cli-help")]
1519fn command_summary_schema(cmd: &clap::Command, names: &[String]) -> Value {
1520    serde_json::json!({
1521        "name": cmd.get_name(),
1522        "command_path": names.join(" "),
1523        "path": names,
1524        "about": styled_to_value(cmd.get_about()),
1525        "long_about": styled_to_value(cmd.get_long_about()),
1526        "usage": Value::Null,
1527        "arguments": [],
1528        "subcommands": [],
1529    })
1530}
1531
1532#[cfg(feature = "cli-help")]
1533fn visible_subcommands(cmd: &clap::Command) -> impl Iterator<Item = &clap::Command> {
1534    cmd.get_subcommands()
1535        .filter(|sub| sub.get_name() != "help" && !sub.is_hide_set())
1536}
1537
1538#[cfg(feature = "cli-help")]
1539fn command_arguments_schema(cmd: &clap::Command, enrich: bool) -> Vec<Value> {
1540    // For the target command, render through the enriched clone so the schema
1541    // documents the `-h, --help` modifiers (`--recursive`, `--output`) just like
1542    // the plain and markdown formats do (clap adds `--help` lazily during build,
1543    // so the raw command would omit it). Descendants stay un-enriched to avoid
1544    // repeating the same modifier doc on every command in a recursive dump.
1545    let owned = enrich.then(|| enriched_help_command(cmd));
1546    let source = owned.as_ref().unwrap_or(cmd);
1547    source
1548        .get_arguments()
1549        .filter(|arg| !arg.is_hide_set())
1550        .map(argument_schema)
1551        .collect()
1552}
1553
1554#[cfg(feature = "cli-help")]
1555fn argument_schema(arg: &clap::Arg) -> Value {
1556    let value_names: Vec<String> = arg
1557        .get_value_names()
1558        .map(|names| names.iter().map(ToString::to_string).collect())
1559        .unwrap_or_default();
1560    let default_values: Vec<String> = arg
1561        .get_default_values()
1562        .iter()
1563        .map(|value| value.to_string_lossy().to_string())
1564        .collect();
1565    serde_json::json!({
1566        "id": arg.get_id().to_string(),
1567        "kind": if arg.get_long().is_some() || arg.get_short().is_some() { "option" } else { "argument" },
1568        "long": arg.get_long(),
1569        "short": arg.get_short().map(|c| c.to_string()),
1570        "help": styled_to_value(arg.get_help()),
1571        "long_help": styled_to_value(arg.get_long_help()),
1572        "required": arg.is_required_set(),
1573        "action": format!("{:?}", arg.get_action()),
1574        "value_names": value_names,
1575        "default_values": default_values,
1576    })
1577}
1578
1579#[cfg(feature = "cli-help")]
1580fn styled_to_value(value: Option<&clap::builder::StyledStr>) -> Value {
1581    value.map_or(Value::Null, |s| Value::String(s.to_string()))
1582}
1583
1584// ═══════════════════════════════════════════
1585// Secret Redaction
1586// ═══════════════════════════════════════════
1587
1588#[derive(Default)]
1589struct RedactionContext {
1590    secret_names: HashSet<String>,
1591}
1592
1593impl RedactionContext {
1594    fn from_options(redaction_options: &RedactionOptions) -> Self {
1595        let secret_names = redaction_options.secret_names.iter().cloned().collect();
1596        Self { secret_names }
1597    }
1598
1599    fn is_secret_key(&self, key: &str) -> bool {
1600        key_has_secret_suffix(key) || self.secret_names.contains(key)
1601    }
1602}
1603
1604fn key_has_secret_suffix(key: &str) -> bool {
1605    key.ends_with("_secret") || key.ends_with("_SECRET")
1606}
1607
1608fn key_has_url_suffix(key: &str) -> bool {
1609    key.ends_with("_url") || key.ends_with("_URL")
1610}
1611
1612const MAX_DEPTH: usize = 256;
1613
1614fn redact_secrets(value: &mut Value) {
1615    let context = RedactionContext::default();
1616    redact_secrets_with_context(value, &context);
1617}
1618
1619fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
1620    redact_secrets_with_context_depth(value, context, 0);
1621}
1622
1623fn redact_secrets_with_context_depth(value: &mut Value, context: &RedactionContext, depth: usize) {
1624    if depth >= MAX_DEPTH {
1625        *value = Value::String("***".into());
1626        return;
1627    }
1628    match value {
1629        Value::Object(map) => {
1630            let keys: Vec<String> = map.keys().cloned().collect();
1631            for key in keys {
1632                if context.is_secret_key(&key) {
1633                    map.insert(key, Value::String("***".into()));
1634                } else if key_has_url_suffix(&key) {
1635                    if let Some(Value::String(s)) = map.get_mut(&key) {
1636                        *s = redact_url_field_value(s, context);
1637                    } else if let Some(v) = map.get_mut(&key) {
1638                        redact_secrets_with_context_depth(v, context, depth + 1);
1639                    }
1640                } else if let Some(v) = map.get_mut(&key) {
1641                    redact_secrets_with_context_depth(v, context, depth + 1);
1642                }
1643            }
1644        }
1645        Value::Array(arr) => {
1646            for v in arr {
1647                redact_secrets_with_context_depth(v, context, depth + 1);
1648            }
1649        }
1650        _ => {}
1651    }
1652}
1653
1654/// Redact secret components of a single URL string, returning `Some(redacted)`
1655/// when `s` is a processable URL, or `None` when it is not (so callers can keep
1656/// the original). Only secret spans change; all other bytes are preserved.
1657fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
1658    // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
1659    // The gate is scheme + no-whitespace only — NOT "parses as a URL library
1660    // object". Span location below is purely byte-wise, so we never re-serialize
1661    // the URL; adding a `url::Url::parse` gate here would diverge across
1662    // languages (e.g. ports > 65535 or empty hosts that one library rejects and
1663    // another accepts) and silently leak secrets in the values it rejects.
1664    if !s.contains("://") || !is_single_url(s) {
1665        return None;
1666    }
1667    let scheme_sep = s.find("://")?;
1668    let scheme = &s[..scheme_sep];
1669    let rest = &s[scheme_sep + 3..];
1670
1671    // Authority runs from after "://" to the first '/', '?', or '#'.
1672    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
1673    let authority = &rest[..auth_end];
1674    let remainder = &rest[auth_end..];
1675
1676    let new_authority = redact_userinfo_password(authority);
1677
1678    // Query runs from the first '?' to the first '#' (or end).
1679    let new_remainder = match remainder.find('?') {
1680        Some(q) => {
1681            let (path, q_onwards) = remainder.split_at(q);
1682            let query_body = &q_onwards[1..];
1683            let (query, fragment) = match query_body.find('#') {
1684                Some(h) => (&query_body[..h], &query_body[h..]),
1685                None => (query_body, ""),
1686            };
1687            format!("{path}?{}{fragment}", redact_query(query, context))
1688        }
1689        None => remainder.to_string(),
1690    };
1691
1692    Some(format!("{scheme}://{new_authority}{new_remainder}"))
1693}
1694
1695fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
1696    if let Some(redacted) = redact_url_in_str(s, context) {
1697        return redacted;
1698    }
1699    let trimmed = s.trim();
1700    if trimmed != s {
1701        if let Some(redacted) = redact_url_in_str(trimmed, context) {
1702            return redacted;
1703        }
1704    }
1705    // Fail closed: a `_url` value we could not parse as a clean scheme-prefixed
1706    // URL, yet which carries a credential sigil (`@` userinfo) or internal
1707    // whitespace, is redacted wholesale rather than passed through. A schemeless
1708    // connection string like `user:pass@host/db` has no scheme anchor for the
1709    // surgical span logic above, so blanket redaction is the safe default.
1710    if s.chars().any(char::is_whitespace) || s.contains('@') {
1711        return "***".to_string();
1712    }
1713    s.to_string()
1714}
1715
1716/// Replace the userinfo password (`user:pass@`) with `***`, preserving the
1717/// username. Authority without `@`, or userinfo without `:`, is unchanged.
1718fn redact_userinfo_password(authority: &str) -> String {
1719    let Some(at) = authority.rfind('@') else {
1720        return authority.to_string();
1721    };
1722    let userinfo = &authority[..at];
1723    match userinfo.find(':') {
1724        Some(colon) => format!("{}:***{}", &authority[..colon], &authority[at..]),
1725        None => authority.to_string(),
1726    }
1727}
1728
1729/// Redact the values of secret-named query parameters, preserving raw bytes of
1730/// every other segment (keys, benign values, encoding, ordering, separators).
1731fn redact_query(query: &str, context: &RedactionContext) -> String {
1732    query
1733        .split('&')
1734        .map(|segment| {
1735            let Some(eq) = segment.find('=') else {
1736                return segment.to_string();
1737            };
1738            let raw_key = &segment[..eq];
1739            // Form-decode the name (`+` → space, percent-decode) for the check.
1740            let name = url::form_urlencoded::parse(segment.as_bytes())
1741                .next()
1742                .map(|(k, _)| k.into_owned())
1743                .unwrap_or_default();
1744            if context.is_secret_key(&name) {
1745                format!("{raw_key}=***")
1746            } else {
1747                segment.to_string()
1748            }
1749        })
1750        .collect::<Vec<_>>()
1751        .join("&")
1752}
1753
1754/// True when `s` begins with a URL scheme (`ALPHA *(ALPHA / DIGIT / "+" / "-" /
1755/// ".") "://"`) and contains no ASCII whitespace — i.e. a single bare URL, not
1756/// a URL embedded in prose.
1757fn is_single_url(s: &str) -> bool {
1758    if s.bytes().any(|b| b.is_ascii_whitespace()) {
1759        return false;
1760    }
1761    let bytes = s.as_bytes();
1762    if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
1763        return false;
1764    }
1765    let mut i = 1;
1766    while i < bytes.len() {
1767        let c = bytes[i];
1768        if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
1769            i += 1;
1770        } else {
1771            break;
1772        }
1773    }
1774    s[i..].starts_with("://")
1775}
1776
1777fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
1778    let context = RedactionContext::default();
1779    apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
1780}
1781
1782fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
1783    let context = RedactionContext::from_options(redaction_options);
1784    apply_redaction_policy_with_context(value, redaction_options.policy, &context);
1785}
1786
1787fn apply_redaction_policy_with_context(
1788    value: &mut Value,
1789    redaction_policy: Option<RedactionPolicy>,
1790    context: &RedactionContext,
1791) {
1792    match redaction_policy {
1793        Some(RedactionPolicy::RedactionTraceOnly) => {
1794            if let Value::Object(map) = value {
1795                if let Some(trace) = map.get_mut("trace") {
1796                    redact_secrets_with_context(trace, context);
1797                }
1798            }
1799        }
1800        Some(RedactionPolicy::RedactionNone) => {}
1801        None => redact_secrets_with_context(value, context),
1802    }
1803}
1804
1805// ═══════════════════════════════════════════
1806// Suffix Processing
1807// ═══════════════════════════════════════════
1808
1809/// Strip a suffix matching exact lowercase or exact uppercase only.
1810fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
1811    if let Some(s) = key.strip_suffix(suffix_lower) {
1812        return Some(s.to_string());
1813    }
1814    let suffix_upper: String = suffix_lower
1815        .chars()
1816        .map(|c| c.to_ascii_uppercase())
1817        .collect();
1818    if let Some(s) = key.strip_suffix(&suffix_upper) {
1819        return Some(s.to_string());
1820    }
1821    None
1822}
1823
1824/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
1825fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
1826    let code = extract_currency_code(key)?;
1827    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
1828    let stripped = &key[..key.len() - suffix_len];
1829    if stripped.is_empty() {
1830        return None;
1831    }
1832    Some((stripped.to_string(), code.to_string()))
1833}
1834
1835/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
1836/// when suffix matches and type is valid. None for no match or type mismatch.
1837/// Accept an integer value, including an integral-valued float (`3.0` → `3`).
1838/// Non-integral floats and out-of-range values return `None`. This keeps the
1839/// four language implementations consistent: JS/TS cannot distinguish `3` from
1840/// `3.0` after JSON parsing, so the value's integrality — not its lexical form —
1841/// decides whether an integer-required suffix applies.
1842fn as_int(value: &Value) -> Option<i64> {
1843    if let Some(i) = value.as_i64() {
1844        return Some(i);
1845    }
1846    let f = value.as_f64()?;
1847    if f.is_finite() && f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f) {
1848        return Some(f as i64);
1849    }
1850    None
1851}
1852
1853/// Like [`as_int`] but for non-negative integers (rejects negatives).
1854fn as_uint(value: &Value) -> Option<u64> {
1855    if let Some(u) = value.as_u64() {
1856        return Some(u);
1857    }
1858    let f = value.as_f64()?;
1859    if f.is_finite() && f.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&f) {
1860        return Some(f as u64);
1861    }
1862    None
1863}
1864
1865fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
1866    // Group 1: compound timestamp suffixes
1867    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
1868        return as_int(value)
1869            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1870    }
1871    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
1872        return as_int(value)
1873            .and_then(|s| s.checked_mul(1000))
1874            .and_then(|ms| format_rfc3339_ms(ms).map(|formatted| (stripped, formatted)));
1875    }
1876    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
1877        return as_int(value).and_then(|ns| {
1878            format_rfc3339_ms(ns.div_euclid(1_000_000)).map(|formatted| (stripped, formatted))
1879        });
1880    }
1881
1882    // Group 2: compound currency suffixes
1883    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
1884        return as_uint(value).map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
1885    }
1886    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
1887        return as_uint(value).map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
1888    }
1889    if let Some((stripped, code)) = try_strip_generic_cents(key) {
1890        return as_uint(value).map(|n| {
1891            (
1892                stripped,
1893                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
1894            )
1895        });
1896    }
1897
1898    // Group 3: multi-char suffixes
1899    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
1900        return value.as_str().map(|s| (stripped, s.to_string()));
1901    }
1902    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
1903        return value
1904            .is_number()
1905            .then(|| (stripped, format!("{} minutes", number_str(value))));
1906    }
1907    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
1908        return value
1909            .is_number()
1910            .then(|| (stripped, format!("{} hours", number_str(value))));
1911    }
1912    if let Some(stripped) = strip_suffix_ci(key, "_days") {
1913        return value
1914            .is_number()
1915            .then(|| (stripped, format!("{} days", number_str(value))));
1916    }
1917
1918    // Group 4: single-unit suffixes
1919    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
1920        return value
1921            .is_number()
1922            .then(|| (stripped, format!("{}msats", number_str(value))));
1923    }
1924    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
1925        return value
1926            .is_number()
1927            .then(|| (stripped, format!("{}sats", number_str(value))));
1928    }
1929    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
1930        return as_int(value).map(|n| (stripped, format_bytes_human(n)));
1931    }
1932    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
1933        return value
1934            .is_number()
1935            .then(|| (stripped, format!("{}%", number_str(value))));
1936    }
1937    // Group 5: short suffixes (last to avoid false positives)
1938    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
1939        return value
1940            .is_number()
1941            .then(|| (stripped, format!("{} BTC", number_str(value))));
1942    }
1943    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
1944        return as_uint(value).map(|n| (stripped, format!("¥{}", format_with_commas(n))));
1945    }
1946    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
1947        return value
1948            .is_number()
1949            .then(|| (stripped, format!("{}ns", number_str(value))));
1950    }
1951    if let Some(stripped) = strip_suffix_ci(key, "_us") {
1952        return value
1953            .is_number()
1954            .then(|| (stripped, format!("{}μs", number_str(value))));
1955    }
1956    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
1957        return format_ms_value(value).map(|v| (stripped, v));
1958    }
1959    if let Some(stripped) = strip_suffix_ci(key, "_s") {
1960        return value
1961            .is_number()
1962            .then(|| (stripped, format!("{}s", number_str(value))));
1963    }
1964
1965    None
1966}
1967
1968/// Process object fields: strip keys, format values, detect collisions.
1969fn process_object_fields<'a>(
1970    map: &'a serde_json::Map<String, Value>,
1971) -> Vec<(String, &'a Value, Option<String>)> {
1972    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
1973    for (key, value) in map {
1974        if let Some(stripped) = strip_suffix_ci(key, "_secret") {
1975            entries.push((stripped, key.as_str(), value, None));
1976            continue;
1977        }
1978        match try_process_field(key, value) {
1979            Some((stripped, formatted)) => {
1980                entries.push((stripped, key.as_str(), value, Some(formatted)));
1981            }
1982            None => {
1983                entries.push((key.clone(), key.as_str(), value, None));
1984            }
1985        }
1986    }
1987
1988    // Detect collisions
1989    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1990    for (stripped, _, _, _) in &entries {
1991        *counts.entry(stripped.clone()).or_insert(0) += 1;
1992    }
1993
1994    // Resolve collisions: revert both key and formatted value
1995    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
1996        .into_iter()
1997        .map(|(stripped, original, value, formatted)| {
1998            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
1999                (original.to_string(), value, None)
2000            } else {
2001                (stripped, value, formatted)
2002            }
2003        })
2004        .collect();
2005
2006    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
2007    result
2008}
2009
2010// ═══════════════════════════════════════════
2011// Formatting Helpers
2012// ═══════════════════════════════════════════
2013
2014fn number_str(value: &Value) -> String {
2015    match value {
2016        Value::Number(n) => format_number(n),
2017        _ => String::new(),
2018    }
2019}
2020
2021/// Render a JSON number canonically for YAML/plain output: an integral-valued
2022/// float drops its trailing `.0` so `3.0` and `3` both render as `3`. This
2023/// matches Go (`strconv.FormatFloat(_, 'f', -1, 64)`), TypeScript
2024/// (`Number.prototype.toString`), and Python (`int(v)` for integral floats),
2025/// keeping the four implementations byte-identical.
2026fn format_number(n: &serde_json::Number) -> String {
2027    if n.is_f64() {
2028        if let Some(f) = n.as_f64() {
2029            if f.is_finite() && f.fract() == 0.0 && f.abs() < 1e21 {
2030                return format!("{f:.0}");
2031            }
2032        }
2033    }
2034    normalize_exponent(&n.to_string())
2035}
2036
2037fn normalize_exponent(s: &str) -> String {
2038    let Some(e) = s.find(['e', 'E']) else {
2039        return s.to_string();
2040    };
2041    let mantissa = &s[..e];
2042    let mut exp = &s[e + 1..];
2043    let mut sign = "";
2044    if exp.starts_with(['+', '-']) {
2045        sign = &exp[..1];
2046        exp = &exp[1..];
2047    }
2048    let exp = exp.trim_start_matches('0');
2049    let exp = if exp.is_empty() { "0" } else { exp };
2050    format!("{mantissa}e{sign}{exp}")
2051}
2052
2053/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
2054fn format_ms_as_seconds(ms: f64) -> String {
2055    let formatted = format!("{:.3}", ms / 1000.0);
2056    let trimmed = formatted.trim_end_matches('0');
2057    if trimmed.ends_with('.') {
2058        format!("{}0s", trimmed)
2059    } else {
2060        format!("{}s", trimmed)
2061    }
2062}
2063
2064/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
2065fn format_ms_value(value: &Value) -> Option<String> {
2066    let n = value.as_f64()?;
2067    if n.abs() >= 1000.0 {
2068        Some(format_ms_as_seconds(n))
2069    } else if let Some(i) = value.as_i64() {
2070        Some(format!("{}ms", i))
2071    } else {
2072        Some(format!("{}ms", number_str(value)))
2073    }
2074}
2075
2076/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
2077const MIN_RFC3339_MS: i64 = -62135596800000;
2078const MAX_RFC3339_MS: i64 = 253402300799999;
2079
2080fn format_rfc3339_ms(ms: i64) -> Option<String> {
2081    use chrono::{DateTime, Utc};
2082    if !(MIN_RFC3339_MS..=MAX_RFC3339_MS).contains(&ms) {
2083        return None;
2084    }
2085    let secs = ms.div_euclid(1000);
2086    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
2087    DateTime::from_timestamp(secs, nanos).map(|dt| {
2088        dt.with_timezone(&Utc)
2089            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2090    })
2091}
2092
2093/// Format bytes as human-readable size (binary units). Handles negative values.
2094fn format_bytes_human(bytes: i64) -> String {
2095    const KB: f64 = 1024.0;
2096    const MB: f64 = KB * 1024.0;
2097    const GB: f64 = MB * 1024.0;
2098    const TB: f64 = GB * 1024.0;
2099
2100    let sign = if bytes < 0 { "-" } else { "" };
2101    let b = (bytes as f64).abs();
2102    if b >= TB {
2103        format!("{sign}{:.1}TB", b / TB)
2104    } else if b >= GB {
2105        format!("{sign}{:.1}GB", b / GB)
2106    } else if b >= MB {
2107        format!("{sign}{:.1}MB", b / MB)
2108    } else if b >= KB {
2109        format!("{sign}{:.1}KB", b / KB)
2110    } else {
2111        format!("{bytes}B")
2112    }
2113}
2114
2115/// Format a number with thousands separators.
2116fn format_with_commas(n: u64) -> String {
2117    let s = n.to_string();
2118    let mut result = String::with_capacity(s.len() + s.len() / 3);
2119    for (i, c) in s.chars().enumerate() {
2120        if i > 0 && (s.len() - i).is_multiple_of(3) {
2121            result.push(',');
2122        }
2123        result.push(c);
2124    }
2125    result
2126}
2127
2128/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
2129fn extract_currency_code(key: &str) -> Option<&str> {
2130    let without_cents = key
2131        .strip_suffix("_cents")
2132        .or_else(|| key.strip_suffix("_CENTS"))?;
2133    let last_underscore = without_cents.rfind('_')?;
2134    let code = &without_cents[last_underscore + 1..];
2135    if code.is_empty()
2136        || !(3..=4).contains(&code.len())
2137        || !code.bytes().all(|b| b.is_ascii_alphabetic())
2138    {
2139        return None;
2140    }
2141    Some(code)
2142}
2143
2144// ═══════════════════════════════════════════
2145// YAML Rendering
2146// ═══════════════════════════════════════════
2147
2148fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
2149    let prefix = "  ".repeat(indent);
2150    match value {
2151        Value::Object(map) => {
2152            let processed = process_object_fields(map);
2153            for (display_key, v, formatted) in processed {
2154                if let Some(fv) = formatted {
2155                    lines.push(format!(
2156                        "{}{}: \"{}\"",
2157                        prefix,
2158                        yaml_key(&display_key),
2159                        escape_yaml_str(&fv)
2160                    ));
2161                } else {
2162                    match v {
2163                        Value::Object(inner) if !inner.is_empty() => {
2164                            lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2165                            render_yaml_processed(v, indent + 1, lines);
2166                        }
2167                        Value::Object(_) => {
2168                            lines.push(format!("{}{}: {{}}", prefix, yaml_key(&display_key)));
2169                        }
2170                        Value::Array(arr) => {
2171                            if arr.is_empty() {
2172                                lines.push(format!("{}{}: []", prefix, yaml_key(&display_key)));
2173                            } else {
2174                                lines.push(format!("{}{}:", prefix, yaml_key(&display_key)));
2175                                for item in arr {
2176                                    if item.is_object() {
2177                                        lines.push(format!("{}  -", prefix));
2178                                        render_yaml_processed(item, indent + 2, lines);
2179                                    } else {
2180                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
2181                                    }
2182                                }
2183                            }
2184                        }
2185                        _ => {
2186                            lines.push(format!(
2187                                "{}{}: {}",
2188                                prefix,
2189                                yaml_key(&display_key),
2190                                yaml_scalar(v)
2191                            ));
2192                        }
2193                    }
2194                }
2195            }
2196        }
2197        _ => {
2198            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2199        }
2200    }
2201}
2202
2203fn render_yaml_raw(value: &Value, indent: usize, lines: &mut Vec<String>) {
2204    let prefix = "  ".repeat(indent);
2205    match value {
2206        Value::Object(map) => {
2207            for key in sorted_value_keys(map) {
2208                render_yaml_field_raw(&prefix, &key, &map[&key], indent, lines);
2209            }
2210        }
2211        Value::Array(arr) => {
2212            render_yaml_array_raw(arr, indent, lines);
2213        }
2214        _ => {
2215            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
2216        }
2217    }
2218}
2219
2220fn render_yaml_field_raw(
2221    prefix: &str,
2222    key: &str,
2223    value: &Value,
2224    indent: usize,
2225    lines: &mut Vec<String>,
2226) {
2227    match value {
2228        Value::Object(inner) if !inner.is_empty() => {
2229            lines.push(format!("{}{}:", prefix, yaml_key(key)));
2230            render_yaml_raw(value, indent + 1, lines);
2231        }
2232        Value::Object(_) => {
2233            lines.push(format!("{}{}: {{}}", prefix, yaml_key(key)));
2234        }
2235        Value::Array(arr) => {
2236            if arr.is_empty() {
2237                lines.push(format!("{}{}: []", prefix, yaml_key(key)));
2238            } else {
2239                lines.push(format!("{}{}:", prefix, yaml_key(key)));
2240                render_yaml_array_raw(arr, indent + 1, lines);
2241            }
2242        }
2243        _ => {
2244            lines.push(format!(
2245                "{}{}: {}",
2246                prefix,
2247                yaml_key(key),
2248                yaml_scalar(value)
2249            ));
2250        }
2251    }
2252}
2253
2254fn render_yaml_array_raw(arr: &[Value], indent: usize, lines: &mut Vec<String>) {
2255    let prefix = "  ".repeat(indent);
2256    for item in arr {
2257        match item {
2258            Value::Object(inner) if !inner.is_empty() => {
2259                lines.push(format!("{}-", prefix));
2260                render_yaml_raw(item, indent + 1, lines);
2261            }
2262            Value::Array(nested) if !nested.is_empty() => {
2263                lines.push(format!("{}-", prefix));
2264                render_yaml_array_raw(nested, indent + 1, lines);
2265            }
2266            Value::Object(_) => {
2267                lines.push(format!("{}- {{}}", prefix));
2268            }
2269            Value::Array(_) => {
2270                lines.push(format!("{}- []", prefix));
2271            }
2272            _ => {
2273                lines.push(format!("{}- {}", prefix, yaml_scalar(item)));
2274            }
2275        }
2276    }
2277}
2278
2279fn escape_yaml_str(s: &str) -> String {
2280    s.replace('\\', "\\\\")
2281        .replace('"', "\\\"")
2282        .replace('\n', "\\n")
2283        .replace('\r', "\\r")
2284        .replace('\t', "\\t")
2285        .replace('\x0c', "\\f")
2286        .replace('\x0b', "\\v")
2287}
2288
2289fn yaml_key(key: &str) -> String {
2290    if is_safe_key(key) {
2291        key.to_string()
2292    } else {
2293        format!("\"{}\"", escape_yaml_str(key))
2294    }
2295}
2296
2297fn quote_logfmt_key(key: &str) -> String {
2298    if is_safe_key(key) {
2299        key.to_string()
2300    } else {
2301        quote_logfmt_value(key)
2302    }
2303}
2304
2305fn is_safe_key(key: &str) -> bool {
2306    !key.is_empty()
2307        && key
2308            .bytes()
2309            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
2310}
2311
2312fn yaml_scalar(value: &Value) -> String {
2313    match value {
2314        Value::String(s) => format!("\"{}\"", escape_yaml_str(s)),
2315        Value::Null => "null".to_string(),
2316        Value::Bool(b) => b.to_string(),
2317        Value::Number(n) => format_number(n),
2318        Value::Object(_) | Value::Array(_) => {
2319            format!("\"{}\"", escape_yaml_str(&canonical_json(value)))
2320        }
2321    }
2322}
2323
2324// ═══════════════════════════════════════════
2325// Plain Rendering (logfmt)
2326// ═══════════════════════════════════════════
2327
2328fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2329    if let Value::Object(map) = value {
2330        let processed = process_object_fields(map);
2331        for (display_key, v, formatted) in processed {
2332            let full_key = if prefix.is_empty() {
2333                display_key
2334            } else {
2335                format!("{}.{}", prefix, display_key)
2336            };
2337            if let Some(fv) = formatted {
2338                pairs.push((full_key, fv));
2339            } else {
2340                match v {
2341                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
2342                    Value::Array(arr) => {
2343                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2344                        pairs.push((full_key, joined));
2345                    }
2346                    Value::Null => pairs.push((full_key, String::new())),
2347                    _ => pairs.push((full_key, plain_scalar(v))),
2348                }
2349            }
2350        }
2351    }
2352}
2353
2354fn collect_plain_pairs_raw(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
2355    if let Value::Object(map) = value {
2356        for key in sorted_value_keys(map) {
2357            let v = &map[&key];
2358            let full_key = if prefix.is_empty() {
2359                key.clone()
2360            } else {
2361                format!("{}.{}", prefix, key)
2362            };
2363            match v {
2364                Value::Object(_) => collect_plain_pairs_raw(v, &full_key, pairs),
2365                Value::Array(arr) => {
2366                    let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
2367                    pairs.push((full_key, joined));
2368                }
2369                Value::Null => pairs.push((full_key, String::new())),
2370                _ => pairs.push((full_key, plain_scalar(v))),
2371            }
2372        }
2373    }
2374}
2375
2376fn plain_scalar(value: &Value) -> String {
2377    match value {
2378        Value::String(s) => s.clone(),
2379        Value::Null => "null".to_string(),
2380        Value::Bool(b) => b.to_string(),
2381        Value::Number(n) => format_number(n),
2382        Value::Object(_) | Value::Array(_) => canonical_json(value),
2383    }
2384}
2385
2386fn quote_logfmt_value(value: &str) -> String {
2387    if value.is_empty() {
2388        return String::new();
2389    }
2390    if !value
2391        .chars()
2392        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
2393    {
2394        return value.to_string();
2395    }
2396    let escaped = value
2397        .replace('\\', "\\\\")
2398        .replace('"', "\\\"")
2399        .replace('\n', "\\n")
2400        .replace('\r', "\\r")
2401        .replace('\t', "\\t")
2402        .replace('\x0c', "\\f")
2403        .replace('\x0b', "\\v");
2404    format!("\"{}\"", escaped)
2405}
2406
2407fn canonical_json(value: &Value) -> String {
2408    serde_json::to_string(&sort_json_value(value))
2409        .unwrap_or_else(|_| "<unsupported:json>".to_string())
2410}
2411
2412fn sort_json_value(value: &Value) -> Value {
2413    match value {
2414        Value::Object(map) => {
2415            let mut out = serde_json::Map::new();
2416            for key in sorted_value_keys(map) {
2417                if let Some(v) = map.get(&key) {
2418                    out.insert(key, sort_json_value(v));
2419                }
2420            }
2421            Value::Object(out)
2422        }
2423        Value::Array(arr) => Value::Array(arr.iter().map(sort_json_value).collect()),
2424        _ => value.clone(),
2425    }
2426}
2427
2428fn sorted_value_keys(map: &serde_json::Map<String, Value>) -> Vec<String> {
2429    let mut keys: Vec<String> = map.keys().cloned().collect();
2430    keys.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
2431    keys
2432}
2433
2434#[cfg(test)]
2435mod tests;