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    writeln!(writer, "{table}").ok();
62    writeln!(writer).ok();
63    let row_count = result.rows.len();
64    writeln!(
65        writer,
66        "({} row{})",
67        row_count,
68        if row_count == 1 { "" } else { "s" }
69    )
70    .ok();
71    writeln!(writer).ok();
72}
73
74/// Format and print query results in expanded (vertical) format.
75///
76/// Each row is printed as a block with `@ Row N` header, followed by
77/// column_name | value pairs. Matches Python cqlsh `EXPAND ON` behavior.
78pub fn print_expanded(result: &CqlResult, colorizer: &CqlColorizer, writer: &mut dyn Write) {
79    if result.columns.is_empty() {
80        return;
81    }
82
83    if result.rows.is_empty() {
84        writeln!(writer, "\n(0 rows)\n").ok();
85        return;
86    }
87
88    let max_col_width = result
89        .columns
90        .iter()
91        .map(|c| c.name.len())
92        .max()
93        .unwrap_or(0);
94
95    writeln!(writer).ok();
96
97    for (row_idx, row) in result.rows.iter().enumerate() {
98        writeln!(writer, "@ Row {}", row_idx + 1).ok();
99        writeln!(writer, "{}", "-".repeat(max_col_width + 10)).ok();
100        for (col_idx, col) in result.columns.iter().enumerate() {
101            let value = row
102                .get(col_idx)
103                .map(|v| colorizer.colorize_value(v))
104                .unwrap_or_else(|| colorizer.colorize_value(&crate::driver::types::CqlValue::Null));
105            writeln!(
106                writer,
107                " {:>width$} | {}",
108                colorizer.colorize_header(&col.name),
109                value,
110                width = max_col_width
111            )
112            .ok();
113        }
114        writeln!(writer).ok();
115    }
116
117    let row_count = result.rows.len();
118    writeln!(
119        writer,
120        "({} row{})",
121        row_count,
122        if row_count == 1 { "" } else { "s" }
123    )
124    .ok();
125    writeln!(writer).ok();
126}
127
128/// Format and print query results as a JSON array.
129///
130/// Each row becomes a JSON object mapping column names to values.
131/// Matches the format produced by Python cqlsh `--json`.
132/// NaN and Infinity float values are serialized as quoted strings since they
133/// are not valid JSON numbers.
134pub fn print_json(result: &CqlResult, writer: &mut dyn Write) {
135    use crate::driver::types::CqlValue;
136
137    if result.columns.is_empty() || result.rows.is_empty() {
138        writeln!(writer, "[]").ok();
139        return;
140    }
141
142    writeln!(writer, "[").ok();
143    let last_row = result.rows.len() - 1;
144    for (row_idx, row) in result.rows.iter().enumerate() {
145        write!(writer, "  {{").ok();
146        for (col_idx, col) in result.columns.iter().enumerate() {
147            if col_idx > 0 {
148                write!(writer, ", ").ok();
149            }
150            let val = row.get(col_idx).unwrap_or(&CqlValue::Null);
151            write!(
152                writer,
153                "\"{}\": {}",
154                json_escape_string(&col.name),
155                cql_value_to_json(val)
156            )
157            .ok();
158        }
159        if row_idx < last_row {
160            writeln!(writer, "}},").ok();
161        } else {
162            writeln!(writer, "}}").ok();
163        }
164    }
165    writeln!(writer, "]").ok();
166}
167
168/// Escape a string for use as a JSON string value (without surrounding quotes).
169fn json_escape_string(s: &str) -> String {
170    let mut out = String::with_capacity(s.len());
171    for c in s.chars() {
172        match c {
173            '"' => out.push_str("\\\""),
174            '\\' => out.push_str("\\\\"),
175            '\n' => out.push_str("\\n"),
176            '\r' => out.push_str("\\r"),
177            '\t' => out.push_str("\\t"),
178            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
179            c => out.push(c),
180        }
181    }
182    out
183}
184
185/// Serialize a CqlValue to a JSON token.
186fn cql_value_to_json(val: &crate::driver::types::CqlValue) -> String {
187    use crate::driver::types::CqlValue;
188    match val {
189        CqlValue::Null | CqlValue::Unset => "null".to_string(),
190        CqlValue::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
191        CqlValue::Int(v) => v.to_string(),
192        CqlValue::BigInt(v) | CqlValue::Counter(v) => v.to_string(),
193        CqlValue::SmallInt(v) => v.to_string(),
194        CqlValue::TinyInt(v) => v.to_string(),
195        CqlValue::Float(v) => {
196            if v.is_finite() {
197                v.to_string()
198            } else {
199                format!("\"{}\"", val)
200            }
201        }
202        CqlValue::Double(v) => {
203            if v.is_finite() {
204                v.to_string()
205            } else {
206                format!("\"{}\"", val)
207            }
208        }
209        CqlValue::Decimal(v) => format!("\"{}\"", v),
210        CqlValue::Varint(v) => format!("\"{}\"", v),
211        CqlValue::Text(s) | CqlValue::Ascii(s) => {
212            format!("\"{}\"", json_escape_string(s))
213        }
214        CqlValue::Uuid(u) | CqlValue::TimeUuid(u) => format!("\"{}\"", u),
215        CqlValue::Inet(addr) => format!("\"{}\"", addr),
216        CqlValue::Blob(bytes) => {
217            let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
218            format!("\"0x{hex}\"")
219        }
220        CqlValue::Timestamp(_) | CqlValue::Date(_) | CqlValue::Time(_) => {
221            format!("\"{}\"", val)
222        }
223        CqlValue::Duration {
224            months,
225            days,
226            nanoseconds,
227        } => format!("\"{months}mo{days}d{nanoseconds}ns\""),
228        CqlValue::List(items) | CqlValue::Set(items) => {
229            let elems: Vec<String> = items.iter().map(cql_value_to_json).collect();
230            format!("[{}]", elems.join(", "))
231        }
232        CqlValue::Map(entries) => {
233            let pairs: Vec<String> = entries
234                .iter()
235                .map(|(k, v)| {
236                    let key = match k {
237                        CqlValue::Text(s) | CqlValue::Ascii(s) => {
238                            format!("\"{}\"", json_escape_string(s))
239                        }
240                        other => format!("\"{}\"", json_escape_string(&other.to_string())),
241                    };
242                    format!("{key}: {}", cql_value_to_json(v))
243                })
244                .collect();
245            format!("{{{}}}", pairs.join(", "))
246        }
247        CqlValue::Tuple(items) => {
248            let elems: Vec<String> = items
249                .iter()
250                .map(|opt| opt.as_ref().map_or("null".to_string(), cql_value_to_json))
251                .collect();
252            format!("[{}]", elems.join(", "))
253        }
254        CqlValue::UserDefinedType { fields, .. } => {
255            let pairs: Vec<String> = fields
256                .iter()
257                .map(|(name, v)| {
258                    let json_val = v.as_ref().map_or("null".to_string(), cql_value_to_json);
259                    format!("\"{}\": {json_val}", json_escape_string(name))
260                })
261                .collect();
262            format!("{{{}}}", pairs.join(", "))
263        }
264    }
265}
266
267/// Format tracing session output matching Python cqlsh style.
268///
269/// Displays session metadata and a table of trace events sorted by elapsed time.
270pub fn print_trace(
271    trace: &crate::driver::TracingSession,
272    colorizer: &CqlColorizer,
273    writer: &mut dyn Write,
274) {
275    writeln!(writer).ok();
276    writeln!(
277        writer,
278        "{} {}",
279        colorizer.colorize_trace_label("Tracing session:"),
280        trace.trace_id
281    )
282    .ok();
283    writeln!(writer).ok();
284
285    if let Some(ref request) = trace.request {
286        writeln!(writer, " Request: {request}").ok();
287    }
288    if let Some(ref coordinator) = trace.coordinator {
289        writeln!(writer, " Coordinator: {coordinator}").ok();
290    }
291    if let Some(duration) = trace.duration {
292        writeln!(writer, " Duration: {} microseconds", duration).ok();
293    }
294    if let Some(ref started_at) = trace.started_at {
295        writeln!(writer, " Started at: {started_at}").ok();
296    }
297
298    if !trace.events.is_empty() {
299        writeln!(writer).ok();
300
301        let mut table = Table::new();
302        table.set_content_arrangement(ContentArrangement::Disabled);
303        table.load_preset(CQLSH_PRESET);
304        table.set_header(vec![
305            Cell::new(colorizer.colorize_header("activity")),
306            Cell::new(colorizer.colorize_header("timestamp")),
307            Cell::new(colorizer.colorize_header("source")),
308            Cell::new(colorizer.colorize_header("source_elapsed")),
309            Cell::new(colorizer.colorize_header("thread")),
310        ]);
311
312        for event in &trace.events {
313            let elapsed_str = event
314                .source_elapsed
315                .map(|e| format!("{e}"))
316                .unwrap_or_default();
317            table.add_row(vec![
318                Cell::new(event.activity.as_deref().unwrap_or("")),
319                Cell::new(""),
320                Cell::new(event.source.as_deref().unwrap_or("")),
321                Cell::new(&elapsed_str).set_alignment(CellAlignment::Right),
322                Cell::new(event.thread.as_deref().unwrap_or("")),
323            ]);
324        }
325
326        writeln!(writer, "{table}").ok();
327    }
328    writeln!(writer).ok();
329}
330
331/// Check if a CQL type name represents a numeric type.
332fn is_numeric_type(type_name: &str) -> bool {
333    let lower = type_name.to_lowercase();
334    matches!(
335        lower.as_str(),
336        "int"
337            | "bigint"
338            | "smallint"
339            | "tinyint"
340            | "float"
341            | "double"
342            | "decimal"
343            | "varint"
344            | "counter"
345    ) || lower.contains("int")
346        || lower.contains("float")
347        || lower.contains("double")
348        || lower.contains("decimal")
349        || lower.contains("counter")
350        || lower.contains("varint")
351}
352
353/// A comfy-table preset matching Python cqlsh's simple pipe-separated output.
354///
355/// Preset char positions (comfy-table v7):
356///   0=LeftBorder, 1=RightBorder, 2=TopBorder, 3=BottomBorder,
357///   4=LeftHeaderIntersection, 5=HeaderLines, 6=MiddleHeaderIntersections,
358///   7=RightHeaderIntersection, 8=VerticalLines, 9=HorizontalLines,
359///   10=MiddleIntersections, 11=LeftBorderIntersections,
360///   12=RightBorderIntersections, 13=TopBorderIntersections,
361///   14=BottomBorderIntersections, 15=TopLeftCorner, 16=TopRightCorner,
362///   17=BottomLeftCorner, 18=BottomRightCorner
363///
364/// Example:
365/// ```text
366///  name | age | city
367/// ------+-----+------
368///  Alice | 30 | NYC
369///  Bob   | 25 | LA
370/// ```
371//                    0123456789012345678
372const CQLSH_PRESET: &str = "     -+ |          ";
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use crate::driver::types::{CqlColumn, CqlResult, CqlRow, CqlValue};
378
379    fn no_color() -> CqlColorizer {
380        CqlColorizer::new(false)
381    }
382
383    fn sample_result() -> CqlResult {
384        CqlResult {
385            columns: vec![
386                CqlColumn {
387                    name: "name".to_string(),
388                    type_name: "text".to_string(),
389                },
390                CqlColumn {
391                    name: "age".to_string(),
392                    type_name: "int".to_string(),
393                },
394            ],
395            rows: vec![
396                CqlRow {
397                    values: vec![CqlValue::Text("Alice".to_string()), CqlValue::Int(30)],
398                },
399                CqlRow {
400                    values: vec![CqlValue::Text("Bob".to_string()), CqlValue::Int(25)],
401                },
402            ],
403            has_rows: true,
404            tracing_id: None,
405            warnings: vec![],
406        }
407    }
408
409    #[test]
410    fn tabular_output_contains_headers_and_rows() {
411        let result = sample_result();
412        let mut buf = Vec::new();
413        print_tabular(&result, &no_color(), &mut buf);
414        let output = String::from_utf8(buf).unwrap();
415        assert!(output.contains("name"));
416        assert!(output.contains("age"));
417        assert!(output.contains("Alice"));
418        assert!(output.contains("Bob"));
419        assert!(output.contains("(2 rows)"));
420    }
421
422    #[test]
423    fn expanded_output_shows_row_headers() {
424        let result = sample_result();
425        let mut buf = Vec::new();
426        print_expanded(&result, &no_color(), &mut buf);
427        let output = String::from_utf8(buf).unwrap();
428        assert!(output.contains("@ Row 1"));
429        assert!(output.contains("@ Row 2"));
430        assert!(output.contains("Alice"));
431        assert!(output.contains("(2 rows)"));
432    }
433
434    #[test]
435    fn tabular_empty_result_produces_no_output() {
436        let result = CqlResult::empty();
437        let mut buf = Vec::new();
438        print_tabular(&result, &no_color(), &mut buf);
439        assert!(buf.is_empty());
440    }
441
442    #[test]
443    fn single_row_says_row_not_rows() {
444        let result = CqlResult {
445            columns: vec![CqlColumn {
446                name: "id".to_string(),
447                type_name: "int".to_string(),
448            }],
449            rows: vec![CqlRow {
450                values: vec![CqlValue::Int(1)],
451            }],
452            has_rows: true,
453            tracing_id: None,
454            warnings: vec![],
455        };
456        let mut buf = Vec::new();
457        print_tabular(&result, &no_color(), &mut buf);
458        let output = String::from_utf8(buf).unwrap();
459        assert!(output.contains("(1 row)"));
460        assert!(!output.contains("(1 rows)"));
461    }
462
463    #[test]
464    fn numeric_type_detection() {
465        assert!(is_numeric_type("int"));
466        assert!(is_numeric_type("bigint"));
467        assert!(is_numeric_type("float"));
468        assert!(is_numeric_type("double"));
469        assert!(is_numeric_type("decimal"));
470        assert!(!is_numeric_type("text"));
471        assert!(!is_numeric_type("uuid"));
472        assert!(!is_numeric_type("boolean"));
473    }
474
475    #[test]
476    fn tabular_row_separators_not_pipes() {
477        let result = sample_result();
478        let mut buf = Vec::new();
479        print_tabular(&result, &no_color(), &mut buf);
480        let output = String::from_utf8(buf).unwrap();
481        assert!(
482            !output.contains("||||"),
483            "row separators should not contain pipe characters"
484        );
485        assert!(
486            output.contains("-+-") || output.contains("---"),
487            "header separator should use dashes"
488        );
489    }
490
491    #[test]
492    fn tabular_columns_separated_by_pipes() {
493        let result = sample_result();
494        let mut buf = Vec::new();
495        print_tabular(&result, &no_color(), &mut buf);
496        let output = String::from_utf8(buf).unwrap();
497        assert!(
498            output.contains("| "),
499            "columns should be separated by pipes"
500        );
501    }
502
503    #[test]
504    fn trace_output_format() {
505        use crate::driver::{TracingEvent, TracingSession};
506        use std::collections::HashMap;
507
508        let trace = TracingSession {
509            trace_id: uuid::Uuid::nil(),
510            client: Some("127.0.0.1".to_string()),
511            command: Some("QUERY".to_string()),
512            coordinator: Some("127.0.0.1".to_string()),
513            duration: Some(1234),
514            parameters: HashMap::new(),
515            request: Some("SELECT * FROM test".to_string()),
516            started_at: Some("2024-01-01 00:00:00".to_string()),
517            events: vec![TracingEvent {
518                activity: Some("Parsing request".to_string()),
519                source: Some("127.0.0.1".to_string()),
520                source_elapsed: Some(100),
521                thread: Some("Native-Transport-1".to_string()),
522            }],
523        };
524
525        let mut buf = Vec::new();
526        print_trace(&trace, &no_color(), &mut buf);
527        let output = String::from_utf8(buf).unwrap();
528        assert!(output.contains("Tracing session:"));
529        assert!(output.contains("SELECT * FROM test"));
530        assert!(output.contains("1234 microseconds"));
531        assert!(output.contains("Parsing request"));
532    }
533
534    #[test]
535    fn wide_table_not_truncated() {
536        let columns: Vec<CqlColumn> = (0..20)
537            .map(|i| CqlColumn {
538                name: format!("column_{i}"),
539                type_name: "text".to_string(),
540            })
541            .collect();
542        let rows = vec![CqlRow {
543            values: (0..20)
544                .map(|i| CqlValue::Text(format!("value_{i}_with_long_content")))
545                .collect(),
546        }];
547        let result = CqlResult {
548            columns,
549            rows,
550            has_rows: true,
551            tracing_id: None,
552            warnings: vec![],
553        };
554        let mut buf = Vec::new();
555        print_tabular(&result, &no_color(), &mut buf);
556        let output = String::from_utf8(buf).unwrap();
557        // All 20 columns should appear on the header line
558        assert!(output.contains("column_0"));
559        assert!(output.contains("column_19"));
560        // Values should not be truncated
561        assert!(output.contains("value_19_with_long_content"));
562    }
563}