diff --git a/Cargo.lock b/Cargo.lock index 5e16b3e..b420012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,25 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ansi-str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "060de1453b69f46304b28274f382132f4e72c55637cf362920926a70d090890d" +dependencies = [ + "ansitok", +] + +[[package]] +name = "ansitok" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a8acea8c2f1c60f0a92a8cd26bf96ca97db56f10bbcab238bbe0cceba659ee" +dependencies = [ + "nom", + "vte", +] + [[package]] name = "anstream" version = "0.6.21" @@ -64,6 +83,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ascii" version = "1.1.0" @@ -103,6 +128,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -183,17 +214,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "comfy-table" -version = "7.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" -dependencies = [ - "crossterm 0.29.0", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "console" version = "0.15.11" @@ -637,7 +657,6 @@ dependencies = [ "anstyle", "base64", "clap", - "comfy-table", "crossterm 0.28.1", "directories", "dotenvy", @@ -653,6 +672,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "tabled", "tar", "tiny_http", ] @@ -1060,6 +1080,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1111,6 +1137,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -1190,6 +1226,19 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "ansi-str", + "ansitok", + "bytecount", + "fnv", + "unicode-width", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1283,6 +1332,28 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1760,6 +1831,32 @@ dependencies = [ "libc", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "ansi-str", + "ansitok", + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tar" version = "0.4.44" @@ -1784,6 +1881,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "ansitok", + "unicode-width", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2030,6 +2137,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index b5cea7c..6619202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ open = "5" rand = "0.8" sha2 = "0.10" tiny_http = "0.12" -comfy-table = "7" +tabled = { version = "0.20", features = ["ansi"] } inquire = "0.9.4" indicatif = "0.17" nix = { version = "0.29", features = ["fs"] } diff --git a/src/connections.rs b/src/connections.rs index cc26394..1e3818a 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -71,12 +71,15 @@ pub fn types_list(workspace_id: &str, format: &str) { "json" => println!("{}", serde_json::to_string_pretty(&body.connection_types).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&body.connection_types).unwrap()), "table" => { - let mut table = crate::util::make_table(); - table.set_header(["NAME", "LABEL"]); - for ct in &body.connection_types { - table.add_row([&ct.name, &ct.label]); + if body.connection_types.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No connection types found.".dark_grey()); + } else { + let rows: Vec> = body.connection_types.iter() + .map(|ct| vec![ct.name.clone(), ct.label.clone()]) + .collect(); + crate::table::print(&["NAME", "LABEL"], &rows); } - println!("{table}"); } _ => unreachable!(), } @@ -312,12 +315,15 @@ pub fn list(workspace_id: &str, format: &str) { print!("{}", serde_yaml::to_string(&body.connections).unwrap()); } "table" => { - let mut table = crate::util::make_table(); - table.set_header(["ID", "NAME", "SOURCE TYPE"]); - for c in &body.connections { - table.add_row([&c.id, &c.name, &c.source_type]); + if body.connections.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No connections found.".dark_grey()); + } else { + let rows: Vec> = body.connections.iter() + .map(|c| vec![c.id.clone(), c.name.clone(), c.source_type.clone()]) + .collect(); + crate::table::print(&["ID", "NAME", "SOURCE TYPE"], &rows); } - println!("{table}"); } _ => unreachable!(), } diff --git a/src/datasets.rs b/src/datasets.rs index 083ee9d..326670c 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -475,17 +475,18 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: "json" => println!("{}", serde_json::to_string_pretty(&body.datasets).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&body.datasets).unwrap()), "table" => { - let mut table = crate::util::make_table(); - table.set_header(["ID", "LABEL", "FULL NAME", "CREATED AT"]); - table.column_mut(1).unwrap().set_constraint( - comfy_table::ColumnConstraint::UpperBoundary(comfy_table::Width::Fixed(30)) - ); - for d in &body.datasets { - let created_at = d.created_at.split('.').next().unwrap_or(&d.created_at).replace('T', " "); - let full_name = format!("datasets.main.{}", d.table_name); - table.add_row([&d.id, &d.label, &full_name, &created_at]); + if body.datasets.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No datasets found.".dark_grey()); + } else { + let rows: Vec> = body.datasets.iter().map(|d| vec![ + d.id.clone(), + d.label.clone(), + format!("datasets.main.{}", d.table_name), + crate::util::format_date(&d.created_at), + ]).collect(); + crate::table::print(&["ID", "LABEL", "FULL NAME", "CREATED AT"], &rows); } - println!("{table}"); if body.has_more { let next = offset.unwrap_or(0) + body.count as u32; use crossterm::style::Stylize; @@ -547,8 +548,8 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&d).unwrap()), "table" => { - let created_at = d.created_at.split('.').next().unwrap_or(&d.created_at).replace('T', " "); - let updated_at = d.updated_at.split('.').next().unwrap_or(&d.updated_at).replace('T', " "); + let created_at = crate::util::format_date(&d.created_at); + let updated_at = crate::util::format_date(&d.updated_at); println!("id: {}", d.id); println!("label: {}", d.label); println!("full_name: datasets.main.{}", d.table_name); @@ -557,12 +558,10 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { println!("updated_at: {updated_at}"); if !d.columns.is_empty() { println!(); - let mut table = crate::util::make_table(); - table.set_header(["COLUMN", "DATA TYPE", "NULLABLE"]); - for col in &d.columns { - table.add_row([&col.name, &col.data_type, &col.nullable.to_string()]); - } - println!("{table}"); + let rows: Vec> = d.columns.iter().map(|col| vec![ + col.name.clone(), col.data_type.clone(), col.nullable.to_string(), + ]).collect(); + crate::table::print(&["COLUMN", "DATA TYPE", "NULLABLE"], &rows); } } _ => unreachable!(), diff --git a/src/main.rs b/src/main.rs index 4379d87..2f5deac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod init; mod query; mod results; mod skill; +mod table; mod tables; mod util; mod workspace; diff --git a/src/query.rs b/src/query.rs index e3169c8..e4ece56 100644 --- a/src/query.rs +++ b/src/query.rs @@ -111,13 +111,7 @@ pub fn execute(sql: &str, workspace_id: &str, connection: Option<&str>, format: } } "table" => { - let mut table = crate::util::make_table(); - table.set_header(&result.columns); - for row in &result.rows { - let cells: Vec = row.iter().map(value_to_string).collect(); - table.add_row(cells); - } - println!("{table}"); + crate::table::print_json(&result.columns, &result.rows); use crossterm::style::Stylize; let id_part = result.result_id.as_deref().map(|id| format!(" [result-id: {id}]")).unwrap_or_default(); eprintln!("{}", format!("\n{} row{} ({} ms){}", result.row_count, if result.row_count == 1 { "" } else { "s" }, result.execution_time_ms, id_part).dark_grey()); diff --git a/src/results.rs b/src/results.rs index aa86fbc..79fe238 100644 --- a/src/results.rs +++ b/src/results.rs @@ -104,13 +104,7 @@ pub fn get(result_id: &str, workspace_id: &str, format: &str) { } } "table" => { - let mut table = crate::util::make_table(); - table.set_header(&result.columns); - for row in &result.rows { - let cells: Vec = row.iter().map(value_to_string).collect(); - table.add_row(cells); - } - println!("{table}"); + crate::table::print_json(&result.columns, &result.rows); use crossterm::style::Stylize; eprintln!("{}", format!("\n{} row{} ({} ms) [result-id: {}]", result.row_count, if result.row_count == 1 { "" } else { "s" }, result.execution_time_ms, result.result_id).dark_grey()); } diff --git a/src/table.rs b/src/table.rs new file mode 100644 index 0000000..5596e1a --- /dev/null +++ b/src/table.rs @@ -0,0 +1,79 @@ +use tabled::settings::{ + Color, Modify, Style, + object::{Rows, Segment}, + style::BorderColor, + width::Width, +}; + +fn term_width() -> usize { + crossterm::terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(120) +} + +fn style_table(table: &mut tabled::Table) { + let tw = term_width(); + + table + .with(Style::modern_rounded()) + .with(Width::wrap(tw).keep_words(true)) + .with(Modify::new(Segment::all()).with(BorderColor::filled(Color::FG_BRIGHT_BLACK))) + .with(Modify::new(Rows::first()).with(Color::FG_GREEN)); +} + +/// Print a table with string data. Headers are &str slices, rows are Vec. +pub fn print(headers: &[&str], rows: &[Vec]) { + let mut builder = tabled::builder::Builder::new(); + builder.push_record(headers.iter().map(|h| h.to_string())); + for row in rows { + builder.push_record(row.iter().map(|c| c.to_string())); + } + let mut table = builder.build(); + style_table(&mut table); + println!("{table}"); +} + +/// Print a table with JSON-typed data. Numbers, bools, and nulls get per-cell coloring. +pub fn print_json(headers: &[String], rows: &[Vec]) { + use tabled::settings::object::Cell; + + let mut builder = tabled::builder::Builder::new(); + builder.push_record(headers.iter().map(|h| h.to_string())); + + // Track cells that need coloring: (row_index, col_index, color) + let mut colored_cells: Vec<(usize, usize, Color)> = Vec::new(); + + for (ri, row) in rows.iter().enumerate() { + let string_row: Vec = row + .iter() + .enumerate() + .map(|(ci, v)| { + match v { + serde_json::Value::Number(n) => { + colored_cells.push((ri + 1, ci, Color::FG_CYAN)); + n.to_string() + } + serde_json::Value::Null => { + colored_cells.push((ri + 1, ci, Color::FG_BRIGHT_BLACK)); + String::new() + } + serde_json::Value::Bool(b) => { + colored_cells.push((ri + 1, ci, Color::FG_YELLOW)); + b.to_string() + } + _ => v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()), + } + }) + .collect(); + builder.push_record(string_row); + } + + let mut table = builder.build(); + style_table(&mut table); + + for (r, c, color) in colored_cells { + table.with(Modify::new(Cell::new(r, c)).with(color)); + } + + println!("{table}"); +} diff --git a/src/tables.rs b/src/tables.rs index 581c9d6..bf6cf9d 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -133,14 +133,17 @@ pub fn list( "json" => println!("{}", serde_json::to_string_pretty(&out).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&out).unwrap()), "table" => { - let mut table = crate::util::make_table(); - table.set_header(["TABLE", "COLUMN", "DATA_TYPE", "NULLABLE"]); - for t in &out { - for col in &t.columns { - table.add_row([&t.table, &col.name, &col.data_type, &col.nullable.to_string()]); - } + if out.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No tables found.".dark_grey()); + } else { + let rows: Vec> = out.iter().flat_map(|t| { + t.columns.iter().map(|col| vec![ + t.table.clone(), col.name.clone(), col.data_type.clone(), col.nullable.to_string(), + ]) + }).collect(); + crate::table::print(&["TABLE", "COLUMN", "DATA_TYPE", "NULLABLE"], &rows); } - println!("{table}"); } _ => unreachable!(), } @@ -153,12 +156,17 @@ pub fn list( "json" => println!("{}", serde_json::to_string_pretty(&out).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(&out).unwrap()), "table" => { - let mut table = crate::util::make_table(); - table.set_header(["TABLE", "SYNCED", "LAST_SYNC"]); - for r in &out { - table.add_row([&r.table, &r.synced.to_string(), r.last_sync.as_deref().unwrap_or("-")]); + if out.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No tables found.".dark_grey()); + } else { + let rows: Vec> = out.iter().map(|r| vec![ + r.table.clone(), + r.synced.to_string(), + r.last_sync.as_deref().map(crate::util::format_date).unwrap_or_else(|| "-".to_string()), + ]).collect(); + crate::table::print(&["TABLE", "SYNCED", "LAST_SYNC"], &rows); } - println!("{table}"); } _ => unreachable!(), } diff --git a/src/util.rs b/src/util.rs index 4a0dce3..af39430 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,15 +1,13 @@ +/// Format an ISO date string compactly: "2024-03-15 14:23" (no seconds, no timezone). +pub fn format_date(s: &str) -> String { + let s = s.split('.').next().unwrap_or(s).trim_end_matches('Z'); + let s = s.replace('T', " "); + s.chars().take(16).collect() +} + pub fn api_error(body: String) -> String { serde_json::from_str::(&body) .ok() .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) .unwrap_or(body) } - -pub fn make_table() -> comfy_table::Table { - let mut table = comfy_table::Table::new(); - table.load_preset(comfy_table::presets::UTF8_FULL_CONDENSED); - if let Ok((width, _)) = crossterm::terminal::size() { - table.set_width(width); - } - table -} diff --git a/src/workspace.rs b/src/workspace.rs index e7b31cb..9d68ed9 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -158,13 +158,16 @@ pub fn list(format: &str) { print!("{}", serde_yaml::to_string(&body.workspaces).unwrap()); } "table" => { - let mut table = crate::util::make_table(); - table.set_header(["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"]); - for w in &body.workspaces { - let marker = if w.public_id == default_id { "*" } else { "" }; - table.add_row([marker, &w.public_id, &w.name, &w.provision_status]); + if body.workspaces.is_empty() { + use crossterm::style::Stylize; + eprintln!("{}", "No workspaces found.".dark_grey()); + } else { + let rows: Vec> = body.workspaces.iter().map(|w| { + let marker = if w.public_id == default_id { "*" } else { "" }; + vec![marker.to_string(), w.public_id.clone(), w.name.clone(), w.provision_status.clone()] + }).collect(); + crate::table::print(&["DEFAULT", "PUBLIC_ID", "NAME", "PROVISION_STATUS"], &rows); } - println!("{table}"); } _ => unreachable!(), }