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