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