mod cell;
mod columns;
mod formatters;
mod renderer;
use formatters::{
print_collection_info, print_collections_list, print_count, print_facet_table,
print_groups_table, print_query_table, print_quotas_table, print_shard_keys_table,
};
pub fn render_report(
report: &qql::executor::ExecutionReport,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if json {
let s = serde_json::to_string_pretty(report)?;
println!("{}", s);
return Ok(());
}
if report.results.is_empty() {
println!("(empty result)");
return Ok(());
}
if report.results.len() == 1 {
render_response(&report.results[0], false)?;
} else {
for (i, resp) in report.results.iter().enumerate() {
if i > 0 {
println!();
}
if report.results.len() > 1 {
println!("── statement {} ──", i + 1);
}
render_response(resp, false)?;
}
println!("{} succeeded, {} failed", report.succeeded, report.failed);
}
Ok(())
}
pub fn render_response(
response: &qql::executor::ExecResponse,
json: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use qql::executor::ExecData;
if json {
let s = serde_json::to_string_pretty(response)?;
println!("{}", s);
return Ok(());
}
let data = response.data.as_ref();
match response.operation.as_str() {
"QUERY" | "CROSS_RERANK" => {
print_query_table(data.and_then(ExecData::hits), true)?;
}
"SCROLL" | "GET_POINTS" => {
print_query_table(data.and_then(ExecData::hits), false)?;
}
"FACET" => {
print_facet_table(data.and_then(ExecData::facet))?;
}
"QUERY_GROUPS" => {
print_groups_table(data.and_then(ExecData::groups))?;
}
"COUNT" => {
print_count(response.count());
}
"SHOW_COLLECTIONS" => {
print_collections_list(data.and_then(ExecData::collections))?;
}
"SHOW_COLLECTION" | "show_collection" => {
print_collection_info(data.and_then(ExecData::collection))?;
}
"SHOW_SHARD_KEYS" => {
print_shard_keys_table(data.and_then(ExecData::shard_keys))?;
}
"SHOW_QUOTAS" => {
print_quotas_table(data.and_then(ExecData::quotas))?;
}
_ => {
println!("{}", response.message);
if let Some(count) = response.count() {
println!(" count: {}", count);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::cell::{Alignment, Cell, escape_controls, stringify_value};
use super::columns::{detect_query_columns, query_cell};
use super::renderer::Table;
use super::*;
use qql::executor::{ExecData, ExecResponse, FacetHit, GroupedSearchResult, SearchHit};
use qql::{PlanFacetValue, PlanGroupId, PlanPointId, PlanShardKey};
fn search_hit(id: PlanPointId, score: f32, payload: serde_json::Value) -> SearchHit {
SearchHit {
id,
score,
payload: payload
.as_object()
.cloned()
.map(|map| map.into_iter().collect()),
collection: None,
vector: None,
}
}
#[test]
fn table_renders_psql_layout() {
let mut table = Table::new(vec!["id".into(), "score".into()]);
table.add_cells(vec![
Cell::text("42"),
Cell {
value: "0.95".into(),
alignment: Alignment::Right,
},
]);
let mut output = Vec::new();
table.render(&mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
" id | score \n----+-------\n 42 | 0.95 \n(1 row)\n"
);
}
#[test]
fn table_with_max_col_width_truncates() {
let mut table = Table::new(vec!["id".into(), "content".into()]).with_max_col_width(10);
table.add_row(vec![
"1".into(),
"a very long text that will truncate".into(),
]);
let mut output = Vec::new();
table.render(&mut output).unwrap();
let s = String::from_utf8(output).unwrap();
assert!(s.contains("a very lo…"));
}
#[test]
fn empty_table_renders_header_and_zero_row_footer() {
let table = Table::new(vec!["id".into()]);
assert!(table.is_empty());
let mut output = Vec::new();
table.render(&mut output).unwrap();
assert_eq!(String::from_utf8(output).unwrap(), " id \n----\n(0 rows)\n");
}
#[test]
fn rows_are_normalized_to_the_declared_columns() {
let mut table = Table::new(vec!["one".into(), "two".into()]);
table.add_row(vec!["value".into()]);
let mut output = Vec::new();
table.render(&mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
" one | two \n-------+-----\n value | \n(1 row)\n"
);
}
#[test]
fn unicode_cells_align_using_terminal_width() {
let mut table = Table::new(vec!["city".into(), "status".into()]);
table.add_row(vec!["東京".into(), "ready".into()]);
table.add_row(vec!["Oslo".into(), "👩🔬".into()]);
let mut output = Vec::new();
table.render(&mut output).unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
" city | status \n------+--------\n 東京 | ready \n Oslo | 👩🔬 \n(2 rows)\n"
);
}
#[test]
fn control_characters_are_escaped_before_rendering() {
assert_eq!(escape_controls("line\n\t\u{1b}"), r"line\n\t\u{1b}");
}
#[test]
fn typed_collection_and_count_payloads_render() {
let collections = ExecResponse {
ok: true,
operation: "SHOW_COLLECTIONS".into(),
message: "Collections listed".into(),
data: Some(ExecData::Collections(vec![
"berlin_airbnb".into(),
"sec10k".into(),
])),
telemetry: None,
};
assert_eq!(
collections.data.as_ref().unwrap().collections().unwrap(),
["berlin_airbnb", "sec10k"]
);
assert!(render_response(&collections, false).is_ok());
let count = ExecResponse {
ok: true,
operation: "COUNT".into(),
message: "Counted".into(),
data: Some(ExecData::Count(2500)),
telemetry: None,
};
assert_eq!(count.count(), Some(2500));
assert!(render_response(&count, false).is_ok());
}
#[test]
fn stringify_handles_all_types() {
assert_eq!(stringify_value(&serde_json::json!("hello")), "hello");
assert_eq!(stringify_value(&serde_json::json!(42)), "42");
assert_eq!(stringify_value(&serde_json::json!(true)), "true");
assert_eq!(stringify_value(&serde_json::json!(null)), "");
assert_eq!(stringify_value(&serde_json::json!([1, 2, 3])), "[1,2,3]");
}
#[test]
fn detect_columns_from_search_hits() {
let hits = vec![search_hit(
PlanPointId::String("abc".into()),
0.95,
serde_json::json!({"title": "hello", "year": 2024, "nested": {"deep": true}}),
)];
let cols = detect_query_columns(&hits, true);
let labels = cols
.iter()
.map(|column| column.label.as_str())
.collect::<Vec<_>>();
assert!(labels.contains(&"id"));
assert!(labels.contains(&"score"));
assert!(labels.contains(&"title"));
assert!(labels.contains(&"year"));
assert!(labels.contains(&"nested"));
}
#[test]
fn detect_columns_hides_score_for_unscored_operations() {
let hits = vec![search_hit(
PlanPointId::Number(7),
0.0,
serde_json::json!({}),
)];
let labels = detect_query_columns(&hits, false)
.iter()
.map(|column| column.label.clone())
.collect::<Vec<_>>();
assert!(!labels.contains(&"score".to_string()));
}
#[test]
fn query_cells_read_payload_fields_and_preserve_json() {
let hit = search_hit(
PlanPointId::String("abc".into()),
0.95,
serde_json::json!({
"title": "hello",
"year": 2024,
"nested": {"deep": true}
}),
);
let columns = detect_query_columns(std::slice::from_ref(&hit), true);
let column = |label| columns.iter().find(|column| column.label == label).unwrap();
assert_eq!(query_cell(&hit, column("id")).value, "abc");
assert_eq!(query_cell(&hit, column("score")).value, "0.95");
assert_eq!(query_cell(&hit, column("title")).value, "hello");
assert_eq!(query_cell(&hit, column("year")).value, "2024");
assert_eq!(query_cell(&hit, column("nested")).value, r#"{"deep":true}"#);
assert_eq!(query_cell(&hit, column("year")).alignment, Alignment::Right);
}
#[test]
fn colliding_payload_keys_are_labeled_and_read_unambiguously() {
let hit = search_hit(
PlanPointId::String("point-1".into()),
0.95,
serde_json::json!({"id": "external-id", "score": 10}),
);
let columns = detect_query_columns(std::slice::from_ref(&hit), true);
let column = |label| columns.iter().find(|column| column.label == label).unwrap();
assert!(columns.iter().any(|column| column.label == "payload.id"));
assert!(columns.iter().any(|column| column.label == "payload.score"));
assert_eq!(query_cell(&hit, column("payload.id")).value, "external-id");
assert_eq!(query_cell(&hit, column("payload.score")).value, "10");
}
#[test]
fn compute_alignments_distinguishes_numeric_and_text_columns() {
let mut table = Table::new(vec!["name".to_string(), "count".to_string()]);
table.add_cells(vec![
Cell::from_json(Some(&serde_json::json!("alice"))),
Cell::from_json(Some(&serde_json::json!(42))),
]);
table.add_cells(vec![
Cell::from_json(Some(&serde_json::json!("bob"))),
Cell::from_json(Some(&serde_json::json!(100))),
]);
let alignments = table.compute_alignments();
assert_eq!(alignments, vec![Alignment::Left, Alignment::Right]);
}
#[test]
fn render_response_dispatches_typed_payloads() {
let get_points_resp = ExecResponse {
ok: true,
operation: "GET_POINTS".into(),
message: "Found 1 hits".into(),
data: Some(ExecData::Hits(vec![search_hit(
PlanPointId::Number(10),
0.0,
serde_json::json!({"tag": "test"}),
)])),
telemetry: None,
};
assert!(render_response(&get_points_resp, false).is_ok());
let query_resp = ExecResponse {
ok: true,
operation: "QUERY".into(),
message: "Found 1 hits".into(),
data: Some(ExecData::Hits(vec![search_hit(
PlanPointId::Number(11),
0.5,
serde_json::json!({"text": "hello"}),
)])),
telemetry: None,
};
assert!(render_response(&query_resp, false).is_ok());
let facet_resp = ExecResponse {
ok: true,
operation: "FACET".into(),
message: "Found 2 facet hit(s)".into(),
data: Some(ExecData::Facet(vec![
FacetHit {
value: PlanFacetValue::Keyword("books".into()),
count: 15,
},
FacetHit {
value: PlanFacetValue::Integer(3),
count: 8,
},
])),
telemetry: None,
};
assert!(render_response(&facet_resp, false).is_ok());
let groups_resp = ExecResponse {
ok: true,
operation: "QUERY_GROUPS".into(),
message: "Found 1 group(s)".into(),
data: Some(ExecData::Groups(vec![GroupedSearchResult {
group_id: PlanGroupId::Keyword("alpha".into()),
hits: vec![search_hit(
PlanPointId::Number(1),
0.9,
serde_json::json!({}),
)],
}])),
telemetry: None,
};
assert!(render_response(&groups_resp, false).is_ok());
let shard_resp = ExecResponse {
ok: true,
operation: "SHOW_SHARD_KEYS".into(),
message: "Shard keys listed".into(),
data: Some(ExecData::ShardKeys(vec![
PlanShardKey::Keyword("tenant_1".into()),
PlanShardKey::Number(2),
])),
telemetry: None,
};
assert!(render_response(&shard_resp, false).is_ok());
let collection_resp = ExecResponse {
ok: true,
operation: "SHOW_COLLECTION".into(),
message: "Collection info".into(),
data: Some(ExecData::Collection(qql::backend::CollectionInfo {
status: "green".into(),
points_count: 12,
indexed_vectors_count: None,
segments_count: 2,
schema: Default::default(),
})),
telemetry: None,
};
assert!(render_response(&collection_resp, false).is_ok());
let quotas_resp = ExecResponse {
ok: true,
operation: "SHOW_QUOTAS".into(),
message: "Quotas listed".into(),
data: Some(ExecData::Quotas(qql::QuotaConfig {
enabled: Some(true),
max_resident_memory_percent: Some(80),
..Default::default()
})),
telemetry: None,
};
assert!(render_response("as_resp, false).is_ok());
let mutation_resp = ExecResponse {
ok: true,
operation: "UPSERT".into(),
message: "Upserted 3 points".into(),
data: Some(ExecData::Mutation { affected: Some(3) }),
telemetry: None,
};
assert!(render_response(&mutation_resp, false).is_ok());
}
}