Skip to main content

agent_first_data/
lib.rs

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