diff --git a/Cargo.toml b/Cargo.toml index 27d8ea40..bddfaa70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,8 +2,7 @@ resolver = "2" members = [ "crates/redis-cloud", - "crates/redis-enterprise", - "crates/redis-common", + "crates/redis-enterprise", "crates/redisctl", ] @@ -59,7 +58,6 @@ pretty_assertions = "1.4" # Internal crates redis-cloud = { path = "crates/redis-cloud" } redis-enterprise = { path = "crates/redis-enterprise" } -redis-common = { path = "crates/redis-common" } [profile.release] opt-level = 3 diff --git a/crates/redis-common/Cargo.toml b/crates/redis-common/Cargo.toml index 684901fb..dd09ebe2 100644 --- a/crates/redis-common/Cargo.toml +++ b/crates/redis-common/Cargo.toml @@ -10,6 +10,7 @@ description = "Shared utilities for Redis CLI tools" keywords = ["redis", "cli", "common", "utilities"] categories = ["command-line-utilities", "api-bindings"] readme = "../../README.md" +publish = false [dependencies] anyhow = { workspace = true } diff --git a/crates/redisctl/Cargo.toml b/crates/redisctl/Cargo.toml index 39e318ae..32ca6207 100644 --- a/crates/redisctl/Cargo.toml +++ b/crates/redisctl/Cargo.toml @@ -27,7 +27,6 @@ path = "src/enterprise_bin.rs" required-features = ["enterprise-only"] [dependencies] -redis-common = { version = "0.1.0", path = "../redis-common" } redis-cloud = { version = "0.1.0", path = "../redis-cloud" } redis-enterprise = { version = "0.1.0", path = "../redis-enterprise" } @@ -43,6 +42,15 @@ chrono = "0.4" rpassword = { workspace = true } urlencoding = "2.1" +# Dependencies from redis-common +thiserror = { workspace = true } +serde_yaml = { workspace = true } +comfy-table = { workspace = true } +jmespath = { workspace = true } +config = { workspace = true } +toml = { workspace = true } +directories = { workspace = true } + # Conditional dependencies for feature-gated binaries [features] default = ["full"] diff --git a/crates/redisctl/src/cli.rs b/crates/redisctl/src/cli.rs index 34ed3e66..c3a1a61e 100644 --- a/crates/redisctl/src/cli.rs +++ b/crates/redisctl/src/cli.rs @@ -1,6 +1,7 @@ use crate::commands::api::ApiCommands; +use crate::config::DeploymentType; +use crate::output::OutputFormat; use clap::{Parser, Subcommand}; -use redis_common::{DeploymentType, OutputFormat}; #[derive(Parser)] #[command(name = "redisctl")] diff --git a/crates/redisctl/src/cloud_bin.rs b/crates/redisctl/src/cloud_bin.rs index c4aa3d16..a578adf1 100644 --- a/crates/redisctl/src/cloud_bin.rs +++ b/crates/redisctl/src/cloud_bin.rs @@ -1,13 +1,16 @@ // Redis Cloud only binary // This binary only includes Cloud functionality to reduce size for Cloud-only deployments +use crate::config::{Config, DeploymentType}; use anyhow::Result; use clap::Parser; -use redis_common::{Config, DeploymentType}; use tracing::info; mod cli; mod commands; +mod config; +mod error; +mod output; use cli::{Cli, Commands}; use commands::{cloud, profile}; @@ -53,7 +56,7 @@ async fn main() -> Result<()> { fn get_cloud_profile<'a>( config: &'a Config, profile_name: &Option, -) -> Result<&'a redis_common::Profile> { +) -> Result<&'a crate::config::Profile> { let env_profile = std::env::var("REDISCTL_PROFILE").ok(); let profile_name = profile_name .as_deref() diff --git a/crates/redisctl/src/commands/api.rs b/crates/redisctl/src/commands/api.rs index cca9ecb9..dc3e8263 100644 --- a/crates/redisctl/src/commands/api.rs +++ b/crates/redisctl/src/commands/api.rs @@ -2,10 +2,10 @@ #![allow(dead_code)] +use crate::output::{OutputFormat, print_output}; use anyhow::{Context, Result}; use clap::Subcommand; use redis_cloud::CloudClient; -use redis_common::{OutputFormat, print_output}; use redis_enterprise::EnterpriseClient; use serde_json::Value; use std::fs; diff --git a/crates/redisctl/src/commands/cloud.rs b/crates/redisctl/src/commands/cloud.rs index bbf65b76..f7f2b557 100644 --- a/crates/redisctl/src/commands/cloud.rs +++ b/crates/redisctl/src/commands/cloud.rs @@ -1,6 +1,7 @@ +use crate::config::{Profile, ProfileCredentials}; +use crate::output::{OutputFormat, print_output}; use anyhow::Result; use redis_cloud::CloudClient; -use redis_common::{OutputFormat, Profile, ProfileCredentials, print_output}; use crate::cli::{ AccountCommands, AclCommands, ApiKeyCommands, BackupCommands, CloudAccountCommands, diff --git a/crates/redisctl/src/commands/cloud_billing.rs b/crates/redisctl/src/commands/cloud_billing.rs index a365a23c..8d7ae170 100644 --- a/crates/redisctl/src/commands/cloud_billing.rs +++ b/crates/redisctl/src/commands/cloud_billing.rs @@ -1,6 +1,6 @@ +use crate::output::{OutputFormat, print_output}; use anyhow::Result; use redis_cloud::{CloudBillingHandler, CloudClient}; -use redis_common::{OutputFormat, print_output}; use crate::cli::BillingCommands; diff --git a/crates/redisctl/src/commands/enterprise.rs b/crates/redisctl/src/commands/enterprise.rs index 3b61d5b1..c809cd5a 100644 --- a/crates/redisctl/src/commands/enterprise.rs +++ b/crates/redisctl/src/commands/enterprise.rs @@ -1,5 +1,6 @@ +use crate::config::{Profile, ProfileCredentials}; +use crate::output::{OutputFormat, print_output}; use anyhow::Result; -use redis_common::{OutputFormat, Profile, ProfileCredentials, print_output}; use redis_enterprise::EnterpriseClient; use std::io::Write; diff --git a/crates/redisctl/src/commands/profile.rs b/crates/redisctl/src/commands/profile.rs index a264b29c..885ebcda 100644 --- a/crates/redisctl/src/commands/profile.rs +++ b/crates/redisctl/src/commands/profile.rs @@ -1,7 +1,6 @@ +use crate::config::{Config, DeploymentType, Profile, ProfileCredentials}; +use crate::output::{OutputFormat, print_output}; use anyhow::Result; -use redis_common::{ - Config, DeploymentType, OutputFormat, Profile, ProfileCredentials, print_output, -}; use crate::cli::ProfileCommands; diff --git a/crates/redisctl/src/config.rs b/crates/redisctl/src/config.rs new file mode 100644 index 00000000..5da29bbe --- /dev/null +++ b/crates/redisctl/src/config.rs @@ -0,0 +1,123 @@ +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Config { + #[serde(default)] + pub default: Option, // Name of the default profile + #[serde(default)] + pub profiles: HashMap, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Profile { + pub deployment_type: DeploymentType, + #[serde(flatten)] + pub credentials: ProfileCredentials, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)] +pub enum DeploymentType { + Cloud, + Enterprise, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum ProfileCredentials { + Cloud { + api_key: String, + api_secret: String, + #[serde(default = "default_cloud_url")] + api_url: String, + }, + Enterprise { + url: String, + username: String, + password: Option, // Optional for prompting + #[serde(default)] + insecure: bool, + }, +} + +fn default_cloud_url() -> String { + "https://api.redislabs.com/v1".to_string() +} + +impl ProfileCredentials { + pub fn has_password(&self) -> bool { + match self { + ProfileCredentials::Enterprise { password, .. } => password.is_some(), + _ => false, + } + } +} + +impl Config { + pub fn load() -> Result { + let config_path = Self::config_path()?; + + if !config_path.exists() { + return Ok(Config::default()); + } + + let content = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config from {:?}", config_path))?; + + toml::from_str(&content) + .with_context(|| format!("Failed to parse config from {:?}", config_path)) + } + + pub fn save(&self) -> Result<()> { + let config_path = Self::config_path()?; + + // Create parent directories if they don't exist + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create config directory {:?}", parent))?; + } + + let content = toml::to_string_pretty(self).context("Failed to serialize config")?; + + fs::write(&config_path, content) + .with_context(|| format!("Failed to write config to {:?}", config_path))?; + + Ok(()) + } + + pub fn get_profile(&self, name: Option<&str>) -> Option<&Profile> { + let env_profile = std::env::var("REDISCTL_PROFILE").ok(); + let profile_name = name + .or(self.default.as_deref()) + .or(env_profile.as_deref())?; + + self.profiles.get(profile_name) + } + + pub fn set_profile(&mut self, name: String, profile: Profile) { + self.profiles.insert(name, profile); + } + + pub fn remove_profile(&mut self, name: &str) -> Option { + self.profiles.remove(name) + } + + pub fn list_profiles(&self) -> Vec<(&String, &Profile)> { + let mut profiles: Vec<_> = self.profiles.iter().collect(); + profiles.sort_by_key(|(name, _)| *name); + profiles + } + + fn config_path() -> Result { + let proj_dirs = ProjectDirs::from("com", "redis", "redisctl") + .context("Failed to determine config directory")?; + + Ok(proj_dirs.config_dir().join("config.toml")) + } +} diff --git a/crates/redisctl/src/enterprise_bin.rs b/crates/redisctl/src/enterprise_bin.rs index 8c7b6217..866baf05 100644 --- a/crates/redisctl/src/enterprise_bin.rs +++ b/crates/redisctl/src/enterprise_bin.rs @@ -1,13 +1,16 @@ // Redis Enterprise only binary // This binary only includes Enterprise functionality to reduce size for Enterprise-only deployments +use crate::config::{Config, DeploymentType}; use anyhow::Result; use clap::Parser; -use redis_common::{Config, DeploymentType}; use tracing::info; mod cli; mod commands; +mod config; +mod error; +mod output; use cli::{Cli, Commands}; use commands::{enterprise, profile}; @@ -62,7 +65,7 @@ async fn main() -> Result<()> { fn get_enterprise_profile<'a>( config: &'a Config, profile_name: &Option, -) -> Result<&'a redis_common::Profile> { +) -> Result<&'a crate::config::Profile> { let env_profile = std::env::var("REDISCTL_PROFILE").ok(); let profile_name = profile_name .as_deref() diff --git a/crates/redisctl/src/error.rs b/crates/redisctl/src/error.rs new file mode 100644 index 00000000..3b8ac500 --- /dev/null +++ b/crates/redisctl/src/error.rs @@ -0,0 +1,59 @@ +#![allow(dead_code)] + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum RedisCtlError { + #[error("Configuration error: {0}")] + Config(#[from] ConfigError), + + #[error("Profile error: {0}")] + Profile(#[from] ProfileError), + + #[error("Command routing error: {0}")] + Routing(#[from] RoutingError), +} + +#[derive(Error, Debug)] +pub enum ConfigError { + #[error("Profile '{name}' not found")] + ProfileNotFound { name: String }, + + #[error("No default profile set")] + NoDefaultProfile, + + #[error("Config file error: {message}")] + FileError { message: String }, +} + +#[derive(Error, Debug)] +pub enum ProfileError { + #[error("Profile '{name}' is type '{actual_type}' but command requires '{expected_type}'")] + TypeMismatch { + name: String, + actual_type: String, + expected_type: String, + }, + + #[error("Missing credentials for profile '{name}'")] + MissingCredentials { name: String }, +} + +#[derive(Error, Debug)] +pub enum RoutingError { + #[error( + "Command '{command}' exists in both cloud and enterprise. Use 'redisctl cloud {command}' or 'redisctl enterprise {command}'" + )] + AmbiguousCommand { command: String }, + + #[error("Command '{command}' not found in {deployment_type}")] + CommandNotFound { + command: String, + deployment_type: String, + }, + + #[error( + "No profile specified and no default profile set. Use --profile or set REDISCTL_PROFILE" + )] + NoProfileSpecified, +} diff --git a/crates/redisctl/src/lib.rs b/crates/redisctl/src/lib.rs new file mode 100644 index 00000000..3238783e --- /dev/null +++ b/crates/redisctl/src/lib.rs @@ -0,0 +1,86 @@ +//! # redisctl +//! +//! A unified command-line interface for managing Redis deployments across Cloud and Enterprise. +//! +//! ## Overview +//! +//! `redisctl` is a comprehensive CLI tool that unifies management of both Redis Cloud and +//! Redis Enterprise deployments. It automatically detects which API to use based on your +//! configuration profile or explicit command selection, providing a consistent interface +//! for all Redis management tasks. +//! +//! ## Installation +//! +//! Install the CLI tool from crates.io: +//! +//! ```bash +//! cargo install redisctl +//! ``` +//! +//! ## Quick Start +//! +//! ### Configure Authentication +//! +//! For Redis Cloud: +//! ```bash +//! export REDIS_CLOUD_API_KEY="your-api-key" +//! export REDIS_CLOUD_API_SECRET="your-api-secret" +//! ``` +//! +//! For Redis Enterprise: +//! ```bash +//! export REDIS_ENTERPRISE_URL="https://cluster.example.com:9443" +//! export REDIS_ENTERPRISE_USER="admin@example.com" +//! export REDIS_ENTERPRISE_PASSWORD="your-password" +//! ``` +//! +//! Or use profiles: +//! ```bash +//! redisctl profile set prod-cloud \ +//! --deployment-type cloud \ +//! --api-key YOUR_KEY \ +//! --api-secret YOUR_SECRET +//! ``` +//! +//! ### Basic Usage +//! +//! ```bash +//! # List all profiles +//! redisctl profile list +//! +//! # Cloud-specific commands +//! redisctl cloud subscription list +//! redisctl cloud database list +//! +//! # Enterprise-specific commands +//! redisctl enterprise cluster info +//! redisctl enterprise database list +//! +//! # Smart routing (auto-detects based on profile) +//! redisctl database list --profile prod-cloud +//! ``` +//! +//! ## Features +//! +//! - **Unified Interface** - Single CLI for both Redis Cloud and Enterprise +//! - **Smart Command Routing** - Automatically routes commands based on deployment type +//! - **Profile Management** - Save and switch between multiple Redis deployments +//! - **Multiple Output Formats** - JSON, YAML, and Table output with JMESPath queries +//! - **Comprehensive API Coverage** - Full implementation of both Cloud and Enterprise REST APIs +//! +//! ## Using as a Library +//! +//! If you need to programmatically interact with Redis Cloud or Enterprise APIs, +//! use the dedicated library crates instead: +//! +//! - [`redis-cloud`](https://docs.rs/redis-cloud) - Redis Cloud REST API client +//! - [`redis-enterprise`](https://docs.rs/redis-enterprise) - Redis Enterprise REST API client +//! +//! ## Documentation +//! +//! For complete documentation and examples, see the [GitHub repository](https://github.com/joshrotenberg/redisctl). + +// Internal modules for CLI functionality +pub(crate) mod config; +pub(crate) mod error; +pub(crate) mod output; diff --git a/crates/redisctl/src/main.rs b/crates/redisctl/src/main.rs index f88e7e74..3a81e21e 100644 --- a/crates/redisctl/src/main.rs +++ b/crates/redisctl/src/main.rs @@ -1,10 +1,13 @@ +use crate::config::Config; use anyhow::Result; use clap::Parser; -use redis_common::Config; use tracing::info; mod cli; mod commands; +mod config; +mod error; +mod output; mod router; use cli::Cli; diff --git a/crates/redisctl/src/output.rs b/crates/redisctl/src/output.rs new file mode 100644 index 00000000..c4e59aea --- /dev/null +++ b/crates/redisctl/src/output.rs @@ -0,0 +1,115 @@ +#![allow(dead_code)] + +use anyhow::{Context, Result}; +use comfy_table::Table; +use jmespath::compile; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum OutputFormat { + Json, + Yaml, + Table, +} + +impl Default for OutputFormat { + fn default() -> Self { + Self::Json + } +} + +pub fn print_output( + data: T, + format: OutputFormat, + query: Option<&str>, +) -> Result<()> { + let mut json_value = serde_json::to_value(data)?; + + // Apply JMESPath query if provided + if let Some(query_str) = query { + let expr = compile(query_str).context("Invalid JMESPath expression")?; + // Convert Value to string then parse as Variable + let json_str = serde_json::to_string(&json_value)?; + let data = jmespath::Variable::from_json(&json_str) + .map_err(|e| anyhow::anyhow!("Failed to parse JSON for JMESPath: {}", e))?; + let result = expr.search(&data).context("JMESPath query failed")?; + // Convert result back to JSON string then parse as Value + let result_str = result.to_string(); + json_value = + serde_json::from_str(&result_str).context("Failed to parse JMESPath result")?; + } + + match format { + OutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&json_value)?); + } + OutputFormat::Yaml => { + println!("{}", serde_yaml::to_string(&json_value)?); + } + OutputFormat::Table => { + print_as_table(&json_value)?; + } + } + + Ok(()) +} + +fn print_as_table(value: &Value) -> Result<()> { + match value { + Value::Array(arr) if !arr.is_empty() => { + let mut table = Table::new(); + + // Get headers from first object + if let Value::Object(first) = &arr[0] { + let headers: Vec = first.keys().cloned().collect(); + table.set_header(&headers); + + // Add rows + for item in arr { + if let Value::Object(obj) = item { + let row: Vec = headers + .iter() + .map(|h| format_value(obj.get(h).unwrap_or(&Value::Null))) + .collect(); + table.add_row(row); + } + } + } else { + // Simple array of values + table.set_header(vec!["Value"]); + for item in arr { + table.add_row(vec![format_value(item)]); + } + } + + println!("{}", table); + } + Value::Object(obj) => { + let mut table = Table::new(); + table.set_header(vec!["Key", "Value"]); + + for (key, val) in obj { + table.add_row(vec![key.clone(), format_value(val)]); + } + + println!("{}", table); + } + _ => { + println!("{}", format_value(value)); + } + } + + Ok(()) +} + +fn format_value(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => s.clone(), + Value::Array(arr) => format!("[{} items]", arr.len()), + Value::Object(obj) => format!("{{{} fields}}", obj.len()), + } +} diff --git a/crates/redisctl/src/router.rs b/crates/redisctl/src/router.rs index 6115ff63..3014bfa7 100644 --- a/crates/redisctl/src/router.rs +++ b/crates/redisctl/src/router.rs @@ -1,7 +1,6 @@ +use crate::config::{Config, DeploymentType, Profile, ProfileCredentials}; +use crate::error::{ProfileError, RoutingError}; use anyhow::{Result, bail}; -use redis_common::{ - Config, DeploymentType, Profile, ProfileCredentials, ProfileError, RoutingError, -}; use std::borrow::Cow; use tracing::{debug, info}; @@ -78,7 +77,7 @@ async fn route_database_command( profile_name: &Option, deployment_type: Option, command: crate::cli::DatabaseCommands, - output_format: redis_common::OutputFormat, + output_format: crate::output::OutputFormat, query: Option<&str>, ) -> Result<()> { if let Some(dep_type) = deployment_type { @@ -109,7 +108,7 @@ async fn route_cluster_command( profile_name: &Option, deployment_type: Option, command: crate::cli::ClusterCommands, - output_format: redis_common::OutputFormat, + output_format: crate::output::OutputFormat, query: Option<&str>, ) -> Result<()> { if let Some(dep_type) = deployment_type { @@ -144,7 +143,7 @@ async fn route_user_command( profile_name: &Option, deployment_type: Option, command: crate::cli::UserCommands, - output_format: redis_common::OutputFormat, + output_format: crate::output::OutputFormat, query: Option<&str>, ) -> Result<()> { if let Some(dep_type) = deployment_type { @@ -175,7 +174,7 @@ async fn route_account_command( profile_name: &Option, deployment_type: Option, command: crate::cli::AccountCommands, - output_format: redis_common::OutputFormat, + output_format: crate::output::OutputFormat, query: Option<&str>, ) -> Result<()> { if let Some(dep_type) = deployment_type {