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/cli/mod.rs b/rust/crates/sift_cli/src/cli/mod.rs index 01684fdb4..1e001beaa 100644 --- a/rust/crates/sift_cli/src/cli/mod.rs +++ b/rust/crates/sift_cli/src/cli/mod.rs @@ -887,16 +887,26 @@ pub enum OutputFormats { } #[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..9312d5a23 100644 --- a/rust/crates/sift_cli/src/cmd/get/assets.rs +++ b/rust/crates/sift_cli/src/cmd/get/assets.rs @@ -13,20 +13,19 @@ 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)?; 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 +33,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")?); } @@ -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..8ab4deeef --- /dev/null +++ b/rust/crates/sift_cli/src/util/table.rs @@ -0,0 +1,13 @@ +use comfy_table::{ContentArrangement, Row, Table, presets::ASCII_FULL_CONDENSED}; + +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..3ffdcc270 --- /dev/null +++ b/rust/crates/sift_cli/src/util/table/test.rs @@ -0,0 +1,75 @@ +use super::new_table; +use crate::util::tty::{hyperlink, link_style}; + +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() { + Some(']') => { + while let Some(c) = chars.next() { + if c == '\x07' { + break; + } + if c == '\x1b' { + chars.next(); + break; + } + } + } + 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\\"))); +}