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("trace".to_string(), serde_json::json!({"duration_ms": 0}));
262    Value::Object(obj)
263}
264
265// ═══════════════════════════════════════════
266// Secret Redaction
267// ═══════════════════════════════════════════
268
269fn redact_secrets(value: &mut Value) {
270    match value {
271        Value::Object(map) => {
272            let keys: Vec<String> = map.keys().cloned().collect();
273            for key in keys {
274                if key.ends_with("_secret") || key.ends_with("_SECRET") {
275                    match map.get(&key) {
276                        Some(Value::Object(_)) | Some(Value::Array(_)) => {
277                            // Traverse containers, don't replace
278                        }
279                        _ => {
280                            map.insert(key.clone(), Value::String("***".into()));
281                            continue;
282                        }
283                    }
284                }
285                if let Some(v) = map.get_mut(&key) {
286                    redact_secrets(v);
287                }
288            }
289        }
290        Value::Array(arr) => {
291            for v in arr {
292                redact_secrets(v);
293            }
294        }
295        _ => {}
296    }
297}
298
299fn apply_redaction_policy(value: &mut Value, redaction_policy: RedactionPolicy) {
300    match redaction_policy {
301        RedactionPolicy::RedactionTraceOnly => {
302            if let Value::Object(map) = value {
303                if let Some(trace) = map.get_mut("trace") {
304                    redact_secrets(trace);
305                }
306            }
307        }
308        RedactionPolicy::RedactionNone => {}
309    }
310}
311
312// ═══════════════════════════════════════════
313// Suffix Processing
314// ═══════════════════════════════════════════
315
316/// Strip a suffix matching exact lowercase or exact uppercase only.
317fn strip_suffix_ci(key: &str, suffix_lower: &str) -> Option<String> {
318    if let Some(s) = key.strip_suffix(suffix_lower) {
319        return Some(s.to_string());
320    }
321    let suffix_upper: String = suffix_lower
322        .chars()
323        .map(|c| c.to_ascii_uppercase())
324        .collect();
325    if let Some(s) = key.strip_suffix(&suffix_upper) {
326        return Some(s.to_string());
327    }
328    None
329}
330
331/// Extract currency code from `_{code}_cents` / `_{CODE}_CENTS` pattern.
332fn try_strip_generic_cents(key: &str) -> Option<(String, String)> {
333    let code = extract_currency_code(key)?;
334    let suffix_len = code.len() + "_cents".len() + 1; // _{code}_cents
335    let stripped = &key[..key.len() - suffix_len];
336    if stripped.is_empty() {
337        return None;
338    }
339    Some((stripped.to_string(), code.to_string()))
340}
341
342/// Try suffix-driven processing. Returns Some((stripped_key, formatted_value))
343/// when suffix matches and type is valid. None for no match or type mismatch.
344fn try_process_field(key: &str, value: &Value) -> Option<(String, String)> {
345    // Group 1: compound timestamp suffixes
346    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ms") {
347        return value.as_i64().map(|ms| (stripped, format_rfc3339_ms(ms)));
348    }
349    if let Some(stripped) = strip_suffix_ci(key, "_epoch_s") {
350        return value
351            .as_i64()
352            .map(|s| (stripped, format_rfc3339_ms(s * 1000)));
353    }
354    if let Some(stripped) = strip_suffix_ci(key, "_epoch_ns") {
355        return value
356            .as_i64()
357            .map(|ns| (stripped, format_rfc3339_ms(ns.div_euclid(1_000_000))));
358    }
359
360    // Group 2: compound currency suffixes
361    if let Some(stripped) = strip_suffix_ci(key, "_usd_cents") {
362        return value
363            .as_u64()
364            .map(|n| (stripped, format!("${}.{:02}", n / 100, n % 100)));
365    }
366    if let Some(stripped) = strip_suffix_ci(key, "_eur_cents") {
367        return value
368            .as_u64()
369            .map(|n| (stripped, format!("€{}.{:02}", n / 100, n % 100)));
370    }
371    if let Some((stripped, code)) = try_strip_generic_cents(key) {
372        return value.as_u64().map(|n| {
373            (
374                stripped,
375                format!("{}.{:02} {}", n / 100, n % 100, code.to_uppercase()),
376            )
377        });
378    }
379
380    // Group 3: multi-char suffixes
381    if let Some(stripped) = strip_suffix_ci(key, "_rfc3339") {
382        return value.as_str().map(|s| (stripped, s.to_string()));
383    }
384    if let Some(stripped) = strip_suffix_ci(key, "_minutes") {
385        return value
386            .is_number()
387            .then(|| (stripped, format!("{} minutes", number_str(value))));
388    }
389    if let Some(stripped) = strip_suffix_ci(key, "_hours") {
390        return value
391            .is_number()
392            .then(|| (stripped, format!("{} hours", number_str(value))));
393    }
394    if let Some(stripped) = strip_suffix_ci(key, "_days") {
395        return value
396            .is_number()
397            .then(|| (stripped, format!("{} days", number_str(value))));
398    }
399
400    // Group 4: single-unit suffixes
401    if let Some(stripped) = strip_suffix_ci(key, "_msats") {
402        return value
403            .is_number()
404            .then(|| (stripped, format!("{}msats", number_str(value))));
405    }
406    if let Some(stripped) = strip_suffix_ci(key, "_sats") {
407        return value
408            .is_number()
409            .then(|| (stripped, format!("{}sats", number_str(value))));
410    }
411    if let Some(stripped) = strip_suffix_ci(key, "_bytes") {
412        return value.as_i64().map(|n| (stripped, format_bytes_human(n)));
413    }
414    if let Some(stripped) = strip_suffix_ci(key, "_percent") {
415        return value
416            .is_number()
417            .then(|| (stripped, format!("{}%", number_str(value))));
418    }
419    if let Some(stripped) = strip_suffix_ci(key, "_secret") {
420        return Some((stripped, "***".to_string()));
421    }
422
423    // Group 5: short suffixes (last to avoid false positives)
424    if let Some(stripped) = strip_suffix_ci(key, "_btc") {
425        return value
426            .is_number()
427            .then(|| (stripped, format!("{} BTC", number_str(value))));
428    }
429    if let Some(stripped) = strip_suffix_ci(key, "_jpy") {
430        return value
431            .as_u64()
432            .map(|n| (stripped, format!("¥{}", format_with_commas(n))));
433    }
434    if let Some(stripped) = strip_suffix_ci(key, "_ns") {
435        return value
436            .is_number()
437            .then(|| (stripped, format!("{}ns", number_str(value))));
438    }
439    if let Some(stripped) = strip_suffix_ci(key, "_us") {
440        return value
441            .is_number()
442            .then(|| (stripped, format!("{}μs", number_str(value))));
443    }
444    if let Some(stripped) = strip_suffix_ci(key, "_ms") {
445        return format_ms_value(value).map(|v| (stripped, v));
446    }
447    if let Some(stripped) = strip_suffix_ci(key, "_s") {
448        return value
449            .is_number()
450            .then(|| (stripped, format!("{}s", number_str(value))));
451    }
452
453    None
454}
455
456/// Process object fields: strip keys, format values, detect collisions.
457fn process_object_fields<'a>(
458    map: &'a serde_json::Map<String, Value>,
459) -> Vec<(String, &'a Value, Option<String>)> {
460    let mut entries: Vec<(String, &'a str, &'a Value, Option<String>)> = Vec::new();
461    for (key, value) in map {
462        match try_process_field(key, value) {
463            Some((stripped, formatted)) => {
464                entries.push((stripped, key.as_str(), value, Some(formatted)));
465            }
466            None => {
467                entries.push((key.clone(), key.as_str(), value, None));
468            }
469        }
470    }
471
472    // Detect collisions
473    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
474    for (stripped, _, _, _) in &entries {
475        *counts.entry(stripped.clone()).or_insert(0) += 1;
476    }
477
478    // Resolve collisions: revert both key and formatted value
479    let mut result: Vec<(String, &'a Value, Option<String>)> = entries
480        .into_iter()
481        .map(|(stripped, original, value, formatted)| {
482            if counts.get(&stripped).copied().unwrap_or(0) > 1 && original != stripped.as_str() {
483                (original.to_string(), value, None)
484            } else {
485                (stripped, value, formatted)
486            }
487        })
488        .collect();
489
490    result.sort_by(|(a, _, _), (b, _, _)| a.encode_utf16().cmp(b.encode_utf16()));
491    result
492}
493
494// ═══════════════════════════════════════════
495// Formatting Helpers
496// ═══════════════════════════════════════════
497
498fn number_str(value: &Value) -> String {
499    match value {
500        Value::Number(n) => n.to_string(),
501        _ => String::new(),
502    }
503}
504
505/// Format ms as seconds: 3 decimal places, trim trailing zeros, min 1 decimal.
506fn format_ms_as_seconds(ms: f64) -> String {
507    let formatted = format!("{:.3}", ms / 1000.0);
508    let trimmed = formatted.trim_end_matches('0');
509    if trimmed.ends_with('.') {
510        format!("{}0s", trimmed)
511    } else {
512        format!("{}s", trimmed)
513    }
514}
515
516/// Format `_ms` value: < 1000 → `{n}ms`, ≥ 1000 → seconds.
517fn format_ms_value(value: &Value) -> Option<String> {
518    let n = value.as_f64()?;
519    if n.abs() >= 1000.0 {
520        Some(format_ms_as_seconds(n))
521    } else if let Some(i) = value.as_i64() {
522        Some(format!("{}ms", i))
523    } else {
524        Some(format!("{}ms", number_str(value)))
525    }
526}
527
528/// Convert unix milliseconds (signed) to RFC 3339 with UTC timezone.
529fn format_rfc3339_ms(ms: i64) -> String {
530    use chrono::{DateTime, Utc};
531    let secs = ms.div_euclid(1000);
532    let nanos = (ms.rem_euclid(1000) * 1_000_000) as u32;
533    match DateTime::from_timestamp(secs, nanos) {
534        Some(dt) => dt
535            .with_timezone(&Utc)
536            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
537        None => ms.to_string(),
538    }
539}
540
541/// Format bytes as human-readable size (binary units). Handles negative values.
542fn format_bytes_human(bytes: i64) -> String {
543    const KB: f64 = 1024.0;
544    const MB: f64 = KB * 1024.0;
545    const GB: f64 = MB * 1024.0;
546    const TB: f64 = GB * 1024.0;
547
548    let sign = if bytes < 0 { "-" } else { "" };
549    let b = (bytes as f64).abs();
550    if b >= TB {
551        format!("{sign}{:.1}TB", b / TB)
552    } else if b >= GB {
553        format!("{sign}{:.1}GB", b / GB)
554    } else if b >= MB {
555        format!("{sign}{:.1}MB", b / MB)
556    } else if b >= KB {
557        format!("{sign}{:.1}KB", b / KB)
558    } else {
559        format!("{bytes}B")
560    }
561}
562
563/// Format a number with thousands separators.
564fn format_with_commas(n: u64) -> String {
565    let s = n.to_string();
566    let mut result = String::with_capacity(s.len() + s.len() / 3);
567    for (i, c) in s.chars().enumerate() {
568        if i > 0 && (s.len() - i).is_multiple_of(3) {
569            result.push(',');
570        }
571        result.push(c);
572    }
573    result
574}
575
576/// Extract currency code from a `_{code}_cents` / `_{CODE}_CENTS` suffix.
577fn extract_currency_code(key: &str) -> Option<&str> {
578    let without_cents = key
579        .strip_suffix("_cents")
580        .or_else(|| key.strip_suffix("_CENTS"))?;
581    let last_underscore = without_cents.rfind('_')?;
582    let code = &without_cents[last_underscore + 1..];
583    if code.is_empty() {
584        return None;
585    }
586    Some(code)
587}
588
589// ═══════════════════════════════════════════
590// YAML Rendering
591// ═══════════════════════════════════════════
592
593fn render_yaml_processed(value: &Value, indent: usize, lines: &mut Vec<String>) {
594    let prefix = "  ".repeat(indent);
595    match value {
596        Value::Object(map) => {
597            let processed = process_object_fields(map);
598            for (display_key, v, formatted) in processed {
599                if let Some(fv) = formatted {
600                    lines.push(format!(
601                        "{}{}: \"{}\"",
602                        prefix,
603                        display_key,
604                        escape_yaml_str(&fv)
605                    ));
606                } else {
607                    match v {
608                        Value::Object(inner) if !inner.is_empty() => {
609                            lines.push(format!("{}{}:", prefix, display_key));
610                            render_yaml_processed(v, indent + 1, lines);
611                        }
612                        Value::Object(_) => {
613                            lines.push(format!("{}{}: {{}}", prefix, display_key));
614                        }
615                        Value::Array(arr) => {
616                            if arr.is_empty() {
617                                lines.push(format!("{}{}: []", prefix, display_key));
618                            } else {
619                                lines.push(format!("{}{}:", prefix, display_key));
620                                for item in arr {
621                                    if item.is_object() {
622                                        lines.push(format!("{}  -", prefix));
623                                        render_yaml_processed(item, indent + 2, lines);
624                                    } else {
625                                        lines.push(format!("{}  - {}", prefix, yaml_scalar(item)));
626                                    }
627                                }
628                            }
629                        }
630                        _ => {
631                            lines.push(format!("{}{}: {}", prefix, display_key, yaml_scalar(v)));
632                        }
633                    }
634                }
635            }
636        }
637        _ => {
638            lines.push(format!("{}{}", prefix, yaml_scalar(value)));
639        }
640    }
641}
642
643fn escape_yaml_str(s: &str) -> String {
644    s.replace('\\', "\\\\")
645        .replace('"', "\\\"")
646        .replace('\n', "\\n")
647        .replace('\r', "\\r")
648        .replace('\t', "\\t")
649}
650
651fn yaml_scalar(value: &Value) -> String {
652    match value {
653        Value::String(s) => {
654            format!("\"{}\"", escape_yaml_str(s))
655        }
656        Value::Null => "null".to_string(),
657        Value::Bool(b) => b.to_string(),
658        Value::Number(n) => n.to_string(),
659        other => format!("\"{}\"", other.to_string().replace('"', "\\\"")),
660    }
661}
662
663// ═══════════════════════════════════════════
664// Plain Rendering (logfmt)
665// ═══════════════════════════════════════════
666
667fn collect_plain_pairs(value: &Value, prefix: &str, pairs: &mut Vec<(String, String)>) {
668    if let Value::Object(map) = value {
669        let processed = process_object_fields(map);
670        for (display_key, v, formatted) in processed {
671            let full_key = if prefix.is_empty() {
672                display_key
673            } else {
674                format!("{}.{}", prefix, display_key)
675            };
676            if let Some(fv) = formatted {
677                pairs.push((full_key, fv));
678            } else {
679                match v {
680                    Value::Object(_) => collect_plain_pairs(v, &full_key, pairs),
681                    Value::Array(arr) => {
682                        let joined = arr.iter().map(plain_scalar).collect::<Vec<_>>().join(",");
683                        pairs.push((full_key, joined));
684                    }
685                    Value::Null => pairs.push((full_key, String::new())),
686                    _ => pairs.push((full_key, plain_scalar(v))),
687                }
688            }
689        }
690    }
691}
692
693fn plain_scalar(value: &Value) -> String {
694    match value {
695        Value::String(s) => s.clone(),
696        Value::Null => "null".to_string(),
697        Value::Bool(b) => b.to_string(),
698        Value::Number(n) => n.to_string(),
699        other => other.to_string(),
700    }
701}
702
703#[cfg(test)]
704mod tests;