Skip to main content

agent_first_data/
lib.rs

1//! Agent-First Data (AFDATA) output formatting and protocol templates.
2//!
3//! 20 public APIs and 3 types (+ 2 optional help renderers):
4//! - 3 protocol builders: [`build_json_ok`], [`build_json_error`], [`build_json`]
5//! - 3 redacted value helpers: [`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 redaction utilities: [`internal_redact_secrets`], [`internal_redact_secrets_with_options`]
9//! - 1 parse utility: [`parse_size`]
10//! - 4 CLI helpers: [`cli_parse_output`], [`cli_parse_log_filters`], [`cli_output`], [`build_cli_error`]
11//! - 3 types: [`OutputFormat`], [`RedactionPolicy`], [`RedactionOptions`]
12//! - (feature `cli-help`): [`cli_render_help`] — recursive plain-text help for clap commands
13//! - (feature `cli-help-markdown`): [`cli_render_help_markdown`] — recursive Markdown help
14
15#[cfg(feature = "tracing")]
16pub mod afdata_tracing;
17
18use serde_json::Value;
19use std::collections::HashSet;
20
21// ═══════════════════════════════════════════
22// Public API: Protocol Builders
23// ═══════════════════════════════════════════
24
25/// Build `{code: "ok", result: ..., trace?: ...}`.
26pub fn build_json_ok(result: Value, trace: Option<Value>) -> Value {
27    match trace {
28        Some(t) => serde_json::json!({"code": "ok", "result": result, "trace": t}),
29        None => serde_json::json!({"code": "ok", "result": result}),
30    }
31}
32
33/// Build `{code: "error", error: message, hint?: ..., trace?: ...}`.
34pub fn build_json_error(message: &str, hint: Option<&str>, trace: Option<Value>) -> Value {
35    let mut obj = serde_json::Map::new();
36    obj.insert("code".to_string(), Value::String("error".to_string()));
37    obj.insert("error".to_string(), Value::String(message.to_string()));
38    if let Some(h) = hint {
39        obj.insert("hint".to_string(), Value::String(h.to_string()));
40    }
41    if let Some(t) = trace {
42        obj.insert("trace".to_string(), t);
43    }
44    Value::Object(obj)
45}
46
47/// Build `{code: "<custom>", ...fields, trace?: ...}`.
48pub fn build_json(code: &str, fields: Value, trace: Option<Value>) -> Value {
49    let mut obj = match fields {
50        Value::Object(map) => map,
51        _ => serde_json::Map::new(),
52    };
53    obj.insert("code".to_string(), Value::String(code.to_string()));
54    if let Some(t) = trace {
55        obj.insert("trace".to_string(), t);
56    }
57    Value::Object(obj)
58}
59
60// ═══════════════════════════════════════════
61// Public API: Output Formatters
62// ═══════════════════════════════════════════
63
64/// Redaction policy for [`output_json_with`].
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub enum RedactionPolicy {
67    /// Redact only inside top-level `trace`.
68    RedactionTraceOnly,
69    /// Do not redact any fields.
70    RedactionNone,
71    /// Replace every `_secret` subtree with `"***"`.
72    RedactionStrict,
73}
74
75/// Redaction options for legacy secret field names.
76#[derive(Clone, Debug, Default, PartialEq, Eq)]
77pub struct RedactionOptions {
78    /// Optional scoped policy. `None` means default full redaction.
79    pub policy: Option<RedactionPolicy>,
80    /// Field names to treat as secrets in addition to `_secret` suffixes.
81    ///
82    /// Matching is exact field-name equality at any nesting level.
83    pub secret_names: Vec<String>,
84}
85
86/// Format as single-line JSON with full `_secret` redaction.
87pub fn output_json(value: &Value) -> String {
88    serialize_json_output(&redacted_value(value))
89}
90
91/// Format as single-line JSON with configurable redaction policy.
92pub fn output_json_with(value: &Value, redaction_policy: RedactionPolicy) -> String {
93    serialize_json_output(&redacted_value_with(value, redaction_policy))
94}
95
96/// Format as single-line JSON with configurable redaction options.
97pub fn output_json_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
98    serialize_json_output(&redacted_value_with_options(value, redaction_options))
99}
100
101fn serialize_json_output(value: &Value) -> String {
102    match serde_json::to_string(value) {
103        Ok(s) => s,
104        Err(err) => serde_json::json!({
105            "error": "output_json_failed",
106            "detail": err.to_string(),
107        })
108        .to_string(),
109    }
110}
111
112/// Format as multi-line YAML. Keys stripped, values formatted, secrets redacted.
113pub fn output_yaml(value: &Value) -> String {
114    let mut lines = vec!["---".to_string()];
115    let v = redacted_value(value);
116    render_yaml_processed(&v, 0, &mut lines);
117    lines.join("\n")
118}
119
120/// Format as multi-line YAML with configurable redaction options.
121pub fn output_yaml_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
122    let mut lines = vec!["---".to_string()];
123    let v = redacted_value_with_options(value, redaction_options);
124    render_yaml_processed(&v, 0, &mut lines);
125    lines.join("\n")
126}
127
128/// Format as single-line logfmt. Keys stripped, values formatted, secrets redacted.
129pub fn output_plain(value: &Value) -> String {
130    let mut pairs: Vec<(String, String)> = Vec::new();
131    let v = redacted_value(value);
132    collect_plain_pairs(&v, "", &mut pairs);
133    pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
134    pairs
135        .into_iter()
136        .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
137        .collect::<Vec<_>>()
138        .join(" ")
139}
140
141/// Format as single-line logfmt with configurable redaction options.
142pub fn output_plain_with_options(value: &Value, redaction_options: &RedactionOptions) -> String {
143    let mut pairs: Vec<(String, String)> = Vec::new();
144    let v = redacted_value_with_options(value, redaction_options);
145    collect_plain_pairs(&v, "", &mut pairs);
146    pairs.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
147    pairs
148        .into_iter()
149        .map(|(k, v)| format!("{}={}", k, quote_logfmt_value(&v)))
150        .collect::<Vec<_>>()
151        .join(" ")
152}
153
154// ═══════════════════════════════════════════
155// Public API: Redaction & Utility
156// ═══════════════════════════════════════════
157
158/// Redact `_secret` fields in-place.
159pub fn internal_redact_secrets(value: &mut Value) {
160    redact_secrets(value);
161}
162
163/// Redact secret fields in-place using configurable redaction options.
164pub fn internal_redact_secrets_with_options(
165    value: &mut Value,
166    redaction_options: &RedactionOptions,
167) {
168    apply_redaction_options(value, redaction_options);
169}
170
171/// Return a JSON value copy with default `_secret` redaction applied.
172pub fn redacted_value(value: &Value) -> Value {
173    let mut v = value.clone();
174    redact_secrets(&mut v);
175    v
176}
177
178/// Return a JSON value copy with an explicit redaction policy applied.
179pub fn redacted_value_with(value: &Value, redaction_policy: RedactionPolicy) -> Value {
180    let mut v = value.clone();
181    apply_redaction_policy(&mut v, redaction_policy);
182    v
183}
184
185/// Return a JSON value copy with configurable redaction options applied.
186pub fn redacted_value_with_options(value: &Value, redaction_options: &RedactionOptions) -> Value {
187    let mut v = value.clone();
188    apply_redaction_options(&mut v, redaction_options);
189    v
190}
191
192/// Parse a human-readable size string into bytes.
193///
194/// Accepts bare number, or number followed by unit letter
195/// (`B`, `K`, `M`, `G`, `T`). Case-insensitive. Trims whitespace.
196/// Returns `None` for invalid or negative input.
197pub fn parse_size(s: &str) -> Option<u64> {
198    let s = s.trim();
199    if s.is_empty() {
200        return None;
201    }
202    let last = *s.as_bytes().last()?;
203    let (num_str, mult) = match last {
204        b'B' | b'b' => (&s[..s.len() - 1], 1u64),
205        b'K' | b'k' => (&s[..s.len() - 1], 1024),
206        b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
207        b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
208        b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
209        b'0'..=b'9' | b'.' => (s, 1),
210        _ => return None,
211    };
212    if num_str.is_empty() {
213        return None;
214    }
215    if let Ok(n) = num_str.parse::<u64>() {
216        return n.checked_mul(mult);
217    }
218    // Integer overflow must not silently fall back to float parsing.
219    if !num_str.contains('.') && !num_str.contains('e') && !num_str.contains('E') {
220        return None;
221    }
222    let f: f64 = num_str.parse().ok()?;
223    if f < 0.0 || f.is_nan() || f.is_infinite() {
224        return None;
225    }
226    let result = f * mult as f64;
227    if result >= u64::MAX as f64 {
228        return None;
229    }
230    Some(result as u64)
231}
232
233// ═══════════════════════════════════════════
234// Public API: CLI Helpers
235// ═══════════════════════════════════════════
236
237/// Output format for CLI and pipe/MCP modes.
238#[derive(Clone, Copy, Debug, PartialEq, Eq)]
239pub enum OutputFormat {
240    Json,
241    Yaml,
242    Plain,
243}
244
245/// Parse `--output` flag value into [`OutputFormat`].
246///
247/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
248///
249/// ```
250/// use agent_first_data::{cli_parse_output, OutputFormat};
251/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
252/// assert!(cli_parse_output("xml").is_err());
253/// ```
254pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
255    match s {
256        "json" => Ok(OutputFormat::Json),
257        "yaml" => Ok(OutputFormat::Yaml),
258        "plain" => Ok(OutputFormat::Plain),
259        _ => Err(format!(
260            "invalid --output format '{s}': expected json, yaml, or plain"
261        )),
262    }
263}
264
265/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
266///
267/// Accepts pre-split entries as produced by clap's `value_delimiter = ','`.
268///
269/// ```
270/// use agent_first_data::cli_parse_log_filters;
271/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
272/// assert_eq!(f, vec!["query", "error"]);
273/// ```
274pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> Vec<String> {
275    let mut out: Vec<String> = Vec::new();
276    for entry in entries {
277        let s = entry.as_ref().trim().to_ascii_lowercase();
278        if !s.is_empty() && !out.contains(&s) {
279            out.push(s);
280        }
281    }
282    out
283}
284
285/// Dispatch output formatting by [`OutputFormat`].
286///
287/// Equivalent to calling [`output_json`], [`output_yaml`], or [`output_plain`] directly.
288///
289/// ```
290/// use agent_first_data::{cli_output, OutputFormat};
291/// let v = serde_json::json!({"code": "ok"});
292/// let s = cli_output(&v, OutputFormat::Plain);
293/// assert!(s.contains("code=ok"));
294/// ```
295pub fn cli_output(value: &Value, format: OutputFormat) -> String {
296    match format {
297        OutputFormat::Json => output_json(value),
298        OutputFormat::Yaml => output_yaml(value),
299        OutputFormat::Plain => output_plain(value),
300    }
301}
302
303/// Build a standard CLI parse error value.
304///
305/// Use when `Cli::try_parse()` fails or a flag value is invalid.
306/// Print with [`output_json`] and exit with code 2.
307///
308/// ```
309/// let err = agent_first_data::build_cli_error("--output: invalid value 'xml'", None);
310/// assert_eq!(err["code"], "error");
311/// assert_eq!(err["error_code"], "invalid_request");
312/// assert_eq!(err["retryable"], false);
313/// ```
314pub fn build_cli_error(message: &str, hint: Option<&str>) -> Value {
315    let mut obj = serde_json::Map::new();
316    obj.insert("code".to_string(), Value::String("error".to_string()));
317    obj.insert(
318        "error_code".to_string(),
319        Value::String("invalid_request".to_string()),
320    );
321    obj.insert("error".to_string(), Value::String(message.to_string()));
322    if let Some(h) = hint {
323        obj.insert("hint".to_string(), Value::String(h.to_string()));
324    }
325    obj.insert("retryable".to_string(), Value::Bool(false));
326    obj.insert("trace".to_string(), serde_json::json!({"duration_ms": 0}));
327    Value::Object(obj)
328}
329
330// ═══════════════════════════════════════════
331// Public API: CLI Help Rendering (optional)
332// ═══════════════════════════════════════════
333
334/// Render recursive plain-text help for a clap command tree.
335///
336/// Walks to the subcommand identified by `subcommand_path` (empty = root),
337/// then recursively expands all descendant subcommands into a single output.
338/// Agents read `--help` once and get the complete CLI interface.
339///
340/// Requires the `cli-help` feature.
341#[cfg(feature = "cli-help")]
342pub fn cli_render_help(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
343    let target = walk_to_subcommand(cmd, subcommand_path);
344    let mut buf = String::new();
345    render_help_recursive(target, &[], &mut buf, true);
346    buf
347}
348
349/// Render recursive Markdown help for a clap command tree.
350///
351/// Same tree walk as [`cli_render_help`], but outputs Markdown suitable for
352/// documentation generation (`myapp --help-markdown > docs/cli.md`).
353///
354/// Requires the `cli-help-markdown` feature.
355#[cfg(feature = "cli-help-markdown")]
356pub fn cli_render_help_markdown(cmd: &clap::Command, subcommand_path: &[&str]) -> String {
357    let target = walk_to_subcommand(cmd, subcommand_path);
358    let md = clap_markdown::help_markdown_command(target);
359    // Strip the clap-markdown footer (<hr/> + <small>...</small>)
360    md.rfind("\n<hr/>")
361        .map_or(md.clone(), |pos| md[..pos].to_string())
362}
363
364#[cfg(feature = "cli-help")]
365fn walk_to_subcommand<'a>(cmd: &'a clap::Command, path: &[&str]) -> &'a clap::Command {
366    let mut current = cmd;
367    for name in path {
368        current = current.find_subcommand(name).unwrap_or(current);
369    }
370    current
371}
372
373#[cfg(feature = "cli-help")]
374fn render_help_recursive(
375    cmd: &clap::Command,
376    parent_path: &[&str],
377    buf: &mut String,
378    is_root: bool,
379) {
380    use std::fmt::Write;
381
382    // Build the full command path (e.g. "myapp service start")
383    let mut cmd_path = parent_path.to_vec();
384    cmd_path.push(cmd.get_name());
385    let path_str = cmd_path.join(" ");
386
387    // Separator between commands (skip for the first one)
388    if !buf.is_empty() {
389        let _ = writeln!(buf);
390        let _ = writeln!(buf, "{}", "═".repeat(60));
391    }
392
393    // Header: "myapp service start — description"
394    if let Some(about) = cmd.get_about() {
395        let _ = writeln!(buf, "{path_str} — {about}");
396    } else {
397        let _ = writeln!(buf, "{path_str}");
398    }
399    let _ = writeln!(buf);
400
401    // Render clap's built-in help for this command (usage, args, options)
402    let styled = cmd.clone().render_long_help();
403    let help_text = styled.to_string();
404
405    // In root command, insert --help-markdown after the "Print help" line
406    if is_root {
407        let mut found_help = false;
408        for line in help_text.lines() {
409            let _ = writeln!(buf, "{line}");
410            if line.trim_start().starts_with("-h, --help") {
411                found_help = true;
412            } else if found_help && line.contains("Print help") {
413                let _ = writeln!(buf, "      --help-markdown");
414                let _ = writeln!(
415                    buf,
416                    "          Output help as Markdown (for documentation generation)"
417                );
418                found_help = false;
419            } else {
420                found_help = false;
421            }
422        }
423    } else {
424        let _ = write!(buf, "{help_text}");
425    }
426
427    // Recurse into visible subcommands
428    for sub in cmd.get_subcommands() {
429        if sub.get_name() == "help" {
430            continue; // skip clap's auto-generated "help" subcommand
431        }
432        render_help_recursive(sub, &cmd_path, buf, false);
433    }
434}
435
436// ═══════════════════════════════════════════
437// Secret Redaction
438// ═══════════════════════════════════════════
439
440#[derive(Default)]
441struct RedactionContext {
442    secret_names: HashSet<String>,
443}
444
445impl RedactionContext {
446    fn from_options(redaction_options: &RedactionOptions) -> Self {
447        let secret_names = redaction_options.secret_names.iter().cloned().collect();
448        Self { secret_names }
449    }
450
451    fn is_secret_key(&self, key: &str) -> bool {
452        key_has_secret_suffix(key) || self.secret_names.contains(key)
453    }
454}
455
456fn key_has_secret_suffix(key: &str) -> bool {
457    key.ends_with("_secret") || key.ends_with("_SECRET")
458}
459
460fn redact_secrets(value: &mut Value) {
461    let context = RedactionContext::default();
462    redact_secrets_with_context(value, &context);
463}
464
465fn redact_secrets_with_context(value: &mut Value, context: &RedactionContext) {
466    match value {
467        Value::Object(map) => {
468            let keys: Vec<String> = map.keys().cloned().collect();
469            for key in keys {
470                if context.is_secret_key(&key) {
471                    match map.get(&key) {
472                        Some(Value::Object(_)) | Some(Value::Array(_)) => {
473                            // Traverse containers, don't replace
474                        }
475                        _ => {
476                            map.insert(key.clone(), Value::String("***".into()));
477                            continue;
478                        }
479                    }
480                }
481                if let Some(v) = map.get_mut(&key) {
482                    redact_secrets_with_context(v, context);
483                }
484            }
485        }
486        Value::Array(arr) => {
487            for v in arr {
488                redact_secrets_with_context(v, context);
489            }
490        }
491        _ => {}
492    }
493}
494
495fn redact_secrets_strict_with_context(value: &mut Value, context: &RedactionContext) {
496    match value {
497        Value::Object(map) => {
498            let keys: Vec<String> = map.keys().cloned().collect();
499            for key in keys {
500                if context.is_secret_key(&key) {
501                    map.insert(key, Value::String("***".into()));
502                } else if let Some(v) = map.get_mut(&key) {
503                    redact_secrets_strict_with_context(v, context);
504                }
505            }
506        }
507        Value::Array(arr) => {
508            for v in arr {
509                redact_secrets_strict_with_context(v, context);
510            }
511        }
512        _ => {}
513    }
514}
515
516fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
517    let context = RedactionContext::default();
518    apply_redaction_policy_with_context(value, Some(redaction_policy), &context);
519}
520
521fn apply_redaction_options(value: &mut Value, redaction_options: &RedactionOptions) {
522    let context = RedactionContext::from_options(redaction_options);
523    apply_redaction_policy_with_context(value, redaction_options.policy, &context);
524}
525
526fn apply_redaction_policy_with_context(
527    value: &mut Value,
528    redaction_policy: Option<RedactionPolicy>,
529    context: &RedactionContext,
530) {
531    match redaction_policy {
532        Some(RedactionPolicy::RedactionTraceOnly) => {
533            if let Value::Object(map) = value {
534                if let Some(trace) = map.get_mut("trace") {
535                    redact_secrets_with_context(trace, context);
536                }
537            }
538        }
539        Some(RedactionPolicy::RedactionNone) => {}
540        Some(RedactionPolicy::RedactionStrict) => {
541            redact_secrets_strict_with_context(value, context)
542        }
543        None => redact_secrets_with_context(value, context),
544    }
545}
546
547// ═══════════════════════════════════════════
548// Suffix Processing
549// ═══════════════════════════════════════════
550
551/// Strip a suffix matching exact lowercase or exact uppercase only.
552fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
553    if let Some(s) = key.strip_suffix(suffix_lower) {
554        return Some(s.to_string());
555    }
556    let suffix_upper: String = suffix_lower
557        .chars()
558        .map(|c| c.to_ascii_uppercase())
559        .collect();
560    if let Some(s) = key.strip_suffix(&suffix_upper) {
561        return Some(s.to_string());
562    }
563    None
564}
565
566/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
567fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
568    let code = extract_currency_code(key)?;
569    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
570    let stripped = &key[..key.len() - suffix_len];
571    if stripped.is_empty() {
572        return None;
573    }
574    Some((stripped.to_string(), code.to_string()))
575}
576
577/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
578/// when suffix matches and type is valid. None for no match or type mismatch.
579fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
580    // Group 1: compound timestamp suffixes
581    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
582        return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
583    }
584    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
585        return value
586            .as_i64()
587            .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
588    }
589    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
590        return value
591            .as_i64()
592            .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
593    }
594
595    // Group 2: compound currency suffixes
596    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
597        return value
598            .as_u64()
599            .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
600    }
601    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
602        return value
603            .as_u64()
604            .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
605    }
606    if let Some((stripped, code)) = try_strip_generic_cents(key) {
607        return value.as_u64().map(|n| {
608            (
609                stripped,
610                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
611            )
612        });
613    }
614
615    // Group 3: multi-char suffixes
616    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
617        return value.as_str().map(|s| (stripped, s.to_string()));
618    }
619    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
620        return value
621            .is_number()
622            .then(|| (stripped, format!("{} minutes", number_str(value))));
623    }
624    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
625        return value
626            .is_number()
627            .then(|| (stripped, format!("{} hours", number_str(value))));
628    }
629    if let Some(stripped) = strip_suffix_ci(key, "_days") {
630        return value
631            .is_number()
632            .then(|| (stripped, format!("{} days", number_str(value))));
633    }
634
635    // Group 4: single-unit suffixes
636    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
637        return value
638            .is_number()
639            .then(|| (stripped, format!("{}msats", number_str(value))));
640    }
641    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
642        return value
643            .is_number()
644            .then(|| (stripped, format!("{}sats", number_str(value))));
645    }
646    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
647        return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
648    }
649    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
650        return value
651            .is_number()
652            .then(|| (stripped, format!("{}%", number_str(value))));
653    }
654    if let Some(stripped) = strip_suffix_ci(key, "_secret") {
655        return Some((stripped, "***".to_string()));
656    }
657
658    // Group 5: short suffixes (last to avoid false positives)
659    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
660        return value
661            .is_number()
662            .then(|| (stripped, format!("{} BTC", number_str(value))));
663    }
664    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
665        return value
666            .as_u64()
667            .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
668    }
669    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
670        return value
671            .is_number()
672            .then(|| (stripped, format!("{}ns", number_str(value))));
673    }
674    if let Some(stripped) = strip_suffix_ci(key, "_us") {
675        return value
676            .is_number()
677            .then(|| (stripped, format!("{}μs", number_str(value))));
678    }
679    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
680        return format_ms_value(value).map(|v| (stripped, v));
681    }
682    if let Some(stripped) = strip_suffix_ci(key, "_s") {
683        return value
684            .is_number()
685            .then(|| (stripped, format!("{}s", number_str(value))));
686    }
687
688    None
689}
690
691/// Process object fields: strip keys, format values, detect collisions.
692fn process_object_fields<'a>(
693    map: &'a serde_json::Map<String, Value>,
694) -> Vec<(String, &'a Value, Option<String>)> {
695    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
696    for (key, value) in map {
697        match try_process_field(key, value) {
698            Some((stripped, formatted)) => {
699                entries.push((stripped, key.as_str(), value, Some(formatted)));
700            }
701            None => {
702                entries.push((key.clone(), key.as_str(), value, None));
703            }
704        }
705    }
706
707    // Detect collisions
708    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
709    for (stripped, _, _, _) in &entries {
710        *counts.entry(stripped.clone()).or_insert(0) += 1;
711    }
712
713    // Resolve collisions: revert both key and formatted value
714    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
715        .into_iter()
716        .map(|(stripped, original, value, formatted)| {
717            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
718                (original.to_string(), value, None)
719            } else {
720                (stripped, value, formatted)
721            }
722        })
723        .collect();
724
725    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
726    result
727}
728
729// ═══════════════════════════════════════════
730// Formatting Helpers
731// ═══════════════════════════════════════════
732
733fn number_str(value: &Value) -> String {
734    match value {
735        Value::Number(n) => n.to_string(),
736        _ => String::new(),
737    }
738}
739
740/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
741fn format_ms_as_seconds(ms: f64) -> String {
742    let formatted = format!("{:.3}", ms / 1000.0);
743    let trimmed = formatted.trim_end_matches('0');
744    if trimmed.ends_with('.') {
745        format!("{}0s", trimmed)
746    } else {
747        format!("{}s", trimmed)
748    }
749}
750
751/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
752fn format_ms_value(value: &Value) -> Option<String> {
753    let n = value.as_f64()?;
754    if n.abs() >= 1000.0 {
755        Some(format_ms_as_seconds(n))
756    } else if let Some(i) = value.as_i64() {
757        Some(format!("{}ms", i))
758    } else {
759        Some(format!("{}ms", number_str(value)))
760    }
761}
762
763/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
764fn format_rfc3339_ms(ms: i64) -> String {
765    use chrono::{DateTime, Utc};
766    let secs = ms.div_euclid(1000);
767    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
768    match DateTime::from_timestamp(secs, nanos) {
769        Some(dt) => dt
770            .with_timezone(&Utc)
771            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
772        None => ms.to_string(),
773    }
774}
775
776/// Format bytes as human-readable size (binary units). Handles negative values.
777fn format_bytes_human(bytes: i64) -> String {
778    const KB: f64 = 1024.0;
779    const MB: f64 = KB * 1024.0;
780    const GB: f64 = MB * 1024.0;
781    const TB: f64 = GB * 1024.0;
782
783    let sign = if bytes < 0 { "-" } else { "" };
784    let b = (bytes as f64).abs();
785    if b >= TB {
786        format!("{sign}{:.1}TB", b / TB)
787    } else if b >= GB {
788        format!("{sign}{:.1}GB", b / GB)
789    } else if b >= MB {
790        format!("{sign}{:.1}MB", b / MB)
791    } else if b >= KB {
792        format!("{sign}{:.1}KB", b / KB)
793    } else {
794        format!("{bytes}B")
795    }
796}
797
798/// Format a number with thousands separators.
799fn format_with_commas(n: u64) -> String {
800    let s = n.to_string();
801    let mut result = String::with_capacity(s.len() + s.len() / 3);
802    for (i, c) in s.chars().enumerate() {
803        if i > 0 && (s.len() - i).is_multiple_of(3) {
804            result.push(',');
805        }
806        result.push(c);
807    }
808    result
809}
810
811/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
812fn extract_currency_code(key: &str) -> Option<&str> {
813    let without_cents = key
814        .strip_suffix("_cents")
815        .or_else(|| key.strip_suffix("_CENTS"))?;
816    let last_underscore = without_cents.rfind('_')?;
817    let code = &without_cents[last_underscore + 1..];
818    if code.is_empty() {
819        return None;
820    }
821    Some(code)
822}
823
824// ═══════════════════════════════════════════
825// YAML Rendering
826// ═══════════════════════════════════════════
827
828fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
829    let prefix = "  ".repeat(indent);
830    match value {
831        Value::Object(map) => {
832            let processed = process_object_fields(map);
833            for (display_key, v, formatted) in processed {
834                if let Some(fv) = formatted {
835                    lines.push(format!(
836                        "{}{}: \"{}\"",
837                        prefix,
838                        display_key,
839                        escape_yaml_str(&fv)
840                    ));
841                } else {
842                    match v {
843                        Value::Object(inner) if !inner.is_empty() => {
844                            lines.push(format!("{}{}:", prefix, display_key));
845                            render_yaml_processed(v, indent + 1, lines);
846                        }
847                        Value::Object(_) => {
848                            lines.push(format!("{}{}: {{}}", prefix, display_key));
849                        }
850                        Value::Array(arr) => {
851                            if arr.is_empty() {
852                                lines.push(format!("{}{}: []", prefix, display_key));
853                            } else {
854                                lines.push(format!("{}{}:", prefix, display_key));
855                                for item in arr {
856                                    if item.is_object() {
857                                        lines.push(format!("{}  -", prefix));
858                                        render_yaml_processed(item, indent + 2, lines);
859                                    } else {
860                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
861                                    }
862                                }
863                            }
864                        }
865                        _ => {
866                            lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
867                        }
868                    }
869                }
870            }
871        }
872        _ => {
873            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
874        }
875    }
876}
877
878fn escape_yaml_str(s: &str) -> String {
879    s.replace('\\', "\\\\")
880        .replace('"', "\\\"")
881        .replace('\n', "\\n")
882        .replace('\r', "\\r")
883        .replace('\t', "\\t")
884}
885
886fn yaml_scalar(value: &Value) -> String {
887    match value {
888        Value::String(s) => {
889            format!("\"{}\"", escape_yaml_str(s))
890        }
891        Value::Null => "null".to_string(),
892        Value::Bool(b) => b.to_string(),
893        Value::Number(n) => n.to_string(),
894        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
895    }
896}
897
898// ═══════════════════════════════════════════
899// Plain Rendering (logfmt)
900// ═══════════════════════════════════════════
901
902fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
903    if let Value::Object(map) = value {
904        let processed = process_object_fields(map);
905        for (display_key, v, formatted) in processed {
906            let full_key = if prefix.is_empty() {
907                display_key
908            } else {
909                format!("{}.{}", prefix, display_key)
910            };
911            if let Some(fv) = formatted {
912                pairs.push((full_key, fv));
913            } else {
914                match v {
915                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
916                    Value::Array(arr) => {
917                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
918                        pairs.push((full_key, joined));
919                    }
920                    Value::Null => pairs.push((full_key, String::new())),
921                    _ => pairs.push((full_key, plain_scalar(v))),
922                }
923            }
924        }
925    }
926}
927
928fn plain_scalar(value: &Value) -> String {
929    match value {
930        Value::String(s) => s.clone(),
931        Value::Null => "null".to_string(),
932        Value::Bool(b) => b.to_string(),
933        Value::Number(n) => n.to_string(),
934        other => other.to_string(),
935    }
936}
937
938fn quote_logfmt_value(value: &str) -> String {
939    if value.is_empty() {
940        return String::new();
941    }
942    if !value
943        .chars()
944        .any(|c| c.is_whitespace() || matches!(c, '=' | '"' | '\\'))
945    {
946        return value.to_string();
947    }
948    let escaped = value
949        .replace('\\', "\\\\")
950        .replace('"', "\\\"")
951        .replace('\n', "\\n")
952        .replace('\r', "\\r")
953        .replace('\t', "\\t");
954    format!("\"{}\"", escaped)
955}
956
957#[cfg(test)]
958mod tests;