Skip to main content

agent_first_data/
lib.rs

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