Skip to main content

agent_first_data/
lib.rs

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