diff --git a/Cargo.lock b/Cargo.lock index 9d0190868d..f0c01e7210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -892,6 +892,7 @@ dependencies = [ "bytes", "chrono", "clap", + "comfy-table", "diffy", "dirs", "hex", @@ -1551,6 +1552,17 @@ dependencies = [ "tokio-util", ] +[[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 = "compact_str" version = "0.7.1" @@ -2172,7 +2184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3499285f91..53d6e35726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,9 @@ moka = { version = "0.12", features = ["sync"] } # Async stream utilities futures-util = "0.3" +# Terminal tables — `buzz --format table` human-friendly list rendering (buzz-cli) +comfy-table = "7" + # WebSocket client (test client) tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] } url = "2" diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd..de6b34c080 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -86,6 +86,9 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Random number generation — full jitter for exponential backoff in with_retry rand = { workspace = true } +# Terminal tables — renders list output for the global `--format table` flag +comfy-table = { workspace = true } + [dev-dependencies] # Scratch files for channel-templates.json fixtures in tests tempfile = "3" diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..8cc0242506 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -94,16 +94,10 @@ pub async fn cmd_list_channels( .collect(); let output = match format { crate::OutputFormat::Compact => { - let compact: Vec = channels - .iter() - .map(|c| { - serde_json::json!({ - "channel_id": c.get("channel_id").cloned().unwrap_or_default(), - "name": c.get("name").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() + serde_json::to_string(&compact_channels(&channels)).unwrap_or_default() + } + crate::OutputFormat::Table => { + crate::output::render_rows(&["channel_id", "name"], &compact_channels(&channels)) } crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(), }; @@ -111,6 +105,19 @@ pub async fn cmd_list_channels( Ok(()) } +/// Reduced channel fields shared by `--format compact` and `--format table`. +fn compact_channels(channels: &[serde_json::Value]) -> Vec { + channels + .iter() + .map(|c| { + serde_json::json!({ + "channel_id": c.get("channel_id").cloned().unwrap_or_default(), + "name": c.get("name").cloned().unwrap_or_default(), + }) + }) + .collect() +} + /// Search channels by human-readable name (kind:39000 group metadata). /// /// The relay's access control already filters out channels the caller can't see diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d3d5c7f81a..7f1c24c49e 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -44,19 +44,10 @@ pub async fn cmd_get_feed( let normalized = normalize_events(&events); let output = match format { crate::OutputFormat::Compact => { - let evts: Vec = - serde_json::from_str(&normalized).unwrap_or_default(); - let compact: Vec = evts - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() + serde_json::to_string(&compact_feed(&normalized)).unwrap_or_default() + } + crate::OutputFormat::Table => { + crate::output::render_rows(&["id", "content", "created_at"], &compact_feed(&normalized)) } crate::OutputFormat::Json => normalized, }; @@ -64,6 +55,20 @@ pub async fn cmd_get_feed( Ok(()) } +/// Reduced feed fields shared by `--format compact` and `--format table`. +fn compact_feed(normalized: &str) -> Vec { + let evts: Vec = serde_json::from_str(normalized).unwrap_or_default(); + evts.iter() + .map(|e| { + serde_json::json!({ + "id": e.get("id").cloned().unwrap_or_default(), + "content": e.get("content").cloned().unwrap_or_default(), + "created_at": e.get("created_at").cloned().unwrap_or_default(), + }) + }) + .collect() +} + pub async fn dispatch( cmd: crate::FeedCmd, client: &BuzzClient, diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..f29e0b4c3f 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -242,24 +242,31 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec { fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { match format { crate::OutputFormat::Compact => { - let events: Vec = - serde_json::from_str(normalized).unwrap_or_default(); - let compact: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() + serde_json::to_string(&compact_events(normalized)).unwrap_or_default() } + crate::OutputFormat::Table => crate::output::render_rows( + &["id", "content", "created_at"], + &compact_events(normalized), + ), crate::OutputFormat::Json => normalized.to_string(), } } +/// Reduced message fields shared by `--format compact` and `--format table`. +fn compact_events(normalized: &str) -> Vec { + let events: Vec = serde_json::from_str(normalized).unwrap_or_default(); + events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.get("id").cloned().unwrap_or_default(), + "content": e.get("content").cloned().unwrap_or_default(), + "created_at": e.get("created_at").cloned().unwrap_or_default(), + }) + }) + .collect() +} + pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 3f8325b4b9..f7776ee25f 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -61,14 +61,10 @@ pub async fn cmd_get_users( .collect(); let output = match format { crate::OutputFormat::Compact => { - let compact: Vec = profiles - .iter() - .map(|p| serde_json::json!({ - "pubkey": p.get("pubkey").cloned().unwrap_or_default(), - "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), - })) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() + serde_json::to_string(&compact_profiles(&profiles)).unwrap_or_default() + } + crate::OutputFormat::Table => { + crate::output::render_rows(&["pubkey", "display_name"], &compact_profiles(&profiles)) } crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; @@ -76,6 +72,23 @@ pub async fn cmd_get_users( Ok(()) } +/// Reduced user fields shared by `--format compact` and `--format table`. +fn compact_profiles(profiles: &[serde_json::Value]) -> Vec { + profiles + .iter() + .map(|p| { + serde_json::json!({ + "pubkey": p.get("pubkey").cloned().unwrap_or_default(), + "display_name": p + .get("display_name") + .or_else(|| p.get("name")) + .cloned() + .unwrap_or_default(), + }) + }) + .collect() +} + /// Search for users by display name via NIP-50 full-text search on kind:0 profiles. /// Returns [] if the relay does not implement NIP-50 search. async fn search_by_name( @@ -132,14 +145,10 @@ async fn search_by_name( .collect(); let output = match format { crate::OutputFormat::Compact => { - let compact: Vec = profiles - .iter() - .map(|p| serde_json::json!({ - "pubkey": p.get("pubkey").cloned().unwrap_or_default(), - "display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(), - })) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() + serde_json::to_string(&compact_profiles(&profiles)).unwrap_or_default() + } + crate::OutputFormat::Table => { + crate::output::render_rows(&["pubkey", "display_name"], &compact_profiles(&profiles)) } crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 6ab81a082d..36e01187f0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -2,6 +2,7 @@ pub mod agent_management; mod client; mod commands; mod error; +mod output; mod validate; use clap::{Parser, Subcommand}; @@ -89,7 +90,9 @@ struct Cli { #[arg(long, env = "BUZZ_AUTH_TAG")] auth_tag: Option, - /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). + /// Output format: 'json' (default, full fields), 'compact' (reduced + /// fields), or 'table' (human-friendly terminal table of the reduced + /// fields). #[arg(long, value_enum, default_value = "json")] format: OutputFormat, @@ -169,6 +172,9 @@ pub enum OutputFormat { /// Reduced fields for agent scanning #[value(name = "compact")] Compact, + /// Human-friendly terminal table of the reduced (compact) fields + #[value(name = "table")] + Table, } #[derive(Subcommand)] diff --git a/crates/buzz-cli/src/output.rs b/crates/buzz-cli/src/output.rs new file mode 100644 index 0000000000..a24fcb1826 --- /dev/null +++ b/crates/buzz-cli/src/output.rs @@ -0,0 +1,153 @@ +//! Human-friendly terminal table rendering for the global `--format table` +//! flag. +//! +//! List commands already build a stable "compact" projection — a flat array of +//! JSON objects with the human-scannable subset of fields. `--format table` +//! reuses that exact projection and renders it as a bordered terminal table so +//! the column set stays in lockstep with `--format compact` for free. + +use comfy_table::{presets::UTF8_FULL, ContentArrangement, Table}; + +/// Render `rows` as a terminal table using the explicit, ordered `headers`. +/// +/// `headers` is passed in (not derived from the row objects) on purpose: the +/// workspace builds `serde_json` without the `preserve_order` feature, so +/// `Value` objects are `BTreeMap`-backed and iterating their keys would sort +/// alphabetically — turning a declared `id, content, created_at` projection +/// into `content, created_at, id`. The caller owns column order; each list +/// command passes the same field order its compact projection declares. +/// +/// Missing keys render as empty cells. An empty `rows` slice renders as +/// `(no results)` so a table request never prints a bare, confusing blank +/// line. +pub fn render_rows(headers: &[&str], rows: &[serde_json::Value]) -> String { + if rows.is_empty() { + return "(no results)".to_string(); + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(headers.iter().copied()); + + for row in rows { + let cells: Vec = headers + .iter() + .map(|h| row.get(*h).map(cell_value).unwrap_or_default()) + .collect(); + table.add_row(cells); + } + + table.to_string() +} + +/// Render a single JSON value as a table cell. Strings drop their surrounding +/// quotes; nulls become an empty cell; everything else uses its compact JSON +/// form (numbers, bools, and any nested array/object). All cell text is passed +/// through [`sanitize`] first. +fn cell_value(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => sanitize(s), + other => sanitize(&other.to_string()), + } +} + +/// Replace terminal control characters (ESC, BEL, CR, and the rest of the C0 +/// set, DEL, and the C1 range) with spaces. +/// +/// Table cells print raw to the terminal, so attacker-controlled relay content +/// — message bodies, feed items, display names, channel names — could +/// otherwise smuggle ANSI/OSC escape sequences that erase, recolor, or spoof +/// the user's terminal output. The JSON and compact paths are unaffected +/// because `serde_json` already escapes control characters as `\uXXXX`; only +/// the table path decodes them back to raw bytes, so only it needs this. +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::render_rows; + use serde_json::json; + + #[test] + fn empty_rows_render_no_results_placeholder() { + assert_eq!(render_rows(&["id"], &[]), "(no results)"); + } + + #[test] + fn columns_follow_declared_header_order_not_alphabetical() { + // `content` < `created_at` < `id` alphabetically, so a BTreeMap-backed + // key iteration would reorder them. The explicit header order must win. + let rows = vec![json!({ "id": "e1", "content": "hi", "created_at": 5 })]; + let out = render_rows(&["id", "content", "created_at"], &rows); + let header_line = out + .lines() + .find(|l| l.contains("content")) + .expect("header row present"); + let id_pos = header_line.find("id").expect("id header"); + let content_pos = header_line.find("content").expect("content header"); + let created_pos = header_line.find("created_at").expect("created_at header"); + assert!( + id_pos < content_pos && content_pos < created_pos, + "declared order id