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::CqlResult;
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#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::driver::types::{CqlColumn, CqlResult, CqlRow, CqlValue};
380
381    fn no_color() -> CqlColorizer {
382        CqlColorizer::new(false)
383    }
384
385    fn sample_result() -> CqlResult {
386        CqlResult {
387            columns: vec![
388                CqlColumn {
389                    name: "name".to_string(),
390                    type_name: "text".to_string(),
391                },
392                CqlColumn {
393                    name: "age".to_string(),
394                    type_name: "int".to_string(),
395                },
396            ],
397            rows: vec![
398                CqlRow {
399                    values: vec![CqlValue::Text("Alice".to_string()), CqlValue::Int(30)],
400                },
401                CqlRow {
402                    values: vec![CqlValue::Text("Bob".to_string()), CqlValue::Int(25)],
403                },
404            ],
405            has_rows: true,
406            tracing_id: None,
407            warnings: vec![],
408        }
409    }
410
411    #[test]
412    fn tabular_output_contains_headers_and_rows() {
413        let result = sample_result();
414        let mut buf = Vec::new();
415        print_tabular(&result, &no_color(), &mut buf);
416        let output = String::from_utf8(buf).unwrap();
417        assert!(output.contains("name"));
418        assert!(output.contains("age"));
419        assert!(output.contains("Alice"));
420        assert!(output.contains("Bob"));
421        assert!(output.contains("(2 rows)"));
422    }
423
424    #[test]
425    fn expanded_output_shows_row_headers() {
426        let result = sample_result();
427        let mut buf = Vec::new();
428        print_expanded(&result, &no_color(), &mut buf);
429        let output = String::from_utf8(buf).unwrap();
430        assert!(output.contains("@ Row 1"));
431        assert!(output.contains("@ Row 2"));
432        assert!(output.contains("Alice"));
433        assert!(output.contains("(2 rows)"));
434    }
435
436    #[test]
437    fn tabular_empty_result_produces_no_output() {
438        let result = CqlResult::empty();
439        let mut buf = Vec::new();
440        print_tabular(&result, &no_color(), &mut buf);
441        assert!(buf.is_empty());
442    }
443
444    #[test]
445    fn single_row_says_row_not_rows() {
446        let result = CqlResult {
447            columns: vec![CqlColumn {
448                name: "id".to_string(),
449                type_name: "int".to_string(),
450            }],
451            rows: vec![CqlRow {
452                values: vec![CqlValue::Int(1)],
453            }],
454            has_rows: true,
455            tracing_id: None,
456            warnings: vec![],
457        };
458        let mut buf = Vec::new();
459        print_tabular(&result, &no_color(), &mut buf);
460        let output = String::from_utf8(buf).unwrap();
461        assert!(output.contains("(1 row)"));
462        assert!(!output.contains("(1 rows)"));
463    }
464
465    #[test]
466    fn numeric_type_detection() {
467        assert!(is_numeric_type("int"));
468        assert!(is_numeric_type("bigint"));
469        assert!(is_numeric_type("float"));
470        assert!(is_numeric_type("double"));
471        assert!(is_numeric_type("decimal"));
472        assert!(!is_numeric_type("text"));
473        assert!(!is_numeric_type("uuid"));
474        assert!(!is_numeric_type("boolean"));
475    }
476
477    #[test]
478    fn tabular_row_separators_not_pipes() {
479        let result = sample_result();
480        let mut buf = Vec::new();
481        print_tabular(&result, &no_color(), &mut buf);
482        let output = String::from_utf8(buf).unwrap();
483        assert!(
484            !output.contains("||||"),
485            "row separators should not contain pipe characters"
486        );
487        assert!(
488            output.contains("-+-") || output.contains("---"),
489            "header separator should use dashes"
490        );
491    }
492
493    #[test]
494    fn tabular_columns_separated_by_pipes() {
495        let result = sample_result();
496        let mut buf = Vec::new();
497        print_tabular(&result, &no_color(), &mut buf);
498        let output = String::from_utf8(buf).unwrap();
499        assert!(
500            output.contains("| "),
501            "columns should be separated by pipes"
502        );
503    }
504
505    #[test]
506    fn trace_output_format() {
507        use crate::driver::{TracingEvent, TracingSession};
508        use std::collections::HashMap;
509
510        let trace = TracingSession {
511            trace_id: uuid::Uuid::nil(),
512            client: Some("127.0.0.1".to_string()),
513            command: Some("QUERY".to_string()),
514            coordinator: Some("127.0.0.1".to_string()),
515            duration: Some(1234),
516            parameters: HashMap::new(),
517            request: Some("SELECT * FROM test".to_string()),
518            started_at: Some("2024-01-01 00:00:00".to_string()),
519            events: vec![TracingEvent {
520                activity: Some("Parsing request".to_string()),
521                source: Some("127.0.0.1".to_string()),
522                source_elapsed: Some(100),
523                thread: Some("Native-Transport-1".to_string()),
524            }],
525        };
526
527        let mut buf = Vec::new();
528        print_trace(&trace, &no_color(), &mut buf);
529        let output = String::from_utf8(buf).unwrap();
530        assert!(output.contains("Tracing session:"));
531        assert!(output.contains("SELECT * FROM test"));
532        assert!(output.contains("1234 microseconds"));
533        assert!(output.contains("Parsing request"));
534    }
535
536    #[test]
537    fn wide_table_not_truncated() {
538        let columns: Vec<CqlColumn> = (0..20)
539            .map(|i| CqlColumn {
540                name: format!("column_{i}"),
541                type_name: "text".to_string(),
542            })
543            .collect();
544        let rows = vec![CqlRow {
545            values: (0..20)
546                .map(|i| CqlValue::Text(format!("value_{i}_with_long_content")))
547                .collect(),
548        }];
549        let result = CqlResult {
550            columns,
551            rows,
552            has_rows: true,
553            tracing_id: None,
554            warnings: vec![],
555        };
556        let mut buf = Vec::new();
557        print_tabular(&result, &no_color(), &mut buf);
558        let output = String::from_utf8(buf).unwrap();
559        // All 20 columns should appear on the header line
560        assert!(output.contains("column_0"));
561        assert!(output.contains("column_19"));
562        // Values should not be truncated
563        assert!(output.contains("value_19_with_long_content"));
564    }
565}