From 32f01e57ca3b8214356aa4241186b225b205acc8 Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Wed, 17 Dec 2025 11:51:56 -0800 Subject: [PATCH] feat: add module name lookup for module get and database create Add --name flag to 'enterprise module get': - Look up modules by name instead of UID - Case-insensitive matching - Helpful suggestions on partial matches - Error with available versions if multiple matches found Add --module flag to 'enterprise database create': - Specify modules by name (e.g., --module search --module ReJSON) - Auto-resolves module names to proper module_list format - Supports module args via colon syntax (--module search:ARGS) - Can be repeated for multiple modules - Case-insensitive matching with helpful error messages Examples: # Get module by name redisctl enterprise module get --name ReJSON # Create database with modules redisctl enterprise database create --name mydb --memory 1073741824 \ --module search --module ReJSON --- crates/redisctl/src/cli/enterprise.rs | 9 ++ .../src/commands/enterprise/database.rs | 2 + .../src/commands/enterprise/database_impl.rs | 102 ++++++++++++++++++ .../src/commands/enterprise/module.rs | 12 ++- .../src/commands/enterprise/module_impl.rs | 92 +++++++++++++++- 5 files changed, 210 insertions(+), 7 deletions(-) diff --git a/crates/redisctl/src/cli/enterprise.rs b/crates/redisctl/src/cli/enterprise.rs index 38187057..2b3db4bb 100644 --- a/crates/redisctl/src/cli/enterprise.rs +++ b/crates/redisctl/src/cli/enterprise.rs @@ -368,6 +368,10 @@ pub enum EnterpriseDatabaseCommands { # With specific port redisctl enterprise database create --name service-db --memory 1073741824 --port 12000 + # With modules (auto-resolves name to module) + redisctl enterprise database create --name search-db --memory 1073741824 \\ + --module search --module ReJSON + # Complete configuration from file redisctl enterprise database create --data @database.json @@ -424,6 +428,11 @@ NOTE: Memory size is in bytes. Common values: #[arg(long)] redis_password: Option, + /// Module to enable (by name, can be repeated). Use 'module list' to see available modules. + /// Format: module_name or module_name:args (e.g., --module search --module ReJSON) + #[arg(long = "module", value_name = "NAME[:ARGS]")] + modules: Vec, + /// Advanced: Full database configuration as JSON string or @file.json #[arg(long)] data: Option, diff --git a/crates/redisctl/src/commands/enterprise/database.rs b/crates/redisctl/src/commands/enterprise/database.rs index d6aa3c74..2154dfa3 100644 --- a/crates/redisctl/src/commands/enterprise/database.rs +++ b/crates/redisctl/src/commands/enterprise/database.rs @@ -35,6 +35,7 @@ pub async fn handle_database_command( proxy_policy, crdb, redis_password, + modules, data, dry_run, } => { @@ -52,6 +53,7 @@ pub async fn handle_database_command( proxy_policy.as_deref(), *crdb, redis_password.as_deref(), + modules, data.as_deref(), *dry_run, output_format, diff --git a/crates/redisctl/src/commands/enterprise/database_impl.rs b/crates/redisctl/src/commands/enterprise/database_impl.rs index d740e791..2cf6ba27 100644 --- a/crates/redisctl/src/commands/enterprise/database_impl.rs +++ b/crates/redisctl/src/commands/enterprise/database_impl.rs @@ -62,6 +62,7 @@ pub async fn create_database( proxy_policy: Option<&str>, crdb: bool, redis_password: Option<&str>, + modules: &[String], data: Option<&str>, dry_run: bool, output_format: OutputFormat, @@ -145,6 +146,107 @@ pub async fn create_database( ); } + // Handle module resolution if --module flags were provided + if !modules.is_empty() { + let module_handler = redis_enterprise::ModuleHandler::new( + conn_mgr.create_enterprise_client(profile_name).await?, + ); + let available_modules = module_handler.list().await.map_err(RedisCtlError::from)?; + + let mut module_list: Vec = Vec::new(); + + for module_spec in modules { + // Parse module_name:args format + let (module_name, module_args) = if let Some(idx) = module_spec.find(':') { + let (name, args) = module_spec.split_at(idx); + (name.trim(), Some(args[1..].trim())) // Skip the ':' character + } else { + (module_spec.as_str(), None) + }; + + // Find matching module (case-insensitive) + let matching: Vec<_> = available_modules + .iter() + .filter(|m| { + m.module_name + .as_ref() + .map(|n| n.eq_ignore_ascii_case(module_name)) + .unwrap_or(false) + }) + .collect(); + + match matching.len() { + 0 => { + // No exact match - try partial match and suggest + let partial_matches: Vec<_> = available_modules + .iter() + .filter(|m| { + m.module_name + .as_ref() + .map(|n| n.to_lowercase().contains(&module_name.to_lowercase())) + .unwrap_or(false) + }) + .collect(); + + if partial_matches.is_empty() { + return Err(RedisCtlError::InvalidInput { + message: format!( + "Module '{}' not found. Use 'enterprise module list' to see available modules.", + module_name + ), + }); + } else { + let suggestions: Vec<_> = partial_matches + .iter() + .filter_map(|m| m.module_name.as_deref()) + .collect(); + return Err(RedisCtlError::InvalidInput { + message: format!( + "Module '{}' not found. Did you mean one of: {}?", + module_name, + suggestions.join(", ") + ), + }); + } + } + 1 => { + // Build module config using the actual module name from the API + let actual_name = matching[0].module_name.as_deref().unwrap_or(module_name); + let mut module_config = serde_json::json!({ + "module_name": actual_name + }); + if let Some(args) = module_args { + module_config["module_args"] = serde_json::json!(args); + } + module_list.push(module_config); + } + _ => { + // Multiple matches - show versions and ask user to be specific + let versions: Vec<_> = matching + .iter() + .map(|m| { + format!( + "{} (version: {})", + m.module_name.as_deref().unwrap_or("unknown"), + m.semantic_version.as_deref().unwrap_or("unknown") + ) + }) + .collect(); + return Err(RedisCtlError::InvalidInput { + message: format!( + "Multiple modules found matching '{}'. Available versions:\n {}", + module_name, + versions.join("\n ") + ), + }); + } + } + } + + // Add module_list to request (CLI modules override --data modules) + request_obj.insert("module_list".to_string(), serde_json::json!(module_list)); + } + let path = if dry_run { "/v1/bdbs/dry-run" } else { diff --git a/crates/redisctl/src/commands/enterprise/module.rs b/crates/redisctl/src/commands/enterprise/module.rs index 0eab409f..5a3f6d11 100644 --- a/crates/redisctl/src/commands/enterprise/module.rs +++ b/crates/redisctl/src/commands/enterprise/module.rs @@ -1,4 +1,4 @@ -use clap::Subcommand; +use clap::{ArgGroup, Subcommand}; #[derive(Debug, Subcommand)] pub enum ModuleCommands { @@ -6,10 +6,16 @@ pub enum ModuleCommands { #[command(visible_alias = "ls")] List, - /// Get module details + /// Get module details by UID or name + #[command(group(ArgGroup::new("identifier").required(true).args(["uid", "name"])))] Get { /// Module UID - uid: String, + #[arg(conflicts_with = "name")] + uid: Option, + + /// Module name (e.g., "ReJSON", "search") + #[arg(long, conflicts_with = "uid")] + name: Option, }, /// Upload new module diff --git a/crates/redisctl/src/commands/enterprise/module_impl.rs b/crates/redisctl/src/commands/enterprise/module_impl.rs index 6ff5d85b..b4c08cf0 100644 --- a/crates/redisctl/src/commands/enterprise/module_impl.rs +++ b/crates/redisctl/src/commands/enterprise/module_impl.rs @@ -21,8 +21,16 @@ pub async fn handle_module_commands( ) -> CliResult<()> { match cmd { ModuleCommands::List => handle_list(conn_mgr, profile_name, output_format, query).await, - ModuleCommands::Get { uid } => { - handle_get(conn_mgr, profile_name, uid, output_format, query).await + ModuleCommands::Get { uid, name } => { + handle_get( + conn_mgr, + profile_name, + uid.as_deref(), + name.as_deref(), + output_format, + query, + ) + .await } ModuleCommands::Upload { file } => { handle_upload(conn_mgr, profile_name, file, output_format, query).await @@ -61,14 +69,90 @@ async fn handle_list( async fn handle_get( conn_mgr: &ConnectionManager, profile_name: Option<&str>, - uid: &str, + uid: Option<&str>, + name: Option<&str>, output_format: OutputFormat, query: Option<&str>, ) -> CliResult<()> { let client = conn_mgr.create_enterprise_client(profile_name).await?; let handler = ModuleHandler::new(client); - let module = handler.get(uid).await.map_err(RedisCtlError::from)?; + // Resolve module UID from name if provided + let resolved_uid = if let Some(module_name) = name { + let modules = handler.list().await.map_err(RedisCtlError::from)?; + let matching: Vec<_> = modules + .iter() + .filter(|m| { + m.module_name + .as_ref() + .map(|n| n.eq_ignore_ascii_case(module_name)) + .unwrap_or(false) + }) + .collect(); + + match matching.len() { + 0 => { + // No exact match - try partial match and suggest + let partial_matches: Vec<_> = modules + .iter() + .filter(|m| { + m.module_name + .as_ref() + .map(|n| n.to_lowercase().contains(&module_name.to_lowercase())) + .unwrap_or(false) + }) + .collect(); + + if partial_matches.is_empty() { + return Err(anyhow::anyhow!( + "No module found with name '{}'. Use 'module list' to see available modules.", + module_name + ) + .into()); + } else { + let suggestions: Vec<_> = partial_matches + .iter() + .filter_map(|m| m.module_name.as_deref()) + .collect(); + return Err(anyhow::anyhow!( + "No module found with name '{}'. Did you mean one of: {}?", + module_name, + suggestions.join(", ") + ) + .into()); + } + } + 1 => matching[0].uid.clone(), + _ => { + // Multiple matches - show versions and ask user to be specific + let versions: Vec<_> = matching + .iter() + .map(|m| { + format!( + "{} (uid: {}, version: {})", + m.module_name.as_deref().unwrap_or("unknown"), + m.uid, + m.semantic_version.as_deref().unwrap_or("unknown") + ) + }) + .collect(); + return Err(anyhow::anyhow!( + "Multiple modules found with name '{}'. Please use --uid to specify:\n {}", + module_name, + versions.join("\n ") + ) + .into()); + } + } + } else { + uid.expect("Either uid or name must be provided") + .to_string() + }; + + let module = handler + .get(&resolved_uid) + .await + .map_err(RedisCtlError::from)?; let module_json = serde_json::to_value(&module)?; let output_data = if let Some(q) = query {