From 2fe4bf10991ae2ba99012e3f84366e546380cbfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 08:17:19 +0000 Subject: [PATCH 1/4] Initial plan From f2778038c56dffde4fc44e9b954630ef9e25357f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 08:42:15 +0000 Subject: [PATCH 2/4] feat: [#41] add template subcommand for create command This implements the template generation CLI subcommand, allowing users to generate configuration file templates via the CLI. Changes: - Updated CLI command structure to support create subcommands (environment/template) - Added CreateAction enum with Environment and Template variants - Implemented handle_template_generation() handler that calls existing domain method - Added user-friendly success messages with next steps instructions - Updated command routing to handle new subcommand structure - Added comprehensive tests for CLI argument parsing and template generation - Updated existing tests to work with new subcommand structure The implementation is a thin CLI wrapper that delegates to the existing EnvironmentCreationConfig::generate_template_file() method from the domain layer. CLI Usage: - Generate template: torrust-tracker-deployer create template - Custom path: torrust-tracker-deployer create template ./config/my-env.json - Create environment: torrust-tracker-deployer create environment --env-file Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/cli/commands.rs | 69 ++++++++-- src/presentation/cli/mod.rs | 129 +++++++++++++++--- src/presentation/commands/create/errors.rs | 11 +- src/presentation/commands/create/mod.rs | 2 +- .../commands/create/subcommand.rs | 124 +++++++++++++---- .../commands/create/tests/integration.rs | 34 +++-- src/presentation/commands/create/tests/mod.rs | 1 + .../commands/create/tests/template.rs | 115 ++++++++++++++++ src/presentation/commands/mod.rs | 5 +- 9 files changed, 421 insertions(+), 69 deletions(-) create mode 100644 src/presentation/commands/create/tests/template.rs diff --git a/src/presentation/cli/commands.rs b/src/presentation/cli/commands.rs index a90f0bd1..ba8b8d5b 100644 --- a/src/presentation/cli/commands.rs +++ b/src/presentation/cli/commands.rs @@ -13,19 +13,13 @@ use std::path::PathBuf; /// Each variant represents a specific operation that can be performed. #[derive(Subcommand, Debug)] pub enum Commands { - /// Create a new deployment environment + /// Create operations (environment creation or template generation) /// - /// This command creates a new environment based on a configuration file. - /// The configuration file specifies the environment name, SSH credentials, - /// and other settings required for environment creation. + /// This command provides subcommands for creating environments and generating + /// configuration templates. Create { - /// Path to the environment configuration file - /// - /// The configuration file must be in JSON format and contain all - /// required fields for environment creation. Use --help for more - /// information about the configuration format. - #[arg(long, short = 'f', value_name = "FILE")] - env_file: PathBuf, + #[command(subcommand)] + action: CreateAction, }, /// Destroy an existing deployment environment @@ -65,3 +59,56 @@ pub enum Commands { // version: String, // }, } + +/// Actions available for the create command +#[derive(Debug, Subcommand)] +pub enum CreateAction { + /// Create environment from configuration file + /// + /// This subcommand creates a new deployment environment based on a + /// configuration file. The configuration file specifies the environment + /// name, SSH credentials, and other settings required for creation. + Environment { + /// Path to the environment configuration file + /// + /// The configuration file must be in JSON format and contain all + /// required fields for environment creation. + #[arg(long, short = 'f', value_name = "FILE")] + env_file: PathBuf, + }, + + /// Generate template configuration file + /// + /// This subcommand generates a JSON configuration template file with + /// placeholder values. Edit the template to provide your actual + /// configuration values, then use 'create environment' to create + /// the environment. + Template { + /// Output path for the template file (optional) + /// + /// If not provided, creates environment-template.json in the + /// current working directory. Parent directories will be created + /// automatically if they don't exist. + #[arg(value_name = "PATH")] + output_path: Option, + }, +} + +impl CreateAction { + /// Get the default template output path + #[must_use] + pub fn default_template_path() -> PathBuf { + PathBuf::from("environment-template.json") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_use_default_template_path() { + let default_path = CreateAction::default_template_path(); + assert_eq!(default_path, PathBuf::from("environment-template.json")); + } +} diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 45f7e55d..566b5aeb 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -11,7 +11,7 @@ pub mod args; pub mod commands; pub use args::GlobalArgs; -pub use commands::Commands; +pub use commands::{Commands, CreateAction}; /// Command-line interface for Torrust Tracker Deployer /// @@ -168,10 +168,11 @@ mod tests { } #[test] - fn it_should_parse_create_subcommand() { + fn it_should_parse_create_environment_subcommand() { let args = vec![ "torrust-tracker-deployer", "create", + "environment", "--env-file", "config.json", ]; @@ -179,29 +180,41 @@ mod tests { assert!(cli.command.is_some()); match cli.command.unwrap() { - Commands::Create { env_file } => { - assert_eq!(env_file, std::path::PathBuf::from("config.json")); - } + Commands::Create { action } => match action { + crate::presentation::cli::CreateAction::Environment { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("config.json")); + } + _ => panic!("Expected Environment action"), + }, Commands::Destroy { .. } => panic!("Expected Create command"), } } #[test] - fn it_should_parse_create_with_short_flag() { - let args = vec!["torrust-tracker-deployer", "create", "-f", "env.json"]; + fn it_should_parse_create_environment_with_short_flag() { + let args = vec![ + "torrust-tracker-deployer", + "create", + "environment", + "-f", + "env.json", + ]; let cli = Cli::try_parse_from(args).unwrap(); match cli.command.unwrap() { - Commands::Create { env_file } => { - assert_eq!(env_file, std::path::PathBuf::from("env.json")); - } + Commands::Create { action } => match action { + crate::presentation::cli::CreateAction::Environment { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("env.json")); + } + _ => panic!("Expected Environment action"), + }, Commands::Destroy { .. } => panic!("Expected Create command"), } } #[test] - fn it_should_require_env_file_parameter_for_create() { - let args = vec!["torrust-tracker-deployer", "create"]; + fn it_should_require_env_file_parameter_for_create_environment() { + let args = vec!["torrust-tracker-deployer", "create", "environment"]; let result = Cli::try_parse_from(args); assert!(result.is_err()); @@ -214,12 +227,13 @@ mod tests { } #[test] - fn it_should_parse_working_dir_global_option() { + fn it_should_parse_working_dir_global_option_with_create_environment() { let args = vec![ "torrust-tracker-deployer", "--working-dir", "/tmp/workspace", "create", + "environment", "--env-file", "config.json", ]; @@ -231,16 +245,25 @@ mod tests { ); match cli.command.unwrap() { - Commands::Create { env_file } => { - assert_eq!(env_file, std::path::PathBuf::from("config.json")); - } + Commands::Create { action } => match action { + crate::presentation::cli::CreateAction::Environment { env_file } => { + assert_eq!(env_file, std::path::PathBuf::from("config.json")); + } + _ => panic!("Expected Environment action"), + }, Commands::Destroy { .. } => panic!("Expected Create command"), } } #[test] fn it_should_use_default_working_dir_when_not_specified() { - let args = vec!["torrust-tracker-deployer", "create", "-f", "config.json"]; + let args = vec![ + "torrust-tracker-deployer", + "create", + "environment", + "-f", + "config.json", + ]; let cli = Cli::try_parse_from(args).unwrap(); assert_eq!(cli.global.working_dir, std::path::PathBuf::from(".")); @@ -255,10 +278,82 @@ mod tests { let error = result.unwrap_err(); assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + let help_text = error.to_string(); + assert!( + help_text.contains("environment") || help_text.contains("template"), + "Help text should mention subcommands: {help_text}" + ); + } + + #[test] + fn it_should_parse_create_template_without_path() { + let args = vec!["torrust-tracker-deployer", "create", "template"]; + let cli = Cli::try_parse_from(args).unwrap(); + + match cli.command.unwrap() { + Commands::Create { action } => match action { + crate::presentation::cli::CreateAction::Template { output_path } => { + assert!(output_path.is_none()); + } + _ => panic!("Expected Template action"), + }, + Commands::Destroy { .. } => panic!("Expected Create command"), + } + } + + #[test] + fn it_should_parse_create_template_with_custom_path() { + let args = vec![ + "torrust-tracker-deployer", + "create", + "template", + "./config/my-env.json", + ]; + let cli = Cli::try_parse_from(args).unwrap(); + + match cli.command.unwrap() { + Commands::Create { action } => match action { + crate::presentation::cli::CreateAction::Template { output_path } => { + assert_eq!( + output_path, + Some(std::path::PathBuf::from("./config/my-env.json")) + ); + } + _ => panic!("Expected Template action"), + }, + Commands::Destroy { .. } => panic!("Expected Create command"), + } + } + + #[test] + fn it_should_show_create_environment_help() { + let args = vec!["torrust-tracker-deployer", "create", "environment", "--help"]; + let result = Cli::try_parse_from(args); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + let help_text = error.to_string(); assert!( help_text.contains("env-file") || help_text.contains("configuration"), "Help text should mention env-file parameter" ); } + + #[test] + fn it_should_show_create_template_help() { + let args = vec!["torrust-tracker-deployer", "create", "template", "--help"]; + let result = Cli::try_parse_from(args); + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + + let help_text = error.to_string(); + assert!( + help_text.contains("template") || help_text.contains("placeholder"), + "Help text should mention template generation" + ); + } } diff --git a/src/presentation/commands/create/errors.rs b/src/presentation/commands/create/errors.rs index c5a56bcf..8be2f9a8 100644 --- a/src/presentation/commands/create/errors.rs +++ b/src/presentation/commands/create/errors.rs @@ -65,6 +65,14 @@ pub enum CreateSubcommandError { #[source] CreateCommandHandlerError, ), + + /// Template generation failed + #[error("Template generation failed")] + TemplateGenerationFailed( + /// Underlying template generation error from domain layer + #[source] + crate::domain::config::CreateConfigError, + ), } impl CreateSubcommandError { @@ -100,7 +108,7 @@ impl CreateSubcommandError { 4. Use absolute paths or paths relative to current directory Example: - torrust-tracker-deployer create --env-file ./config/environment.json + torrust-tracker-deployer create environment --env-file ./config/environment.json For more information about configuration format, see the documentation." } @@ -144,6 +152,7 @@ For more information, see the configuration documentation." }, Self::ConfigValidationFailed(inner) => inner.help(), Self::CommandFailed(inner) => inner.help(), + Self::TemplateGenerationFailed(inner) => inner.help(), } } } diff --git a/src/presentation/commands/create/mod.rs b/src/presentation/commands/create/mod.rs index dcb4a722..9d8a44eb 100644 --- a/src/presentation/commands/create/mod.rs +++ b/src/presentation/commands/create/mod.rs @@ -42,4 +42,4 @@ mod tests; // Re-export commonly used types for convenience pub use config_loader::ConfigLoader; pub use errors::{ConfigFormat, CreateSubcommandError}; -pub use subcommand::handle; +pub use subcommand::handle_create_command; diff --git a/src/presentation/commands/create/subcommand.rs b/src/presentation/commands/create/subcommand.rs index cc6a426b..e304b87f 100644 --- a/src/presentation/commands/create/subcommand.rs +++ b/src/presentation/commands/create/subcommand.rs @@ -4,20 +4,106 @@ //! including configuration file loading, argument processing, user interaction, //! and command execution. -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use crate::application::command_handlers::create::CreateCommandHandler; use crate::domain::config::EnvironmentCreationConfig; use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::cli::commands::CreateAction; use crate::presentation::user_output::{UserOutput, VerbosityLevel}; use crate::shared::{Clock, SystemClock}; use super::config_loader::ConfigLoader; use super::errors::CreateSubcommandError; -/// Handle the create subcommand +/// Handle the create command with its subcommands +/// +/// This function routes between different create subcommands (environment or template). +/// +/// # Arguments +/// +/// * `action` - The create action to perform (environment creation or template generation) +/// * `working_dir` - Root directory for environment data storage +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or a `CreateSubcommandError` on failure. +/// +/// # Errors +/// +/// Returns an error if the subcommand execution fails. +#[allow(clippy::result_large_err)] // Error contains detailed context for user guidance +pub fn handle_create_command( + action: CreateAction, + working_dir: &Path, +) -> Result<(), CreateSubcommandError> { + match action { + CreateAction::Environment { env_file } => handle_environment_creation(&env_file, working_dir), + CreateAction::Template { output_path } => { + let template_path = output_path.unwrap_or_else(CreateAction::default_template_path); + handle_template_generation(&template_path) + } + } +} + +/// Handle template generation +/// +/// This function generates a configuration template file with placeholder values +/// that users can edit to create their own environment configurations. +/// +/// # Arguments +/// +/// * `output_path` - Path where the template file should be created +/// +/// # Returns +/// +/// Returns `Ok(())` on success, or a `CreateSubcommandError` on failure. +/// +/// # Errors +/// +/// Returns an error if template file creation fails. +#[allow(clippy::result_large_err)] // Error contains detailed context for user guidance +fn handle_template_generation(output_path: &PathBuf) -> Result<(), CreateSubcommandError> { + // Create user output for progress messages + let mut output = UserOutput::new(VerbosityLevel::Normal); + + output.progress("Generating configuration template..."); + + // Call existing domain method - template generation implemented in PR #48 + // This is async, so we need to use tokio runtime + tokio::runtime::Runtime::new() + .expect("Failed to create tokio runtime") + .block_on(async { + EnvironmentCreationConfig::generate_template_file(output_path) + .await + .map_err(CreateSubcommandError::TemplateGenerationFailed) + })?; + + output.success(&format!( + "Configuration template generated: {}", + output_path.display() + )); + println!(); + println!("Next steps:"); + println!("1. Edit the template file and replace placeholder values:"); + println!(" - REPLACE_WITH_ENVIRONMENT_NAME: Choose a unique environment name (e.g., 'dev', 'staging')"); + println!(" - REPLACE_WITH_SSH_PRIVATE_KEY_PATH: Path to your SSH private key"); + println!(" - REPLACE_WITH_SSH_PUBLIC_KEY_PATH: Path to your SSH public key"); + println!("2. Review default values:"); + println!(" - username: 'torrust' (can be changed if needed)"); + println!(" - port: 22 (standard SSH port)"); + println!("3. Create the environment:"); + println!( + " torrust-tracker-deployer create environment --env-file {}", + output_path.display() + ); + + Ok(()) +} + +/// Handle environment creation from configuration file /// /// This function orchestrates the environment creation workflow from the /// presentation layer by: @@ -46,25 +132,11 @@ use super::errors::CreateSubcommandError; /// This function will return an error if the configuration file cannot be /// loaded, parsed, validated, or if the create command execution fails. /// All errors include detailed context and actionable troubleshooting guidance. -/// -/// # Example -/// -/// ```rust,no_run -/// use std::path::{Path, PathBuf}; -/// use torrust_tracker_deployer_lib::presentation::commands::create; -/// -/// let result = create::handle( -/// Path::new("config/environment.json"), -/// &PathBuf::from(".") -/// ); -/// -/// if let Err(e) = result { -/// eprintln!("Create failed: {e}"); -/// eprintln!("Help: {}", e.help()); -/// } -/// ``` #[allow(clippy::result_large_err)] // Error contains detailed context for user guidance -pub fn handle(env_file: &Path, working_dir: &Path) -> Result<(), CreateSubcommandError> { +fn handle_environment_creation( + env_file: &Path, + working_dir: &Path, +) -> Result<(), CreateSubcommandError> { // Create user output with default stdout/stderr channels let mut output = UserOutput::new(VerbosityLevel::Normal); @@ -152,7 +224,7 @@ mod tests { fs::write(&config_path, config_json).unwrap(); let working_dir = temp_dir.path(); - let result = handle(&config_path, working_dir); + let result = handle_environment_creation(&config_path, working_dir); assert!( result.is_ok(), @@ -176,7 +248,7 @@ mod tests { let config_path = temp_dir.path().join("nonexistent.json"); let working_dir = temp_dir.path(); - let result = handle(&config_path, working_dir); + let result = handle_environment_creation(&config_path, working_dir); assert!(result.is_err()); match result.unwrap_err() { @@ -196,7 +268,7 @@ mod tests { fs::write(&config_path, r#"{"invalid json"#).unwrap(); let working_dir = temp_dir.path(); - let result = handle(&config_path, working_dir); + let result = handle_environment_creation(&config_path, working_dir); assert!(result.is_err()); match result.unwrap_err() { @@ -226,11 +298,11 @@ mod tests { let working_dir = temp_dir.path(); // Create environment first time - let result1 = handle(&config_path, working_dir); + let result1 = handle_environment_creation(&config_path, working_dir); assert!(result1.is_ok(), "First create should succeed"); // Try to create same environment again - let result2 = handle(&config_path, working_dir); + let result2 = handle_environment_creation(&config_path, working_dir); assert!(result2.is_err(), "Second create should fail"); match result2.unwrap_err() { @@ -260,7 +332,7 @@ mod tests { }"#; fs::write(&config_path, config_json).unwrap(); - let result = handle(&config_path, &custom_working_dir); + let result = handle_environment_creation(&config_path, &custom_working_dir); assert!(result.is_ok(), "Should create in custom working dir"); diff --git a/src/presentation/commands/create/tests/integration.rs b/src/presentation/commands/create/tests/integration.rs index 83b24f61..e0d016d4 100644 --- a/src/presentation/commands/create/tests/integration.rs +++ b/src/presentation/commands/create/tests/integration.rs @@ -5,16 +5,28 @@ use tempfile::TempDir; +use crate::presentation::cli::CreateAction; use crate::presentation::commands::create; use super::fixtures; +/// Helper function to call the environment creation handler +fn handle_environment_creation( + config_path: &std::path::Path, + working_dir: &std::path::Path, +) -> Result<(), create::CreateSubcommandError> { + let action = CreateAction::Environment { + env_file: config_path.to_path_buf(), + }; + create::handle_create_command(action, working_dir) +} + #[test] fn it_should_create_environment_from_valid_config() { let temp_dir = TempDir::new().unwrap(); let config_path = fixtures::create_valid_config(temp_dir.path(), "integration-test-env"); - let result = create::handle(&config_path, temp_dir.path()); + let result = handle_environment_creation(&config_path, temp_dir.path()); assert!( result.is_ok(), @@ -39,7 +51,7 @@ fn it_should_reject_nonexistent_config_file() { let temp_dir = TempDir::new().unwrap(); let nonexistent_path = temp_dir.path().join("nonexistent.json"); - let result = create::handle(&nonexistent_path, temp_dir.path()); + let result = handle_environment_creation(&nonexistent_path, temp_dir.path()); assert!(result.is_err(), "Should fail for nonexistent file"); match result.unwrap_err() { @@ -55,7 +67,7 @@ fn it_should_reject_invalid_json() { let temp_dir = TempDir::new().unwrap(); let config_path = fixtures::create_invalid_json_config(temp_dir.path()); - let result = create::handle(&config_path, temp_dir.path()); + let result = handle_environment_creation(&config_path, temp_dir.path()); assert!(result.is_err(), "Should fail for invalid JSON"); match result.unwrap_err() { @@ -71,7 +83,7 @@ fn it_should_reject_invalid_environment_name() { let temp_dir = TempDir::new().unwrap(); let config_path = fixtures::create_config_with_invalid_name(temp_dir.path()); - let result = create::handle(&config_path, temp_dir.path()); + let result = handle_environment_creation(&config_path, temp_dir.path()); assert!(result.is_err(), "Should fail for invalid environment name"); match result.unwrap_err() { @@ -87,7 +99,7 @@ fn it_should_reject_missing_ssh_keys() { let temp_dir = TempDir::new().unwrap(); let config_path = fixtures::create_config_with_missing_keys(temp_dir.path()); - let result = create::handle(&config_path, temp_dir.path()); + let result = handle_environment_creation(&config_path, temp_dir.path()); assert!(result.is_err(), "Should fail for missing SSH keys"); match result.unwrap_err() { @@ -104,11 +116,11 @@ fn it_should_reject_duplicate_environment() { let config_path = fixtures::create_valid_config(temp_dir.path(), "duplicate-test-env"); // Create environment first time - let result1 = create::handle(&config_path, temp_dir.path()); + let result1 = handle_environment_creation(&config_path, temp_dir.path()); assert!(result1.is_ok(), "First create should succeed"); // Try to create same environment again - let result2 = create::handle(&config_path, temp_dir.path()); + let result2 = handle_environment_creation(&config_path, temp_dir.path()); assert!(result2.is_err(), "Second create should fail"); match result2.unwrap_err() { @@ -127,7 +139,7 @@ fn it_should_create_environment_in_custom_working_dir() { let config_path = fixtures::create_valid_config(temp_dir.path(), "custom-dir-env"); - let result = create::handle(&config_path, &custom_working_dir); + let result = handle_environment_creation(&config_path, &custom_working_dir); assert!(result.is_ok(), "Should create in custom working dir"); @@ -147,7 +159,7 @@ fn it_should_provide_help_for_all_error_types() { // Test ConfigFileNotFound let nonexistent = temp_dir.path().join("nonexistent.json"); - if let Err(e) = create::handle(&nonexistent, temp_dir.path()) { + if let Err(e) = handle_environment_creation(&nonexistent, temp_dir.path()) { let help = e.help(); assert!(!help.is_empty()); assert!(help.contains("File Not Found") || help.contains("Check that the file path")); @@ -155,7 +167,7 @@ fn it_should_provide_help_for_all_error_types() { // Test ConfigParsingFailed let invalid_json = fixtures::create_invalid_json_config(temp_dir.path()); - if let Err(e) = create::handle(&invalid_json, temp_dir.path()) { + if let Err(e) = handle_environment_creation(&invalid_json, temp_dir.path()) { let help = e.help(); assert!(!help.is_empty()); assert!(help.contains("JSON") || help.contains("syntax")); @@ -163,7 +175,7 @@ fn it_should_provide_help_for_all_error_types() { // Test ConfigValidationFailed let invalid_name = fixtures::create_config_with_invalid_name(temp_dir.path()); - if let Err(e) = create::handle(&invalid_name, temp_dir.path()) { + if let Err(e) = handle_environment_creation(&invalid_name, temp_dir.path()) { let help = e.help(); assert!(!help.is_empty()); // Should delegate to config error help diff --git a/src/presentation/commands/create/tests/mod.rs b/src/presentation/commands/create/tests/mod.rs index 7014c6ae..87f906d9 100644 --- a/src/presentation/commands/create/tests/mod.rs +++ b/src/presentation/commands/create/tests/mod.rs @@ -5,3 +5,4 @@ pub mod fixtures; pub mod integration; +pub mod template; diff --git a/src/presentation/commands/create/tests/template.rs b/src/presentation/commands/create/tests/template.rs new file mode 100644 index 00000000..6a580bd8 --- /dev/null +++ b/src/presentation/commands/create/tests/template.rs @@ -0,0 +1,115 @@ +//! Integration Tests for Template Generation + +use tempfile::TempDir; + +use crate::presentation::cli::CreateAction; +use crate::presentation::commands::create; + +#[test] +fn it_should_generate_template_with_default_path() { + let temp_dir = TempDir::new().unwrap(); + + // Change to temp directory so template is created there + let original_dir = std::env::current_dir().unwrap(); + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let action = CreateAction::Template { + output_path: None, + }; + + let result = create::handle_create_command(action, temp_dir.path()); + + // Restore original directory + std::env::set_current_dir(original_dir).unwrap(); + + assert!(result.is_ok(), "Template generation should succeed: {:?}", result.err()); + + // Verify file exists at default path + let template_path = temp_dir.path().join("environment-template.json"); + assert!( + template_path.exists(), + "Template file should be created at: {}", + template_path.display() + ); + + // Verify file content is valid JSON + let content = std::fs::read_to_string(&template_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content) + .expect("Template should be valid JSON"); + + // Verify structure + assert!(parsed["environment"]["name"].is_string()); + assert!(parsed["ssh_credentials"]["private_key_path"].is_string()); + assert_eq!(parsed["ssh_credentials"]["username"], "torrust"); + assert_eq!(parsed["ssh_credentials"]["port"], 22); +} + +#[test] +fn it_should_generate_template_with_custom_path() { + let temp_dir = TempDir::new().unwrap(); + let custom_path = temp_dir.path().join("config").join("my-env.json"); + + let action = CreateAction::Template { + output_path: Some(custom_path.clone()), + }; + + let result = create::handle_create_command(action, temp_dir.path()); + + assert!(result.is_ok(), "Template generation should succeed"); + + // Verify file exists at custom path + assert!( + custom_path.exists(), + "Template file should be created at custom path: {}", + custom_path.display() + ); + + // Verify parent directory was created + assert!(custom_path.parent().unwrap().exists()); +} + +#[test] +fn it_should_generate_valid_json_template() { + let temp_dir = TempDir::new().unwrap(); + let template_path = temp_dir.path().join("test.json"); + + let action = CreateAction::Template { + output_path: Some(template_path.clone()), + }; + + create::handle_create_command(action, temp_dir.path()).unwrap(); + + // Read and parse the generated template + let content = std::fs::read_to_string(&template_path).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + // Verify structure matches expectations + assert!(parsed.is_object()); + assert!(parsed["environment"].is_object()); + assert!(parsed["ssh_credentials"].is_object()); + + // Verify placeholder values + assert_eq!(parsed["environment"]["name"], "REPLACE_WITH_ENVIRONMENT_NAME"); + assert_eq!(parsed["ssh_credentials"]["private_key_path"], "REPLACE_WITH_SSH_PRIVATE_KEY_PATH"); + assert_eq!(parsed["ssh_credentials"]["public_key_path"], "REPLACE_WITH_SSH_PUBLIC_KEY_PATH"); + + // Verify default values + assert_eq!(parsed["ssh_credentials"]["username"], "torrust"); + assert_eq!(parsed["ssh_credentials"]["port"], 22); +} + +#[test] +fn it_should_create_parent_directories() { + let temp_dir = TempDir::new().unwrap(); + let deep_path = temp_dir.path().join("a").join("b").join("c").join("template.json"); + + let action = CreateAction::Template { + output_path: Some(deep_path.clone()), + }; + + let result = create::handle_create_command(action, temp_dir.path()); + + assert!(result.is_ok(), "Should create parent directories"); + assert!(deep_path.exists(), "Template should be created at deep path"); + assert!(deep_path.parent().unwrap().exists(), "Parent directories should exist"); +} diff --git a/src/presentation/commands/mod.rs b/src/presentation/commands/mod.rs index 473cbf7d..f2489c9c 100644 --- a/src/presentation/commands/mod.rs +++ b/src/presentation/commands/mod.rs @@ -25,6 +25,7 @@ pub mod destroy; /// # Arguments /// /// * `command` - The parsed CLI command to execute +/// * `working_dir` - Working directory for environment data storage /// /// # Returns /// @@ -54,8 +55,8 @@ pub mod destroy; /// ``` pub fn execute(command: Commands, working_dir: &std::path::Path) -> Result<(), CommandError> { match command { - Commands::Create { env_file } => { - create::handle(&env_file, working_dir)?; + Commands::Create { action } => { + create::handle_create_command(action, working_dir)?; Ok(()) } Commands::Destroy { environment } => { From 6e192057b2a9f14d8dbd0fa937f40757fda8fc10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 08:47:08 +0000 Subject: [PATCH 3/4] style: apply rustfmt formatting Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/cli/mod.rs | 7 ++- .../commands/create/subcommand.rs | 8 +-- .../commands/create/tests/template.rs | 54 +++++++++++++------ 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 566b5aeb..cd05aacb 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -327,7 +327,12 @@ mod tests { #[test] fn it_should_show_create_environment_help() { - let args = vec!["torrust-tracker-deployer", "create", "environment", "--help"]; + let args = vec![ + "torrust-tracker-deployer", + "create", + "environment", + "--help", + ]; let result = Cli::try_parse_from(args); assert!(result.is_err()); diff --git a/src/presentation/commands/create/subcommand.rs b/src/presentation/commands/create/subcommand.rs index e304b87f..e8c33e86 100644 --- a/src/presentation/commands/create/subcommand.rs +++ b/src/presentation/commands/create/subcommand.rs @@ -4,7 +4,7 @@ //! including configuration file loading, argument processing, user interaction, //! and command execution. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -40,7 +40,9 @@ pub fn handle_create_command( working_dir: &Path, ) -> Result<(), CreateSubcommandError> { match action { - CreateAction::Environment { env_file } => handle_environment_creation(&env_file, working_dir), + CreateAction::Environment { env_file } => { + handle_environment_creation(&env_file, working_dir) + } CreateAction::Template { output_path } => { let template_path = output_path.unwrap_or_else(CreateAction::default_template_path); handle_template_generation(&template_path) @@ -65,7 +67,7 @@ pub fn handle_create_command( /// /// Returns an error if template file creation fails. #[allow(clippy::result_large_err)] // Error contains detailed context for user guidance -fn handle_template_generation(output_path: &PathBuf) -> Result<(), CreateSubcommandError> { +fn handle_template_generation(output_path: &Path) -> Result<(), CreateSubcommandError> { // Create user output for progress messages let mut output = UserOutput::new(VerbosityLevel::Normal); diff --git a/src/presentation/commands/create/tests/template.rs b/src/presentation/commands/create/tests/template.rs index 6a580bd8..a4a725c7 100644 --- a/src/presentation/commands/create/tests/template.rs +++ b/src/presentation/commands/create/tests/template.rs @@ -8,21 +8,23 @@ use crate::presentation::commands::create; #[test] fn it_should_generate_template_with_default_path() { let temp_dir = TempDir::new().unwrap(); - + // Change to temp directory so template is created there let original_dir = std::env::current_dir().unwrap(); std::env::set_current_dir(temp_dir.path()).unwrap(); - let action = CreateAction::Template { - output_path: None, - }; + let action = CreateAction::Template { output_path: None }; let result = create::handle_create_command(action, temp_dir.path()); - + // Restore original directory std::env::set_current_dir(original_dir).unwrap(); - assert!(result.is_ok(), "Template generation should succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "Template generation should succeed: {:?}", + result.err() + ); // Verify file exists at default path let template_path = temp_dir.path().join("environment-template.json"); @@ -34,8 +36,8 @@ fn it_should_generate_template_with_default_path() { // Verify file content is valid JSON let content = std::fs::read_to_string(&template_path).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(&content) - .expect("Template should be valid JSON"); + let parsed: serde_json::Value = + serde_json::from_str(&content).expect("Template should be valid JSON"); // Verify structure assert!(parsed["environment"]["name"].is_string()); @@ -87,12 +89,21 @@ fn it_should_generate_valid_json_template() { assert!(parsed.is_object()); assert!(parsed["environment"].is_object()); assert!(parsed["ssh_credentials"].is_object()); - + // Verify placeholder values - assert_eq!(parsed["environment"]["name"], "REPLACE_WITH_ENVIRONMENT_NAME"); - assert_eq!(parsed["ssh_credentials"]["private_key_path"], "REPLACE_WITH_SSH_PRIVATE_KEY_PATH"); - assert_eq!(parsed["ssh_credentials"]["public_key_path"], "REPLACE_WITH_SSH_PUBLIC_KEY_PATH"); - + assert_eq!( + parsed["environment"]["name"], + "REPLACE_WITH_ENVIRONMENT_NAME" + ); + assert_eq!( + parsed["ssh_credentials"]["private_key_path"], + "REPLACE_WITH_SSH_PRIVATE_KEY_PATH" + ); + assert_eq!( + parsed["ssh_credentials"]["public_key_path"], + "REPLACE_WITH_SSH_PUBLIC_KEY_PATH" + ); + // Verify default values assert_eq!(parsed["ssh_credentials"]["username"], "torrust"); assert_eq!(parsed["ssh_credentials"]["port"], 22); @@ -101,7 +112,12 @@ fn it_should_generate_valid_json_template() { #[test] fn it_should_create_parent_directories() { let temp_dir = TempDir::new().unwrap(); - let deep_path = temp_dir.path().join("a").join("b").join("c").join("template.json"); + let deep_path = temp_dir + .path() + .join("a") + .join("b") + .join("c") + .join("template.json"); let action = CreateAction::Template { output_path: Some(deep_path.clone()), @@ -110,6 +126,12 @@ fn it_should_create_parent_directories() { let result = create::handle_create_command(action, temp_dir.path()); assert!(result.is_ok(), "Should create parent directories"); - assert!(deep_path.exists(), "Template should be created at deep path"); - assert!(deep_path.parent().unwrap().exists(), "Parent directories should exist"); + assert!( + deep_path.exists(), + "Template should be created at deep path" + ); + assert!( + deep_path.parent().unwrap().exists(), + "Parent directories should exist" + ); } From 48dd8a09710238c60fe17206ad92830bedb0f8cc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 09:05:34 +0000 Subject: [PATCH 4/4] fix: resolve clippy linting warnings - Merge identical match arms in error help method - Replace wildcard patterns with explicit variants in test assertions Co-authored-by: josecelano <58816+josecelano@users.noreply.github.com> --- src/presentation/cli/mod.rs | 20 +++++++++++++++----- src/presentation/commands/create/errors.rs | 5 +++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index cd05aacb..4cedbfd9 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -184,7 +184,9 @@ mod tests { crate::presentation::cli::CreateAction::Environment { env_file } => { assert_eq!(env_file, std::path::PathBuf::from("config.json")); } - _ => panic!("Expected Environment action"), + crate::presentation::cli::CreateAction::Template { .. } => { + panic!("Expected Environment action") + } }, Commands::Destroy { .. } => panic!("Expected Create command"), } @@ -206,7 +208,9 @@ mod tests { crate::presentation::cli::CreateAction::Environment { env_file } => { assert_eq!(env_file, std::path::PathBuf::from("env.json")); } - _ => panic!("Expected Environment action"), + crate::presentation::cli::CreateAction::Template { .. } => { + panic!("Expected Environment action") + } }, Commands::Destroy { .. } => panic!("Expected Create command"), } @@ -249,7 +253,9 @@ mod tests { crate::presentation::cli::CreateAction::Environment { env_file } => { assert_eq!(env_file, std::path::PathBuf::from("config.json")); } - _ => panic!("Expected Environment action"), + crate::presentation::cli::CreateAction::Template { .. } => { + panic!("Expected Environment action") + } }, Commands::Destroy { .. } => panic!("Expected Create command"), } @@ -295,7 +301,9 @@ mod tests { crate::presentation::cli::CreateAction::Template { output_path } => { assert!(output_path.is_none()); } - _ => panic!("Expected Template action"), + crate::presentation::cli::CreateAction::Environment { .. } => { + panic!("Expected Template action") + } }, Commands::Destroy { .. } => panic!("Expected Create command"), } @@ -319,7 +327,9 @@ mod tests { Some(std::path::PathBuf::from("./config/my-env.json")) ); } - _ => panic!("Expected Template action"), + crate::presentation::cli::CreateAction::Environment { .. } => { + panic!("Expected Template action") + } }, Commands::Destroy { .. } => panic!("Expected Create command"), } diff --git a/src/presentation/commands/create/errors.rs b/src/presentation/commands/create/errors.rs index 8be2f9a8..b2636e95 100644 --- a/src/presentation/commands/create/errors.rs +++ b/src/presentation/commands/create/errors.rs @@ -150,9 +150,10 @@ Example valid configuration: For more information, see the configuration documentation." } }, - Self::ConfigValidationFailed(inner) => inner.help(), + Self::ConfigValidationFailed(inner) | Self::TemplateGenerationFailed(inner) => { + inner.help() + } Self::CommandFailed(inner) => inner.help(), - Self::TemplateGenerationFailed(inner) => inner.help(), } } }