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 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
74pub 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
128pub 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
168fn 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
185fn 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
267pub 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
331fn 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
353const 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 assert!(output.contains("column_0"));
559 assert!(output.contains("column_19"));
560 assert!(output.contains("value_19_with_long_content"));
562 }
563}