Skip to main content

agent_first_data/
lib.rs

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