From fb1a49140df63667e0555241bab0be0bb0109c96 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 19 Aug 2026 13:42:36 -0700 Subject: [PATCH 1/3] get common args clap --- rust/crates/sift_cli/src/cli/mod.rs | 17 ++++++++++++++--- rust/crates/sift_cli/src/cmd/get/assets.rs | 8 ++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index 01684fdb4..f52b8edd0 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -886,17 +886,28 @@ pub enum OutputFormats { Json, } +/// Options common to every `get` subcommand. #[derive(clap::Args)] -pub struct GetAssetArgs { +pub struct GetArgs { /// Filter option for filtering search with CEL expression #[arg(long)] pub filter: Option, /// Caps returned results to set number - #[arg(long, default_value = "50")] - pub limit: Option, + #[arg(long, default_value_t = 50)] + pub limit: u32, + + /// Orders results as a comma-separated list of "FIELD_NAME[ desc]" + #[arg(long, default_value = "modified_date desc")] + pub order_by: String, /// Determines the output format #[arg(long, value_enum)] pub output_format: Option, } + +#[derive(clap::Args)] +pub struct GetAssetArgs { + #[command(flatten)] + pub common: GetArgs, +} diff --git a/rust/crates/sift_cli/src/cmd/get/assets.rs b/rust/crates/sift_cli/src/cmd/get/assets.rs index affec4ae3..14b4c3ed1 100644 --- a/rust/crates/sift_cli/src/cmd/get/assets.rs +++ b/rust/crates/sift_cli/src/cmd/get/assets.rs @@ -24,9 +24,9 @@ pub async fn run(ctx: Context, args: GetAssetArgs) -> Result { let ListAssetsResponse { assets, .. } = AssetServiceClient::new(grpc_channel) .list_assets(ListAssetsRequest { - filter: args.filter.unwrap_or_default(), - order_by: "modified_date desc".to_string(), - page_size: args.limit.unwrap_or_default(), + filter: args.common.filter.unwrap_or_default(), + order_by: args.common.order_by, + page_size: args.common.limit, ..Default::default() }) .await @@ -34,7 +34,7 @@ pub async fn run(ctx: Context, args: GetAssetArgs) -> Result { .into_inner(); let app_uri = ctx.app_uri.as_deref().and_then(normalize_app_uri); let mut output = Output::new(); - match args.output_format { + match args.common.output_format { Some(OutputFormats::Json) => { output.line(serde_json::to_string_pretty(&assets).context("failed to encode assets")?); } From 79f238471d86bdff1a52b1235cf616923fe61a24 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 19 Aug 2026 14:31:44 -0700 Subject: [PATCH 2/3] comfy_table dependencie and util functions --- Cargo.toml | 1 + rust/crates/sift_cli/Cargo.toml | 1 + rust/crates/sift_cli/src/cmd/get/assets.rs | 17 ++--- rust/crates/sift_cli/src/util/mod.rs | 1 + rust/crates/sift_cli/src/util/table.rs | 20 ++++++ rust/crates/sift_cli/src/util/table/test.rs | 78 +++++++++++++++++++++ 6 files changed, 105 insertions(+), 13 deletions(-) create mode 100644 rust/crates/sift_cli/src/util/table.rs create mode 100644 rust/crates/sift_cli/src/util/table/test.rs diff --git a/Cargo.toml b/Cargo.toml index 8ac26dd4f..8dbed4b04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ bytesize = "2" chrono = { version = "0.4", default-features = false, features = ["clock"] } clap = { version = "4.6", features = ["cargo", "derive", "wrap_help"] } clap_complete = "4.6" +comfy-table = { version = "7.2", features = ["custom_styling"] } crc32fast = "1.4" criterion = { version = "0.8", features = ["html_reports"] } crossterm = "0.29" diff --git a/rust/crates/sift_cli/Cargo.toml b/rust/crates/sift_cli/Cargo.toml index 211f9c88a..db5b0e7ea 100644 --- a/rust/crates/sift_cli/Cargo.toml +++ b/rust/crates/sift_cli/Cargo.toml @@ -25,6 +25,7 @@ arrow-schema = { workspace = true } chrono = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } +comfy-table = { workspace = true } crossterm = { workspace = true } csv = { workspace = true } dirs = { workspace = true } diff --git a/rust/crates/sift_cli/src/cmd/get/assets.rs b/rust/crates/sift_cli/src/cmd/get/assets.rs index 14b4c3ed1..9312d5a23 100644 --- a/rust/crates/sift_cli/src/cmd/get/assets.rs +++ b/rust/crates/sift_cli/src/cmd/get/assets.rs @@ -13,12 +13,11 @@ use crate::{ api::create_grpc_channel, app_uri::normalize_app_uri, explore_url::build_explore_url, + table::new_table, tty::{Output, hyperlink, link_style, stdout_is_tty}, }, }; -const ASSET_ID_CHAR_LENGTH_WHITESPACE_LENGTH: usize = 38; - pub async fn run(ctx: Context, args: GetAssetArgs) -> Result { let grpc_channel = create_grpc_channel(&ctx)?; @@ -43,24 +42,16 @@ pub async fn run(ctx: Context, args: GetAssetArgs) -> Result { output.line("no assets found"); } else { let linkify = stdout_is_tty(); + let mut table = new_table(vec!["ID", "Name"]); - output.line(format_args!( - "{: hyperlink(&link_style(&asset.name), &url), _ => asset.name.clone(), }; - output.line(format_args!( - "{: format!( "View an asset in Explore at {host}/explore?method=single&assets=" diff --git a/rust/crates/sift_cli/src/util/mod.rs b/rust/crates/sift_cli/src/util/mod.rs index 02c0d5a1b..fb3e3ae71 100644 --- a/rust/crates/sift_cli/src/util/mod.rs +++ b/rust/crates/sift_cli/src/util/mod.rs @@ -5,4 +5,5 @@ pub mod channel; pub mod explore_url; pub mod job; pub mod progress; +pub mod table; pub mod tty; diff --git a/rust/crates/sift_cli/src/util/table.rs b/rust/crates/sift_cli/src/util/table.rs new file mode 100644 index 000000000..c1f2b065a --- /dev/null +++ b/rust/crates/sift_cli/src/util/table.rs @@ -0,0 +1,20 @@ +use comfy_table::{ContentArrangement, Row, Table, presets::ASCII_FULL_CONDENSED}; + +/// Builds the table used for the text output of `get` subcommands. +/// +/// Cells may carry OSC 8 hyperlinks and SGR styling, so this relies on +/// comfy-table's `custom_styling` feature to measure visible width rather than +/// byte length; without it the escape sequences inflate column widths and the +/// borders no longer line up. Content arrangement stays disabled so a cell is +/// never wrapped in the middle of an escape sequence. +pub fn new_table>(headers: H) -> Table { + let mut table = Table::new(); + table + .load_preset(ASCII_FULL_CONDENSED) + .set_content_arrangement(ContentArrangement::Disabled) + .set_header(headers); + table +} + +#[cfg(test)] +mod test; diff --git a/rust/crates/sift_cli/src/util/table/test.rs b/rust/crates/sift_cli/src/util/table/test.rs new file mode 100644 index 000000000..87bf7fa4d --- /dev/null +++ b/rust/crates/sift_cli/src/util/table/test.rs @@ -0,0 +1,78 @@ +use super::new_table; +use crate::util::tty::{hyperlink, link_style}; + +/// Strips OSC and SGR escape sequences so only the visible characters remain. +fn visible(line: &str) -> String { + let mut out = String::new(); + let mut chars = line.chars().peekable(); + + while let Some(c) = chars.next() { + if c != '\x1b' { + out.push(c); + continue; + } + match chars.next() { + // OSC, terminated by BEL or ESC \ + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' { + break; + } + if c == '\x1b' { + chars.next(); + break; + } + } + } + // CSI, terminated by a final byte in the alphabetic range + Some('[') => { + for c in chars.by_ref() { + if c.is_ascii_alphabetic() { + break; + } + } + } + _ => {} + } + } + out +} + +#[test] +fn borders_stay_aligned_when_a_cell_holds_a_styled_hyperlink() { + let mut table = new_table(vec!["ID", "Name"]); + table.add_row(vec![ + "c6a9e2b8-0000-4000-8000-1234567890ab".to_string(), + hyperlink( + &link_style("engine"), + "https://app.siftstack.com/explore?method=single&assets=engine", + ), + ]); + table.add_row(vec![ + "7f13aa20-1111-4000-8000-abcdefabcdef".to_string(), + "avionics-bench-3".to_string(), + ]); + + let rendered = table.to_string(); + let widths: Vec = rendered + .lines() + .map(|line| visible(line).chars().count()) + .collect(); + + assert!(!widths.is_empty(), "table rendered no lines"); + assert!( + widths.iter().all(|w| *w == widths[0]), + "every line should have the same visible width, got {widths:?}:\n{}", + rendered.replace('\x1b', "") + ); +} + +#[test] +fn hyperlink_cell_keeps_its_escape_sequence_intact() { + let url = "https://app.siftstack.com/explore?method=single&assets=engine"; + let mut table = new_table(vec!["Name"]); + table.add_row(vec![hyperlink("engine", url)]); + + let rendered = table.to_string(); + assert!(rendered.contains(&format!("\x1b]8;;{url}\x1b\\engine\x1b]8;;\x1b\\"))); +} From 804707e2cfc4f0b447e973efff9dff36756e7db0 Mon Sep 17 00:00:00 2001 From: Brandon Shippy Date: Wed, 19 Aug 2026 14:47:46 -0700 Subject: [PATCH 3/3] table dep and styling --- rust/crates/sift_cli/src/cli/mod.rs | 1 - rust/crates/sift_cli/src/util/table.rs | 7 ------- rust/crates/sift_cli/src/util/table/test.rs | 3 --- 3 files changed, 11 deletions(-) diff --git a/rust/crates/sift_cli/src/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index f52b8edd0..1e001beaa 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -886,7 +886,6 @@ pub enum OutputFormats { Json, } -/// Options common to every `get` subcommand. #[derive(clap::Args)] pub struct GetArgs { /// Filter option for filtering search with CEL expression diff --git a/rust/crates/sift_cli/src/util/table.rs b/rust/crates/sift_cli/src/util/table.rs index c1f2b065a..8ab4deeef 100644 --- a/rust/crates/sift_cli/src/util/table.rs +++ b/rust/crates/sift_cli/src/util/table.rs @@ -1,12 +1,5 @@ use comfy_table::{ContentArrangement, Row, Table, presets::ASCII_FULL_CONDENSED}; -/// Builds the table used for the text output of `get` subcommands. -/// -/// Cells may carry OSC 8 hyperlinks and SGR styling, so this relies on -/// comfy-table's `custom_styling` feature to measure visible width rather than -/// byte length; without it the escape sequences inflate column widths and the -/// borders no longer line up. Content arrangement stays disabled so a cell is -/// never wrapped in the middle of an escape sequence. pub fn new_table>(headers: H) -> Table { let mut table = Table::new(); table diff --git a/rust/crates/sift_cli/src/util/table/test.rs b/rust/crates/sift_cli/src/util/table/test.rs index 87bf7fa4d..3ffdcc270 100644 --- a/rust/crates/sift_cli/src/util/table/test.rs +++ b/rust/crates/sift_cli/src/util/table/test.rs @@ -1,7 +1,6 @@ use super::new_table; use crate::util::tty::{hyperlink, link_style}; -/// Strips OSC and SGR escape sequences so only the visible characters remain. fn visible(line: &str) -> String { let mut out = String::new(); let mut chars = line.chars().peekable(); @@ -12,7 +11,6 @@ fn visible(line: &str) -> String { continue; } match chars.next() { - // OSC, terminated by BEL or ESC \ Some(']') => { while let Some(c) = chars.next() { if c == '\x07' { @@ -24,7 +22,6 @@ fn visible(line: &str) -> String { } } } - // CSI, terminated by a final byte in the alphabetic range Some('[') => { for c in chars.by_ref() { if c.is_ascii_alphabetic() {