1use std::io::Write;
8
9use comfy_table::{Cell, CellAlignment, ContentArrangement, Table};
10
11use crate::colorizer::CqlColorizer;
12use crate::driver::CqlResult;
13
14pub 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 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 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 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
76pub 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
130pub 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
170fn 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
187fn 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
269pub 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
333fn 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
355const 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 assert!(output.contains("column_0"));
561 assert!(output.contains("column_19"));
562 assert!(output.contains("value_19_with_long_content"));
564 }
565}