diff --git a/src/application/command_handlers/list/errors.rs b/src/application/command_handlers/list/errors.rs new file mode 100644 index 00000000..3dd3f58f --- /dev/null +++ b/src/application/command_handlers/list/errors.rs @@ -0,0 +1,141 @@ +//! Error types for list command handler + +use std::path::PathBuf; + +use crate::shared::error::kind::ErrorKind; +use crate::shared::error::traceable::Traceable; + +/// Comprehensive error type for the `ListCommandHandler` +#[derive(Debug, thiserror::Error)] +pub enum ListCommandHandlerError { + /// Data directory not found + #[error("Data directory not found: '{path}'")] + DataDirectoryNotFound { path: PathBuf }, + + /// Permission denied accessing directory + #[error("Permission denied accessing directory: '{path}'")] + PermissionDenied { path: PathBuf }, + + /// Failed to scan environments directory + #[error("Failed to scan environments directory: {message}")] + ScanError { message: String }, +} + +impl Traceable for ListCommandHandlerError { + fn trace_format(&self) -> String { + match self { + Self::DataDirectoryNotFound { path } => { + format!( + "ListCommandHandlerError: Data directory not found - '{}'", + path.display() + ) + } + Self::ScanError { message } => { + format!("ListCommandHandlerError: Scan error - {message}") + } + Self::PermissionDenied { path } => { + format!( + "ListCommandHandlerError: Permission denied - '{}'", + path.display() + ) + } + } + } + + fn trace_source(&self) -> Option<&dyn Traceable> { + None + } + + fn error_kind(&self) -> ErrorKind { + match self { + Self::DataDirectoryNotFound { .. } + | Self::ScanError { .. } + | Self::PermissionDenied { .. } => ErrorKind::FileSystem, + } + } +} + +impl ListCommandHandlerError { + /// Provides detailed troubleshooting guidance for this error + /// + /// # Example + /// + /// ``` + /// use std::path::PathBuf; + /// use torrust_tracker_deployer_lib::application::command_handlers::list::errors::ListCommandHandlerError; + /// + /// let error = ListCommandHandlerError::DataDirectoryNotFound { + /// path: PathBuf::from("./data"), + /// }; + /// + /// let help = error.help(); + /// assert!(help.contains("Verify current directory")); + /// ``` + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DataDirectoryNotFound { .. } => { + "Data Directory Not Found - Troubleshooting: + +1. Verify current directory: + - Run: pwd + - Expected: Your deployer workspace directory + +2. Check if data directory exists: + - Run: ls -la data/ + - Should contain environment subdirectories + +3. Create environment first: + - Run: torrust-tracker-deployer create environment --env-file + +Common causes: +- Running from the wrong directory +- No environments have been created yet +- Data directory was moved or deleted + +For more information, see docs/user-guide/commands.md" + } + Self::ScanError { .. } => { + "Scan Error - Troubleshooting: + +1. Check directory permissions: + - Run: ls -ld data/ + - Should have read permission (r--) + +2. Verify filesystem health: + - Check for disk errors or filesystem issues + +3. Try running with elevated permissions if needed + +Common causes: +- File system errors +- Corrupted directory entries +- Network filesystem issues + +For more information, see docs/user-guide/commands.md" + } + Self::PermissionDenied { .. } => { + "Permission Denied - Troubleshooting: + +1. Check directory permissions: + - Run: ls -ld data/ + - Should have read permission (r--) + +2. Check file permissions: + - Run: ls -l data/*/environment.json + - Should have read permission (r--) + +3. Fix permissions if needed: + - Run: chmod +rx data/ + - Run: chmod +r data/*/environment.json + +Common causes: +- File created by different user +- Restrictive umask settings +- SELinux or AppArmor restrictions + +For more information, see docs/user-guide/commands.md" + } + } + } +} diff --git a/src/application/command_handlers/list/handler.rs b/src/application/command_handlers/list/handler.rs new file mode 100644 index 00000000..effef623 --- /dev/null +++ b/src/application/command_handlers/list/handler.rs @@ -0,0 +1,224 @@ +//! List command handler implementation +//! +//! **Purpose**: List all environments in the deployment workspace +//! +//! This handler scans the data directory for environments and extracts +//! summary information for display. It is a read-only operation that +//! does not modify any state or make any network calls. +//! +//! ## Design Strategy +//! +//! The list command scans local storage for environments: +//! +//! 1. **Directory Scan**: Find all environment directories in data/ +//! 2. **Load Summaries**: Extract lightweight info from each environment +//! 3. **Graceful Degradation**: Continue on per-environment errors +//! 4. **Report Failures**: Include failed environments in the result +//! +//! ## Design Rationale +//! +//! This command works directly with the data directory rather than through +//! the repository abstraction because: +//! +//! - Need to enumerate all environments (repository has no list method) +//! - Must handle partially corrupted data gracefully +//! - Performance: lightweight scanning without full deserialization where possible + +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use tracing::{instrument, warn}; + +use super::errors::ListCommandHandlerError; +use super::info::{EnvironmentList, EnvironmentSummary}; +use crate::domain::environment::name::EnvironmentName; +use crate::domain::environment::repository::EnvironmentRepository; +use crate::domain::environment::state::AnyEnvironmentState; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; + +/// `ListCommandHandler` scans and lists all environments +/// +/// **Purpose**: Read-only enumeration of environments in the workspace +/// +/// This handler scans the data directory and extracts summary information +/// for each environment found. It handles partial failures gracefully, +/// continuing to list valid environments even when some fail to load. +/// +/// ## Error Handling +/// +/// - **Empty directory**: Returns empty list (not an error) +/// - **Per-environment errors**: Collected and reported, don't stop listing +/// - **Fatal errors**: Directory not found, permission denied +pub struct ListCommandHandler { + repository_factory: Arc, + data_directory: Arc, +} + +impl ListCommandHandler { + /// Create a new `ListCommandHandler` + #[must_use] + pub fn new(repository_factory: Arc, data_directory: Arc) -> Self { + Self { + repository_factory, + data_directory, + } + } + + /// Execute the list command workflow + /// + /// Scans the data directory and extracts summary information for all + /// environments found. + /// + /// # Returns + /// + /// * `Ok(EnvironmentList)` - List of environment summaries (may include failures) + /// * `Err(ListCommandHandlerError)` - If the data directory cannot be accessed + /// + /// # Errors + /// + /// Returns an error if: + /// * Data directory does not exist + /// * Permission denied accessing data directory + #[instrument( + name = "list_command", + skip_all, + fields( + command_type = "list", + data_directory = %self.data_directory.display() + ) + )] + pub fn execute(&self) -> Result { + // Verify data directory exists + if !self.data_directory.exists() { + return Err(ListCommandHandlerError::DataDirectoryNotFound { + path: self.data_directory.to_path_buf(), + }); + } + + // Scan for environment directories + let env_dirs = self.scan_environment_directories()?; + + // Load summaries for each environment + let (summaries, failures) = self.load_environment_summaries(&env_dirs); + + Ok(EnvironmentList::new( + summaries, + failures, + self.data_directory.to_string_lossy().to_string(), + )) + } + + /// Scan the data directory for environment subdirectories + fn scan_environment_directories(&self) -> Result, ListCommandHandlerError> { + let entries = fs::read_dir(&self.data_directory).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + ListCommandHandlerError::PermissionDenied { + path: self.data_directory.to_path_buf(), + } + } else { + ListCommandHandlerError::ScanError { + message: e.to_string(), + } + } + })?; + + let mut env_names = Vec::new(); + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + warn!("Failed to read directory entry: {e}"); + continue; + } + }; + + // Only consider directories (environments are stored in subdirectories) + let path = entry.path(); + if !path.is_dir() { + continue; + } + + // Check if this directory contains an environment.json file + let env_file = path.join("environment.json"); + if !env_file.exists() { + continue; + } + + // Extract directory name as environment name + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + env_names.push(name.to_string()); + } + } + + Ok(env_names) + } + + /// Load summaries for all discovered environments + /// + /// Returns a tuple of (successful summaries, failed environments) + fn load_environment_summaries( + &self, + env_names: &[String], + ) -> (Vec, Vec<(String, String)>) { + let mut summaries = Vec::new(); + let mut failures = Vec::new(); + + for name in env_names { + match self.load_environment_summary(name) { + Ok(summary) => summaries.push(summary), + Err(error) => { + warn!( + environment = %name, + error = %error, + "Failed to load environment" + ); + failures.push((name.clone(), error)); + } + } + } + + (summaries, failures) + } + + /// Load summary for a single environment + fn load_environment_summary(&self, name: &str) -> Result { + // Validate environment name + let env_name = EnvironmentName::new(name.to_string()) + .map_err(|e| format!("Invalid environment name: {e}"))?; + + // Create repository for the base data directory + // (repository internally handles {base_dir}/{env_name}/environment.json) + let repository = self + .repository_factory + .create(self.data_directory.to_path_buf()); + + // Load environment from repository + let any_env = Self::load_environment(&repository, &env_name)?; + + // Extract summary + Ok(Self::extract_summary(&any_env)) + } + + /// Load environment from repository + fn load_environment( + repository: &Arc, + env_name: &EnvironmentName, + ) -> Result { + repository + .load(env_name) + .map_err(|e| format!("Failed to load environment: {e}"))? + .ok_or_else(|| format!("Environment '{env_name}' not found in repository")) + } + + /// Extract summary information from an environment + fn extract_summary(any_env: &AnyEnvironmentState) -> EnvironmentSummary { + let name = any_env.name().to_string(); + let state = any_env.state_display_name().to_string(); + let provider = any_env.provider_display_name().to_string(); + let created_at = any_env.created_at().to_rfc3339(); + + EnvironmentSummary::new(name, state, provider, created_at) + } +} diff --git a/src/application/command_handlers/list/info.rs b/src/application/command_handlers/list/info.rs new file mode 100644 index 00000000..7233b026 --- /dev/null +++ b/src/application/command_handlers/list/info.rs @@ -0,0 +1,168 @@ +//! Data Transfer Objects for environment list display +//! +//! These DTOs encapsulate the lightweight information extracted from environments +//! for list display purposes. They provide a clean separation between the domain +//! model and the presentation layer. + +/// Lightweight environment summary for list display +/// +/// This DTO contains minimal information about an environment suitable for +/// display in a list view. It is designed to be fast to extract and small +/// in memory footprint. +#[derive(Debug, Clone)] +pub struct EnvironmentSummary { + /// Name of the environment + pub name: String, + + /// Current state of the environment (e.g., "Created", "Provisioned", "Running", "Destroyed") + pub state: String, + + /// Provider name (e.g., "LXD", "Hetzner Cloud") + pub provider: String, + + /// When the environment was created (ISO 8601 format) + pub created_at: String, +} + +impl EnvironmentSummary { + /// Create a new `EnvironmentSummary` + #[must_use] + pub fn new(name: String, state: String, provider: String, created_at: String) -> Self { + Self { + name, + state, + provider, + created_at, + } + } +} + +/// Collection of environment summaries with metadata +/// +/// This DTO wraps a list of environment summaries along with metadata +/// about the listing operation, including any partial failures encountered. +#[derive(Debug, Clone)] +pub struct EnvironmentList { + /// Successfully loaded environment summaries + pub environments: Vec, + + /// Total count of environments found + pub total_count: usize, + + /// Environments that failed to load (name, error message) + pub failed_environments: Vec<(String, String)>, + + /// Path to the data directory that was scanned + pub data_directory: String, +} + +impl EnvironmentList { + /// Create a new `EnvironmentList` + #[must_use] + pub fn new( + environments: Vec, + failed_environments: Vec<(String, String)>, + data_directory: String, + ) -> Self { + let total_count = environments.len(); + Self { + environments, + total_count, + failed_environments, + data_directory, + } + } + + /// Check if the list is empty (no environments found) + #[must_use] + pub fn is_empty(&self) -> bool { + self.environments.is_empty() && self.failed_environments.is_empty() + } + + /// Check if there were any partial failures + #[must_use] + pub fn has_failures(&self) -> bool { + !self.failed_environments.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_should_create_environment_summary() { + let summary = EnvironmentSummary::new( + "test-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + ); + + assert_eq!(summary.name, "test-env"); + assert_eq!(summary.state, "Running"); + assert_eq!(summary.provider, "LXD"); + assert_eq!(summary.created_at, "2026-01-05T10:30:00Z"); + } + + #[test] + fn it_should_create_environment_list() { + let summaries = vec![ + EnvironmentSummary::new( + "env1".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + ), + EnvironmentSummary::new( + "env2".to_string(), + "Created".to_string(), + "Hetzner".to_string(), + "2026-01-06T14:15:30Z".to_string(), + ), + ]; + + let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); + + assert_eq!(list.total_count, 2); + assert!(!list.is_empty()); + assert!(!list.has_failures()); + } + + #[test] + fn it_should_detect_empty_list() { + let list = EnvironmentList::new(vec![], vec![], "/path/to/data".to_string()); + + assert!(list.is_empty()); + assert_eq!(list.total_count, 0); + } + + #[test] + fn it_should_detect_partial_failures() { + let summaries = vec![EnvironmentSummary::new( + "env1".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + )]; + + let failures = vec![("broken-env".to_string(), "Invalid JSON".to_string())]; + + let list = EnvironmentList::new(summaries, failures, "/path/to/data".to_string()); + + assert!(!list.is_empty()); + assert!(list.has_failures()); + assert_eq!(list.failed_environments.len(), 1); + } + + #[test] + fn it_should_not_be_empty_when_only_failures_exist() { + let failures = vec![("broken-env".to_string(), "Invalid JSON".to_string())]; + + let list = EnvironmentList::new(vec![], failures, "/path/to/data".to_string()); + + // Not empty because we found something (even though it failed to load) + assert!(!list.is_empty()); + assert!(list.has_failures()); + } +} diff --git a/src/application/command_handlers/list/mod.rs b/src/application/command_handlers/list/mod.rs new file mode 100644 index 00000000..c20f07a2 --- /dev/null +++ b/src/application/command_handlers/list/mod.rs @@ -0,0 +1,49 @@ +//! List Command Module +//! +//! This module implements the delivery-agnostic `ListCommandHandler` +//! for listing all environments in the deployment workspace. +//! +//! ## Architecture +//! +//! The `ListCommandHandler` implements the Command Pattern and uses Dependency Injection +//! to interact with infrastructure services through interfaces: +//! +//! - **Repository Pattern**: Scans data directory via `RepositoryFactory` +//! - **Domain-Driven Design**: Uses domain objects from `domain::environment` +//! +//! ## Design Principles +//! +//! - **Delivery-Agnostic**: Works with CLI, REST API, or any delivery mechanism +//! - **Read-Only Operation**: Never modifies environment state +//! - **No Network Calls**: Scans local data directory only +//! - **Lightweight Loading**: Loads only summary data (name, state, provider, `created_at`) +//! - **Graceful Degradation**: Partial failures don't stop the entire listing +//! - **Explicit Errors**: All errors implement helpful error messages with actionable guidance +//! +//! ## Information Displayed +//! +//! The command displays a summary table with: +//! +//! - Environment name +//! - Current state (including Destroyed) +//! - Provider name +//! - Creation timestamp (ISO 8601) +//! +//! ## Error Handling Strategy +//! +//! - **Empty directory**: Not an error - shows friendly message, exit code 0 +//! - **Fatal errors**: Permission denied, scan failure - exit code 1 +//! - **Partial failure**: Shows valid environments + warnings, exit code 0 + +pub mod errors; +pub mod handler; +pub mod info; + +#[cfg(test)] +mod tests; + +// Re-export main types for convenience +pub use errors::ListCommandHandlerError; +pub use handler::ListCommandHandler; +pub use info::EnvironmentList; +pub use info::EnvironmentSummary; diff --git a/src/application/command_handlers/list/tests/mod.rs b/src/application/command_handlers/list/tests/mod.rs new file mode 100644 index 00000000..45c370e9 --- /dev/null +++ b/src/application/command_handlers/list/tests/mod.rs @@ -0,0 +1,6 @@ +//! Tests for the list command handler +//! +//! Integration tests that verify the handler correctly scans and lists +//! environments from different workspace scenarios. + +// Integration tests will be added in Phase 5 when testing infrastructure is in place diff --git a/src/application/command_handlers/mod.rs b/src/application/command_handlers/mod.rs index d197e05a..5a102a36 100644 --- a/src/application/command_handlers/mod.rs +++ b/src/application/command_handlers/mod.rs @@ -12,6 +12,7 @@ //! - `configure` - Infrastructure configuration and software installation //! - `create` - Environment creation and initialization //! - `destroy` - Infrastructure destruction and teardown +//! - `list` - List all environments in the workspace (read-only) //! - `provision` - Infrastructure provisioning using `OpenTofu` //! - `register` - Register existing instances as alternative to provisioning //! - `release` - Software release to target instances @@ -26,6 +27,7 @@ pub mod common; pub mod configure; pub mod create; pub mod destroy; +pub mod list; pub mod provision; pub mod register; pub mod release; @@ -36,6 +38,7 @@ pub mod test; pub use configure::ConfigureCommandHandler; pub use create::CreateCommandHandler; pub use destroy::DestroyCommandHandler; +pub use list::ListCommandHandler; pub use provision::ProvisionCommandHandler; pub use register::RegisterCommandHandler; pub use release::ReleaseCommandHandler; diff --git a/src/application/command_handlers/show/handler.rs b/src/application/command_handlers/show/handler.rs index a38db9d4..c8980c12 100644 --- a/src/application/command_handlers/show/handler.rs +++ b/src/application/command_handlers/show/handler.rs @@ -116,12 +116,12 @@ impl ShowCommandHandler { /// Extract information from environment based on its state fn extract_info(any_env: &AnyEnvironmentState) -> EnvironmentInfo { let name = any_env.name().to_string(); - let state = Self::format_state_name(any_env.state_name()); - let provider = Self::format_provider_name(any_env.provider_name()); + let state = any_env.state_display_name().to_string(); + let provider = any_env.provider_display_name().to_string(); let created_at = any_env.created_at(); - let next_step = Self::get_next_step_guidance(any_env.state_name()); + let state_name = any_env.state_name().to_string(); - let mut info = EnvironmentInfo::new(name, state, provider, created_at, next_step); + let mut info = EnvironmentInfo::new(name, state, provider, created_at, state_name); // Add infrastructure info if instance IP is available if let Some(instance_ip) = any_env.instance_ip() { @@ -167,146 +167,12 @@ impl ShowCommandHandler { "released" | "running" | "release_failed" | "run_failed" ) } - - /// Format state name for display - fn format_state_name(state_name: &str) -> String { - // Convert snake_case to Title Case - state_name - .split('_') - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - Some(first) => first.to_uppercase().chain(chars).collect(), - None => String::new(), - } - }) - .collect::>() - .join(" ") - } - - /// Format provider name for display - fn format_provider_name(provider_name: &str) -> String { - match provider_name { - "lxd" => "LXD".to_string(), - "hetzner" => "Hetzner Cloud".to_string(), - other => Self::format_state_name(other), - } - } - - /// Get next step guidance based on current state - fn get_next_step_guidance(state_name: &str) -> String { - match state_name { - "created" => "Run 'provision' to create infrastructure.".to_string(), - "provisioning" => { - "Provisioning in progress. Wait for completion or check logs.".to_string() - } - "provisioned" => "Run 'configure' to set up the system.".to_string(), - "configuring" => { - "Configuration in progress. Wait for completion or check logs.".to_string() - } - "configured" => "Run 'release' to deploy the tracker software.".to_string(), - "releasing" => "Release in progress. Wait for completion or check logs.".to_string(), - "released" => "Run 'run' to start the tracker services.".to_string(), - "running" => "Services are running. Use 'test' to verify health.".to_string(), - "destroying" => "Destruction in progress. Wait for completion.".to_string(), - "destroyed" => { - "Environment has been destroyed. Create a new environment to redeploy.".to_string() - } - "provision_failed" => { - "Provisioning failed. Check error details and retry 'provision'.".to_string() - } - "configure_failed" => { - "Configuration failed. Check error details and retry 'configure'.".to_string() - } - "release_failed" => { - "Release failed. Check error details and retry 'release'.".to_string() - } - "run_failed" => "Run failed. Check error details and retry 'run'.".to_string(), - "destroy_failed" => { - "Destruction failed. Check error details and retry 'destroy'.".to_string() - } - _ => format!("Unknown state: {state_name}. Check environment state file."), - } - } } #[cfg(test)] mod tests { use super::*; - mod format_state_name { - use super::*; - - #[test] - fn it_should_format_simple_state() { - assert_eq!(ShowCommandHandler::format_state_name("created"), "Created"); - assert_eq!(ShowCommandHandler::format_state_name("running"), "Running"); - } - - #[test] - fn it_should_format_compound_state() { - assert_eq!( - ShowCommandHandler::format_state_name("provision_failed"), - "Provision Failed" - ); - assert_eq!( - ShowCommandHandler::format_state_name("configure_failed"), - "Configure Failed" - ); - } - } - - mod format_provider_name { - use super::*; - - #[test] - fn it_should_format_lxd() { - assert_eq!(ShowCommandHandler::format_provider_name("lxd"), "LXD"); - } - - #[test] - fn it_should_format_hetzner() { - assert_eq!( - ShowCommandHandler::format_provider_name("hetzner"), - "Hetzner Cloud" - ); - } - - #[test] - fn it_should_format_unknown_provider() { - assert_eq!(ShowCommandHandler::format_provider_name("aws"), "Aws"); - } - } - - mod get_next_step_guidance { - use super::*; - - #[test] - fn it_should_guide_from_created_state() { - let guidance = ShowCommandHandler::get_next_step_guidance("created"); - assert!(guidance.contains("provision")); - } - - #[test] - fn it_should_guide_from_provisioned_state() { - let guidance = ShowCommandHandler::get_next_step_guidance("provisioned"); - assert!(guidance.contains("configure")); - } - - #[test] - fn it_should_guide_from_running_state() { - let guidance = ShowCommandHandler::get_next_step_guidance("running"); - assert!(guidance.contains("test")); - } - - #[test] - fn it_should_handle_failed_states() { - let guidance = ShowCommandHandler::get_next_step_guidance("provision_failed"); - assert!(guidance.contains("failed")); - assert!(guidance.contains("retry")); - } - } - mod should_show_services { use super::*; diff --git a/src/application/command_handlers/show/info.rs b/src/application/command_handlers/show/info.rs index 922f33b8..fe7c10eb 100644 --- a/src/application/command_handlers/show/info.rs +++ b/src/application/command_handlers/show/info.rs @@ -33,8 +33,8 @@ pub struct EnvironmentInfo { /// Tracker service information, available for Released/Running states pub services: Option, - /// Guidance for the next step based on current state - pub next_step: String, + /// Internal state name (e.g., "created", "provisioned") for guidance generation + pub state_name: String, } impl EnvironmentInfo { @@ -45,7 +45,7 @@ impl EnvironmentInfo { state: String, provider: String, created_at: DateTime, - next_step: String, + state_name: String, ) -> Self { Self { name, @@ -54,7 +54,7 @@ impl EnvironmentInfo { created_at, infrastructure: None, services: None, - next_step, + state_name, } } diff --git a/src/bootstrap/container.rs b/src/bootstrap/container.rs index 7803f447..d77a7b51 100644 --- a/src/bootstrap/container.rs +++ b/src/bootstrap/container.rs @@ -17,6 +17,7 @@ use crate::presentation::controllers::create::subcommands::environment::CreateEn use crate::presentation::controllers::create::subcommands::schema::CreateSchemaCommandController; use crate::presentation::controllers::create::subcommands::template::CreateTemplateCommandController; use crate::presentation::controllers::destroy::DestroyCommandController; +use crate::presentation::controllers::list::ListCommandController; use crate::presentation::controllers::provision::ProvisionCommandController; use crate::presentation::controllers::register::RegisterCommandController; use crate::presentation::controllers::release::ReleaseCommandController; @@ -51,6 +52,7 @@ pub struct Container { repository_factory: Arc, repository: Arc, clock: Arc, + data_directory: Arc, } impl Container { @@ -89,6 +91,7 @@ impl Container { // Create repository once for the entire application let data_dir = working_dir.join("data"); + let data_directory: Arc = Arc::from(data_dir.as_path()); let repository = repository_factory.create(data_dir); let clock: Arc = Arc::new(SystemClock); @@ -98,6 +101,7 @@ impl Container { repository_factory, repository, clock, + data_directory, } } @@ -257,6 +261,25 @@ impl Container { pub fn create_show_controller(&self) -> ShowCommandController { ShowCommandController::new(self.repository(), self.user_output()) } + + /// Create a new `ListCommandController` + #[must_use] + pub fn create_list_controller(&self) -> ListCommandController { + ListCommandController::new( + self.repository_factory(), + self.data_directory(), + self.user_output(), + ) + } + + /// Get shared reference to data directory path + /// + /// Returns an `Arc` pointing to the data directory where + /// environment state files are stored. + #[must_use] + pub fn data_directory(&self) -> Arc { + Arc::clone(&self.data_directory) + } } impl Default for Container { diff --git a/src/domain/environment/state/mod.rs b/src/domain/environment/state/mod.rs index 6d385701..2a403426 100644 --- a/src/domain/environment/state/mod.rs +++ b/src/domain/environment/state/mod.rs @@ -275,6 +275,36 @@ impl AnyEnvironmentState { } } + /// Returns a human-readable display name for the current state. + /// + /// This provides a user-friendly representation suitable for CLI output, + /// reports, and other user-facing contexts. Failed states include a space + /// for readability (e.g., "Provision Failed"). + /// + /// # Returns + /// + /// A static string representing the display name (e.g., "Created", "Provision Failed"). + #[must_use] + pub fn state_display_name(&self) -> &'static str { + match self { + Self::Created(_) => "Created", + Self::Provisioning(_) => "Provisioning", + Self::Provisioned(_) => "Provisioned", + Self::Configuring(_) => "Configuring", + Self::Configured(_) => "Configured", + Self::Releasing(_) => "Releasing", + Self::Released(_) => "Released", + Self::Running(_) => "Running", + Self::Destroying(_) => "Destroying", + Self::ProvisionFailed(_) => "Provision Failed", + Self::ConfigureFailed(_) => "Configure Failed", + Self::ReleaseFailed(_) => "Release Failed", + Self::RunFailed(_) => "Run Failed", + Self::DestroyFailed(_) => "Destroy Failed", + Self::Destroyed(_) => "Destroyed", + } + } + /// Check if the environment is in a success (non-error) state /// /// Success states are those representing normal operation flow, including @@ -440,6 +470,22 @@ impl AnyEnvironmentState { self.context().user_inputs.provider_config().provider_name() } + /// Get the human-readable provider display name regardless of current state + /// + /// This method provides access to the provider display name without needing to + /// pattern match on the specific state variant. + /// + /// # Returns + /// + /// A static string representing the provider display name (e.g., "LXD", "Hetzner Cloud"). + #[must_use] + pub fn provider_display_name(&self) -> &'static str { + self.context() + .user_inputs + .provider_config() + .provider_display_name() + } + /// Get the tracker configuration regardless of current state /// /// This method provides access to the tracker configuration without needing to diff --git a/src/domain/provider/config.rs b/src/domain/provider/config.rs index 526364ea..f899b573 100644 --- a/src/domain/provider/config.rs +++ b/src/domain/provider/config.rs @@ -106,6 +106,39 @@ impl ProviderConfig { self.provider().as_str() } + /// Returns a human-readable display name for the provider. + /// + /// This method converts the internal provider identifier to a user-friendly + /// format suitable for display in CLI output, logs, or documentation. + /// + /// # Examples + /// + /// ```rust + /// use torrust_tracker_deployer_lib::domain::provider::{ProviderConfig, LxdConfig, HetznerConfig}; + /// use torrust_tracker_deployer_lib::domain::ProfileName; + /// use torrust_tracker_deployer_lib::shared::secrets::ApiToken; + /// + /// let lxd_config = ProviderConfig::Lxd(LxdConfig { + /// profile_name: ProfileName::new("test").unwrap(), + /// }); + /// assert_eq!(lxd_config.provider_display_name(), "LXD"); + /// + /// let hetzner_config = ProviderConfig::Hetzner(HetznerConfig { + /// api_token: ApiToken::from("token"), + /// server_type: "cx22".to_string(), + /// location: "nbg1".to_string(), + /// image: "ubuntu-24.04".to_string(), + /// }); + /// assert_eq!(hetzner_config.provider_display_name(), "Hetzner Cloud"); + /// ``` + #[must_use] + pub fn provider_display_name(&self) -> &'static str { + match self { + Self::Lxd(_) => "LXD", + Self::Hetzner(_) => "Hetzner Cloud", + } + } + /// Returns a reference to the LXD configuration if this is an LXD provider. /// /// # Returns diff --git a/src/presentation/controllers/list/errors.rs b/src/presentation/controllers/list/errors.rs new file mode 100644 index 00000000..9f08766e --- /dev/null +++ b/src/presentation/controllers/list/errors.rs @@ -0,0 +1,158 @@ +//! Error types for the List Subcommand +//! +//! This module defines error types that can occur during CLI list command execution. +//! All errors follow the project's error handling principles by providing clear, +//! contextual, and actionable error messages with `.help()` methods. + +use std::path::PathBuf; + +use thiserror::Error; + +use crate::presentation::views::progress::ProgressReporterError; + +/// List command specific errors +/// +/// This enum contains all error variants specific to the list command, +/// including directory access and scanning errors. +/// Each variant includes relevant context and actionable error messages. +#[derive(Debug, Error)] +pub enum ListSubcommandError { + // ===== Data Directory Errors ===== + /// Data directory not found + /// + /// The data directory where environments are stored does not exist. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Data directory not found: '{path}' +Tip: Run from the deployer workspace directory or specify --working-dir" + )] + DataDirectoryNotFound { path: PathBuf }, + + /// Permission denied accessing directory + /// + /// Access to the data directory was denied. + /// Use `.help()` for detailed troubleshooting steps. + #[error( + "Permission denied accessing directory: '{path}' +Tip: Check file permissions for the data directory" + )] + PermissionDenied { path: PathBuf }, + + /// Failed to scan environments directory + /// + /// An error occurred while scanning the data directory. + #[error( + "Failed to scan environments directory: {message} +Tip: Check filesystem health and permissions" + )] + ScanError { message: String }, + + // ===== Internal Errors ===== + /// Progress reporting failed + /// + /// Failed to report progress to the user due to an internal error. + /// This indicates a critical internal error. + #[error( + "Failed to report progress: {source} +Tip: This is a critical bug - please report it with full logs using --log-output file-and-stderr" + )] + ProgressReportingFailed { + #[source] + source: ProgressReporterError, + }, +} + +// ============================================================================ +// ERROR CONVERSIONS +// ============================================================================ + +impl From for ListSubcommandError { + fn from(source: ProgressReporterError) -> Self { + Self::ProgressReportingFailed { source } + } +} + +impl ListSubcommandError { + /// Get detailed troubleshooting guidance for this error + /// + /// This method provides comprehensive troubleshooting steps that can be + /// displayed to users when they need more help resolving the error. + #[must_use] + pub fn help(&self) -> &'static str { + match self { + Self::DataDirectoryNotFound { .. } => { + "Data Directory Not Found - Detailed Troubleshooting: + +1. Verify current directory: + - Run: pwd + - Expected: Your deployer workspace directory + +2. Check if data directory exists: + - Run: ls -la data/ + - Should contain environment subdirectories + +3. Create environment first: + - Run: torrust-tracker-deployer create environment --env-file + +Common causes: +- Running from the wrong directory +- No environments have been created yet +- Data directory was moved or deleted + +For more information, see docs/user-guide/commands.md" + } + Self::PermissionDenied { .. } => { + "Permission Denied - Detailed Troubleshooting: + +1. Check directory permissions: + - Run: ls -ld data/ + - Should have read permission (r--) + +2. Check file permissions: + - Run: ls -l data/*/environment.json + - Should have read permission (r--) + +3. Fix permissions if needed: + - Run: chmod +rx data/ + - Run: chmod +r data/*/environment.json + +Common causes: +- File created by different user +- Restrictive umask settings +- SELinux or AppArmor restrictions + +For more information, see docs/user-guide/commands.md" + } + Self::ScanError { .. } => { + "Scan Error - Detailed Troubleshooting: + +1. Check directory permissions: + - Run: ls -ld data/ + - Should have read permission (r--) + +2. Verify filesystem health: + - Check for disk errors or filesystem issues + +3. Try running with elevated permissions if needed + +Common causes: +- File system errors +- Corrupted directory entries +- Network filesystem issues + +For more information, see docs/user-guide/commands.md" + } + Self::ProgressReportingFailed { .. } => { + "Progress Reporting Failed - This is an internal error: + +1. This indicates a bug in the application +2. Please report this issue with: + - Full command output + - Log file contents (use --log-output file-and-stderr) + - Steps to reproduce + +Report issues at: https://github.com/torrust/torrust-tracker-deployer/issues" + } + } + } +} diff --git a/src/presentation/controllers/list/handler.rs b/src/presentation/controllers/list/handler.rs new file mode 100644 index 00000000..967c0051 --- /dev/null +++ b/src/presentation/controllers/list/handler.rs @@ -0,0 +1,154 @@ +//! List Command Handler +//! +//! This module handles the list command execution at the presentation layer, +//! displaying a summary of all environments in the workspace. + +use std::cell::RefCell; +use std::path::Path; +use std::sync::Arc; + +use parking_lot::ReentrantMutex; + +use crate::application::command_handlers::list::info::EnvironmentList; +use crate::application::command_handlers::list::{ListCommandHandler, ListCommandHandlerError}; +use crate::infrastructure::persistence::repository_factory::RepositoryFactory; +use crate::presentation::views::commands::list::EnvironmentListView; +use crate::presentation::views::progress::ProgressReporter; +use crate::presentation::views::UserOutput; + +use super::errors::ListSubcommandError; + +/// Steps in the list workflow +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ListStep { + ScanEnvironments, + DisplayResults, +} + +impl ListStep { + /// All steps in execution order + const ALL: &'static [Self] = &[Self::ScanEnvironments, Self::DisplayResults]; + + /// Total number of steps + const fn count() -> usize { + Self::ALL.len() + } + + /// User-facing description for the step + fn description(self) -> &'static str { + match self { + Self::ScanEnvironments => "Scanning for environments", + Self::DisplayResults => "Displaying results", + } + } +} + +/// Presentation layer controller for list command workflow +/// +/// Lists all environments in the workspace with summary information. +/// This is a read-only command that scans local storage without network calls. +/// +/// ## Responsibilities +/// +/// - Scan data directory for environments +/// - Delegate to application layer for data extraction +/// - Display environment list to the user +/// - Handle partial failures gracefully +/// +/// ## Architecture +/// +/// This controller implements the Presentation Layer pattern, handling +/// user interaction while delegating business logic to the application layer. +pub struct ListCommandController { + handler: ListCommandHandler, + progress: ProgressReporter, +} + +impl ListCommandController { + /// Create a new `ListCommandController` with dependencies + /// + /// # Arguments + /// + /// * `repository_factory` - Factory for creating environment repositories + /// * `data_directory` - Path to the data directory + /// * `user_output` - Shared output service for user feedback + #[allow(clippy::needless_pass_by_value)] // Arc parameters are moved to constructor for ownership + pub fn new( + repository_factory: Arc, + data_directory: Arc, + user_output: Arc>>, + ) -> Self { + let handler = ListCommandHandler::new(repository_factory, data_directory); + let progress = ProgressReporter::new(user_output, ListStep::count()); + + Self { handler, progress } + } + + /// Execute the list command workflow + /// + /// This method orchestrates the two-step workflow: + /// 1. Scan for environments via application layer + /// 2. Display results to user + /// + /// # Errors + /// + /// Returns `ListSubcommandError` if any step fails + pub fn execute(&mut self) -> Result<(), ListSubcommandError> { + // Step 1: Scan for environments via application layer + let env_list = self.scan_environments()?; + + // Step 2: Display results + self.display_results(&env_list)?; + + Ok(()) + } + + /// Step 1: Scan for environments via application layer + fn scan_environments(&mut self) -> Result { + self.progress + .start_step(ListStep::ScanEnvironments.description())?; + + let env_list = self.handler.execute().map_err(Self::map_handler_error)?; + + let count = env_list.total_count; + self.progress + .complete_step(Some(&format!("Found {count} environment(s)")))?; + + Ok(env_list) + } + + /// Map application layer errors to presentation errors + fn map_handler_error(error: ListCommandHandlerError) -> ListSubcommandError { + match error { + ListCommandHandlerError::DataDirectoryNotFound { path } => { + ListSubcommandError::DataDirectoryNotFound { path } + } + ListCommandHandlerError::PermissionDenied { path } => { + ListSubcommandError::PermissionDenied { path } + } + ListCommandHandlerError::ScanError { message } => { + ListSubcommandError::ScanError { message } + } + } + } + + /// Step 2: Display environment list + /// + /// Orchestrates a functional pipeline to display the environment list: + /// `EnvironmentList` → `String` → stdout + /// + /// The output is written to stdout (not stderr) as it represents the final + /// command result rather than progress information. + fn display_results(&mut self, env_list: &EnvironmentList) -> Result<(), ListSubcommandError> { + self.progress + .start_step(ListStep::DisplayResults.description())?; + + // Pipeline: EnvironmentList → render → output to stdout + self.progress + .result(&EnvironmentListView::render(env_list))?; + + self.progress.complete_step(Some("Results displayed"))?; + + Ok(()) + } +} diff --git a/src/presentation/controllers/list/mod.rs b/src/presentation/controllers/list/mod.rs new file mode 100644 index 00000000..a8d62d48 --- /dev/null +++ b/src/presentation/controllers/list/mod.rs @@ -0,0 +1,49 @@ +//! List Command Presentation Module +//! +//! This module implements the CLI presentation layer for the list command, +//! handling argument processing and user interaction. +//! +//! ## Architecture +//! +//! The list command presentation layer follows the DDD pattern, providing +//! a read-only view of all environments in the workspace. +//! +//! ## Components +//! +//! - `errors` - Presentation layer error types with `.help()` methods +//! - `handler` - Main command handler orchestrating the workflow +//! +//! ## Usage Example +//! +//! ### Basic Usage +//! +//! ```rust +//! use std::path::Path; +//! use std::sync::Arc; +//! use torrust_tracker_deployer_lib::bootstrap::Container; +//! use torrust_tracker_deployer_lib::presentation::dispatch::ExecutionContext; +//! use torrust_tracker_deployer_lib::presentation::controllers::list; +//! use torrust_tracker_deployer_lib::presentation::views::VerbosityLevel; +//! +//! # fn main() { +//! let container = Container::new(VerbosityLevel::Normal, Path::new(".")); +//! let context = ExecutionContext::new(Arc::new(container)); +//! +//! // Call the list handler +//! if let Err(e) = context +//! .container() +//! .create_list_controller() +//! .execute() +//! { +//! eprintln!("List failed: {e}"); +//! eprintln!("\n{}", e.help()); +//! } +//! # } +//! ``` + +pub mod errors; +pub mod handler; +pub use handler::ListCommandController; + +// Re-export commonly used types for convenience +pub use errors::ListSubcommandError; diff --git a/src/presentation/controllers/mod.rs b/src/presentation/controllers/mod.rs index f9561eca..74715f3d 100644 --- a/src/presentation/controllers/mod.rs +++ b/src/presentation/controllers/mod.rs @@ -254,6 +254,7 @@ pub mod configure; pub mod constants; pub mod create; pub mod destroy; +pub mod list; pub mod provision; pub mod register; pub mod release; diff --git a/src/presentation/dispatch/router.rs b/src/presentation/dispatch/router.rs index 3f5a6345..deb2b584 100644 --- a/src/presentation/dispatch/router.rs +++ b/src/presentation/dispatch/router.rs @@ -179,5 +179,9 @@ pub async fn route_command( .execute(&environment)?; Ok(()) } + Commands::List => { + context.container().create_list_controller().execute()?; + Ok(()) + } } } diff --git a/src/presentation/errors.rs b/src/presentation/errors.rs index e67bcf34..b621be15 100644 --- a/src/presentation/errors.rs +++ b/src/presentation/errors.rs @@ -21,9 +21,10 @@ use thiserror::Error; use crate::presentation::controllers::{ configure::ConfigureSubcommandError, create::CreateCommandError, - destroy::DestroySubcommandError, provision::ProvisionSubcommandError, - register::errors::RegisterSubcommandError, release::ReleaseSubcommandError, - run::RunSubcommandError, show::ShowSubcommandError, test::TestSubcommandError, + destroy::DestroySubcommandError, list::ListSubcommandError, + provision::ProvisionSubcommandError, register::errors::RegisterSubcommandError, + release::ReleaseSubcommandError, run::RunSubcommandError, show::ShowSubcommandError, + test::TestSubcommandError, }; /// Errors that can occur during CLI command execution @@ -96,6 +97,13 @@ pub enum CommandError { #[error("Show command failed: {0}")] Show(Box), + /// List command specific errors + /// + /// Encapsulates all errors that can occur during environment listing. + /// Use `.help()` for detailed troubleshooting steps. + #[error("List command failed: {0}")] + List(Box), + /// User output lock acquisition failed /// /// Failed to acquire the mutex lock for user output. This typically indicates @@ -158,6 +166,12 @@ impl From for CommandError { } } +impl From for CommandError { + fn from(error: ListSubcommandError) -> Self { + Self::List(Box::new(error)) + } +} + impl CommandError { /// Get detailed troubleshooting guidance for this error /// @@ -203,6 +217,7 @@ impl CommandError { Self::Release(e) => e.help().to_string(), Self::Run(e) => e.help().to_string(), Self::Show(e) => e.help().to_string(), + Self::List(e) => e.help().to_string(), Self::UserOutputLockFailed => "User Output Lock Failed - Detailed Troubleshooting: This error indicates that a panic occurred in another thread while it was using diff --git a/src/presentation/input/cli/commands.rs b/src/presentation/input/cli/commands.rs index f5e1ea0f..72413145 100644 --- a/src/presentation/input/cli/commands.rs +++ b/src/presentation/input/cli/commands.rs @@ -203,8 +203,26 @@ pub enum Commands { /// The environment name must match an existing environment. environment: String, }, -} + /// List all environments in the deployment workspace + /// + /// This command provides a quick overview of all environments with their + /// names, states, and providers. It scans the local data directory and + /// does not make any network calls. + /// + /// The output includes: + /// - Environment name + /// - Current state (Created, Provisioned, Configured, Released, Running, Destroyed) + /// - Provider (LXD, Hetzner Cloud) + /// - Creation timestamp + /// + /// # Examples + /// + /// ```text + /// torrust-tracker-deployer list + /// ``` + List, +} /// Actions available for the create command #[derive(Debug, Subcommand)] pub enum CreateAction { diff --git a/src/presentation/input/cli/mod.rs b/src/presentation/input/cli/mod.rs index 43508ae2..116bed26 100644 --- a/src/presentation/input/cli/mod.rs +++ b/src/presentation/input/cli/mod.rs @@ -54,7 +54,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Destroy command") } } @@ -79,7 +80,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Destroy command") } } @@ -129,7 +131,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Destroy command") } } @@ -223,7 +226,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Create command") } } @@ -257,7 +261,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Create command") } } @@ -312,7 +317,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Create command") } } @@ -396,7 +402,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Create command") } } @@ -434,7 +441,8 @@ mod tests { | Commands::Register { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Create command") } } @@ -621,7 +629,8 @@ mod tests { | Commands::Test { .. } | Commands::Release { .. } | Commands::Run { .. } - | Commands::Show { .. } => { + | Commands::Show { .. } + | Commands::List => { panic!("Expected Register command") } } diff --git a/src/presentation/views/commands/list/environment_list.rs b/src/presentation/views/commands/list/environment_list.rs new file mode 100644 index 00000000..6e2187ff --- /dev/null +++ b/src/presentation/views/commands/list/environment_list.rs @@ -0,0 +1,289 @@ +//! Environment List View for List Command +//! +//! This module provides a view for rendering the environment list +//! as a formatted table for terminal display. + +use crate::application::command_handlers::list::info::EnvironmentList; + +/// View for rendering environment list +/// +/// This view is responsible for formatting and rendering the list of +/// environments that users see when running the `list` command. +/// +/// # Design +/// +/// Following MVC pattern, this view: +/// - Receives data from the controller via the `EnvironmentList` DTO +/// - Formats the output as a table for display +/// - Handles edge cases (empty list, partial failures) +/// - Returns a string ready for output to stdout +/// +/// # Examples +/// +/// ```rust +/// use torrust_tracker_deployer_lib::application::command_handlers::list::info::{ +/// EnvironmentList, EnvironmentSummary, +/// }; +/// use torrust_tracker_deployer_lib::presentation::views::commands::list::EnvironmentListView; +/// +/// let summaries = vec![ +/// EnvironmentSummary::new( +/// "my-production".to_string(), +/// "Running".to_string(), +/// "Hetzner Cloud".to_string(), +/// "2026-01-05T10:30:00Z".to_string(), +/// ), +/// ]; +/// +/// let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); +/// let output = EnvironmentListView::render(&list); +/// assert!(output.contains("my-production")); +/// assert!(output.contains("Running")); +/// ``` +pub struct EnvironmentListView; + +impl EnvironmentListView { + /// Render environment list as a formatted string + /// + /// Takes the environment list and produces a human-readable table output + /// suitable for displaying to users via stdout. + /// + /// # Arguments + /// + /// * `list` - Environment list to render + /// + /// # Returns + /// + /// A formatted string containing: + /// - Header with count + /// - Table with environment summaries + /// - Warnings for failed environments (if any) + /// - Help text for empty workspaces + #[must_use] + pub fn render(list: &EnvironmentList) -> String { + let mut lines = Vec::new(); + + if list.is_empty() { + return Self::render_empty(list); + } + + // Header with count + lines.push(String::new()); + lines.push(format!("Environments ({} found):", list.total_count)); + lines.push(String::new()); + + // Table header + lines.push(Self::render_table_header()); + lines.push(Self::render_table_separator()); + + // Table rows + for env in &list.environments { + lines.push(Self::render_table_row(env)); + } + + // Partial failure warnings + if list.has_failures() { + lines.push(String::new()); + lines.push("Warning: Failed to load the following environments:".to_string()); + for (name, error) in &list.failed_environments { + lines.push(format!(" - {name}: {error}")); + } + lines.push(String::new()); + lines.push("For troubleshooting, see docs/user-guide/commands.md".to_string()); + } + + lines.join("\n") + } + + /// Render empty workspace message + fn render_empty(list: &EnvironmentList) -> String { + let mut lines = Vec::new(); + + lines.push(String::new()); + lines.push(format!("No environments found in: {}", list.data_directory)); + lines.push(String::new()); + lines.push("To create a new environment:".to_string()); + lines.push( + " torrust-tracker-deployer create environment --env-file ".to_string(), + ); + lines.push(String::new()); + lines.push("For more information, see docs/user-guide/commands.md".to_string()); + + lines.join("\n") + } + + /// Render table header row + fn render_table_header() -> String { + format!( + "{:<20} {:<18} {:<14} {}", + "Name", "State", "Provider", "Created" + ) + } + + /// Render table separator + fn render_table_separator() -> String { + "─".repeat(76) + } + + /// Render a single table row + fn render_table_row( + env: &crate::application::command_handlers::list::info::EnvironmentSummary, + ) -> String { + format!( + "{:<20} {:<18} {:<14} {}", + Self::truncate(&env.name, 20), + Self::truncate(&env.state, 18), + Self::truncate(&env.provider, 14), + &env.created_at + ) + } + + /// Truncate a string to fit column width + fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else if max_len > 3 { + format!("{}...", &s[..max_len - 3]) + } else { + s[..max_len].to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::application::command_handlers::list::info::EnvironmentSummary; + + #[test] + fn it_should_render_empty_workspace() { + let list = EnvironmentList::new(vec![], vec![], "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + assert!(output.contains("No environments found in: /path/to/data")); + assert!(output.contains("create environment --env-file")); + } + + #[test] + fn it_should_render_environment_list_with_header() { + let summaries = vec![EnvironmentSummary::new( + "test-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + )]; + + let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + assert!(output.contains("Environments (1 found):")); + assert!(output.contains("Name")); + assert!(output.contains("State")); + assert!(output.contains("Provider")); + assert!(output.contains("Created")); + } + + #[test] + fn it_should_render_environment_rows() { + let summaries = vec![ + EnvironmentSummary::new( + "production".to_string(), + "Running".to_string(), + "Hetzner Cloud".to_string(), + "2026-01-05T10:30:00Z".to_string(), + ), + EnvironmentSummary::new( + "staging".to_string(), + "Provisioned".to_string(), + "LXD".to_string(), + "2026-01-06T14:15:30Z".to_string(), + ), + ]; + + let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + assert!(output.contains("production")); + assert!(output.contains("Running")); + assert!(output.contains("Hetzner Cloud")); + assert!(output.contains("staging")); + assert!(output.contains("Provisioned")); + assert!(output.contains("LXD")); + } + + #[test] + fn it_should_render_partial_failure_warnings() { + let summaries = vec![EnvironmentSummary::new( + "good-env".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + )]; + + let failures = vec![ + ("broken-env".to_string(), "Invalid JSON".to_string()), + ("old-env".to_string(), "Permission denied".to_string()), + ]; + + let list = EnvironmentList::new(summaries, failures, "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + assert!(output.contains("Warning: Failed to load the following environments:")); + assert!(output.contains("broken-env: Invalid JSON")); + assert!(output.contains("old-env: Permission denied")); + } + + #[test] + fn it_should_truncate_long_names() { + let summaries = vec![EnvironmentSummary::new( + "very-long-environment-name-that-exceeds-column-width".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + )]; + + let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + // Should truncate the long name + assert!(output.contains("very-long-environ...")); + } + + #[test] + fn it_should_handle_multiple_environments() { + let summaries = vec![ + EnvironmentSummary::new( + "env1".to_string(), + "Running".to_string(), + "LXD".to_string(), + "2026-01-05T10:30:00Z".to_string(), + ), + EnvironmentSummary::new( + "env2".to_string(), + "Created".to_string(), + "Hetzner Cloud".to_string(), + "2026-01-06T14:15:30Z".to_string(), + ), + EnvironmentSummary::new( + "env3".to_string(), + "Destroyed".to_string(), + "LXD".to_string(), + "2026-01-07T09:00:12Z".to_string(), + ), + ]; + + let list = EnvironmentList::new(summaries, vec![], "/path/to/data".to_string()); + + let output = EnvironmentListView::render(&list); + + assert!(output.contains("Environments (3 found):")); + assert!(output.contains("env1")); + assert!(output.contains("env2")); + assert!(output.contains("env3")); + } +} diff --git a/src/presentation/views/commands/list/mod.rs b/src/presentation/views/commands/list/mod.rs new file mode 100644 index 00000000..3345bec1 --- /dev/null +++ b/src/presentation/views/commands/list/mod.rs @@ -0,0 +1,7 @@ +//! Views for List Command +//! +//! This module contains view components for rendering list command output. + +pub mod environment_list; + +pub use environment_list::EnvironmentListView; diff --git a/src/presentation/views/commands/mod.rs b/src/presentation/views/commands/mod.rs index f7212e03..454964f1 100644 --- a/src/presentation/views/commands/mod.rs +++ b/src/presentation/views/commands/mod.rs @@ -4,5 +4,6 @@ //! Each command has its own submodule with views for rendering //! command-specific output. +pub mod list; pub mod provision; pub mod show; diff --git a/src/presentation/views/commands/show/environment_info.rs b/src/presentation/views/commands/show/environment_info.rs index f4ddde29..0801016f 100644 --- a/src/presentation/views/commands/show/environment_info.rs +++ b/src/presentation/views/commands/show/environment_info.rs @@ -31,7 +31,7 @@ use crate::application::command_handlers::show::info::EnvironmentInfo; /// "Created".to_string(), /// "LXD".to_string(), /// created_at, -/// "Next: Run 'provision my-env' to create infrastructure".to_string(), +/// "created".to_string(), /// ); /// /// let output = EnvironmentInfoView::render(&info); @@ -73,7 +73,7 @@ impl EnvironmentInfoView { /// "Provisioned".to_string(), /// "LXD".to_string(), /// Utc::now(), - /// "Next: Run 'configure prod-env' to install software".to_string(), + /// "provisioned".to_string(), /// ).with_infrastructure(InfrastructureInfo::new( /// IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), /// 22, @@ -140,10 +140,46 @@ impl EnvironmentInfoView { // Next step guidance lines.push(String::new()); // blank line - lines.push(info.next_step.clone()); + lines.push(Self::get_next_step_guidance(&info.state_name)); lines.join("\n") } + + /// Get next step guidance based on current state + fn get_next_step_guidance(state_name: &str) -> String { + match state_name { + "created" => "Run 'provision' to create infrastructure.".to_string(), + "provisioning" => { + "Provisioning in progress. Wait for completion or check logs.".to_string() + } + "provisioned" => "Run 'configure' to set up the system.".to_string(), + "configuring" => { + "Configuration in progress. Wait for completion or check logs.".to_string() + } + "configured" => "Run 'release' to deploy the tracker software.".to_string(), + "releasing" => "Release in progress. Wait for completion or check logs.".to_string(), + "released" => "Run 'run' to start the tracker services.".to_string(), + "running" => "Services are running. Use 'test' to verify health.".to_string(), + "destroying" => "Destruction in progress. Wait for completion.".to_string(), + "destroyed" => { + "Environment has been destroyed. Create a new environment to redeploy.".to_string() + } + "provision_failed" => { + "Provisioning failed. Run 'destroy' and create a new environment.".to_string() + } + "configure_failed" => { + "Configuration failed. Run 'destroy' and create a new environment.".to_string() + } + "release_failed" => { + "Release failed. Run 'destroy' and create a new environment.".to_string() + } + "run_failed" => "Run failed. Run 'destroy' and create a new environment.".to_string(), + "destroy_failed" => { + "Destruction failed. Check error details and retry 'destroy'.".to_string() + } + _ => format!("Unknown state: {state_name}. Check environment state file."), + } + } } #[cfg(test)] @@ -167,7 +203,7 @@ mod tests { "Created".to_string(), "LXD".to_string(), test_timestamp(), - "Next: Run 'provision test-env' to create infrastructure".to_string(), + "created".to_string(), ); let output = EnvironmentInfoView::render(&info); @@ -176,7 +212,7 @@ mod tests { assert!(output.contains("State: Created")); assert!(output.contains("Provider: LXD")); assert!(output.contains("Created: 2025-01-07 12:30:45 UTC")); - assert!(output.contains("Next: Run 'provision test-env'")); + assert!(output.contains("Run 'provision' to create infrastructure.")); } #[test] @@ -186,7 +222,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), test_timestamp(), - "Next: Run 'configure prod-env' to install software".to_string(), + "provisioned".to_string(), ) .with_infrastructure(InfrastructureInfo::new( IpAddr::V4(Ipv4Addr::new(10, 140, 190, 171)), @@ -213,7 +249,7 @@ mod tests { "Running".to_string(), "LXD".to_string(), test_timestamp(), - "Tracker is running!".to_string(), + "running".to_string(), ) .with_services(ServiceInfo::new( vec!["udp://10.0.0.1:6969/announce".to_string()], @@ -242,7 +278,7 @@ mod tests { "Running".to_string(), "LXD".to_string(), test_timestamp(), - "Tracker is running!".to_string(), + "running".to_string(), ) .with_infrastructure(InfrastructureInfo::new( IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100)), @@ -276,7 +312,7 @@ mod tests { "Provisioned".to_string(), "LXD".to_string(), test_timestamp(), - "Next step".to_string(), + "provisioned".to_string(), ) .with_infrastructure(InfrastructureInfo::new( IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), @@ -289,4 +325,33 @@ mod tests { assert!(output.contains("-p 2222")); } + + mod get_next_step_guidance { + use super::*; + + #[test] + fn it_should_guide_from_created_state() { + let guidance = EnvironmentInfoView::get_next_step_guidance("created"); + assert!(guidance.contains("provision")); + } + + #[test] + fn it_should_guide_from_provisioned_state() { + let guidance = EnvironmentInfoView::get_next_step_guidance("provisioned"); + assert!(guidance.contains("configure")); + } + + #[test] + fn it_should_guide_from_running_state() { + let guidance = EnvironmentInfoView::get_next_step_guidance("running"); + assert!(guidance.contains("test")); + } + + #[test] + fn it_should_handle_failed_states() { + let guidance = EnvironmentInfoView::get_next_step_guidance("provision_failed"); + assert!(guidance.contains("failed")); + assert!(guidance.contains("destroy")); + } + } } diff --git a/src/presentation/views/progress/mod.rs b/src/presentation/views/progress/mod.rs index a971c7c3..de76fca2 100644 --- a/src/presentation/views/progress/mod.rs +++ b/src/presentation/views/progress/mod.rs @@ -254,9 +254,9 @@ impl ProgressReporter { let duration = start.elapsed(); self.with_output(|output| { if let Some(msg) = result { - output.result(&format!(" ✓ {} (took {})", msg, format_duration(duration))); + output.progress(&format!(" ✓ {} (took {})", msg, format_duration(duration))); } else { - output.result(&format!(" ✓ Done (took {})", format_duration(duration))); + output.progress(&format!(" ✓ Done (took {})", format_duration(duration))); } })?; } @@ -301,7 +301,7 @@ impl ProgressReporter { /// ``` pub fn sub_step(&mut self, description: &str) -> Result<(), ProgressReporterError> { self.with_output(|output| { - output.result(&format!(" → {description}")); + output.progress(&format!(" → {description}")); })?; Ok(()) } @@ -565,7 +565,7 @@ mod tests { #[test] fn it_should_complete_step_with_result_message() { let test_output = TestUserOutput::new(VerbosityLevel::Normal); - let (output, stdout, _stderr) = test_output.into_reentrant_wrapped(); + let (output, _stdout, stderr) = test_output.into_reentrant_wrapped(); let mut progress = ProgressReporter::new(output, 1); progress @@ -575,16 +575,16 @@ mod tests { .complete_step(Some("Data loaded successfully")) .expect("Failed to complete step"); - let stdout_content = String::from_utf8(stdout.lock().clone()).unwrap(); - assert!(stdout_content.contains("✓ Data loaded successfully")); - assert!(stdout_content.contains("took")); + let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap(); + assert!(stderr_content.contains("✓ Data loaded successfully")); + assert!(stderr_content.contains("took")); assert!(progress.step_start.is_none()); } #[test] fn it_should_complete_step_without_result_message() { let test_output = TestUserOutput::new(VerbosityLevel::Normal); - let (output, stdout, _stderr) = test_output.into_reentrant_wrapped(); + let (output, _stdout, stderr) = test_output.into_reentrant_wrapped(); let mut progress = ProgressReporter::new(output, 1); progress @@ -594,16 +594,16 @@ mod tests { .complete_step(None) .expect("Failed to complete step"); - let stdout_content = String::from_utf8(stdout.lock().clone()).unwrap(); - assert!(stdout_content.contains("✓ Done")); - assert!(stdout_content.contains("took")); + let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap(); + assert!(stderr_content.contains("✓ Done")); + assert!(stderr_content.contains("took")); assert!(progress.step_start.is_none()); } #[test] fn it_should_report_sub_steps() { let test_output = TestUserOutput::new(VerbosityLevel::Normal); - let (output, stdout, _stderr) = test_output.into_reentrant_wrapped(); + let (output, _stdout, stderr) = test_output.into_reentrant_wrapped(); let mut progress = ProgressReporter::new(output, 1); progress @@ -619,9 +619,9 @@ mod tests { .complete_step(None) .expect("Failed to complete step"); - let stdout_content = String::from_utf8(stdout.lock().clone()).unwrap(); - assert!(stdout_content.contains("→ Creating VM")); - assert!(stdout_content.contains("→ Configuring network")); + let stderr_content = String::from_utf8(stderr.lock().clone()).unwrap(); + assert!(stderr_content.contains("→ Creating VM")); + assert!(stderr_content.contains("→ Configuring network")); } #[test] @@ -741,12 +741,14 @@ mod tests { assert!(stderr_content.contains("[2/3] Provisioning infrastructure...")); assert!(stderr_content.contains("[3/3] Finalizing environment...")); assert!(stderr_content.contains("✅ Environment 'test-env' created successfully")); + assert!(stderr_content.contains("✓ Configuration loaded: test-env")); + assert!(stderr_content.contains("→ Creating virtual machine")); + assert!(stderr_content.contains("→ Configuring network")); + assert!(stderr_content.contains("✓ Instance created: test-instance")); + assert!(stderr_content.contains("✓ Done")); let stdout_content = String::from_utf8(stdout.lock().clone()).expect("Invalid UTF-8"); - assert!(stdout_content.contains("✓ Configuration loaded: test-env")); - assert!(stdout_content.contains("→ Creating virtual machine")); - assert!(stdout_content.contains("→ Configuring network")); - assert!(stdout_content.contains("✓ Instance created: test-instance")); - assert!(stdout_content.contains("✓ Done")); + // stdout should be empty - all progress goes to stderr + assert!(stdout_content.is_empty()); } } diff --git a/src/testing/e2e/process_runner.rs b/src/testing/e2e/process_runner.rs index dd23d9c9..c2e8e0ba 100644 --- a/src/testing/e2e/process_runner.rs +++ b/src/testing/e2e/process_runner.rs @@ -362,6 +362,75 @@ impl ProcessRunner { Ok(ProcessResult::new(output)) } + + /// Run the list command with the production binary + /// + /// This method runs `cargo run -- list` with optional working directory + /// for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + /// + /// # Panics + /// + /// Panics if the working directory path contains invalid UTF-8. + pub fn run_list_command(&self) -> Result { + let mut cmd = Command::new("cargo"); + + if let Some(working_dir) = &self.working_dir { + // Build command with working directory + cmd.args([ + "run", + "--", + "list", + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "list"]); + } + + let output = cmd.output().context("Failed to execute list command")?; + + Ok(ProcessResult::new(output)) + } + + /// Run the show command with the production binary + /// + /// This method runs `cargo run -- show ` with + /// optional working directory for the application itself via `--working-dir`. + /// + /// # Errors + /// + /// Returns an error if the command fails to execute. + /// + /// # Panics + /// + /// Panics if the working directory path contains invalid UTF-8. + pub fn run_show_command(&self, environment_name: &str) -> Result { + let mut cmd = Command::new("cargo"); + + if let Some(working_dir) = &self.working_dir { + // Build command with working directory + cmd.args([ + "run", + "--", + "show", + environment_name, + "--working-dir", + working_dir.to_str().unwrap(), + ]); + } else { + // No working directory, use relative paths + cmd.args(["run", "--", "show", environment_name]); + } + + let output = cmd.output().context("Failed to execute show command")?; + + Ok(ProcessResult::new(output)) + } } impl Default for ProcessRunner { diff --git a/tests/e2e/list_command.rs b/tests/e2e/list_command.rs new file mode 100644 index 00000000..393985ca --- /dev/null +++ b/tests/e2e/list_command.rs @@ -0,0 +1,199 @@ +//! End-to-End Black Box Tests for List Command +//! +//! This test suite provides true black-box testing of the list command +//! by running the production application as an external process. These tests +//! verify that the list command correctly displays environments in the +//! working directory. +//! +//! ## Test Approach +//! +//! - **Black Box**: Runs production binary as external process +//! - **Isolation**: Uses temporary directories for complete test isolation +//! - **Coverage**: Tests list command with and without environments +//! - **Verification**: Validates environment names appear in output +//! +//! ## Test Scenarios +//! +//! 1. Empty workspace: List command shows no environments +//! 2. Single environment: List command shows created environment +//! 3. Multiple environments: List command shows all created environments + +use super::super::support::{EnvironmentStateAssertions, ProcessRunner, TempWorkspace}; +use anyhow::Result; +use torrust_dependency_installer::{verify_dependencies, Dependency}; +use torrust_tracker_deployer_lib::testing::e2e::tasks::black_box::create_test_environment_config; + +/// Verify that all required dependencies are installed for list command E2E tests. +/// +/// **Current State**: No system dependencies required. +/// +/// These black-box tests run the production binary as an external process and verify +/// the list command workflow. Currently, they only test the command interface and +/// output formatting, without requiring infrastructure tools. +/// +/// # Errors +/// +/// Returns an error if any required dependencies are missing or cannot be detected. +fn verify_required_dependencies() -> Result<()> { + // Currently no system dependencies required - empty array + let required_deps: &[Dependency] = &[]; + verify_dependencies(required_deps)?; + Ok(()) +} + +#[test] +fn it_should_report_no_data_directory_when_workspace_is_empty() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace (empty) + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Act: Run list command on empty workspace + let list_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_list_command() + .expect("Failed to run list command"); + + // Assert: Command should fail because no data directory exists + // This is expected behavior - the list command reports that + // no environments have been created yet + assert!( + !list_result.success(), + "List command should fail when no data directory exists" + ); + + // Assert: Error message should indicate data directory not found + let stderr = list_result.stderr(); + assert!( + stderr.contains("Data directory not found") + || stderr.contains("No environments") + || stderr.contains("data"), + "Expected error about missing data directory, got: {stderr}" + ); +} + +#[test] +fn it_should_list_created_environment() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-list-single"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Verify environment was created + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-list-single"); + + // Act: Run list command + let list_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_list_command() + .expect("Failed to run list command"); + + // Assert: Command should succeed + assert!( + list_result.success(), + "List command failed with exit code: {:?}\nstderr: {}", + list_result.exit_code(), + list_result.stderr() + ); + + // Assert: Output should contain the environment name + let stdout = list_result.stdout(); + assert!( + stdout.contains("test-list-single"), + "Expected environment name 'test-list-single' in output, got: {stdout}" + ); +} + +#[test] +fn it_should_list_multiple_environments() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create first environment + let config1 = create_test_environment_config("test-list-first"); + temp_workspace + .write_config_file("env1.json", &config1) + .expect("Failed to write first config file"); + + let create_result1 = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./env1.json") + .expect("Failed to run first create command"); + + assert!( + create_result1.success(), + "First create command failed: {}", + create_result1.stderr() + ); + + // Create second environment + let config2 = create_test_environment_config("test-list-second"); + temp_workspace + .write_config_file("env2.json", &config2) + .expect("Failed to write second config file"); + + let create_result2 = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./env2.json") + .expect("Failed to run second create command"); + + assert!( + create_result2.success(), + "Second create command failed: {}", + create_result2.stderr() + ); + + // Verify both environments were created + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-list-first"); + env_assertions.assert_environment_exists("test-list-second"); + + // Act: Run list command + let list_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_list_command() + .expect("Failed to run list command"); + + // Assert: Command should succeed + assert!( + list_result.success(), + "List command failed with exit code: {:?}\nstderr: {}", + list_result.exit_code(), + list_result.stderr() + ); + + // Assert: Output should contain both environment names + let stdout = list_result.stdout(); + assert!( + stdout.contains("test-list-first"), + "Expected environment name 'test-list-first' in output, got: {stdout}" + ); + assert!( + stdout.contains("test-list-second"), + "Expected environment name 'test-list-second' in output, got: {stdout}" + ); +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index 8ee7212b..d50a2c11 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -5,3 +5,5 @@ pub mod create_command; pub mod destroy_command; +pub mod list_command; +pub mod show_command; diff --git a/tests/e2e/show_command.rs b/tests/e2e/show_command.rs new file mode 100644 index 00000000..a29b6616 --- /dev/null +++ b/tests/e2e/show_command.rs @@ -0,0 +1,223 @@ +//! End-to-End Black Box Tests for Show Command +//! +//! This test suite provides true black-box testing of the show command +//! by running the production application as an external process. These tests +//! verify that the show command correctly displays environment details. +//! +//! ## Test Approach +//! +//! - **Black Box**: Runs production binary as external process +//! - **Isolation**: Uses temporary directories for complete test isolation +//! - **Coverage**: Tests show command with existing and non-existing environments +//! - **Verification**: Validates environment details appear in output +//! +//! ## Test Scenarios +//! +//! 1. Non-existing environment: Show command reports environment not found +//! 2. Created environment: Show command displays environment details +//! 3. State information: Show command includes state-aware details + +use super::super::support::{EnvironmentStateAssertions, ProcessRunner, TempWorkspace}; +use anyhow::Result; +use torrust_dependency_installer::{verify_dependencies, Dependency}; +use torrust_tracker_deployer_lib::testing::e2e::tasks::black_box::create_test_environment_config; + +/// Verify that all required dependencies are installed for show command E2E tests. +/// +/// **Current State**: No system dependencies required. +/// +/// These black-box tests run the production binary as an external process and verify +/// the show command workflow. Currently, they only test the command interface and +/// output formatting, without requiring infrastructure tools. +/// +/// # Errors +/// +/// Returns an error if any required dependencies are missing or cannot be detected. +fn verify_required_dependencies() -> Result<()> { + // Currently no system dependencies required - empty array + let required_deps: &[Dependency] = &[]; + verify_dependencies(required_deps)?; + Ok(()) +} + +#[test] +fn it_should_report_environment_not_found_when_environment_does_not_exist() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace (empty) + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Act: Run show command for a non-existing environment + let show_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_show_command("non-existing-env") + .expect("Failed to run show command"); + + // Assert: Command should fail because environment doesn't exist + assert!( + !show_result.success(), + "Show command should fail when environment does not exist" + ); + + // Assert: Error message should indicate environment not found + let stderr = show_result.stderr(); + assert!( + stderr.contains("not found") + || stderr.contains("Not found") + || stderr.contains("does not exist") + || stderr.contains("No environment") + || stderr.contains("data"), + "Expected error about missing environment, got: {stderr}" + ); +} + +#[test] +fn it_should_show_created_environment_details() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-show-env"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Verify environment was created + let env_assertions = EnvironmentStateAssertions::new(temp_workspace.path()); + env_assertions.assert_environment_exists("test-show-env"); + + // Act: Run show command + let show_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_show_command("test-show-env") + .expect("Failed to run show command"); + + // Assert: Command should succeed + assert!( + show_result.success(), + "Show command failed with exit code: {:?}\nstderr: {}", + show_result.exit_code(), + show_result.stderr() + ); + + // Assert: Output should contain the environment name + let stdout = show_result.stdout(); + assert!( + stdout.contains("test-show-env"), + "Expected environment name 'test-show-env' in output, got: {stdout}" + ); +} + +#[test] +fn it_should_show_environment_state() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-show-state"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Act: Run show command + let show_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_show_command("test-show-state") + .expect("Failed to run show command"); + + // Assert: Command should succeed + assert!( + show_result.success(), + "Show command failed with exit code: {:?}\nstderr: {}", + show_result.exit_code(), + show_result.stderr() + ); + + // Assert: Output should contain state information + // A newly created environment should be in 'created' state + let stdout = show_result.stdout(); + assert!( + stdout.contains("created") || stdout.contains("Created") || stdout.contains("state"), + "Expected state information in output, got: {stdout}" + ); +} + +#[test] +fn it_should_show_provider_information() { + // Verify dependencies before running tests + verify_required_dependencies().expect("Dependency verification failed"); + + // Arrange: Create temporary workspace + let temp_workspace = TempWorkspace::new().expect("Failed to create temp workspace"); + + // Create environment configuration file + let config = create_test_environment_config("test-show-provider"); + temp_workspace + .write_config_file("environment.json", &config) + .expect("Failed to write config file"); + + // Create environment + let create_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_create_command("./environment.json") + .expect("Failed to run create command"); + + assert!( + create_result.success(), + "Create command failed: {}", + create_result.stderr() + ); + + // Act: Run show command + let show_result = ProcessRunner::new() + .working_dir(temp_workspace.path()) + .run_show_command("test-show-provider") + .expect("Failed to run show command"); + + // Assert: Command should succeed + assert!( + show_result.success(), + "Show command failed with exit code: {:?}\nstderr: {}", + show_result.exit_code(), + show_result.stderr() + ); + + // Assert: Output should contain provider information + // The test config uses 'lxd' provider + let stdout = show_result.stdout(); + assert!( + stdout.contains("lxd") || stdout.contains("LXD") || stdout.contains("provider"), + "Expected provider information in output, got: {stdout}" + ); +}