Skip to main content

agent_first_data/
lib.rs

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