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