Skip to main content

agent_first_data/
lib.rs

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