Skip to main content

obz_core/
output.rs

1//! Output formatting pipeline.
2//!
3//! Inspired by pup's `formatter.rs` — a single generic entry point that
4//! handles all output formats for any `T: Serialize`:
5//!
6//! ```text
7//! T: Serialize
8//!     → serde_json::to_value()   (preserves struct field order)
9//!     → match format {
10//!         Json  → pretty print
11//!         Table → extract_rows → flatten → auto-detect headers → comfy_table
12//!         Csv   → extract_rows → flatten → csv::Writer
13//!       }
14//! ```
15//!
16//! Field ordering depends on the data type:
17//!
18//! - **Typed structs** (`MetricSeries`, `SeriesStats`, etc.): fields appear
19//!   in declaration order, guaranteed by serde.
20//! - **`BTreeMap` fields** (`labels`, `attributes`): keys are alphabetically
21//!   ordered by construction.
22//! - **Dynamic `serde_json::Value`** (`ExtensionData.data`): keys preserve
23//!   the insertion order from the upstream provider response. This is
24//!   deterministic for a given provider but not guaranteed to be alphabetical.
25//!
26//! This module relies on `serde_json`'s `preserve_order` feature (enabled
27//! in the workspace `Cargo.toml`). **Do not remove that feature flag** —
28//! without it, `serde_json::Map` falls back to a `BTreeMap` and struct
29//! field order will silently revert to alphabetical sorting.
30//!
31//! Command handlers should call [`format_and_print`] with `&mut io::stdout()`
32//! and nothing else.
33
34use std::collections::{HashMap, HashSet};
35use std::io::{self, Write};
36
37use comfy_table::presets::UTF8_FULL_CONDENSED;
38use comfy_table::{ContentArrangement, Table};
39use serde::Serialize;
40use serde_json::Map;
41
42/// Maximum number of columns displayed in table output.
43const MAX_TABLE_COLUMNS: usize = 12;
44
45/// Maximum string length before truncation in table cells.
46const MAX_CELL_LENGTH: usize = 60;
47
48/// Truncation point for long strings (`MAX_CELL_LENGTH` minus 3 for "...").
49const CELL_TRUNCATE_AT: usize = MAX_CELL_LENGTH - 3;
50
51/// Output format for CLI results.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum OutputFormat {
54    /// JSON output (default, optimized for AI Agents).
55    Json,
56    /// Human-readable table.
57    Table,
58    /// Comma-separated values.
59    Csv,
60}
61
62/// Format and write any serializable value to the given writer.
63///
64/// This is the **single entry point** for all output rendering.
65/// Command handlers should call this function and nothing else.
66///
67/// In production code, pass `&mut io::stdout()` as the writer.
68/// In tests, pass `&mut Vec<u8>` to capture output for assertions.
69///
70/// # Errors
71///
72/// Returns an IO error if writing fails (e.g., broken pipe).
73pub fn format_and_print<T: Serialize>(
74    data: &T,
75    format: OutputFormat,
76    fields: Option<&[String]>,
77    truncate: Option<usize>,
78    writer: &mut impl Write,
79) -> io::Result<()> {
80    let mut value = serde_json::to_value(data).map_err(io::Error::other)?;
81
82    if let Some(fields) = fields {
83        project_fields(&mut value, fields);
84    }
85    if let Some(max_chars) = truncate {
86        let count = truncate_values(&mut value, max_chars);
87        if count > 0 {
88            inject_truncated_count(&mut value, count);
89        }
90    }
91
92    match format {
93        OutputFormat::Json => print_json(&value, writer),
94        OutputFormat::Table => print_table(&value, writer),
95        OutputFormat::Csv => print_csv(&value, writer),
96    }
97}
98
99// --- JSON output ---
100
101/// Pretty-print a JSON value to the given writer.
102fn print_json(value: &serde_json::Value, writer: &mut impl Write) -> io::Result<()> {
103    serde_json::to_writer_pretty(&mut *writer, value).map_err(io::Error::other)?;
104    writeln!(writer)
105}
106
107// --- Table output ---
108
109/// Print a JSON value as a human-readable table.
110///
111/// Automatically extracts rows from the data, flattens nested objects,
112/// and selects the most relevant columns with priority ordering.
113fn print_table(value: &serde_json::Value, writer: &mut impl Write) -> io::Result<()> {
114    let rows = extract_rows(value);
115
116    if rows.is_empty() {
117        return writeln!(writer, "No results found.");
118    }
119
120    let (flat_rows, headers) = flatten_and_collect(&rows);
121
122    // Prioritize common fields.
123    let priority = [
124        "name",
125        "timestamp",
126        "message",
127        "severity",
128        "service",
129        "source",
130        "status",
131        "trace_id",
132        "span_id",
133        "labels",
134        "points",
135        "stats",
136        "result_type",
137        "type",
138        "description",
139        "unit",
140    ];
141    let final_headers = prioritize_headers(&headers, &priority, MAX_TABLE_COLUMNS);
142
143    let mut table = Table::new();
144    table
145        .load_preset(UTF8_FULL_CONDENSED)
146        .set_content_arrangement(ContentArrangement::Dynamic)
147        .set_header(&final_headers);
148
149    for row in &flat_rows {
150        let cells: Vec<String> = final_headers
151            .iter()
152            .map(|h| format_cell(row.get(h.as_str())))
153            .collect();
154        table.add_row(cells);
155    }
156
157    writeln!(writer, "{table}")?;
158
159    // Footer with row count.
160    if flat_rows.len() > 1 {
161        writeln!(writer, "\n({} rows)", flat_rows.len())?;
162    }
163
164    Ok(())
165}
166
167/// Select headers with priority ordering, capped at `max_cols`.
168fn prioritize_headers(all: &[String], priority: &[&str], max_cols: usize) -> Vec<String> {
169    let all_set: HashSet<&str> = all.iter().map(String::as_str).collect();
170    let mut result: Vec<String> = Vec::new();
171
172    // Add priority fields first (if they exist in data).
173    for &p in priority {
174        if all_set.contains(p) && result.len() < max_cols {
175            result.push(p.to_string());
176        }
177    }
178
179    // Fill remaining slots with other fields.
180    for h in all {
181        if result.len() >= max_cols {
182            break;
183        }
184        if !result.contains(h) {
185            result.push(h.clone());
186        }
187    }
188
189    result
190}
191
192// --- CSV output ---
193
194/// Print a JSON value as CSV using the `csv` crate.
195fn print_csv(value: &serde_json::Value, writer: &mut impl Write) -> io::Result<()> {
196    let rows = extract_rows(value);
197    if rows.is_empty() {
198        return Ok(());
199    }
200
201    let (flat_rows, headers) = flatten_and_collect(&rows);
202
203    let mut wtr = csv::Writer::from_writer(&mut *writer);
204    wtr.write_record(&headers).map_err(io::Error::other)?;
205
206    for row in &flat_rows {
207        let cells: Vec<String> = headers
208            .iter()
209            .map(|h| csv_cell(row.get(h.as_str())))
210            .collect();
211        wtr.write_record(&cells).map_err(io::Error::other)?;
212    }
213
214    wtr.flush()?;
215    Ok(())
216}
217
218/// Render a JSON value as a plain string for CSV cells.
219fn csv_cell(value: Option<&serde_json::Value>) -> String {
220    match value {
221        None | Some(serde_json::Value::Null) => String::new(),
222        Some(serde_json::Value::String(s)) => s.clone(),
223        Some(serde_json::Value::Number(n)) => n.to_string(),
224        Some(serde_json::Value::Bool(b)) => b.to_string(),
225        Some(other) => other.to_string(),
226    }
227}
228
229/// Keep only selected fields in row-level output records.
230///
231/// Automatically discovers object arrays inside the `data` section and
232/// applies field projection to each element. Does not require knowing
233/// the array key name (`series`, `entries`, `spans`, etc.), so new
234/// response types are supported without code changes.
235///
236/// Supports dot-notation field selectors (e.g. `labels.env` keeps only the
237/// `env` key within `labels`). When both a parent (`labels`) and a child
238/// (`labels.env`) are specified, the broader parent selector wins.
239fn project_fields(value: &mut serde_json::Value, fields: &[String]) {
240    let selectors = build_field_selectors(fields);
241    if selectors.is_empty() {
242        return;
243    }
244
245    project_in_value(value, &selectors);
246}
247
248fn project_in_value(
249    value: &mut serde_json::Value,
250    selectors: &HashMap<String, Option<HashSet<String>>>,
251) {
252    match value {
253        // Top-level array (e.g. bare Vec<MetricSeries>)
254        serde_json::Value::Array(arr) => {
255            for item in arr {
256                if item.is_object() {
257                    project_row_fields(item, selectors);
258                }
259            }
260        }
261        // Response envelope: dig into "data"
262        serde_json::Value::Object(map) => {
263            if let Some(data) = map.get_mut("data") {
264                project_in_data_section(data, selectors);
265            }
266        }
267        _ => {}
268    }
269}
270
271fn project_in_data_section(
272    data: &mut serde_json::Value,
273    selectors: &HashMap<String, Option<HashSet<String>>>,
274) {
275    match data {
276        // data is directly an array
277        serde_json::Value::Array(arr) => {
278            for item in arr {
279                if item.is_object() {
280                    project_row_fields(item, selectors);
281                }
282            }
283        }
284        // data is an object — find all array-of-objects fields and project them
285        serde_json::Value::Object(map) => {
286            for field_value in map.values_mut() {
287                if let serde_json::Value::Array(arr) = field_value {
288                    let has_objects = arr.iter().any(serde_json::Value::is_object);
289                    if has_objects {
290                        for item in arr {
291                            if item.is_object() {
292                                project_row_fields(item, selectors);
293                            }
294                        }
295                    }
296                }
297            }
298        }
299        _ => {}
300    }
301}
302
303/// Truncate string values exceeding `max_chars` in row-level data only.
304///
305/// Like `project_fields`, this automatically discovers object arrays
306/// inside the `data` section and truncates string values within them.
307/// Structural fields (`result_type`, `trace_id`, `label`, etc.) are
308/// never modified.
309///
310/// Returns the number of string values that were truncated.
311fn truncate_values(value: &mut serde_json::Value, max_chars: usize) -> usize {
312    truncate_in_value(value, max_chars)
313}
314
315fn truncate_in_value(value: &mut serde_json::Value, max_chars: usize) -> usize {
316    match value {
317        serde_json::Value::Array(arr) => arr
318            .iter_mut()
319            .map(|item| truncate_value_recursive(item, max_chars))
320            .sum(),
321        serde_json::Value::Object(map) => {
322            if let Some(data) = map.get_mut("data") {
323                truncate_in_data_section(data, max_chars)
324            } else {
325                0
326            }
327        }
328        _ => 0,
329    }
330}
331
332fn truncate_in_data_section(data: &mut serde_json::Value, max_chars: usize) -> usize {
333    match data {
334        serde_json::Value::Array(arr) => arr
335            .iter_mut()
336            .map(|item| truncate_value_recursive(item, max_chars))
337            .sum(),
338        serde_json::Value::Object(map) => {
339            let mut count = 0;
340            for field_value in map.values_mut() {
341                if let serde_json::Value::Array(arr) = field_value {
342                    let has_objects = arr.iter().any(serde_json::Value::is_object);
343                    if has_objects {
344                        for item in arr {
345                            count += truncate_value_recursive(item, max_chars);
346                        }
347                    }
348                }
349            }
350            count
351        }
352        _ => 0,
353    }
354}
355
356fn inject_truncated_count(value: &mut serde_json::Value, count: usize) {
357    if let serde_json::Value::Object(map) = value {
358        if let Some(serde_json::Value::Object(meta)) = map.get_mut("metadata") {
359            meta.insert(
360                "truncated_values".to_string(),
361                serde_json::Value::Number(count.into()),
362            );
363        }
364    }
365}
366
367// --- Shared utilities ---
368
369/// Extract displayable rows from a JSON value.
370///
371/// Handles common patterns:
372/// - Arrays → each element is a row
373/// - Objects with a "data" wrapper → unwrap and recurse
374/// - Objects with known list fields (series, entries, items) → extract
375/// - Single objects → one-row table
376fn extract_rows(value: &serde_json::Value) -> Vec<&serde_json::Value> {
377    match value {
378        serde_json::Value::Array(arr) => arr.iter().collect(),
379        serde_json::Value::Object(map) => {
380            // Check for known list fields in our response envelope.
381            // Response<T>.data is the payload; inside it we look for list fields.
382            if let Some(data) = map.get("data") {
383                if let serde_json::Value::Object(data_map) = data {
384                    // Metric query: data.series
385                    if let Some(series) = data_map.get("series") {
386                        return extract_rows(series);
387                    }
388                    // Log search: data.entries
389                    if let Some(entries) = data_map.get("entries") {
390                        return extract_rows(entries);
391                    }
392                    // Trace search/get: data.spans
393                    if let Some(spans) = data_map.get("spans") {
394                        return extract_rows(spans);
395                    }
396                    // String list: data.items
397                    if let Some(items) = data_map.get("items") {
398                        return extract_rows(items);
399                    }
400                    // Extension data: data.data (structured extension results)
401                    if let Some(ext_data) = data_map.get("data") {
402                        return extract_rows(ext_data);
403                    }
404                    // Series metadata: data.series (same key)
405                    // Scalar: data.scalar
406                    if let Some(scalar) = data_map.get("scalar") {
407                        // Wrap scalar in a synthetic row for table display.
408                        return vec![scalar];
409                    }
410                    // Metric info: data.info
411                    if let Some(info) = data_map.get("info") {
412                        if !info.is_null() {
413                            return vec![info];
414                        }
415                        return vec![];
416                    }
417                }
418                // data is an array directly
419                return extract_rows(data);
420            }
421            // Single object → one-row table.
422            vec![value]
423        }
424        _ => vec![],
425    }
426}
427
428fn build_field_selectors(fields: &[String]) -> HashMap<String, Option<HashSet<String>>> {
429    let mut selectors: HashMap<String, Option<HashSet<String>>> = HashMap::new();
430
431    for field in fields {
432        let field = field.trim();
433        if field.is_empty() {
434            continue;
435        }
436
437        if let Some((top, nested)) = field.split_once('.') {
438            if top.is_empty() || nested.is_empty() {
439                continue;
440            }
441            match selectors.get_mut(top) {
442                Some(None) => {}
443                Some(Some(nested_fields)) => {
444                    nested_fields.insert(nested.to_string());
445                }
446                None => {
447                    let mut nested_fields = HashSet::new();
448                    nested_fields.insert(nested.to_string());
449                    selectors.insert(top.to_string(), Some(nested_fields));
450                }
451            }
452        } else {
453            selectors.insert(field.to_string(), None);
454        }
455    }
456
457    selectors
458}
459
460fn project_row_fields(
461    row: &mut serde_json::Value,
462    selectors: &HashMap<String, Option<HashSet<String>>>,
463) {
464    let serde_json::Value::Object(original) = row else {
465        return;
466    };
467
468    let taken = std::mem::take(original);
469    let mut projected = Map::new();
470
471    for (key, value) in taken {
472        match selectors.get(&key) {
473            Some(None) => {
474                projected.insert(key, value);
475            }
476            Some(Some(nested_fields)) => {
477                if let Some(nested_value) = project_nested_value(&value, nested_fields) {
478                    projected.insert(key, nested_value);
479                }
480            }
481            None => {}
482        }
483    }
484
485    *original = projected;
486}
487
488fn project_nested_value(
489    value: &serde_json::Value,
490    nested_fields: &HashSet<String>,
491) -> Option<serde_json::Value> {
492    let serde_json::Value::Object(map) = value else {
493        return None;
494    };
495
496    let mut projected = Map::new();
497    for (key, nested_value) in map {
498        if nested_fields.contains(key) {
499            projected.insert(key.clone(), nested_value.clone());
500        }
501    }
502
503    if projected.is_empty() {
504        None
505    } else {
506        Some(serde_json::Value::Object(projected))
507    }
508}
509
510fn truncate_value_recursive(value: &mut serde_json::Value, max_chars: usize) -> usize {
511    match value {
512        serde_json::Value::String(s) => {
513            if truncate_string_in_place(s, max_chars) {
514                1
515            } else {
516                0
517            }
518        }
519        serde_json::Value::Array(arr) => arr
520            .iter_mut()
521            .map(|item| truncate_value_recursive(item, max_chars))
522            .sum(),
523        serde_json::Value::Object(map) => map
524            .values_mut()
525            .map(|v| truncate_value_recursive(v, max_chars))
526            .sum(),
527        serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => 0,
528    }
529}
530
531fn truncate_string_in_place(value: &mut String, max_chars: usize) -> bool {
532    // Fast path: if byte length is within the limit, char count is too
533    // (every char is at least 1 byte). Avoids O(n) char counting for
534    // strings that are clearly short enough.
535    if value.len() <= max_chars {
536        return false;
537    }
538
539    let mut char_count = 0;
540    let mut boundary = value.len();
541    for (idx, _) in value.char_indices() {
542        if char_count == max_chars {
543            boundary = idx;
544            break;
545        }
546        char_count += 1;
547    }
548
549    // All chars fit within max_chars (multi-byte chars made len > max_chars
550    // but char count is still within limit).
551    if char_count < max_chars {
552        return false;
553    }
554
555    let original_char_count = char_count + value[boundary..].chars().count();
556    let truncated = format!(
557        "{}...[truncated, {} chars]",
558        &value[..boundary],
559        original_char_count
560    );
561    *value = truncated;
562    true
563}
564
565/// Flatten rows and collect unique headers preserving insertion order.
566fn flatten_and_collect(
567    rows: &[&serde_json::Value],
568) -> (Vec<Map<String, serde_json::Value>>, Vec<String>) {
569    let flat_rows: Vec<Map<String, serde_json::Value>> =
570        rows.iter().map(|r| flatten_row(r)).collect();
571
572    let mut header_set = HashSet::new();
573    let mut headers: Vec<String> = Vec::new();
574    for row in &flat_rows {
575        for key in row.keys() {
576            if header_set.insert(key.clone()) {
577                headers.push(key.clone());
578            }
579        }
580    }
581
582    (flat_rows, headers)
583}
584
585/// Flatten a JSON value to dot-notation keys.
586///
587/// `{"a": {"b": 1}, "c": 2}` → `{"a.b": 1, "c": 2}`
588///
589/// Arrays are kept as-is (rendered as JSON strings in cells).
590/// Uses `serde_json::Map` (backed by `IndexMap` via `preserve_order`)
591/// to retain the original struct field ordering.
592fn flatten_row(value: &serde_json::Value) -> Map<String, serde_json::Value> {
593    let mut out = Map::new();
594    flatten_into(value, "", &mut out);
595    out
596}
597
598fn flatten_into(value: &serde_json::Value, prefix: &str, out: &mut Map<String, serde_json::Value>) {
599    match value {
600        serde_json::Value::Object(map) => {
601            for (k, v) in map {
602                let key = if prefix.is_empty() {
603                    k.clone()
604                } else {
605                    format!("{prefix}.{k}")
606                };
607                // Don't flatten too deep — stop at depth 2 to keep tables readable.
608                if key.matches('.').count() >= 2 {
609                    out.insert(key, v.clone());
610                } else {
611                    flatten_into(v, &key, out);
612                }
613            }
614        }
615        _ => {
616            if prefix.is_empty() {
617                out.insert("value".to_string(), value.clone());
618            } else {
619                out.insert(prefix.to_string(), value.clone());
620            }
621        }
622    }
623}
624
625/// Format a JSON value for table cell display.
626///
627/// Truncates long strings, compacts arrays, renders nulls as empty.
628fn format_cell(value: Option<&serde_json::Value>) -> String {
629    match value {
630        None | Some(serde_json::Value::Null) => String::new(),
631        Some(serde_json::Value::String(s)) => {
632            if s.len() > MAX_CELL_LENGTH {
633                let mut end = CELL_TRUNCATE_AT;
634                while end > 0 && !s.is_char_boundary(end) {
635                    end -= 1;
636                }
637                format!("{}...", &s[..end])
638            } else {
639                s.clone()
640            }
641        }
642        Some(serde_json::Value::Number(n)) => n.to_string(),
643        Some(serde_json::Value::Bool(b)) => b.to_string(),
644        Some(serde_json::Value::Array(arr)) => {
645            if arr.is_empty() {
646                "[]".to_string()
647            } else if arr.len() <= 3 {
648                // Show small arrays inline.
649                let items: Vec<String> = arr.iter().map(|v| format_cell(Some(v))).collect();
650                format!("[{}]", items.join(", "))
651            } else {
652                format!("[{} items]", arr.len())
653            }
654        }
655        Some(serde_json::Value::Object(map)) => {
656            if map.is_empty() {
657                "{}".to_string()
658            } else {
659                format!("{{{} fields}}", map.len())
660            }
661        }
662    }
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn test_extract_rows_array() {
671        let v = serde_json::json!([{"a": 1}, {"a": 2}]);
672        assert_eq!(extract_rows(&v).len(), 2);
673    }
674
675    #[test]
676    fn test_extract_rows_response_envelope() {
677        let v = serde_json::json!({
678            "status": "success",
679            "data": {
680                "result_type": "matrix",
681                "series": [{"name": "cpu"}, {"name": "mem"}]
682            }
683        });
684        assert_eq!(extract_rows(&v).len(), 2);
685    }
686
687    #[test]
688    fn test_extract_rows_items() {
689        let v = serde_json::json!({
690            "status": "success",
691            "data": {
692                "result_type": "metric_list",
693                "items": ["cpu_usage", "mem_usage"]
694            }
695        });
696        let rows = extract_rows(&v);
697        assert_eq!(rows.len(), 2);
698    }
699
700    #[test]
701    fn test_extract_rows_extension_data_string_array() {
702        let v = serde_json::json!({
703            "status": "success",
704            "data": {
705                "result_type": "services",
706                "data": ["cart", "payment", "frontend"]
707            }
708        });
709        let rows = extract_rows(&v);
710        assert_eq!(rows.len(), 3);
711    }
712
713    #[test]
714    fn test_extract_rows_extension_data_object_array() {
715        let v = serde_json::json!({
716            "status": "success",
717            "data": {
718                "result_type": "services",
719                "data": [
720                    {"name": "cart", "spans": 100},
721                    {"name": "payment", "spans": 50}
722                ]
723            }
724        });
725        let rows = extract_rows(&v);
726        assert_eq!(rows.len(), 2);
727    }
728
729    #[test]
730    fn test_extract_rows_extension_data_single_object() {
731        let v = serde_json::json!({
732            "status": "success",
733            "data": {
734                "result_type": "config",
735                "data": {"key": "value", "count": 42}
736            }
737        });
738        let rows = extract_rows(&v);
739        assert_eq!(rows.len(), 1);
740    }
741
742    #[test]
743    fn test_flatten_row() {
744        let v = serde_json::json!({"a": {"b": 1}, "c": 2});
745        let flat = flatten_row(&v);
746        assert_eq!(flat["a.b"], serde_json::json!(1));
747        assert_eq!(flat["c"], serde_json::json!(2));
748    }
749
750    #[test]
751    fn test_flatten_row_preserves_field_order() {
752        // Use actual struct serialization (not json! macro) to verify that
753        // serde's field-declaration order is preserved through flattening.
754        use crate::model::metric::{DataPoint, MetricSeries, SeriesStats};
755        use std::collections::BTreeMap;
756
757        let series = MetricSeries {
758            name: "cpu".to_string(),
759            labels: BTreeMap::from([("env".to_string(), "prod".to_string())]),
760            points: vec![DataPoint {
761                timestamp: 1,
762                value: 1.0,
763            }],
764            stats: Some(SeriesStats {
765                min: Some(1.0),
766                max: Some(2.0),
767                avg: Some(1.5),
768                count: 2,
769            }),
770            extensions: None,
771        };
772        let value = serde_json::to_value(&series).unwrap();
773        let flat = flatten_row(&value);
774        let keys: Vec<&String> = flat.keys().collect();
775        // Struct field order: name → labels.* → points → stats.* — NOT alphabetical.
776        assert_eq!(keys[0], "name");
777        assert_eq!(keys[1], "labels.env");
778        assert_eq!(keys[2], "points");
779        // stats is flattened: min, max, avg, count (struct definition order).
780        assert_eq!(keys[3], "stats.min");
781        assert_eq!(keys[4], "stats.max");
782        assert_eq!(keys[5], "stats.avg");
783        assert_eq!(keys[6], "stats.count");
784    }
785
786    #[test]
787    fn test_format_cell_truncation() {
788        let long_str = "a".repeat(100);
789        let result = format_cell(Some(&serde_json::Value::String(long_str)));
790        assert!(result.ends_with("..."));
791        assert!(result.len() <= 63); // 57 chars + "..."
792    }
793
794    #[test]
795    fn test_format_cell_array() {
796        let v = serde_json::json!([1, 2, 3]);
797        assert_eq!(format_cell(Some(&v)), "[1, 2, 3]");
798
799        let big = serde_json::json!([1, 2, 3, 4, 5]);
800        assert_eq!(format_cell(Some(&big)), "[5 items]");
801    }
802
803    #[test]
804    fn test_prioritize_headers() {
805        let all = vec![
806            "z_field".to_string(),
807            "name".to_string(),
808            "message".to_string(),
809            "x_field".to_string(),
810        ];
811        let priority = ["name", "message", "severity"];
812        let result = prioritize_headers(&all, &priority, 3);
813        assert_eq!(result, vec!["name", "message", "z_field"]);
814    }
815
816    /// End-to-end test: JSON output preserves struct field order.
817    ///
818    /// Guards against accidental removal of `serde_json`'s `preserve_order`
819    /// feature — if that feature is dropped, `serde_json::Map` reverts to
820    /// `BTreeMap` and this test will fail with alphabetical key ordering.
821    #[test]
822    fn test_json_output_preserves_struct_field_order() {
823        use crate::model::metric::{DataPoint, MetricSeries, SeriesStats};
824        use std::collections::BTreeMap;
825
826        let series = MetricSeries {
827            name: "cpu".to_string(),
828            labels: BTreeMap::from([("host".to_string(), "web01".to_string())]),
829            points: vec![DataPoint {
830                timestamp: 1,
831                value: 42.0,
832            }],
833            stats: Some(SeriesStats {
834                min: Some(10.0),
835                max: Some(90.0),
836                avg: Some(50.0),
837                count: 5,
838            }),
839            extensions: None,
840        };
841
842        let mut buf = Vec::new();
843        format_and_print(&series, OutputFormat::Json, None, None, &mut buf).unwrap();
844        let output = String::from_utf8(buf).unwrap();
845
846        // stats fields must appear in struct definition order, not alphabetical.
847        let min_pos = output.find("\"min\"").unwrap();
848        let max_pos = output.find("\"max\"").unwrap();
849        let avg_pos = output.find("\"avg\"").unwrap();
850        let count_pos = output.find("\"count\"").unwrap();
851        assert!(
852            min_pos < max_pos && max_pos < avg_pos && avg_pos < count_pos,
853            "stats fields should be in struct order: min, max, avg, count\nActual output:\n{output}"
854        );
855    }
856
857    #[test]
858    fn test_project_fields_basic() {
859        let mut value = serde_json::json!({
860            "status": "success",
861            "metadata": {"provider": "vm"},
862            "data": {
863                "result_type": "spans",
864                "spans": [
865                    {"service": "api", "name": "GET /users", "status": "ok"}
866                ]
867            }
868        });
869
870        project_fields(&mut value, &["service".to_string(), "name".to_string()]);
871
872        assert_eq!(
873            value["data"]["spans"][0],
874            serde_json::json!({"service": "api", "name": "GET /users"})
875        );
876    }
877
878    #[test]
879    fn test_project_fields_dot_notation() {
880        let mut value = serde_json::json!({
881            "status": "success",
882            "data": {
883                "result_type": "matrix",
884                "series": [
885                    {"name": "cpu", "labels": {"env": "prod", "host": "web01"}}
886                ]
887            }
888        });
889
890        project_fields(&mut value, &["labels.env".to_string()]);
891
892        assert_eq!(
893            value["data"]["series"][0],
894            serde_json::json!({"labels": {"env": "prod"}})
895        );
896    }
897
898    #[test]
899    fn test_project_fields_broader_wins() {
900        let mut value = serde_json::json!({
901            "status": "success",
902            "data": {
903                "result_type": "matrix",
904                "series": [
905                    {"labels": {"env": "prod", "host": "web01"}}
906                ]
907            }
908        });
909
910        project_fields(
911            &mut value,
912            &["labels.env".to_string(), "labels".to_string()],
913        );
914
915        assert_eq!(
916            value["data"]["series"][0],
917            serde_json::json!({"labels": {"env": "prod", "host": "web01"}})
918        );
919    }
920
921    #[test]
922    fn test_project_fields_absent_field() {
923        let mut value = serde_json::json!({
924            "status": "success",
925            "data": {
926                "result_type": "log_entries",
927                "entries": [
928                    {"timestamp": 1, "message": "hello"}
929                ]
930            }
931        });
932
933        project_fields(&mut value, &["service".to_string()]);
934
935        assert_eq!(value["data"]["entries"][0], serde_json::json!({}));
936    }
937
938    #[test]
939    fn test_project_fields_with_response_envelope() {
940        let mut value = serde_json::json!({
941            "status": "success",
942            "metadata": {"provider": "vm", "total_count": 1},
943            "data": {
944                "result_type": "trace_detail",
945                "trace_id": "abc123",
946                "span_count": 2,
947                "service_count": 1,
948                "duration_us": 100,
949                "services": ["api"],
950                "spans": [
951                    {"service": "api", "name": "root", "attributes": {"env": "prod"}}
952                ]
953            }
954        });
955
956        project_fields(&mut value, &["service".to_string()]);
957
958        assert_eq!(value["status"], "success");
959        assert_eq!(value["metadata"]["provider"], "vm");
960        assert_eq!(value["data"]["trace_id"], "abc123");
961        assert_eq!(value["data"]["span_count"], 2);
962        // String arrays (services) are not projected — only object arrays are.
963        assert_eq!(value["data"]["services"], serde_json::json!(["api"]));
964        assert_eq!(
965            value["data"]["spans"][0],
966            serde_json::json!({"service": "api"})
967        );
968    }
969
970    #[test]
971    fn test_truncate_basic() {
972        let mut value = serde_json::json!({
973            "status": "success",
974            "metadata": {"provider": "vm"},
975            "data": {
976                "result_type": "log_entries",
977                "entries": [{"message": "abcdefghij"}]
978            }
979        });
980
981        truncate_values(&mut value, 5);
982
983        assert_eq!(
984            value["data"]["entries"][0]["message"],
985            "abcde...[truncated, 10 chars]"
986        );
987    }
988
989    #[test]
990    fn test_truncate_under_limit() {
991        let mut value = serde_json::json!({
992            "status": "success",
993            "data": {
994                "result_type": "log_entries",
995                "entries": [{"message": "short"}]
996            }
997        });
998
999        truncate_values(&mut value, 10);
1000
1001        assert_eq!(value["data"]["entries"][0]["message"], "short");
1002    }
1003
1004    #[test]
1005    fn test_truncate_recursive() {
1006        let mut value = serde_json::json!({
1007            "status": "success",
1008            "data": {
1009                "result_type": "spans",
1010                "spans": [{
1011                    "attributes": {
1012                        "http.body": "abcdefghij",
1013                        "nested": ["klmnopqrstu"]
1014                    }
1015                }]
1016            }
1017        });
1018
1019        truncate_values(&mut value, 4);
1020
1021        assert_eq!(
1022            value["data"]["spans"][0]["attributes"]["http.body"],
1023            "abcd...[truncated, 10 chars]"
1024        );
1025        assert_eq!(
1026            value["data"]["spans"][0]["attributes"]["nested"][0],
1027            "klmn...[truncated, 11 chars]"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_truncate_non_string() {
1033        let mut value = serde_json::json!({
1034            "status": "success",
1035            "data": {
1036                "result_type": "spans",
1037                "spans": [{"duration_us": 123, "ok": true, "missing": null}]
1038            }
1039        });
1040
1041        truncate_values(&mut value, 2);
1042
1043        assert_eq!(value["data"]["spans"][0]["duration_us"], 123);
1044        assert_eq!(value["data"]["spans"][0]["ok"], true);
1045        assert_eq!(
1046            value["data"]["spans"][0]["missing"],
1047            serde_json::Value::Null
1048        );
1049    }
1050
1051    #[test]
1052    fn test_truncate_char_boundary() {
1053        let mut value = serde_json::json!({
1054            "status": "success",
1055            "data": {
1056                "result_type": "log_entries",
1057                "entries": [{"message": "你好世界"}]
1058            }
1059        });
1060
1061        truncate_values(&mut value, 3);
1062
1063        assert_eq!(
1064            value["data"]["entries"][0]["message"],
1065            "你好世...[truncated, 4 chars]"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_truncate_marker_format() {
1071        let mut value = serde_json::json!({
1072            "status": "success",
1073            "data": {
1074                "result_type": "log_entries",
1075                "entries": [{"message": "123456789"}]
1076            }
1077        });
1078
1079        truncate_values(&mut value, 2);
1080
1081        assert_eq!(
1082            value["data"]["entries"][0]["message"],
1083            "12...[truncated, 9 chars]"
1084        );
1085    }
1086
1087    #[test]
1088    fn test_combined_fields_then_truncate() {
1089        let value = serde_json::json!({
1090            "status": "success",
1091            "metadata": {"provider": "dd"},
1092            "data": {
1093                "result_type": "log_entries",
1094                "entries": [{
1095                    "timestamp": 1,
1096                    "service": "api",
1097                    "message": "abcdefghij",
1098                    "attributes": {"env": "prod"}
1099                }]
1100            }
1101        });
1102
1103        let mut buf = Vec::new();
1104        let fields = ["service".to_string(), "message".to_string()];
1105        format_and_print(&value, OutputFormat::Json, Some(&fields), Some(5), &mut buf).unwrap();
1106        let output: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1107
1108        assert_eq!(
1109            output["data"]["entries"][0],
1110            serde_json::json!({
1111                "service": "api",
1112                "message": "abcde...[truncated, 10 chars]"
1113            })
1114        );
1115        assert_eq!(output["metadata"]["truncated_values"], 1);
1116    }
1117
1118    #[test]
1119    fn test_truncate_no_metadata_when_nothing_truncated() {
1120        let value = serde_json::json!({
1121            "status": "success",
1122            "metadata": {"provider": "vm"},
1123            "data": {
1124                "result_type": "log_entries",
1125                "entries": [{"message": "short"}]
1126            }
1127        });
1128
1129        let mut buf = Vec::new();
1130        format_and_print(&value, OutputFormat::Json, None, Some(100), &mut buf).unwrap();
1131        let output: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1132
1133        assert!(output["metadata"]["truncated_values"].is_null());
1134    }
1135
1136    /// CSV output preserves struct field order (no alphabetical sorting).
1137    #[test]
1138    fn test_csv_output_preserves_field_order() {
1139        use crate::model::metric::{DataPoint, MetricSeries, SeriesStats};
1140        use std::collections::BTreeMap;
1141
1142        let series = vec![MetricSeries {
1143            name: "cpu".to_string(),
1144            labels: BTreeMap::from([("env".to_string(), "prod".to_string())]),
1145            points: vec![DataPoint {
1146                timestamp: 1,
1147                value: 42.0,
1148            }],
1149            stats: Some(SeriesStats {
1150                min: Some(10.0),
1151                max: Some(90.0),
1152                avg: Some(50.0),
1153                count: 5,
1154            }),
1155            extensions: None,
1156        }];
1157
1158        let mut buf = Vec::new();
1159        format_and_print(&series, OutputFormat::Csv, None, None, &mut buf).unwrap();
1160        let output = String::from_utf8(buf).unwrap();
1161
1162        let header_line = output.lines().next().unwrap();
1163        let headers: Vec<&str> = header_line.split(',').collect();
1164        // First header should be "name", not alphabetical "labels.env".
1165        assert_eq!(headers[0], "name");
1166        // stats.min should come before stats.count (struct order, not alphabetical).
1167        let min_idx = headers.iter().position(|h| *h == "stats.min").unwrap();
1168        let count_idx = headers.iter().position(|h| *h == "stats.count").unwrap();
1169        assert!(
1170            min_idx < count_idx,
1171            "CSV headers should follow struct field order, got: {header_line}"
1172        );
1173    }
1174
1175    /// Dynamic JSON (`ExtensionData`) preserves insertion order.
1176    #[test]
1177    fn test_flatten_row_dynamic_json_preserves_insertion_order() {
1178        // Simulate a realistic Response<ExtensionData> shape with the full
1179        // envelope (status + data) so extract_rows exercises the real
1180        // data.data unwrap path.
1181        let v = serde_json::json!({
1182            "status": "success",
1183            "data": {
1184                "result_type": "custom",
1185                "data": {
1186                    "zebra": 1,
1187                    "alpha": 2,
1188                    "middle": 3
1189                }
1190            }
1191        });
1192
1193        // extract_rows should unwrap data.data → single object row
1194        let rows = extract_rows(&v);
1195        assert_eq!(rows.len(), 1);
1196        let flat = flatten_row(rows[0]);
1197        let keys: Vec<&String> = flat.keys().collect();
1198        // Keys should preserve insertion order (zebra, alpha, middle),
1199        // NOT alphabetical (alpha, middle, zebra).
1200        assert_eq!(keys, &["zebra", "alpha", "middle"]);
1201    }
1202
1203    #[test]
1204    fn test_truncate_preserves_result_type() {
1205        let value = serde_json::json!({
1206            "status": "success",
1207            "metadata": {"provider": "vm"},
1208            "data": {
1209                "result_type": "log_entries",
1210                "entries": [{"message": "abcdefghij"}]
1211            }
1212        });
1213
1214        let mut buf = Vec::new();
1215        format_and_print(&value, OutputFormat::Json, None, Some(3), &mut buf).unwrap();
1216        let output: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1217
1218        assert_eq!(output["data"]["result_type"], "log_entries");
1219        assert_eq!(
1220            output["data"]["entries"][0]["message"],
1221            "abc...[truncated, 10 chars]"
1222        );
1223    }
1224
1225    #[test]
1226    fn test_truncate_preserves_trace_summary() {
1227        let value = serde_json::json!({
1228            "status": "success",
1229            "metadata": {"provider": "vm"},
1230            "data": {
1231                "result_type": "trace_detail",
1232                "trace_id": "abc123def456",
1233                "span_count": 5,
1234                "services": ["api-gateway", "payment"],
1235                "spans": [
1236                    {"service": "api-gateway", "name": "long-operation-name-that-exceeds"}
1237                ]
1238            }
1239        });
1240
1241        let mut buf = Vec::new();
1242        format_and_print(&value, OutputFormat::Json, None, Some(10), &mut buf).unwrap();
1243        let output: serde_json::Value = serde_json::from_slice(&buf).unwrap();
1244
1245        assert_eq!(output["data"]["trace_id"], "abc123def456");
1246        assert_eq!(output["data"]["result_type"], "trace_detail");
1247        assert_eq!(
1248            output["data"]["services"],
1249            serde_json::json!(["api-gateway", "payment"])
1250        );
1251        assert!(output["data"]["spans"][0]["name"]
1252            .as_str()
1253            .unwrap()
1254            .contains("[truncated"));
1255    }
1256
1257    #[test]
1258    fn test_extract_rows_spans() {
1259        let v = serde_json::json!({
1260            "status": "success",
1261            "data": {
1262                "result_type": "spans",
1263                "spans": [
1264                    {"service": "api", "name": "GET"},
1265                    {"service": "db", "name": "SELECT"}
1266                ]
1267            }
1268        });
1269        assert_eq!(extract_rows(&v).len(), 2);
1270    }
1271
1272    #[test]
1273    fn test_inject_truncated_count_no_metadata() {
1274        let mut value = serde_json::json!({"data": {"entries": []}});
1275        inject_truncated_count(&mut value, 5);
1276        assert!(value.get("metadata").is_none());
1277    }
1278}