From e31a9f3eacfd7bc00e2569576255d00017a81f21 Mon Sep 17 00:00:00 2001 From: djedi-knight Date: Fri, 24 Jul 2026 19:57:44 -0400 Subject: [PATCH 1/2] feat(buzz-cli): add --format table output (#2672) Add a 'table' variant to the global --format flag that renders list outputs (channels, messages, users, feed) as a comfy-table terminal table, reusing each command's existing compact projection for columns. --- Cargo.lock | 14 ++- Cargo.toml | 3 + crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/src/commands/channels.rs | 25 +++-- crates/buzz-cli/src/commands/feed.rs | 29 ++--- crates/buzz-cli/src/commands/messages.rs | 30 +++--- crates/buzz-cli/src/commands/users.rs | 37 ++++--- crates/buzz-cli/src/lib.rs | 8 +- crates/buzz-cli/src/output.rs | 130 +++++++++++++++++++++++ 9 files changed, 225 insertions(+), 54 deletions(-) create mode 100644 crates/buzz-cli/src/output.rs 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..83b7211968 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -94,23 +94,28 @@ 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(&compact_channels(&channels)), crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(), }; println!("{output}"); 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..56c1e6ab2c 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -44,26 +44,29 @@ 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(&compact_feed(&normalized)), crate::OutputFormat::Json => normalized, }; println!("{output}"); 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..92b4fc5e1d 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -242,24 +242,28 @@ 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(&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..9628bd8afb 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -61,21 +61,32 @@ 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(&compact_profiles(&profiles)), crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; println!("{output}"); 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,15 +143,9 @@ 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(&compact_profiles(&profiles)), crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; println!("{output}"); 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..385094435b --- /dev/null +++ b/crates/buzz-cli/src/output.rs @@ -0,0 +1,130 @@ +//! 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 a slice of flat JSON objects as a terminal table. +/// +/// Columns are the union of the objects' keys in first-seen order (each list +/// command feeds a deterministic compact projection, so header order is +/// stable). Missing keys render as empty cells. An empty slice renders as +/// `(no results)` so a table request never prints a bare, confusing blank +/// line. A non-object payload has no columns to project and falls back to +/// compact JSON so nothing is silently dropped. +pub fn render_rows(rows: &[serde_json::Value]) -> String { + if rows.is_empty() { + return "(no results)".to_string(); + } + + let mut headers: Vec = Vec::new(); + for row in rows { + if let Some(obj) = row.as_object() { + for key in obj.keys() { + if !headers.iter().any(|h| h == key) { + headers.push(key.clone()); + } + } + } + } + + if headers.is_empty() { + return serde_json::to_string(rows).unwrap_or_default(); + } + + let mut table = Table::new(); + table + .load_preset(UTF8_FULL) + .set_content_arrangement(ContentArrangement::Dynamic) + .set_header(headers.clone()); + + 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). +fn cell_value(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::render_rows; + use serde_json::json; + + #[test] + fn empty_rows_render_no_results_placeholder() { + assert_eq!(render_rows(&[]), "(no results)"); + } + + #[test] + fn headers_follow_first_seen_key_order() { + let rows = vec![json!({ "channel_id": "abc", "name": "general" })]; + let out = render_rows(&rows); + let header_line = out + .lines() + .find(|l| l.contains("channel_id")) + .expect("header row present"); + let id_pos = header_line.find("channel_id").expect("channel_id header"); + let name_pos = header_line.find("name").expect("name header"); + assert!(id_pos < name_pos, "channel_id must precede name: {out}"); + } + + #[test] + fn values_and_headers_appear_in_output() { + let rows = vec![ + json!({ "id": "e1", "content": "hello" }), + json!({ "id": "e2", "content": "world" }), + ]; + let out = render_rows(&rows); + for needle in ["id", "content", "e1", "hello", "e2", "world"] { + assert!(out.contains(needle), "missing {needle:?} in:\n{out}"); + } + } + + #[test] + fn strings_render_without_surrounding_quotes() { + let rows = vec![json!({ "name": "general" })]; + let out = render_rows(&rows); + assert!(out.contains("general")); + assert!(!out.contains("\"general\""), "quotes leaked: {out}"); + } + + #[test] + fn missing_key_becomes_empty_cell_not_a_panic() { + // Second row omits `content` — the union header set still has it, and + // the cell must be blank rather than dropping the column or panicking. + let rows = vec![ + json!({ "id": "e1", "content": "hi" }), + json!({ "id": "e2" }), + ]; + let out = render_rows(&rows); + assert!(out.contains("content")); + assert!(out.contains("e2")); + } + + #[test] + fn non_string_scalars_render_via_json_form() { + let rows = vec![json!({ "created_at": 1234, "pinned": true })]; + let out = render_rows(&rows); + assert!(out.contains("1234"), "number missing: {out}"); + assert!(out.contains("true"), "bool missing: {out}"); + } +} From 255e1d69846832bb79cd8e3642e239854c9d9eb0 Mon Sep 17 00:00:00 2001 From: djedi-knight Date: Fri, 24 Jul 2026 20:13:19 -0400 Subject: [PATCH 2/2] fix(buzz-cli): preserve table column order and sanitize control chars (#2672) Address Codex review: pass explicit ordered headers to render_rows so BTreeMap-backed serde_json objects don't alphabetize columns, and strip terminal control sequences from cell text to prevent ANSI/OSC injection from attacker-controlled relay content. --- crates/buzz-cli/src/commands/channels.rs | 4 +- crates/buzz-cli/src/commands/feed.rs | 4 +- crates/buzz-cli/src/commands/messages.rs | 5 +- crates/buzz-cli/src/commands/users.rs | 8 +- crates/buzz-cli/src/output.rs | 105 ++++++++++++++--------- 5 files changed, 80 insertions(+), 46 deletions(-) diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 83b7211968..8cc0242506 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -96,7 +96,9 @@ pub async fn cmd_list_channels( crate::OutputFormat::Compact => { serde_json::to_string(&compact_channels(&channels)).unwrap_or_default() } - crate::OutputFormat::Table => crate::output::render_rows(&compact_channels(&channels)), + crate::OutputFormat::Table => { + crate::output::render_rows(&["channel_id", "name"], &compact_channels(&channels)) + } crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(), }; println!("{output}"); diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index 56c1e6ab2c..7f1c24c49e 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -46,7 +46,9 @@ pub async fn cmd_get_feed( crate::OutputFormat::Compact => { serde_json::to_string(&compact_feed(&normalized)).unwrap_or_default() } - crate::OutputFormat::Table => crate::output::render_rows(&compact_feed(&normalized)), + crate::OutputFormat::Table => { + crate::output::render_rows(&["id", "content", "created_at"], &compact_feed(&normalized)) + } crate::OutputFormat::Json => normalized, }; println!("{output}"); diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 92b4fc5e1d..f29e0b4c3f 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -244,7 +244,10 @@ fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { crate::OutputFormat::Compact => { serde_json::to_string(&compact_events(normalized)).unwrap_or_default() } - crate::OutputFormat::Table => crate::output::render_rows(&compact_events(normalized)), + crate::OutputFormat::Table => crate::output::render_rows( + &["id", "content", "created_at"], + &compact_events(normalized), + ), crate::OutputFormat::Json => normalized.to_string(), } } diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index 9628bd8afb..f7776ee25f 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -63,7 +63,9 @@ pub async fn cmd_get_users( crate::OutputFormat::Compact => { serde_json::to_string(&compact_profiles(&profiles)).unwrap_or_default() } - crate::OutputFormat::Table => crate::output::render_rows(&compact_profiles(&profiles)), + crate::OutputFormat::Table => { + crate::output::render_rows(&["pubkey", "display_name"], &compact_profiles(&profiles)) + } crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; println!("{output}"); @@ -145,7 +147,9 @@ async fn search_by_name( crate::OutputFormat::Compact => { serde_json::to_string(&compact_profiles(&profiles)).unwrap_or_default() } - crate::OutputFormat::Table => crate::output::render_rows(&compact_profiles(&profiles)), + crate::OutputFormat::Table => { + crate::output::render_rows(&["pubkey", "display_name"], &compact_profiles(&profiles)) + } crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), }; println!("{output}"); diff --git a/crates/buzz-cli/src/output.rs b/crates/buzz-cli/src/output.rs index 385094435b..a24fcb1826 100644 --- a/crates/buzz-cli/src/output.rs +++ b/crates/buzz-cli/src/output.rs @@ -8,44 +8,33 @@ use comfy_table::{presets::UTF8_FULL, ContentArrangement, Table}; -/// Render a slice of flat JSON objects as a terminal table. +/// Render `rows` as a terminal table using the explicit, ordered `headers`. /// -/// Columns are the union of the objects' keys in first-seen order (each list -/// command feeds a deterministic compact projection, so header order is -/// stable). Missing keys render as empty cells. An empty slice renders as +/// `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. A non-object payload has no columns to project and falls back to -/// compact JSON so nothing is silently dropped. -pub fn render_rows(rows: &[serde_json::Value]) -> String { +/// line. +pub fn render_rows(headers: &[&str], rows: &[serde_json::Value]) -> String { if rows.is_empty() { return "(no results)".to_string(); } - let mut headers: Vec = Vec::new(); - for row in rows { - if let Some(obj) = row.as_object() { - for key in obj.keys() { - if !headers.iter().any(|h| h == key) { - headers.push(key.clone()); - } - } - } - } - - if headers.is_empty() { - return serde_json::to_string(rows).unwrap_or_default(); - } - let mut table = Table::new(); table .load_preset(UTF8_FULL) .set_content_arrangement(ContentArrangement::Dynamic) - .set_header(headers.clone()); + .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()) + .map(|h| row.get(*h).map(cell_value).unwrap_or_default()) .collect(); table.add_row(cells); } @@ -55,15 +44,31 @@ pub fn render_rows(rows: &[serde_json::Value]) -> 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). +/// 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) => s.clone(), - other => other.to_string(), + 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; @@ -71,20 +76,26 @@ mod tests { #[test] fn empty_rows_render_no_results_placeholder() { - assert_eq!(render_rows(&[]), "(no results)"); + assert_eq!(render_rows(&["id"], &[]), "(no results)"); } #[test] - fn headers_follow_first_seen_key_order() { - let rows = vec![json!({ "channel_id": "abc", "name": "general" })]; - let out = render_rows(&rows); + 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("channel_id")) + .find(|l| l.contains("content")) .expect("header row present"); - let id_pos = header_line.find("channel_id").expect("channel_id header"); - let name_pos = header_line.find("name").expect("name header"); - assert!(id_pos < name_pos, "channel_id must precede name: {out}"); + 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