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