Skip to main content

agent_first_data/
lib.rs

1//! Agent-First Data (AFD) output formatting and protocol templates.
2//!
3//! Implements the AFD output convention. JSON is the canonical lossless format.
4//! YAML preserves structure with quoted strings. Plain applies suffix-driven
5//! formatting for human readability.
6//!
7//! ```text
8//! --output json|yaml|plain
9//! ```
10
11use serde_json::Value;
12
13/// Output format for CLI and API responses.
14#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
15pub enum OutputFormat {
16    #[default]
17    Json,
18    Yaml,
19    Plain,
20}
21
22impl OutputFormat {
23    /// Format a JSON value as a single compact line (JSONL-compatible).
24    pub fn format(&self, value: &Value) -> String {
25        match self {
26            Self::Json => serde_json::to_string(value).unwrap_or_default(),
27            Self::Yaml => to_yaml(value),
28            Self::Plain => to_plain(value),
29        }
30    }
31
32    /// Format a JSON value with pretty printing (JSON only; yaml/plain unchanged).
33    pub fn format_pretty(&self, value: &Value) -> String {
34        match self {
35            Self::Json => serde_json::to_string_pretty(value).unwrap_or_default(),
36            Self::Yaml => to_yaml(value),
37            Self::Plain => to_plain(value),
38        }
39    }
40}
41
42// ═══════════════════════════════════════════
43// YAML
44// ═══════════════════════════════════════════
45
46/// Convert a JSON Value into a YAML document.
47///
48/// Strings are always quoted to avoid YAML pitfalls (`no` → `false`, `3.0` → float).
49/// Values are preserved as-is — no suffix-driven transformation.
50/// Starts with `---` for multi-document streaming compatibility.
51pub fn to_yaml(value: &Value) -> String {
52    let mut lines = vec!["---".to_string()];
53    render_yaml(value, 0, &mut lines);
54    lines.join("\n")
55}
56
57fn render_yaml(value: &Value, indent: usize, lines: &mut Vec<String>) {
58    let prefix = "  ".repeat(indent);
59    match value {
60        Value::Object(map) => {
61            for (k, v) in jcs_sorted(map) {
62                match v {
63                    Value::Object(inner) if !inner.is_empty() => {
64                        lines.push(format!("{}{}:", prefix, k));
65                        render_yaml(v, indent + 1, lines);
66                    }
67                    Value::Object(_) => {
68                        lines.push(format!("{}{}: {{}}", prefix, k));
69                    }
70                    Value::Array(arr) => {
71                        if arr.is_empty() {
72                            lines.push(format!("{}{}: []", prefix, k));
73                        } else {
74                            lines.push(format!("{}{}:", prefix, k));
75                            for item in arr {
76                                if item.is_object() {
77                                    lines.push(format!("{}  -", prefix));
78                                    render_yaml(item, indent + 2, lines);
79                                } else {
80                                    lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
81                                }
82                            }
83                        }
84                    }
85                    _ => {
86                        lines.push(format!("{}{}: {}", prefix, k, yaml_scalar(v)));
87                    }
88                }
89            }
90        }
91        _ => {
92            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
93        }
94    }
95}
96
97/// Sort map entries by UTF-16 code unit order (JCS, RFC 8785).
98fn jcs_sorted(map: &serde_json::Map<String, Value>) -> Vec<(&String, &Value)> {
99    let mut entries: Vec<_> = map.iter().collect();
100    entries.sort_by(|(a, _), (b, _)| a.encode_utf16().cmp(b.encode_utf16()));
101    entries
102}
103
104fn yaml_scalar(value: &Value) -> String {
105    match value {
106        Value::String(s) => {
107            let escaped = s
108                .replace('\\', "\\\\")
109                .replace('"', "\\\"")
110                .replace('\n', "\\n")
111                .replace('\r', "\\r")
112                .replace('\t', "\\t");
113            format!("\"{}\"", escaped)
114        }
115        Value::Null => "null".to_string(),
116        Value::Bool(b) => b.to_string(),
117        Value::Number(n) => n.to_string(),
118        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
119    }
120}
121
122// ═══════════════════════════════════════════
123// Plain
124// ═══════════════════════════════════════════
125
126/// Convert a JSON Value into human-readable plain text.
127///
128/// Applies agent-first-data suffix-driven formatting:
129/// - `_ms` → append `ms`, or convert to seconds if ≥ 1000
130/// - `_bytes` → human-readable (`446.1KB`)
131/// - `_epoch_ms` → RFC 3339
132/// - `_secret` → `***`
133/// - Currency suffixes → formatted amounts
134pub fn to_plain(value: &Value) -> String {
135    let mut lines = Vec::new();
136    render_plain(value, 0, &mut lines);
137    lines.join("\n")
138}
139
140fn render_plain(value: &Value, indent: usize, lines: &mut Vec<String>) {
141    let prefix = "  ".repeat(indent);
142    match value {
143        Value::Object(map) => {
144            for (k, v) in jcs_sorted(map) {
145                match v {
146                    Value::Object(_) => {
147                        lines.push(format!("{}{}:", prefix, k));
148                        render_plain(v, indent + 1, lines);
149                    }
150                    Value::Array(arr) => {
151                        if arr.is_empty() {
152                            lines.push(format!("{}{}: []", prefix, k));
153                        } else if arr.iter().all(|v| !v.is_object() && !v.is_array()) {
154                            lines.push(format!("{}{}:", prefix, k));
155                            for item in arr {
156                                lines.push(format!("{}  - {}", prefix, plain_scalar(item)));
157                            }
158                        } else {
159                            lines.push(format!("{}{}:", prefix, k));
160                            for item in arr {
161                                if item.is_object() {
162                                    lines.push(format!("{}  -", prefix));
163                                    render_plain(item, indent + 2, lines);
164                                } else {
165                                    lines.push(format!("{}  - {}", prefix, plain_scalar(item)));
166                                }
167                            }
168                        }
169                    }
170                    _ => {
171                        lines.push(format!("{}{}: {}", prefix, k, format_plain_field(k, v)));
172                    }
173                }
174            }
175        }
176        _ => {
177            lines.push(format!("{}{}", prefix, plain_scalar(value)));
178        }
179    }
180}
181
182/// Format a scalar value for plain output, applying suffix-driven rules.
183///
184/// Suffix priority (most specific first):
185/// 1. `_secret` → `***`
186/// 2. `_epoch_ms` / `_epoch_s` / `_epoch_ns` → RFC 3339
187/// 3. `_rfc3339` → pass through
188/// 4. `_bytes` → human-readable size
189/// 5. Currency: `_msats`, `_sats`, `_btc`, `_usd_cents`, `_eur_cents`, `_cents`, `_jpy`
190/// 6. Duration: `_minutes`, `_hours`, `_days`, `_ms`, `_ns`, `_us`, `_s`
191fn format_plain_field(key: &str, value: &Value) -> String {
192    let lower = key.to_ascii_lowercase();
193
194    // Secret — always redact
195    if lower.ends_with("_secret") {
196        return "***".to_string();
197    }
198
199    // Timestamps → RFC 3339
200    if lower.ends_with("_epoch_ms") {
201        if let Some(ms) = value.as_i64() {
202            return format_rfc3339_ms(ms);
203        }
204    }
205    if lower.ends_with("_epoch_s") {
206        if let Some(s) = value.as_i64() {
207            return format_rfc3339_ms(s * 1000);
208        }
209    }
210    if lower.ends_with("_epoch_ns") {
211        if let Some(ns) = value.as_i64() {
212            return format_rfc3339_ms(ns.div_euclid(1_000_000));
213        }
214    }
215    if lower.ends_with("_rfc3339") {
216        return plain_scalar(value);
217    }
218
219    // Size
220    if lower.ends_with("_bytes") {
221        if let Some(n) = value.as_i64() {
222            return format_bytes_human(n);
223        }
224    }
225
226    // Percentage
227    if lower.ends_with("_percent") {
228        if value.is_number() {
229            return format!("{}%", plain_scalar(value));
230        }
231    }
232
233    // Currency — Bitcoin
234    if lower.ends_with("_msats") {
235        if value.is_number() {
236            return format!("{}msats", plain_scalar(value));
237        }
238    }
239    if lower.ends_with("_sats") {
240        if value.is_number() {
241            return format!("{}sats", plain_scalar(value));
242        }
243    }
244    if lower.ends_with("_btc") {
245        if value.is_number() {
246            return format!("{} BTC", plain_scalar(value));
247        }
248    }
249
250    // Currency — Fiat with symbol
251    if lower.ends_with("_usd_cents") {
252        if let Some(n) = value.as_u64() {
253            return format!("${}.{:02}", n / 100, n % 100);
254        }
255    }
256    if lower.ends_with("_eur_cents") {
257        if let Some(n) = value.as_u64() {
258            return format!("€{}.{:02}", n / 100, n % 100);
259        }
260    }
261    if lower.ends_with("_jpy") {
262        if let Some(n) = value.as_u64() {
263            return format!("¥{}", format_with_commas(n));
264        }
265    }
266    // Currency — Generic _{code}_cents
267    if lower.ends_with("_cents") {
268        if let Some(code) = extract_currency_code(&lower) {
269            if let Some(n) = value.as_u64() {
270                return format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase());
271            }
272        }
273    }
274
275    // Duration — long units (check before short)
276    if lower.ends_with("_minutes") {
277        if value.is_number() {
278            return format!("{} minutes", plain_scalar(value));
279        }
280    }
281    if lower.ends_with("_hours") {
282        if value.is_number() {
283            return format!("{} hours", plain_scalar(value));
284        }
285    }
286    if lower.ends_with("_days") {
287        if value.is_number() {
288            return format!("{} days", plain_scalar(value));
289        }
290    }
291
292    // Duration — ms (with ≥1000 → seconds conversion)
293    if lower.ends_with("_ms") && !lower.ends_with("_epoch_ms") {
294        if let Some(n) = value.as_u64() {
295            return if n >= 1000 {
296                format!("{:.2}s", n as f64 / 1000.0)
297            } else {
298                format!("{}ms", n)
299            };
300        }
301        if let Some(n) = value.as_f64() {
302            return if n >= 1000.0 {
303                format!("{:.2}s", n / 1000.0)
304            } else {
305                format!("{}ms", plain_scalar(value))
306            };
307        }
308    }
309
310    // Duration — ns, us, s
311    if lower.ends_with("_ns") && !lower.ends_with("_epoch_ns") {
312        if value.is_number() {
313            return format!("{}ns", plain_scalar(value));
314        }
315    }
316    if lower.ends_with("_us") {
317        if value.is_number() {
318            return format!("{}μs", plain_scalar(value));
319        }
320    }
321    if lower.ends_with("_s") && !lower.ends_with("_epoch_s") {
322        if value.is_number() {
323            return format!("{}s", plain_scalar(value));
324        }
325    }
326
327    // Default — no transformation
328    plain_scalar(value)
329}
330
331/// Plain scalar: no quotes, raw value.
332fn plain_scalar(value: &Value) -> String {
333    match value {
334        Value::String(s) => s.clone(),
335        Value::Null => "null".to_string(),
336        Value::Bool(b) => b.to_string(),
337        Value::Number(n) => n.to_string(),
338        other => other.to_string(),
339    }
340}
341
342// ═══════════════════════════════════════════
343// Secret redaction
344// ═══════════════════════════════════════════
345
346/// Walk a JSON Value tree and redact any field ending in `_secret`.
347///
348/// Applies the AFD convention: `_secret` suffix signals sensitive data.
349/// String values are replaced with `"***"`. Call this before serializing
350/// config or log output in any format (JSON, YAML, plain).
351pub fn redact_secrets(value: &mut Value) {
352    match value {
353        Value::Object(map) => {
354            let secret_keys: Vec<String> = map
355                .keys()
356                .filter(|k| k.to_ascii_lowercase().ends_with("_secret"))
357                .cloned()
358                .collect();
359            for key in secret_keys {
360                if let Some(Value::String(s)) = map.get_mut(&key) {
361                    *s = "***".into();
362                }
363            }
364            for v in map.values_mut() {
365                redact_secrets(v);
366            }
367        }
368        Value::Array(arr) => {
369            for v in arr {
370                redact_secrets(v);
371            }
372        }
373        _ => {}
374    }
375}
376
377// ═══════════════════════════════════════════
378// AFD Protocol templates
379// ═══════════════════════════════════════════
380
381/// Build `{code: "ok", result: ...}`.
382pub fn ok(result: Value) -> Value {
383    serde_json::json!({"code": "ok", "result": result})
384}
385
386/// Build `{code: "ok", result: ..., trace: ...}`.
387pub fn ok_trace(result: Value, trace: Value) -> Value {
388    serde_json::json!({"code": "ok", "result": result, "trace": trace})
389}
390
391/// Build `{code: "error", error: "message"}`.
392pub fn error(message: &str) -> Value {
393    serde_json::json!({"code": "error", "error": message})
394}
395
396/// Build `{code: "error", error: "message", trace: ...}`.
397pub fn error_trace(message: &str, trace: Value) -> Value {
398    serde_json::json!({"code": "error", "error": message, "trace": trace})
399}
400
401/// Build `{code: "startup", config: ..., args: ..., env: ...}`.
402pub fn startup(config: Value, args: Value, env: Value) -> Value {
403    serde_json::json!({"code": "startup", "config": config, "args": args, "env": env})
404}
405
406/// Build `{code: "<custom>", ...fields}` — tool-defined status line.
407pub fn status(code: &str, fields: Value) -> Value {
408    let mut obj = match fields {
409        Value::Object(map) => map,
410        _ => serde_json::Map::new(),
411    };
412    obj.insert("code".to_string(), Value::String(code.to_string()));
413    Value::Object(obj)
414}
415
416// ═══════════════════════════════════════════
417// Helpers
418// ═══════════════════════════════════════════
419
420/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
421fn format_rfc3339_ms(ms: i64) -> String {
422    use chrono::{DateTime, Utc};
423    let secs = ms.div_euclid(1000);
424    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
425    match DateTime::from_timestamp(secs, nanos) {
426        Some(dt) => dt
427            .with_timezone(&Utc)
428            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
429        None => ms.to_string(),
430    }
431}
432
433/// Format bytes as human-readable size (binary units). Handles negative values.
434fn format_bytes_human(bytes: i64) -> String {
435    const KB: f64 = 1024.0;
436    const MB: f64 = KB * 1024.0;
437    const GB: f64 = MB * 1024.0;
438    const TB: f64 = GB * 1024.0;
439
440    let sign = if bytes < 0 { "-" } else { "" };
441    let b = (bytes as f64).abs();
442    if b >= TB {
443        format!("{sign}{:.1}TB", b / TB)
444    } else if b >= GB {
445        format!("{sign}{:.1}GB", b / GB)
446    } else if b >= MB {
447        format!("{sign}{:.1}MB", b / MB)
448    } else if b >= KB {
449        format!("{sign}{:.1}KB", b / KB)
450    } else {
451        format!("{bytes}B")
452    }
453}
454
455/// Format a number with thousands separators.
456fn format_with_commas(n: u64) -> String {
457    let s = n.to_string();
458    let mut result = String::with_capacity(s.len() + s.len() / 3);
459    for (i, c) in s.chars().enumerate() {
460        if i > 0 && (s.len() - i).is_multiple_of(3) {
461            result.push(',');
462        }
463        result.push(c);
464    }
465    result
466}
467
468/// Extract currency code from a `_{code}_cents` suffix.
469/// e.g., "fare_thb_cents" → Some("thb")
470fn extract_currency_code(key: &str) -> Option<&str> {
471    let without_cents = key.strip_suffix("_cents")?;
472    let last_underscore = without_cents.rfind('_')?;
473    Some(&without_cents[last_underscore + 1..])
474}
475
476// ═══════════════════════════════════════════
477// Size parsing
478// ═══════════════════════════════════════════
479
480/// Parse a human-readable size string into bytes.
481///
482/// Accepts `_size` config values: bare number, or number followed by unit letter
483/// (`B`, `K`, `M`, `G`, `T`). Case-insensitive. Trims whitespace.
484/// Returns `None` for invalid or negative input.
485///
486/// ```text
487/// "10M"  → 10_485_760
488/// "1.5K" → 1_536
489/// "512B" → 512
490/// "1024" → 1_024
491/// ```
492pub fn parse_size(s: &str) -> Option<u64> {
493    let s = s.trim();
494    if s.is_empty() {
495        return None;
496    }
497    let last = *s.as_bytes().last()?;
498    let (num_str, mult) = match last {
499        b'B' | b'b' => (&s[..s.len() - 1], 1u64),
500        b'K' | b'k' => (&s[..s.len() - 1], 1024),
501        b'M' | b'm' => (&s[..s.len() - 1], 1024 * 1024),
502        b'G' | b'g' => (&s[..s.len() - 1], 1024 * 1024 * 1024),
503        b'T' | b't' => (&s[..s.len() - 1], 1024u64 * 1024 * 1024 * 1024),
504        b'0'..=b'9' | b'.' => (s, 1),
505        _ => return None,
506    };
507    if num_str.is_empty() {
508        return None;
509    }
510    if let Ok(n) = num_str.parse::<u64>() {
511        return n.checked_mul(mult);
512    }
513    let f: f64 = num_str.parse().ok()?;
514    if f < 0.0 || f.is_nan() || f.is_infinite() {
515        return None;
516    }
517    let result = f * mult as f64;
518    if result > u64::MAX as f64 {
519        return None;
520    }
521    Some(result as u64)
522}
523
524// ═══════════════════════════════════════════
525// Tests
526// ═══════════════════════════════════════════
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use serde_json::Value;
532
533    const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../spec/fixtures");
534
535    fn load_fixture(name: &str) -> Value {
536        let path = format!("{}/{}", FIXTURES_DIR, name);
537        let data = std::fs::read_to_string(&path)
538            .unwrap_or_else(|e| panic!("failed to read {}: {}", path, e));
539        serde_json::from_str(&data)
540            .unwrap_or_else(|e| panic!("failed to parse {}: {}", path, e))
541    }
542
543    #[test]
544    fn test_plain_fixtures() {
545        let cases = load_fixture("plain.json");
546        for case in cases.as_array().expect("plain.json must be an array") {
547            let name = case["name"].as_str().expect("missing name");
548            let input = &case["input"];
549            let plain = to_plain(input);
550            for expected in case["contains"].as_array().expect("missing contains") {
551                let s = expected.as_str().expect("contains must be strings");
552                assert!(plain.contains(s), "[plain/{name}] expected {s:?} in {plain:?}");
553            }
554            if let Some(not_contains) = case.get("not_contains") {
555                for nc in not_contains.as_array().expect("not_contains must be array") {
556                    let s = nc.as_str().expect("not_contains must be strings");
557                    assert!(!plain.contains(s), "[plain/{name}] unexpected {s:?} in {plain:?}");
558                }
559            }
560        }
561    }
562
563    #[test]
564    fn test_yaml_fixtures() {
565        let cases = load_fixture("yaml.json");
566        for case in cases.as_array().expect("yaml.json must be an array") {
567            let name = case["name"].as_str().expect("missing name");
568            let input = &case["input"];
569            let yaml = to_yaml(input);
570            if let Some(prefix) = case.get("starts_with") {
571                let s = prefix.as_str().expect("starts_with must be string");
572                assert!(yaml.starts_with(s), "[yaml/{name}] expected starts_with {s:?} in {yaml:?}");
573            }
574            if let Some(contains) = case.get("contains") {
575                for expected in contains.as_array().expect("contains must be array") {
576                    let s = expected.as_str().expect("contains must be strings");
577                    assert!(yaml.contains(s), "[yaml/{name}] expected {s:?} in {yaml:?}");
578                }
579            }
580        }
581    }
582
583    #[test]
584    fn test_redact_fixtures() {
585        let cases = load_fixture("redact.json");
586        for case in cases.as_array().expect("redact.json must be an array") {
587            let name = case["name"].as_str().expect("missing name");
588            let mut input = case["input"].clone();
589            let expected = &case["expected"];
590            redact_secrets(&mut input);
591            assert_eq!(&input, expected, "[redact/{name}]");
592        }
593    }
594
595    #[test]
596    fn test_protocol_fixtures() {
597        let cases = load_fixture("protocol.json");
598        for case in cases.as_array().expect("protocol.json must be an array") {
599            let name = case["name"].as_str().expect("missing name");
600            let typ = case["type"].as_str().expect("missing type");
601            let args = &case["args"];
602            let result = match typ {
603                "ok" => ok(args["result"].clone()),
604                "ok_trace" => ok_trace(args["result"].clone(), args["trace"].clone()),
605                "error" => error(args["message"].as_str().expect("missing message")),
606                "error_trace" => error_trace(
607                    args["message"].as_str().expect("missing message"),
608                    args["trace"].clone(),
609                ),
610                "startup" => startup(
611                    args["config"].clone(),
612                    args["args"].clone(),
613                    args["env"].clone(),
614                ),
615                "status" => {
616                    let code = args["code"].as_str().expect("missing code");
617                    let fields = args["fields"].clone();
618                    status(code, fields)
619                }
620                other => panic!("unknown protocol type: {other}"),
621            };
622            if let Some(expected) = case.get("expected") {
623                assert_eq!(&result, expected, "[protocol/{name}]");
624            }
625            if let Some(expected_contains) = case.get("expected_contains") {
626                let ec = expected_contains.as_object().expect("expected_contains must be object");
627                let ro = result.as_object().expect("result must be object");
628                for (k, v) in ec {
629                    assert_eq!(ro.get(k).unwrap_or(&Value::Null), v, "[protocol/{name}] key {k}");
630                }
631            }
632        }
633    }
634
635    #[test]
636    fn test_exact_fixtures() {
637        let cases = load_fixture("exact.json");
638        for case in cases.as_array().expect("exact.json must be an array") {
639            let name = case["name"].as_str().expect("missing name");
640            let format = case["format"].as_str().expect("missing format");
641            let input = &case["input"];
642            let expected = case["expected"].as_str().expect("missing expected");
643            let got = match format {
644                "plain" => to_plain(input),
645                "yaml" => to_yaml(input),
646                other => panic!("unknown format: {other}"),
647            };
648            assert_eq!(got, expected, "[exact/{name}]");
649        }
650    }
651
652    #[test]
653    fn test_helper_fixtures() {
654        let cases = load_fixture("helpers.json");
655        for case in cases.as_array().expect("helpers.json must be an array") {
656            let name = case["name"].as_str().expect("missing name");
657            let test_cases = case["cases"].as_array().expect("missing cases");
658            match name {
659                "format_bytes_human" => {
660                    for tc in test_cases {
661                        let arr = tc.as_array().expect("case must be [input, expected]");
662                        let input = arr[0].as_i64().expect("input must be i64");
663                        let expected = arr[1].as_str().expect("expected must be string");
664                        assert_eq!(format_bytes_human(input), expected, "[helpers/format_bytes_human({input})]");
665                    }
666                }
667                "format_with_commas" => {
668                    for tc in test_cases {
669                        let arr = tc.as_array().expect("case must be [input, expected]");
670                        let input = arr[0].as_u64().expect("input must be u64");
671                        let expected = arr[1].as_str().expect("expected must be string");
672                        assert_eq!(format_with_commas(input), expected, "[helpers/format_with_commas({input})]");
673                    }
674                }
675                "extract_currency_code" => {
676                    for tc in test_cases {
677                        let arr = tc.as_array().expect("case must be [input, expected]");
678                        let input = arr[0].as_str().expect("input must be string");
679                        let expected = if arr[1].is_null() { None } else { arr[1].as_str() };
680                        assert_eq!(extract_currency_code(input), expected, "[helpers/extract_currency_code({input})]");
681                    }
682                }
683                "parse_size" => {
684                    for tc in test_cases {
685                        let arr = tc.as_array().expect("case must be [input, expected]");
686                        let input = arr[0].as_str().expect("input must be string");
687                        let expected = if arr[1].is_null() { None } else { arr[1].as_u64() };
688                        assert_eq!(parse_size(input), expected, "[helpers/parse_size({input:?})]");
689                    }
690                }
691                other => panic!("unknown helper: {other}"),
692            }
693        }
694    }
695}