Skip to main content

agent_first_data/
lib.rs

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