Skip to main content

agent_first_data/
lib.rs

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