diff --git a/Cargo.lock b/Cargo.lock index 89c5a5a5..fd54c625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -586,17 +586,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "comfy-table" -version = "7.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width", -] - [[package]] name = "config" version = "0.15.18" @@ -798,29 +787,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" -dependencies = [ - "bitflags", - "crossterm_winapi", - "document-features", - "parking_lot", - "rustix", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -2920,7 +2886,6 @@ dependencies = [ "clap", "clap_complete", "colored", - "comfy-table", "config", "criterion", "dialoguer", diff --git a/Cargo.toml b/Cargo.toml index 7cd974fa..8b168df2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,6 @@ toml = "0.9" directories = "6.0" # Terminal and output -comfy-table = "7.2" serde_yaml = "0.9" csv = "1.3" jpx-core = "0.2.1" diff --git a/crates/redisctl/Cargo.toml b/crates/redisctl/Cargo.toml index c6810247..c13da153 100644 --- a/crates/redisctl/Cargo.toml +++ b/crates/redisctl/Cargo.toml @@ -49,7 +49,6 @@ flate2 = { workspace = true } # Shared utility dependencies thiserror = { workspace = true } serde_yaml = { workspace = true } -comfy-table = { workspace = true } jpx-core = { workspace = true } config = { workspace = true } diff --git a/crates/redisctl/src/cli/mod.rs b/crates/redisctl/src/cli/mod.rs index cc23adc4..f69a553a 100644 --- a/crates/redisctl/src/cli/mod.rs +++ b/crates/redisctl/src/cli/mod.rs @@ -134,6 +134,16 @@ pub enum OutputFormat { Table, } +impl OutputFormat { + pub fn is_json(&self) -> bool { + matches!(self, Self::Json) + } + + pub fn is_yaml(&self) -> bool { + matches!(self, Self::Yaml) + } +} + /// Top-level commands #[derive(Subcommand, Debug)] pub enum Commands { diff --git a/crates/redisctl/src/commands/api.rs b/crates/redisctl/src/commands/api.rs index f294e6a7..f81a4ad4 100644 --- a/crates/redisctl/src/commands/api.rs +++ b/crates/redisctl/src/commands/api.rs @@ -112,13 +112,10 @@ async fn handle_cloud_api( match result { Ok(response) => { - // Convert CLI OutputFormat to output::OutputFormat + // Raw API responses aren't structured for tables, resolve Auto to Json let format = match output_format { - crate::cli::OutputFormat::Auto | crate::cli::OutputFormat::Json => { - crate::output::OutputFormat::Json - } - crate::cli::OutputFormat::Yaml => crate::output::OutputFormat::Yaml, - crate::cli::OutputFormat::Table => crate::output::OutputFormat::Table, + OutputFormat::Auto => OutputFormat::Json, + other => other, }; print_output(response, format, query.as_deref()).map_err(|e| { @@ -186,13 +183,10 @@ async fn handle_enterprise_api( match result { Ok(response) => { - // Convert CLI OutputFormat to output::OutputFormat + // Raw API responses aren't structured for tables, resolve Auto to Json let format = match output_format { - crate::cli::OutputFormat::Auto | crate::cli::OutputFormat::Json => { - crate::output::OutputFormat::Json - } - crate::cli::OutputFormat::Yaml => crate::output::OutputFormat::Yaml, - crate::cli::OutputFormat::Table => crate::output::OutputFormat::Table, + OutputFormat::Auto => OutputFormat::Json, + other => other, }; print_output(response, format, query.as_deref()).map_err(|e| { diff --git a/crates/redisctl/src/commands/cloud/cloud_account_impl.rs b/crates/redisctl/src/commands/cloud/cloud_account_impl.rs index 5be6db87..c344c334 100644 --- a/crates/redisctl/src/commands/cloud/cloud_account_impl.rs +++ b/crates/redisctl/src/commands/cloud/cloud_account_impl.rs @@ -7,9 +7,11 @@ use crate::connection::ConnectionManager; use crate::error::{RedisCtlError, Result as CliResult}; use anyhow::Context; -use comfy_table::{Cell, Color, Table}; +use colored::Colorize; use redis_cloud::CloudClient; use serde_json::{Value, json}; +use tabled::builder::Builder; +use tabled::settings::Style; /// Parameters for cloud account operations that support async operations pub struct CloudAccountOperationParams<'a> { @@ -69,12 +71,12 @@ pub async fn handle_list( .context("Failed to list cloud accounts")?; // For table output, create a formatted table - if matches!(output_format, OutputFormat::Table) + if matches!(output_format, OutputFormat::Table | OutputFormat::Auto) && query.is_none() && let Some(accounts) = result.get("cloudAccounts").and_then(|a| a.as_array()) { - let mut table = Table::new(); - table.set_header(vec!["ID", "Name", "Provider", "Status", "Created"]); + let mut builder = Builder::default(); + builder.push_record(["ID", "Name", "Provider", "Status", "Created"]); for account in accounts { let id = account.get("id").and_then(|v| v.as_i64()).unwrap_or(0); @@ -89,22 +91,22 @@ pub async fn handle_list( .and_then(|v| v.as_str()) .unwrap_or(""); - let status_cell = match status { - "active" => Cell::new(status).fg(Color::Green), - "inactive" => Cell::new(status).fg(Color::Red), - _ => Cell::new(status), + let status_str = match status { + "active" => status.green().to_string(), + "inactive" => status.red().to_string(), + _ => status.to_string(), }; - table.add_row(vec![ - Cell::new(id), - Cell::new(name), - Cell::new(provider), - status_cell, - Cell::new(created_timestamp), + builder.push_record([ + &id.to_string(), + name, + provider, + &status_str, + created_timestamp, ]); } - println!("{}", table); + println!("{}", builder.build().with(Style::blank())); return Ok(()); } @@ -125,9 +127,9 @@ pub async fn handle_get( .context("Failed to get cloud account")?; // For table output, create a detailed view - if matches!(output_format, OutputFormat::Table) && query.is_none() { - let mut table = Table::new(); - table.set_header(vec!["Field", "Value"]); + if matches!(output_format, OutputFormat::Table | OutputFormat::Auto) && query.is_none() { + let mut builder = Builder::default(); + builder.push_record(["Field", "Value"]); if let Some(obj) = result.as_object() { for (key, value) in obj { @@ -141,11 +143,11 @@ pub async fn handle_get( _ => value.to_string(), } }; - table.add_row(vec![Cell::new(key), Cell::new(display_value)]); + builder.push_record([key.as_str(), &display_value]); } } - println!("{}", table); + println!("{}", builder.build().with(Style::blank())); return Ok(()); } diff --git a/crates/redisctl/src/commands/cloud/connectivity/vpc_peering.rs b/crates/redisctl/src/commands/cloud/connectivity/vpc_peering.rs index c8385db4..a06bf383 100644 --- a/crates/redisctl/src/commands/cloud/connectivity/vpc_peering.rs +++ b/crates/redisctl/src/commands/cloud/connectivity/vpc_peering.rs @@ -682,7 +682,9 @@ fn print_vpc_peering_table(data: &Value) -> CliResult<()> { /// Print VPC peering list in table format fn print_vpc_peering_list_table(data: &Value) -> CliResult<()> { - use comfy_table::{Cell, Color, Table}; + use colored::Colorize; + use tabled::builder::Builder; + use tabled::settings::Style; let peerings = if let Some(arr) = data.as_array() { arr.clone() @@ -698,15 +700,8 @@ fn print_vpc_peering_list_table(data: &Value) -> CliResult<()> { return Ok(()); } - let mut table = Table::new(); - table.set_header(vec![ - "ID", - "Status", - "VPC ID", - "Account ID", - "Region", - "CIDRs", - ]); + let mut builder = Builder::default(); + builder.push_record(["ID", "Status", "VPC ID", "Account ID", "Region", "CIDRs"]); for peering in peerings { let id = peering @@ -741,23 +736,23 @@ fn print_vpc_peering_list_table(data: &Value) -> CliResult<()> { String::new() }; - let status_cell = match status.to_lowercase().as_str() { - "active" => Cell::new(status).fg(Color::Green), - "pending" => Cell::new(status).fg(Color::Yellow), - "failed" | "error" => Cell::new(status).fg(Color::Red), - _ => Cell::new(status), + let status_str = match status.to_lowercase().as_str() { + "active" => status.green().to_string(), + "pending" => status.yellow().to_string(), + "failed" | "error" => status.red().to_string(), + _ => status.to_string(), }; - table.add_row(vec![ - Cell::new(id), - status_cell, - Cell::new(vpc_id), - Cell::new(account_id), - Cell::new(region), - Cell::new(cidrs), + builder.push_record([ + &id.to_string(), + &status_str, + vpc_id, + account_id, + region, + &cidrs, ]); } - println!("{}", table); + println!("{}", builder.build().with(Style::blank())); Ok(()) } diff --git a/crates/redisctl/src/commands/cloud/utils.rs b/crates/redisctl/src/commands/cloud/utils.rs index 4d2e10c6..7689d78c 100644 --- a/crates/redisctl/src/commands/cloud/utils.rs +++ b/crates/redisctl/src/commands/cloud/utils.rs @@ -11,10 +11,7 @@ use unicode_segmentation::UnicodeSegmentation; use std::io::IsTerminal; -use crate::cli::OutputFormat; - use crate::error::{RedisCtlError, Result as CliResult}; -use crate::output::print_output; /// Row structure for vertical table display (used by get commands) #[derive(Tabled)] @@ -189,50 +186,7 @@ pub fn provider_short_name(provider: &str) -> &str { } } -/// Apply JMESPath query to JSON data (using extended runtime with 400+ functions) -pub fn apply_jmespath(data: &Value, query: &str) -> CliResult { - let expr = crate::output::compile_jmespath(query) - .with_context(|| format!("Invalid JMESPath expression: {}", query))?; - - expr.search(data) - .with_context(|| format!("Failed to apply JMESPath query: {}", query)) - .map_err(Into::into) -} - -/// Handle output formatting for different formats -pub fn handle_output( - data: Value, - _output_format: OutputFormat, - query: Option<&str>, -) -> CliResult { - if let Some(q) = query { - apply_jmespath(&data, q) - } else { - Ok(data) - } -} - -/// Print data in requested output format -pub fn print_formatted_output(data: Value, output_format: OutputFormat) -> CliResult<()> { - match output_format { - OutputFormat::Json => { - print_output(data, crate::output::OutputFormat::Json, None).map_err(|e| { - RedisCtlError::OutputError { - message: e.to_string(), - } - })?; - } - OutputFormat::Yaml => { - print_output(data, crate::output::OutputFormat::Yaml, None).map_err(|e| { - RedisCtlError::OutputError { - message: e.to_string(), - } - })?; - } - _ => {} // Table format handled by individual commands - } - Ok(()) -} +pub use crate::output::{apply_jmespath, handle_output, print_formatted_output}; /// Prompts the user for confirmation pub fn confirm_action(message: &str) -> CliResult { diff --git a/crates/redisctl/src/commands/enterprise/status.rs b/crates/redisctl/src/commands/enterprise/status.rs index ebab151a..3e727e1f 100644 --- a/crates/redisctl/src/commands/enterprise/status.rs +++ b/crates/redisctl/src/commands/enterprise/status.rs @@ -10,13 +10,14 @@ use crate::connection::ConnectionManager; use crate::error::Result as CliResult; use anyhow::Context; use colored::Colorize; -use comfy_table::{Cell, Color, Table}; use redis_enterprise::bdb::BdbHandler; use redis_enterprise::cluster::ClusterHandler; use redis_enterprise::nodes::NodeHandler; use redis_enterprise::shards::ShardHandler; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use tabled::builder::Builder; +use tabled::settings::Style; use super::utils::*; @@ -305,26 +306,26 @@ fn print_status_tables( fn print_cluster_table(cluster: &Value) { println!("{}", "CLUSTER".bold()); - let mut table = Table::new(); - table.set_header(vec!["Field", "Value"]); + let mut builder = Builder::default(); + builder.push_record(["Field", "Value"]); let name = cluster.get("name").and_then(|v| v.as_str()).unwrap_or("-"); - table.add_row(vec![Cell::new("Name"), Cell::new(name)]); + builder.push_record(["Name", name]); if let Some(status) = cluster.get("status").and_then(|v| v.as_str()) { - table.add_row(vec![Cell::new("Status"), status_cell(status)]); + builder.push_record(["Status", &status_colored(status)]); } if let Some(rack_aware) = cluster.get("rack_aware").and_then(|v| v.as_bool()) { let label = if rack_aware { "Yes" } else { "No" }; - table.add_row(vec![Cell::new("Rack Aware"), Cell::new(label)]); + builder.push_record(["Rack Aware", label]); } if let Some(exp) = cluster.get("license_expire_time").and_then(|v| v.as_str()) { - table.add_row(vec![Cell::new("License Expires"), Cell::new(exp)]); + builder.push_record(["License Expires", exp]); } - println!("{table}"); + println!("{}", builder.build().with(Style::blank())); println!(); } @@ -332,10 +333,8 @@ fn print_nodes_table(nodes: &Value) { let empty_vec = vec![]; let nodes_array = nodes.as_array().unwrap_or(&empty_vec); println!("{}", "NODES".bold()); - let mut table = Table::new(); - table.set_header(vec![ - "UID", "Address", "Status", "Shards", "Memory", "Rack ID", - ]); + let mut builder = Builder::default(); + builder.push_record(["UID", "Address", "Status", "Shards", "Memory", "Rack ID"]); for node in nodes_array { let uid = node @@ -359,17 +358,17 @@ fn print_nodes_table(nodes: &Value) { .unwrap_or(0.0); let rack_id = node.get("rack_id").and_then(|v| v.as_str()).unwrap_or("-"); - table.add_row(vec![ - Cell::new(&uid), - Cell::new(addr), - status_cell(status), - Cell::new(&shard_count), - Cell::new(format_bytes(total_memory)), - Cell::new(rack_id), + builder.push_record([ + uid.as_str(), + addr, + &status_colored(status), + &shard_count, + &format_bytes(total_memory), + rack_id, ]); } - println!("{table}"); + println!("{}", builder.build().with(Style::blank())); println!(); } @@ -377,8 +376,8 @@ fn print_databases_table(databases: &Value) { let empty_vec = vec![]; let databases_array = databases.as_array().unwrap_or(&empty_vec); println!("{}", "DATABASES".bold()); - let mut table = Table::new(); - table.set_header(vec![ + let mut builder = Builder::default(); + builder.push_record([ "UID", "Name", "Status", @@ -440,18 +439,18 @@ fn print_databases_table(databases: &Value) { }) .unwrap_or_else(|| "-".to_string()); - table.add_row(vec![ - Cell::new(&uid), - Cell::new(name), - status_cell(status), - Cell::new(&memory), - Cell::new(&shard_count), - Cell::new(replication), - Cell::new(&endpoint), + builder.push_record([ + uid.as_str(), + name, + &status_colored(status), + &memory, + &shard_count, + replication, + &endpoint, ]); } - println!("{table}"); + println!("{}", builder.build().with(Style::blank())); println!(); } @@ -459,8 +458,8 @@ fn print_shards_table(shards: &Value) { let empty_vec = vec![]; let shards_array = shards.as_array().unwrap_or(&empty_vec); println!("{}", "SHARDS".bold()); - let mut table = Table::new(); - table.set_header(vec!["UID", "DB", "Node", "Role", "Status"]); + let mut builder = Builder::default(); + builder.push_record(["UID", "DB", "Node", "Role", "Status"]); for shard in shards_array { let uid = shard @@ -484,16 +483,16 @@ fn print_shards_table(shards: &Value) { .and_then(|v| v.as_str()) .unwrap_or("unknown"); - table.add_row(vec![ - Cell::new(&uid), - Cell::new(&bdb_uid), - Cell::new(&node_uid), - Cell::new(role), - status_cell(status), + builder.push_record([ + uid.as_str(), + &bdb_uid, + &node_uid, + role, + &status_colored(status), ]); } - println!("{table}"); + println!("{}", builder.build().with(Style::blank())); println!(); } @@ -501,13 +500,13 @@ fn print_shards_table(shards: &Value) { // Helpers // --------------------------------------------------------------------------- -/// Create a colored cell based on status value -fn status_cell(status: &str) -> Cell { +/// Return a colored string based on status value +fn status_colored(status: &str) -> String { match status.to_lowercase().as_str() { - "active" | "ok" | "healthy" => Cell::new(status).fg(Color::Green), - "degraded" | "pending" | "importing" | "recovery" => Cell::new(status).fg(Color::Yellow), - "critical" | "failed" | "error" | "inactive" | "down" => Cell::new(status).fg(Color::Red), - _ => Cell::new(status), + "active" | "ok" | "healthy" => status.green().to_string(), + "degraded" | "pending" | "importing" | "recovery" => status.yellow().to_string(), + "critical" | "failed" | "error" | "inactive" | "down" => status.red().to_string(), + _ => status.to_string(), } } @@ -607,12 +606,12 @@ mod tests { } #[test] - fn test_status_cell_colors() { + fn test_status_colored() { // Just verify these don't panic - let _ = status_cell("active"); - let _ = status_cell("degraded"); - let _ = status_cell("critical"); - let _ = status_cell("something-else"); + let _ = status_colored("active"); + let _ = status_colored("degraded"); + let _ = status_colored("critical"); + let _ = status_colored("something-else"); } #[test] diff --git a/crates/redisctl/src/commands/enterprise/utils.rs b/crates/redisctl/src/commands/enterprise/utils.rs index cb099774..7195569d 100644 --- a/crates/redisctl/src/commands/enterprise/utils.rs +++ b/crates/redisctl/src/commands/enterprise/utils.rs @@ -1,62 +1,10 @@ //! Utility functions for Enterprise commands -use crate::error::RedisCtlError; - -use crate::cli::OutputFormat; use crate::error::Result as CliResult; -use crate::output::print_output; use anyhow::Context; use dialoguer::Confirm; use serde_json::Value; -/// Apply JMESPath query to JSON data (using extended runtime with 400+ functions) -pub fn apply_jmespath(data: &Value, query: &str) -> CliResult { - let expr = crate::output::compile_jmespath(query) - .with_context(|| format!("Invalid JMESPath expression: {}", query))?; - expr.search(data) - .with_context(|| format!("Failed to apply JMESPath query: {}", query)) - .map_err(Into::into) -} - -/// Handle output with optional JMESPath query -pub fn handle_output( - data: Value, - _output_format: OutputFormat, - query: Option<&str>, -) -> CliResult { - if let Some(q) = query { - apply_jmespath(&data, q) - } else { - Ok(data) - } -} - -/// Print formatted output based on format type -pub fn print_formatted_output(data: Value, output_format: OutputFormat) -> CliResult<()> { - match output_format { - OutputFormat::Json => { - print_output(data, crate::output::OutputFormat::Json, None).map_err(|e| { - RedisCtlError::OutputError { - message: e.to_string(), - } - })?; - } - OutputFormat::Yaml => { - print_output(data, crate::output::OutputFormat::Yaml, None).map_err(|e| { - RedisCtlError::OutputError { - message: e.to_string(), - } - })?; - } - OutputFormat::Table | OutputFormat::Auto => { - print_output(data, crate::output::OutputFormat::Table, None).map_err(|e| { - RedisCtlError::OutputError { - message: e.to_string(), - } - })?; - } - } - Ok(()) -} +pub use crate::output::{apply_jmespath, handle_output, print_formatted_output}; /// Confirm an action with the user pub fn confirm_action(message: &str) -> CliResult { diff --git a/crates/redisctl/src/commands/profile.rs b/crates/redisctl/src/commands/profile.rs index 0b21aa81..788ff5ca 100644 --- a/crates/redisctl/src/commands/profile.rs +++ b/crates/redisctl/src/commands/profile.rs @@ -164,13 +164,7 @@ async fn handle_list( "count": profiles.len() }); - let fmt = match output_format { - OutputFormat::Json => output::OutputFormat::Json, - OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Json, - }; - - output::print_output(&output_data, fmt, None)?; + output::print_output(&output_data, output_format, None)?; } _ => { // Show config file path at the top @@ -305,13 +299,7 @@ async fn handle_path(output_format: OutputFormat) -> Result<(), RedisCtlError> { "config_path": config_path.to_str() }); - let fmt = match output_format { - OutputFormat::Json => output::OutputFormat::Json, - OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Json, - }; - - output::print_output(&output_data, fmt, None)?; + output::print_output(&output_data, output_format, None)?; } _ => { println!("{}", config_path.display()); @@ -1753,12 +1741,7 @@ fn output_validation( ) -> Result<(), RedisCtlError> { match output_format { OutputFormat::Json | OutputFormat::Yaml => { - let fmt = match output_format { - OutputFormat::Json => output::OutputFormat::Json, - OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Json, - }; - output::print_output(&result, fmt, None)?; + output::print_output(&result, output_format, None)?; } _ => { print_validation_human(&result); diff --git a/crates/redisctl/src/main.rs b/crates/redisctl/src/main.rs index 0e8960b1..23b95bf4 100644 --- a/crates/redisctl/src/main.rs +++ b/crates/redisctl/src/main.rs @@ -455,13 +455,7 @@ async fn execute_command(cli: &Cli, conn_mgr: &ConnectionManager) -> Result<(), "name": env!("CARGO_PKG_NAME"), }); - let fmt = match cli.output { - cli::OutputFormat::Json => output::OutputFormat::Json, - cli::OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Json, - }; - - crate::output::print_output(&output_data, fmt, None)?; + crate::output::print_output(&output_data, cli.output, None)?; } _ => { println!("redisctl {}", env!("CARGO_PKG_VERSION")); @@ -965,16 +959,7 @@ async fn handle_cloud_workflow_command( }) }) .collect(); - let output_format = match output { - cli::OutputFormat::Json => output::OutputFormat::Json, - cli::OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Table, - }; - crate::output::print_output( - serde_json::json!(workflow_list), - output_format, - None, - )?; + crate::output::print_output(serde_json::json!(workflow_list), output, None)?; } _ => { println!("Available Cloud Workflows:"); @@ -990,16 +975,10 @@ async fn handle_cloud_workflow_command( let mut workflow_args = WorkflowArgs::new(); workflow_args.insert("args", args); - let output_format = match output { - cli::OutputFormat::Json => output::OutputFormat::Json, - cli::OutputFormat::Yaml => output::OutputFormat::Yaml, - cli::OutputFormat::Table | cli::OutputFormat::Auto => output::OutputFormat::Table, - }; - let context = WorkflowContext { conn_mgr: conn_mgr.clone(), profile_name: profile.map(String::from), - output_format, + output_format: output, wait_timeout: args.wait_timeout as u64, }; @@ -1032,7 +1011,7 @@ async fn handle_cloud_workflow_command( "message": result.message, "outputs": result.outputs, }); - crate::output::print_output(&result_json, output_format, None)?; + crate::output::print_output(&result_json, output, None)?; } _ => { // Human output @@ -1070,16 +1049,7 @@ async fn handle_enterprise_workflow_command( }) }) .collect(); - let output_format = match output { - cli::OutputFormat::Json => output::OutputFormat::Json, - cli::OutputFormat::Yaml => output::OutputFormat::Yaml, - _ => output::OutputFormat::Table, - }; - crate::output::print_output( - serde_json::json!(workflow_list), - output_format, - None, - )?; + crate::output::print_output(serde_json::json!(workflow_list), output, None)?; } _ => { println!("Available Enterprise Workflows:"); @@ -1112,16 +1082,10 @@ async fn handle_enterprise_workflow_command( args.insert("database_name", database_name); args.insert("database_memory_gb", database_memory_gb); - let output_format = match output { - cli::OutputFormat::Json => output::OutputFormat::Json, - cli::OutputFormat::Yaml => output::OutputFormat::Yaml, - cli::OutputFormat::Table | cli::OutputFormat::Auto => output::OutputFormat::Table, - }; - let context = WorkflowContext { conn_mgr: conn_mgr.clone(), profile_name: profile.map(String::from), - output_format, + output_format: output, wait_timeout: if async_ops.wait { async_ops.wait_timeout } else { @@ -1158,7 +1122,7 @@ async fn handle_enterprise_workflow_command( "message": result.message, "outputs": result.outputs, }); - crate::output::print_output(&result_json, output_format, None)?; + crate::output::print_output(&result_json, output, None)?; } _ => { // Human output was already printed by the workflow diff --git a/crates/redisctl/src/output.rs b/crates/redisctl/src/output.rs index 8144514b..6f63e272 100644 --- a/crates/redisctl/src/output.rs +++ b/crates/redisctl/src/output.rs @@ -1,12 +1,19 @@ #![allow(dead_code)] use anyhow::{Context, Result}; -use comfy_table::Table; use jpx_core::Runtime; use regex::Regex; use serde::Serialize; use serde_json::Value; +use std::io::IsTerminal; use std::sync::OnceLock; +use tabled::builder::Builder; +use tabled::settings::Style; + +use crate::error::{RedisCtlError, Result as CliResult}; + +/// Re-export the single OutputFormat enum from cli. +pub use crate::cli::OutputFormat; /// Global JMESPath runtime with extended functions static JMESPATH_RUNTIME: OnceLock = OnceLock::new(); @@ -66,21 +73,19 @@ pub fn compile_jmespath( get_jmespath_runtime().compile(&normalized) } -#[derive(Debug, Clone, Copy, clap::ValueEnum, Default)] -pub enum OutputFormat { - #[default] - Json, - Yaml, - Table, -} - -impl OutputFormat { - pub fn is_json(&self) -> bool { - matches!(self, Self::Json) - } - - pub fn is_yaml(&self) -> bool { - matches!(self, Self::Yaml) +/// Resolve `Auto` format to a concrete format. +/// +/// `Auto` resolves to `Table` when stdout is a TTY, `Json` when piped. +fn resolve_auto(format: OutputFormat) -> OutputFormat { + match format { + OutputFormat::Auto => { + if std::io::stdout().is_terminal() { + OutputFormat::Table + } else { + OutputFormat::Json + } + } + other => other, } } @@ -98,8 +103,9 @@ pub fn print_output( json_value = expr.search(&json_value).context("JMESPath query failed")?; } - match format { - OutputFormat::Json => { + let resolved = resolve_auto(format); + match resolved { + OutputFormat::Json | OutputFormat::Auto => { println!("{}", serde_json::to_string_pretty(&json_value)?); } OutputFormat::Yaml => { @@ -113,15 +119,46 @@ pub fn print_output( Ok(()) } +/// Apply JMESPath query to JSON data (using extended runtime with 400+ functions) +pub fn apply_jmespath(data: &Value, query: &str) -> CliResult { + let expr = compile_jmespath(query) + .with_context(|| format!("Invalid JMESPath expression: {}", query))?; + expr.search(data) + .with_context(|| format!("Failed to apply JMESPath query: {}", query)) + .map_err(Into::into) +} + +/// Handle output with optional JMESPath query +pub fn handle_output( + data: Value, + _output_format: OutputFormat, + query: Option<&str>, +) -> CliResult { + if let Some(q) = query { + apply_jmespath(&data, q) + } else { + Ok(data) + } +} + +/// Print data in the requested output format, mapping errors to `RedisCtlError::OutputError`. +pub fn print_formatted_output(data: Value, output_format: OutputFormat) -> CliResult<()> { + let resolved = resolve_auto(output_format); + print_output(data, resolved, None).map_err(|e| RedisCtlError::OutputError { + message: e.to_string(), + })?; + Ok(()) +} + fn print_as_table(value: &Value) -> Result<()> { match value { Value::Array(arr) if !arr.is_empty() => { - let mut table = Table::new(); + let mut builder = Builder::default(); // Get headers from first object if let Value::Object(first) = &arr[0] { let headers: Vec = first.keys().cloned().collect(); - table.set_header(&headers); + builder.push_record(&headers); // Add rows for item in arr { @@ -130,28 +167,28 @@ fn print_as_table(value: &Value) -> Result<()> { .iter() .map(|h| format_value(obj.get(h).unwrap_or(&Value::Null))) .collect(); - table.add_row(row); + builder.push_record(row); } } } else { // Simple array of values - table.set_header(vec!["Value"]); + builder.push_record(["Value"]); for item in arr { - table.add_row(vec![format_value(item)]); + builder.push_record([format_value(item)]); } } - println!("{}", table); + println!("{}", builder.build().with(Style::blank())); } Value::Object(obj) => { - let mut table = Table::new(); - table.set_header(vec!["Key", "Value"]); + let mut builder = Builder::default(); + builder.push_record(["Key", "Value"]); for (key, val) in obj { - table.add_row(vec![key.clone(), format_value(val)]); + builder.push_record([key.clone(), format_value(val)]); } - println!("{}", table); + println!("{}", builder.build().with(Style::blank())); } _ => { println!("{}", format_value(value)); diff --git a/crates/redisctl/src/workflows/enterprise/init_cluster.rs b/crates/redisctl/src/workflows/enterprise/init_cluster.rs index ff167ff8..ac5d7d62 100644 --- a/crates/redisctl/src/workflows/enterprise/init_cluster.rs +++ b/crates/redisctl/src/workflows/enterprise/init_cluster.rs @@ -39,8 +39,11 @@ impl Workflow for InitClusterWorkflow { Box::pin(async move { use crate::output::OutputFormat; - // Only print human-readable output for Table format - let is_human_output = matches!(context.output_format, OutputFormat::Table); + // Only print human-readable output for Table/Auto format + let is_human_output = matches!( + context.output_format, + OutputFormat::Table | OutputFormat::Auto + ); if is_human_output { println!("Initializing Redis Enterprise cluster...");