Skip to main content

cqlsh_rs/
formatter.rs

1//! Output formatting for CQL query results.
2//!
3//! Provides type-aware tabular formatting using comfy-table and expanded (vertical)
4//! output mode. Paging is handled externally by the `minus` pager crate.
5//! Mirrors the Python cqlsh output formatting behavior.
6
7use std::io::Write;
8
9use comfy_table::{Cell, CellAlignment, ContentArrangement, Table};
10
11use crate::colorizer::CqlColorizer;
12use crate::driver::{CqlColumn, CqlResult, CqlRow};
13
14/// Format and print query results in tabular format.
15///
16/// Uses comfy-table for proper column alignment. Columns render at natural width
17/// (no terminal width constraint) — the pager handles horizontal scrolling.
18/// When a colorizer is provided, values and headers are colored.
19pub fn print_tabular(result: &CqlResult, colorizer: &CqlColorizer, writer: &mut dyn Write) {
20    if result.columns.is_empty() {
21        return;
22    }
23
24    if result.rows.is_empty() {
25        writeln!(writer, "\n(0 rows)\n").ok();
26        return;
27    }
28
29    let mut table = Table::new();
30    table.set_content_arrangement(ContentArrangement::Disabled);
31    table.load_preset(CQLSH_PRESET);
32
33    // Add header row (magenta bold when colored, plain bold otherwise)
34    let headers: Vec<Cell> = result
35        .columns
36        .iter()
37        .map(|c| Cell::new(colorizer.colorize_header(&c.name)))
38        .collect();
39    table.set_header(headers);
40
41    // Add data rows with type-aware alignment and coloring
42    for row in &result.rows {
43        let cells: Vec<Cell> = row
44            .values
45            .iter()
46            .enumerate()
47            .map(|(i, val)| {
48                let display = colorizer.colorize_value(val);
49                let mut cell = Cell::new(display);
50                // Right-align numeric types to match Python cqlsh
51                if is_numeric_type(&result.columns[i].type_name) {
52                    cell = cell.set_alignment(CellAlignment::Right);
53                }
54                cell
55            })
56            .collect();
57        table.add_row(cells);
58    }
59
60    writeln!(writer).ok();
61    for line in format!("{table}").lines() {
62        writeln!(writer, "{}", line.trim_end()).ok();
63    }
64    writeln!(writer).ok();
65    let row_count = result.rows.len();
66    writeln!(
67        writer,
68        "({} row{})",
69        row_count,
70        if row_count == 1 { "" } else { "s" }
71    )
72    .ok();
73    writeln!(writer).ok();
74}
75
76/// Format and print query results in expanded (vertical) format.
77///
78/// Each row is printed as a block with `@ Row N` header, followed by
79/// column_name | value pairs. Matches Python cqlsh `EXPAND ON` behavior.
80pub fn print_expanded(result: &CqlResult, colorizer: &CqlColorizer, writer: &mut dyn Write) {
81    if result.columns.is_empty() {
82        return;
83    }
84
85    if result.rows.is_empty() {
86        writeln!(writer, "\n(0 rows)\n").ok();
87        return;
88    }
89
90    let max_col_width = result
91        .columns
92        .iter()
93        .map(|c| c.name.len())
94        .max()
95        .unwrap_or(0);
96
97    writeln!(writer).ok();
98
99    for (row_idx, row) in result.rows.iter().enumerate() {
100        writeln!(writer, "@ Row {}", row_idx + 1).ok();
101        writeln!(writer, "{}", "-".repeat(max_col_width + 10)).ok();
102        for (col_idx, col) in result.columns.iter().enumerate() {
103            let value = row
104                .get(col_idx)
105                .map(|v| colorizer.colorize_value(v))
106                .unwrap_or_else(|| colorizer.colorize_value(&crate::driver::types::CqlValue::Null));
107            writeln!(
108                writer,
109                " {:>width$} | {}",
110                colorizer.colorize_header(&col.name),
111                value,
112                width = max_col_width
113            )
114            .ok();
115        }
116        writeln!(writer).ok();
117    }
118
119    let row_count = result.rows.len();
120    writeln!(
121        writer,
122        "({} row{})",
123        row_count,
124        if row_count == 1 { "" } else { "s" }
125    )
126    .ok();
127    writeln!(writer).ok();
128}
129
130/// Format and print query results as a JSON array.
131///
132/// Each row becomes a JSON object mapping column names to values.
133/// Matches the format produced by Python cqlsh `--json`.
134/// NaN and Infinity float values are serialized as quoted strings since they
135/// are not valid JSON numbers.
136pub fn print_json(result: &CqlResult, writer: &mut dyn Write) {
137    use crate::driver::types::CqlValue;
138
139    if result.columns.is_empty() || result.rows.is_empty() {
140        writeln!(writer, "[]").ok();
141        return;
142    }
143
144    writeln!(writer, "[").ok();
145    let last_row = result.rows.len() - 1;
146    for (row_idx, row) in result.rows.iter().enumerate() {
147        write!(writer, "  {{").ok();
148        for (col_idx, col) in result.columns.iter().enumerate() {
149            if col_idx > 0 {
150                write!(writer, ", ").ok();
151            }
152            let val = row.get(col_idx).unwrap_or(&CqlValue::Null);
153            write!(
154                writer,
155                "\"{}\": {}",
156                json_escape_string(&col.name),
157                cql_value_to_json(val)
158            )
159            .ok();
160        }
161        if row_idx < last_row {
162            writeln!(writer, "}},").ok();
163        } else {
164            writeln!(writer, "}}").ok();
165        }
166    }
167    writeln!(writer, "]").ok();
168}
169
170/// Escape a string for use as a JSON string value (without surrounding quotes).
171fn json_escape_string(s: &str) -> String {
172    let mut out = String::with_capacity(s.len());
173    for c in s.chars() {
174        match c {
175            '"' => out.push_str("\\\""),
176            '\\' => out.push_str("\\\\"),
177            '\n' => out.push_str("\\n"),
178            '\r' => out.push_str("\\r"),
179            '\t' => out.push_str("\\t"),
180            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
181            c => out.push(c),
182        }
183    }
184    out
185}
186
187/// Serialize a CqlValue to a JSON token.
188fn cql_value_to_json(val: &crate::driver::types::CqlValue) -> String {
189    use crate::driver::types::CqlValue;
190    match val {
191        CqlValue::Null | CqlValue::Unset => "null".to_string(),
192        CqlValue::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
193        CqlValue::Int(v) => v.to_string(),
194        CqlValue::BigInt(v) | CqlValue::Counter(v) => v.to_string(),
195        CqlValue::SmallInt(v) => v.to_string(),
196        CqlValue::TinyInt(v) => v.to_string(),
197        CqlValue::Float(v) => {
198            if v.is_finite() {
199                v.to_string()
200            } else {
201                format!("\"{}\"", val)
202            }
203        }
204        CqlValue::Double(v) => {
205            if v.is_finite() {
206                v.to_string()
207            } else {
208                format!("\"{}\"", val)
209            }
210        }
211        CqlValue::Decimal(v) => format!("\"{}\"", v),
212        CqlValue::Varint(v) => format!("\"{}\"", v),
213        CqlValue::Text(s) | CqlValue::Ascii(s) => {
214            format!("\"{}\"", json_escape_string(s))
215        }
216        CqlValue::Uuid(u) | CqlValue::TimeUuid(u) => format!("\"{}\"", u),
217        CqlValue::Inet(addr) => format!("\"{}\"", addr),
218        CqlValue::Blob(bytes) => {
219            let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
220            format!("\"0x{hex}\"")
221        }
222        CqlValue::Timestamp(_) | CqlValue::Date(_) | CqlValue::Time(_) => {
223            format!("\"{}\"", val)
224        }
225        CqlValue::Duration {
226            months,
227            days,
228            nanoseconds,
229        } => format!("\"{months}mo{days}d{nanoseconds}ns\""),
230        CqlValue::List(items) | CqlValue::Set(items) => {
231            let elems: Vec<String> = items.iter().map(cql_value_to_json).collect();
232            format!("[{}]", elems.join(", "))
233        }
234        CqlValue::Map(entries) => {
235            let pairs: Vec<String> = entries
236                .iter()
237                .map(|(k, v)| {
238                    let key = match k {
239                        CqlValue::Text(s) | CqlValue::Ascii(s) => {
240                            format!("\"{}\"", json_escape_string(s))
241                        }
242                        other => format!("\"{}\"", json_escape_string(&other.to_string())),
243                    };
244                    format!("{key}: {}", cql_value_to_json(v))
245                })
246                .collect();
247            format!("{{{}}}", pairs.join(", "))
248        }
249        CqlValue::Tuple(items) => {
250            let elems: Vec<String> = items
251                .iter()
252                .map(|opt| opt.as_ref().map_or("null".to_string(), cql_value_to_json))
253                .collect();
254            format!("[{}]", elems.join(", "))
255        }
256        CqlValue::UserDefinedType { fields, .. } => {
257            let pairs: Vec<String> = fields
258                .iter()
259                .map(|(name, v)| {
260                    let json_val = v.as_ref().map_or("null".to_string(), cql_value_to_json);
261                    format!("\"{}\": {json_val}", json_escape_string(name))
262                })
263                .collect();
264            format!("{{{}}}", pairs.join(", "))
265        }
266    }
267}
268
269/// Format tracing session output matching Python cqlsh style.
270///
271/// Displays session metadata and a table of trace events sorted by elapsed time.
272pub fn print_trace(
273    trace: &crate::driver::TracingSession,
274    colorizer: &CqlColorizer,
275    writer: &mut dyn Write,
276) {
277    writeln!(writer).ok();
278    writeln!(
279        writer,
280        "{} {}",
281        colorizer.colorize_trace_label("Tracing session:"),
282        trace.trace_id
283    )
284    .ok();
285    writeln!(writer).ok();
286
287    if let Some(ref request) = trace.request {
288        writeln!(writer, " Request: {request}").ok();
289    }
290    if let Some(ref coordinator) = trace.coordinator {
291        writeln!(writer, " Coordinator: {coordinator}").ok();
292    }
293    if let Some(duration) = trace.duration {
294        writeln!(writer, " Duration: {} microseconds", duration).ok();
295    }
296    if let Some(ref started_at) = trace.started_at {
297        writeln!(writer, " Started at: {started_at}").ok();
298    }
299
300    if !trace.events.is_empty() {
301        writeln!(writer).ok();
302
303        let mut table = Table::new();
304        table.set_content_arrangement(ContentArrangement::Disabled);
305        table.load_preset(CQLSH_PRESET);
306        table.set_header(vec![
307            Cell::new(colorizer.colorize_header("activity")),
308            Cell::new(colorizer.colorize_header("timestamp")),
309            Cell::new(colorizer.colorize_header("source")),
310            Cell::new(colorizer.colorize_header("source_elapsed")),
311            Cell::new(colorizer.colorize_header("thread")),
312        ]);
313
314        for event in &trace.events {
315            let elapsed_str = event
316                .source_elapsed
317                .map(|e| format!("{e}"))
318                .unwrap_or_default();
319            table.add_row(vec![
320                Cell::new(event.activity.as_deref().unwrap_or("")),
321                Cell::new(""),
322                Cell::new(event.source.as_deref().unwrap_or("")),
323                Cell::new(&elapsed_str).set_alignment(CellAlignment::Right),
324                Cell::new(event.thread.as_deref().unwrap_or("")),
325            ]);
326        }
327
328        writeln!(writer, "{table}").ok();
329    }
330    writeln!(writer).ok();
331}
332
333/// Check if a CQL type name represents a numeric type.
334fn is_numeric_type(type_name: &str) -> bool {
335    let lower = type_name.to_lowercase();
336    matches!(
337        lower.as_str(),
338        "int"
339            | "bigint"
340            | "smallint"
341            | "tinyint"
342            | "float"
343            | "double"
344            | "decimal"
345            | "varint"
346            | "counter"
347    ) || lower.contains("int")
348        || lower.contains("float")
349        || lower.contains("double")
350        || lower.contains("decimal")
351        || lower.contains("counter")
352        || lower.contains("varint")
353}
354
355/// A comfy-table preset matching Python cqlsh's simple pipe-separated output.
356///
357/// Preset char positions (comfy-table v7):
358///   0=LeftBorder, 1=RightBorder, 2=TopBorder, 3=BottomBorder,
359///   4=LeftHeaderIntersection, 5=HeaderLines, 6=MiddleHeaderIntersections,
360///   7=RightHeaderIntersection, 8=VerticalLines, 9=HorizontalLines,
361///   10=MiddleIntersections, 11=LeftBorderIntersections,
362///   12=RightBorderIntersections, 13=TopBorderIntersections,
363///   14=BottomBorderIntersections, 15=TopLeftCorner, 16=TopRightCorner,
364///   17=BottomLeftCorner, 18=BottomRightCorner
365///
366/// Example:
367/// ```text
368///  name | age | city
369/// ------+-----+------
370///  Alice | 30 | NYC
371///  Bob   | 25 | LA
372/// ```
373//                    0123456789012345678
374const CQLSH_PRESET: &str = "     -+ |          ";
375
376/// A streaming table formatter that computes column widths from the first page
377/// of results and then formats subsequent rows incrementally.
378pub struct StreamingTableFormatter<'w> {
379    columns: Vec<CqlColumn>,
380    colorizer: &'w CqlColorizer,
381    writer: &'w mut dyn Write,
382    /// Buffer for first-page rows used to compute column widths
383    first_page_buffer: Vec<CqlRow>,
384    /// Maximum number of rows to buffer for width computation
385    page_size: usize,
386    /// Whether the header has been written (first page flushed)
387    header_written: bool,
388    /// Computed column widths (set after first page flush)
389    col_widths: Vec<usize>,
390    /// Total rows written
391    row_count: usize,
392    /// Whether we're in expanded mode
393    expanded: bool,
394}
395
396impl<'w> StreamingTableFormatter<'w> {
397    /// Create a new streaming formatter in tabular mode.
398    pub fn new(
399        columns: Vec<CqlColumn>,
400        colorizer: &'w CqlColorizer,
401        writer: &'w mut dyn Write,
402        page_size: usize,
403    ) -> Self {
404        Self {
405            columns,
406            colorizer,
407            writer,
408            first_page_buffer: Vec::with_capacity(page_size),
409            page_size,
410            header_written: false,
411            col_widths: Vec::new(),
412            row_count: 0,
413            expanded: false,
414        }
415    }
416
417    /// Create a new streaming formatter in expanded mode.
418    pub fn new_expanded(
419        columns: Vec<CqlColumn>,
420        colorizer: &'w CqlColorizer,
421        writer: &'w mut dyn Write,
422    ) -> Self {
423        Self {
424            columns,
425            colorizer,
426            writer,
427            first_page_buffer: Vec::new(),
428            page_size: 0,
429            header_written: true, // no header needed for expanded
430            col_widths: Vec::new(),
431            row_count: 0,
432            expanded: true,
433        }
434    }
435
436    /// Add a row to the formatter. Returns Err if writing fails (e.g. broken pipe).
437    pub fn add_row(&mut self, row: CqlRow) -> std::io::Result<()> {
438        self.row_count += 1;
439
440        if self.expanded {
441            return self.write_expanded_row(&row);
442        }
443
444        if !self.header_written {
445            self.first_page_buffer.push(row);
446            if self.first_page_buffer.len() >= self.page_size {
447                self.flush_first_page()?;
448            }
449            return Ok(());
450        }
451
452        self.write_row(&row)
453    }
454
455    pub fn flush_writer(&mut self) -> std::io::Result<()> {
456        self.writer.flush()
457    }
458
459    /// Finish the stream: flush any buffered rows and write the footer.
460    /// Returns the total row count.
461    pub fn finish(mut self) -> std::io::Result<usize> {
462        if !self.header_written && !self.first_page_buffer.is_empty() {
463            self.flush_first_page()?;
464        }
465
466        if self.row_count == 0 {
467            writeln!(self.writer, "\n(0 rows)\n")?;
468        } else {
469            writeln!(self.writer)?;
470            writeln!(
471                self.writer,
472                "({} row{})",
473                self.row_count,
474                if self.row_count == 1 { "" } else { "s" }
475            )?;
476            writeln!(self.writer)?;
477        }
478
479        Ok(self.row_count)
480    }
481
482    /// Compute column widths from the first page buffer and flush everything.
483    fn flush_first_page(&mut self) -> std::io::Result<()> {
484        // Compute widths: max of header name and all values in first page
485        self.col_widths = self
486            .columns
487            .iter()
488            .enumerate()
489            .map(|(i, col)| {
490                let header_width = col.name.len();
491                let max_val_width = self
492                    .first_page_buffer
493                    .iter()
494                    .map(|row| {
495                        row.values
496                            .get(i)
497                            .map(|v| format_value_plain(v).len())
498                            .unwrap_or(0)
499                    })
500                    .max()
501                    .unwrap_or(0);
502                header_width.max(max_val_width)
503            })
504            .collect();
505
506        self.header_written = true;
507
508        // Write header
509        writeln!(self.writer)?;
510        self.write_separator()?;
511        self.write_header_row()?;
512        self.write_separator()?;
513
514        // Write buffered rows
515        let buffered: Vec<CqlRow> = std::mem::take(&mut self.first_page_buffer);
516        for row in &buffered {
517            self.write_row(row)?;
518        }
519
520        Ok(())
521    }
522
523    fn write_separator(&mut self) -> std::io::Result<()> {
524        let parts: Vec<String> = self.col_widths.iter().map(|w| "-".repeat(*w + 2)).collect();
525        writeln!(self.writer, "+{}+", parts.join("+"))
526    }
527
528    fn write_header_row(&mut self) -> std::io::Result<()> {
529        let cells: Vec<String> = self
530            .columns
531            .iter()
532            .enumerate()
533            .map(|(i, col)| {
534                let width = self.col_widths[i];
535                let name = self.colorizer.colorize_header(&col.name);
536                // Pad based on raw name length (not colored length)
537                let padding = width.saturating_sub(col.name.len());
538                format!(" {}{} ", name, " ".repeat(padding))
539            })
540            .collect();
541        writeln!(self.writer, "|{}|", cells.join("|"))
542    }
543
544    fn write_row(&mut self, row: &CqlRow) -> std::io::Result<()> {
545        let cells: Vec<String> = row
546            .values
547            .iter()
548            .enumerate()
549            .map(|(i, val)| {
550                let width = self.col_widths.get(i).copied().unwrap_or(10);
551                let display = self.colorizer.colorize_value(val);
552                let plain_len = format_value_plain(val).len();
553                let padding = width.saturating_sub(plain_len);
554                if is_numeric_type(
555                    self.columns
556                        .get(i)
557                        .map(|c| c.type_name.as_str())
558                        .unwrap_or(""),
559                ) {
560                    // Right-align numeric
561                    format!(" {}{} ", " ".repeat(padding), display)
562                } else {
563                    format!(" {}{} ", display, " ".repeat(padding))
564                }
565            })
566            .collect();
567        writeln!(self.writer, "|{}|", cells.join("|"))
568    }
569
570    fn write_expanded_row(&mut self, row: &CqlRow) -> std::io::Result<()> {
571        let max_col_width = self.columns.iter().map(|c| c.name.len()).max().unwrap_or(0);
572
573        writeln!(self.writer, "@ Row {}", self.row_count)?;
574        writeln!(self.writer, "{}", "-".repeat(max_col_width + 10))?;
575        for (col_idx, col) in self.columns.iter().enumerate() {
576            let value = row
577                .values
578                .get(col_idx)
579                .map(|v| self.colorizer.colorize_value(v))
580                .unwrap_or_else(|| {
581                    self.colorizer
582                        .colorize_value(&crate::driver::types::CqlValue::Null)
583                });
584            writeln!(
585                self.writer,
586                " {:>width$} | {}",
587                self.colorizer.colorize_header(&col.name),
588                value,
589                width = max_col_width
590            )?;
591        }
592        writeln!(self.writer)?;
593        Ok(())
594    }
595}
596
597/// Format a CqlValue to plain text (no ANSI colors) for width calculation.
598fn format_value_plain(val: &crate::driver::types::CqlValue) -> String {
599    use crate::driver::types::CqlValue;
600    match val {
601        CqlValue::Null => "null".to_string(),
602        CqlValue::Text(s) | CqlValue::Ascii(s) => s.clone(),
603        CqlValue::Boolean(b) => if *b { "True" } else { "False" }.to_string(),
604        CqlValue::Int(n) => n.to_string(),
605        CqlValue::BigInt(n) => n.to_string(),
606        CqlValue::SmallInt(n) => n.to_string(),
607        CqlValue::TinyInt(n) => n.to_string(),
608        CqlValue::Float(n) => format!("{}", n),
609        CqlValue::Double(n) => format!("{}", n),
610        CqlValue::Uuid(u) | CqlValue::TimeUuid(u) => u.to_string(),
611        _ => format!("{}", val),
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::driver::types::{CqlColumn, CqlResult, CqlRow, CqlValue};
619
620    fn no_color() -> CqlColorizer {
621        CqlColorizer::new(false)
622    }
623
624    fn sample_result() -> CqlResult {
625        CqlResult {
626            columns: vec![
627                CqlColumn {
628                    name: "name".to_string(),
629                    type_name: "text".to_string(),
630                },
631                CqlColumn {
632                    name: "age".to_string(),
633                    type_name: "int".to_string(),
634                },
635            ],
636            rows: vec![
637                CqlRow {
638                    values: vec![CqlValue::Text("Alice".to_string()), CqlValue::Int(30)],
639                },
640                CqlRow {
641                    values: vec![CqlValue::Text("Bob".to_string()), CqlValue::Int(25)],
642                },
643            ],
644            has_rows: true,
645            tracing_id: None,
646            warnings: vec![],
647        }
648    }
649
650    #[test]
651    fn tabular_output_contains_headers_and_rows() {
652        let result = sample_result();
653        let mut buf = Vec::new();
654        print_tabular(&result, &no_color(), &mut buf);
655        let output = String::from_utf8(buf).unwrap();
656        assert!(output.contains("name"));
657        assert!(output.contains("age"));
658        assert!(output.contains("Alice"));
659        assert!(output.contains("Bob"));
660        assert!(output.contains("(2 rows)"));
661    }
662
663    #[test]
664    fn expanded_output_shows_row_headers() {
665        let result = sample_result();
666        let mut buf = Vec::new();
667        print_expanded(&result, &no_color(), &mut buf);
668        let output = String::from_utf8(buf).unwrap();
669        assert!(output.contains("@ Row 1"));
670        assert!(output.contains("@ Row 2"));
671        assert!(output.contains("Alice"));
672        assert!(output.contains("(2 rows)"));
673    }
674
675    #[test]
676    fn tabular_empty_result_produces_no_output() {
677        let result = CqlResult::empty();
678        let mut buf = Vec::new();
679        print_tabular(&result, &no_color(), &mut buf);
680        assert!(buf.is_empty());
681    }
682
683    #[test]
684    fn single_row_says_row_not_rows() {
685        let result = CqlResult {
686            columns: vec![CqlColumn {
687                name: "id".to_string(),
688                type_name: "int".to_string(),
689            }],
690            rows: vec![CqlRow {
691                values: vec![CqlValue::Int(1)],
692            }],
693            has_rows: true,
694            tracing_id: None,
695            warnings: vec![],
696        };
697        let mut buf = Vec::new();
698        print_tabular(&result, &no_color(), &mut buf);
699        let output = String::from_utf8(buf).unwrap();
700        assert!(output.contains("(1 row)"));
701        assert!(!output.contains("(1 rows)"));
702    }
703
704    #[test]
705    fn numeric_type_detection() {
706        assert!(is_numeric_type("int"));
707        assert!(is_numeric_type("bigint"));
708        assert!(is_numeric_type("float"));
709        assert!(is_numeric_type("double"));
710        assert!(is_numeric_type("decimal"));
711        assert!(!is_numeric_type("text"));
712        assert!(!is_numeric_type("uuid"));
713        assert!(!is_numeric_type("boolean"));
714    }
715
716    #[test]
717    fn tabular_row_separators_not_pipes() {
718        let result = sample_result();
719        let mut buf = Vec::new();
720        print_tabular(&result, &no_color(), &mut buf);
721        let output = String::from_utf8(buf).unwrap();
722        assert!(
723            !output.contains("||||"),
724            "row separators should not contain pipe characters"
725        );
726        assert!(
727            output.contains("-+-") || output.contains("---"),
728            "header separator should use dashes"
729        );
730    }
731
732    #[test]
733    fn tabular_columns_separated_by_pipes() {
734        let result = sample_result();
735        let mut buf = Vec::new();
736        print_tabular(&result, &no_color(), &mut buf);
737        let output = String::from_utf8(buf).unwrap();
738        assert!(
739            output.contains("| "),
740            "columns should be separated by pipes"
741        );
742    }
743
744    #[test]
745    fn trace_output_format() {
746        use crate::driver::{TracingEvent, TracingSession};
747        use std::collections::HashMap;
748
749        let trace = TracingSession {
750            trace_id: uuid::Uuid::nil(),
751            client: Some("127.0.0.1".to_string()),
752            command: Some("QUERY".to_string()),
753            coordinator: Some("127.0.0.1".to_string()),
754            duration: Some(1234),
755            parameters: HashMap::new(),
756            request: Some("SELECT * FROM test".to_string()),
757            started_at: Some("2024-01-01 00:00:00".to_string()),
758            events: vec![TracingEvent {
759                activity: Some("Parsing request".to_string()),
760                source: Some("127.0.0.1".to_string()),
761                source_elapsed: Some(100),
762                thread: Some("Native-Transport-1".to_string()),
763            }],
764        };
765
766        let mut buf = Vec::new();
767        print_trace(&trace, &no_color(), &mut buf);
768        let output = String::from_utf8(buf).unwrap();
769        assert!(output.contains("Tracing session:"));
770        assert!(output.contains("SELECT * FROM test"));
771        assert!(output.contains("1234 microseconds"));
772        assert!(output.contains("Parsing request"));
773    }
774
775    #[test]
776    fn json_escape_special_chars() {
777        assert_eq!(json_escape_string("hello"), "hello");
778        assert_eq!(json_escape_string("say \"hi\""), "say \\\"hi\\\"");
779        assert_eq!(json_escape_string("back\\slash"), "back\\\\slash");
780        assert_eq!(json_escape_string("new\nline"), "new\\nline");
781        assert_eq!(json_escape_string("tab\there"), "tab\\there");
782        assert_eq!(json_escape_string("cr\rhere"), "cr\\rhere");
783        assert_eq!(json_escape_string("\x01"), "\\u0001");
784    }
785
786    #[test]
787    fn cql_value_to_json_scalars() {
788        use crate::driver::types::CqlValue;
789        assert_eq!(cql_value_to_json(&CqlValue::Null), "null");
790        assert_eq!(cql_value_to_json(&CqlValue::Unset), "null");
791        assert_eq!(cql_value_to_json(&CqlValue::Boolean(true)), "true");
792        assert_eq!(cql_value_to_json(&CqlValue::Boolean(false)), "false");
793        assert_eq!(cql_value_to_json(&CqlValue::Int(42)), "42");
794        assert_eq!(cql_value_to_json(&CqlValue::BigInt(-100)), "-100");
795        assert_eq!(cql_value_to_json(&CqlValue::SmallInt(7)), "7");
796        assert_eq!(cql_value_to_json(&CqlValue::TinyInt(-1)), "-1");
797        assert_eq!(cql_value_to_json(&CqlValue::Counter(99)), "99");
798    }
799
800    #[test]
801    fn cql_value_to_json_strings() {
802        use crate::driver::types::CqlValue;
803        assert_eq!(
804            cql_value_to_json(&CqlValue::Text("hello".to_string())),
805            "\"hello\""
806        );
807        assert_eq!(
808            cql_value_to_json(&CqlValue::Ascii("world".to_string())),
809            "\"world\""
810        );
811        assert_eq!(
812            cql_value_to_json(&CqlValue::Text("say \"hi\"".to_string())),
813            "\"say \\\"hi\\\"\""
814        );
815    }
816
817    #[test]
818    fn cql_value_to_json_float_special() {
819        use crate::driver::types::CqlValue;
820        assert_eq!(cql_value_to_json(&CqlValue::Float(1.5)), "1.5");
821        assert_eq!(cql_value_to_json(&CqlValue::Double(2.5)), "2.5");
822
823        let nan_json = cql_value_to_json(&CqlValue::Float(f32::NAN));
824        assert!(nan_json.starts_with('"') && nan_json.ends_with('"'));
825        let inf_json = cql_value_to_json(&CqlValue::Double(f64::INFINITY));
826        assert!(inf_json.starts_with('"') && inf_json.ends_with('"'));
827    }
828
829    #[test]
830    fn cql_value_to_json_uuid_inet_blob() {
831        use crate::driver::types::CqlValue;
832        use std::net::IpAddr;
833        assert_eq!(
834            cql_value_to_json(&CqlValue::Uuid(uuid::Uuid::nil())),
835            "\"00000000-0000-0000-0000-000000000000\""
836        );
837        assert_eq!(
838            cql_value_to_json(&CqlValue::Inet("127.0.0.1".parse::<IpAddr>().unwrap())),
839            "\"127.0.0.1\""
840        );
841        assert_eq!(
842            cql_value_to_json(&CqlValue::Blob(vec![0xca, 0xfe])),
843            "\"0xcafe\""
844        );
845    }
846
847    #[test]
848    fn cql_value_to_json_collections() {
849        use crate::driver::types::CqlValue;
850        let list = CqlValue::List(vec![CqlValue::Int(1), CqlValue::Int(2)]);
851        assert_eq!(cql_value_to_json(&list), "[1, 2]");
852
853        let set = CqlValue::Set(vec![CqlValue::Text("a".to_string())]);
854        assert_eq!(cql_value_to_json(&set), "[\"a\"]");
855
856        let map = CqlValue::Map(vec![(CqlValue::Text("key".to_string()), CqlValue::Int(42))]);
857        assert_eq!(cql_value_to_json(&map), "{\"key\": 42}");
858
859        let map2 = CqlValue::Map(vec![(CqlValue::Int(1), CqlValue::Boolean(true))]);
860        assert_eq!(cql_value_to_json(&map2), "{\"1\": true}");
861    }
862
863    #[test]
864    fn cql_value_to_json_tuple_and_udt() {
865        use crate::driver::types::CqlValue;
866        let tuple = CqlValue::Tuple(vec![Some(CqlValue::Int(1)), None]);
867        assert_eq!(cql_value_to_json(&tuple), "[1, null]");
868
869        let udt = CqlValue::UserDefinedType {
870            keyspace: "ks".to_string(),
871            type_name: "t".to_string(),
872            fields: vec![
873                (
874                    "name".to_string(),
875                    Some(CqlValue::Text("Alice".to_string())),
876                ),
877                ("age".to_string(), None),
878            ],
879        };
880        assert_eq!(
881            cql_value_to_json(&udt),
882            "{\"name\": \"Alice\", \"age\": null}"
883        );
884    }
885
886    #[test]
887    fn cql_value_to_json_duration_decimal_varint() {
888        use crate::driver::types::CqlValue;
889        use bigdecimal::BigDecimal;
890        use num_bigint::BigInt;
891        use std::str::FromStr;
892
893        let dur = CqlValue::Duration {
894            months: 1,
895            days: 2,
896            nanoseconds: 3,
897        };
898        assert_eq!(cql_value_to_json(&dur), "\"1mo2d3ns\"");
899
900        let dec = CqlValue::Decimal(BigDecimal::from_str("3.14").unwrap());
901        assert_eq!(cql_value_to_json(&dec), "\"3.14\"");
902
903        let varint = CqlValue::Varint(BigInt::from(12345));
904        assert_eq!(cql_value_to_json(&varint), "\"12345\"");
905    }
906
907    #[test]
908    fn print_json_empty_result() {
909        let result = CqlResult::empty();
910        let mut buf = Vec::new();
911        print_json(&result, &mut buf);
912        let output = String::from_utf8(buf).unwrap();
913        assert_eq!(output.trim(), "[]");
914    }
915
916    #[test]
917    fn print_json_single_row() {
918        let result = CqlResult {
919            columns: vec![
920                CqlColumn {
921                    name: "id".to_string(),
922                    type_name: "int".to_string(),
923                },
924                CqlColumn {
925                    name: "name".to_string(),
926                    type_name: "text".to_string(),
927                },
928            ],
929            rows: vec![CqlRow {
930                values: vec![CqlValue::Int(1), CqlValue::Text("Alice".to_string())],
931            }],
932            has_rows: true,
933            tracing_id: None,
934            warnings: vec![],
935        };
936        let mut buf = Vec::new();
937        print_json(&result, &mut buf);
938        let output = String::from_utf8(buf).unwrap();
939        assert!(output.contains("\"id\": 1"));
940        assert!(output.contains("\"name\": \"Alice\""));
941        assert!(output.starts_with("[\n"));
942        assert!(output.trim().ends_with("]"));
943    }
944
945    #[test]
946    fn print_json_multiple_rows_has_commas() {
947        let result = CqlResult {
948            columns: vec![CqlColumn {
949                name: "v".to_string(),
950                type_name: "int".to_string(),
951            }],
952            rows: vec![
953                CqlRow {
954                    values: vec![CqlValue::Int(1)],
955                },
956                CqlRow {
957                    values: vec![CqlValue::Int(2)],
958                },
959            ],
960            has_rows: true,
961            tracing_id: None,
962            warnings: vec![],
963        };
964        let mut buf = Vec::new();
965        print_json(&result, &mut buf);
966        let output = String::from_utf8(buf).unwrap();
967        let lines: Vec<&str> = output.lines().collect();
968        assert!(lines[1].ends_with("},"));
969        assert!(lines[2].ends_with("}"));
970    }
971
972    #[test]
973    fn wide_table_not_truncated() {
974        let columns: Vec<CqlColumn> = (0..20)
975            .map(|i| CqlColumn {
976                name: format!("column_{i}"),
977                type_name: "text".to_string(),
978            })
979            .collect();
980        let rows = vec![CqlRow {
981            values: (0..20)
982                .map(|i| CqlValue::Text(format!("value_{i}_with_long_content")))
983                .collect(),
984        }];
985        let result = CqlResult {
986            columns,
987            rows,
988            has_rows: true,
989            tracing_id: None,
990            warnings: vec![],
991        };
992        let mut buf = Vec::new();
993        print_tabular(&result, &no_color(), &mut buf);
994        let output = String::from_utf8(buf).unwrap();
995        // All 20 columns should appear on the header line
996        assert!(output.contains("column_0"));
997        assert!(output.contains("column_19"));
998        // Values should not be truncated
999        assert!(output.contains("value_19_with_long_content"));
1000    }
1001
1002    fn make_col(name: &str, type_name: &str) -> CqlColumn {
1003        CqlColumn {
1004            name: name.to_string(),
1005            type_name: type_name.to_string(),
1006        }
1007    }
1008
1009    fn make_row(values: Vec<CqlValue>) -> CqlRow {
1010        CqlRow { values }
1011    }
1012
1013    #[test]
1014    fn streaming_finish_zero_rows() {
1015        let cols = vec![make_col("id", "int")];
1016        let color = no_color();
1017        let mut buf: Vec<u8> = Vec::new();
1018        let fmt = StreamingTableFormatter::new(cols, &color, &mut buf, 100);
1019        fmt.finish().unwrap();
1020        let output = String::from_utf8(buf).unwrap();
1021        assert!(output.contains("(0 rows)"));
1022    }
1023
1024    #[test]
1025    fn streaming_finish_one_row_singular() {
1026        let cols = vec![make_col("id", "int")];
1027        let color = no_color();
1028        let mut buf: Vec<u8> = Vec::new();
1029        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, 100);
1030        fmt.add_row(make_row(vec![CqlValue::Int(1)])).unwrap();
1031        fmt.finish().unwrap();
1032        let output = String::from_utf8(buf).unwrap();
1033        assert!(output.contains("(1 row)"));
1034        assert!(!output.contains("(1 rows)"));
1035    }
1036
1037    #[test]
1038    fn streaming_finish_multiple_rows_plural() {
1039        let cols = vec![make_col("id", "int")];
1040        let color = no_color();
1041        let mut buf: Vec<u8> = Vec::new();
1042        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, 100);
1043        fmt.add_row(make_row(vec![CqlValue::Int(1)])).unwrap();
1044        fmt.add_row(make_row(vec![CqlValue::Int(2)])).unwrap();
1045        fmt.add_row(make_row(vec![CqlValue::Int(3)])).unwrap();
1046        fmt.finish().unwrap();
1047        let output = String::from_utf8(buf).unwrap();
1048        assert!(output.contains("(3 rows)"));
1049    }
1050
1051    #[test]
1052    fn streaming_tabular_buffered_rows_flushed_on_finish() {
1053        let cols = vec![make_col("name", "text"), make_col("age", "int")];
1054        let color = no_color();
1055        let mut buf: Vec<u8> = Vec::new();
1056        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, 100);
1057        fmt.add_row(make_row(vec![
1058            CqlValue::Text("Alice".to_string()),
1059            CqlValue::Int(30),
1060        ]))
1061        .unwrap();
1062        fmt.add_row(make_row(vec![
1063            CqlValue::Text("Bob".to_string()),
1064            CqlValue::Int(25),
1065        ]))
1066        .unwrap();
1067        fmt.finish().unwrap();
1068        let output = String::from_utf8(buf).unwrap();
1069        assert!(output.contains("name"));
1070        assert!(output.contains("age"));
1071        assert!(output.contains("Alice"));
1072        assert!(output.contains("Bob"));
1073        assert!(output.contains("(2 rows)"));
1074    }
1075
1076    #[test]
1077    fn streaming_flushes_first_page_at_page_size() {
1078        let cols = vec![make_col("id", "int")];
1079        let color = no_color();
1080        let mut buf: Vec<u8> = Vec::new();
1081        let page_size = 3;
1082        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, page_size);
1083        for i in 0..page_size {
1084            fmt.add_row(make_row(vec![CqlValue::Int(i as i32)]))
1085                .unwrap();
1086        }
1087        fmt.finish().unwrap();
1088        let output = String::from_utf8(buf).unwrap();
1089        assert!(output.contains("id"));
1090        assert!(output.contains("(3 rows)"));
1091    }
1092
1093    #[test]
1094    fn streaming_post_flush_rows_written_directly() {
1095        let cols = vec![make_col("id", "int")];
1096        let color = no_color();
1097        let mut buf: Vec<u8> = Vec::new();
1098        let page_size = 2;
1099        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, page_size);
1100        fmt.add_row(make_row(vec![CqlValue::Int(1)])).unwrap();
1101        fmt.add_row(make_row(vec![CqlValue::Int(2)])).unwrap();
1102        fmt.add_row(make_row(vec![CqlValue::Int(3)])).unwrap();
1103        fmt.finish().unwrap();
1104        let output = String::from_utf8(buf).unwrap();
1105        assert!(output.contains("(3 rows)"));
1106    }
1107
1108    #[test]
1109    fn streaming_flush_writer_succeeds() {
1110        let cols = vec![make_col("id", "int")];
1111        let color = no_color();
1112        let mut buf: Vec<u8> = Vec::new();
1113        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, 100);
1114        assert!(fmt.flush_writer().is_ok());
1115    }
1116
1117    #[test]
1118    fn streaming_expanded_mode_writes_row_headers() {
1119        let cols = vec![make_col("name", "text"), make_col("age", "int")];
1120        let color = no_color();
1121        let mut buf: Vec<u8> = Vec::new();
1122        let mut fmt = StreamingTableFormatter::new_expanded(cols, &color, &mut buf);
1123        fmt.add_row(make_row(vec![
1124            CqlValue::Text("Alice".to_string()),
1125            CqlValue::Int(30),
1126        ]))
1127        .unwrap();
1128        fmt.add_row(make_row(vec![
1129            CqlValue::Text("Bob".to_string()),
1130            CqlValue::Int(25),
1131        ]))
1132        .unwrap();
1133        fmt.finish().unwrap();
1134        let output = String::from_utf8(buf).unwrap();
1135        assert!(output.contains("@ Row 1"));
1136        assert!(output.contains("@ Row 2"));
1137        assert!(output.contains("Alice"));
1138        assert!(output.contains("Bob"));
1139        assert!(output.contains("(2 rows)"));
1140    }
1141
1142    #[test]
1143    fn streaming_expanded_zero_rows_footer() {
1144        let cols = vec![make_col("id", "int")];
1145        let color = no_color();
1146        let mut buf: Vec<u8> = Vec::new();
1147        let fmt = StreamingTableFormatter::new_expanded(cols, &color, &mut buf);
1148        fmt.finish().unwrap();
1149        let output = String::from_utf8(buf).unwrap();
1150        assert!(output.contains("(0 rows)"));
1151    }
1152
1153    #[test]
1154    fn streaming_numeric_right_aligned() {
1155        let cols = vec![make_col("name", "text"), make_col("score", "int")];
1156        let color = no_color();
1157        let mut buf: Vec<u8> = Vec::new();
1158        let page_size = 1;
1159        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut buf, page_size);
1160        fmt.add_row(make_row(vec![
1161            CqlValue::Text("Alice".to_string()),
1162            CqlValue::Int(42),
1163        ]))
1164        .unwrap();
1165        fmt.finish().unwrap();
1166        let output = String::from_utf8(buf).unwrap();
1167        assert!(output.contains("42"));
1168        assert!(output.contains("Alice"));
1169    }
1170
1171    #[test]
1172    fn streaming_write_error_propagates() {
1173        struct FailWriter;
1174        impl Write for FailWriter {
1175            fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
1176                Err(std::io::Error::new(
1177                    std::io::ErrorKind::BrokenPipe,
1178                    "broken",
1179                ))
1180            }
1181            fn flush(&mut self) -> std::io::Result<()> {
1182                Err(std::io::Error::new(
1183                    std::io::ErrorKind::BrokenPipe,
1184                    "broken",
1185                ))
1186            }
1187        }
1188
1189        let cols = vec![make_col("id", "int")];
1190        let color = no_color();
1191        let mut writer = FailWriter;
1192        let mut fmt = StreamingTableFormatter::new(cols, &color, &mut writer, 1);
1193        let result = fmt.add_row(make_row(vec![CqlValue::Int(1)]));
1194        assert!(result.is_err());
1195    }
1196}