Skip to main content

torrust_tracker_deployer_lib/presentation/cli/controllers/destroy/
handler.rs

1//! Destroy Command Handler
2//!
3//! This module handles the destroy command execution at the presentation layer,
4//! including environment validation, repository initialization, and user interaction.
5
6use std::cell::RefCell;
7use std::sync::Arc;
8
9use parking_lot::ReentrantMutex;
10
11use crate::application::command_handlers::DestroyCommandHandler;
12use crate::domain::environment::name::EnvironmentName;
13use crate::domain::environment::repository::EnvironmentRepository;
14use crate::domain::environment::state::Destroyed;
15use crate::domain::environment::Environment;
16use crate::presentation::cli::input::cli::OutputFormat;
17use crate::presentation::cli::views::commands::destroy::{DestroyDetailsData, JsonView, TextView};
18use crate::presentation::cli::views::progress::ProgressReporter;
19use crate::presentation::cli::views::Render;
20use crate::presentation::cli::views::UserOutput;
21use crate::shared::clock::Clock;
22
23use super::errors::DestroySubcommandError;
24
25/// Steps in the destroy workflow
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum DestroyStep {
28    ValidateEnvironment,
29    CreateCommandHandler,
30    TearDownInfrastructure,
31}
32
33impl DestroyStep {
34    /// All steps in execution order
35    const ALL: &'static [Self] = &[
36        Self::ValidateEnvironment,
37        Self::CreateCommandHandler,
38        Self::TearDownInfrastructure,
39    ];
40
41    /// Total number of steps
42    const fn count() -> usize {
43        Self::ALL.len()
44    }
45
46    /// User-facing description for the step
47    fn description(self) -> &'static str {
48        match self {
49            Self::ValidateEnvironment => "Validating environment",
50            Self::CreateCommandHandler => "Creating command handler",
51            Self::TearDownInfrastructure => "Tearing down infrastructure",
52        }
53    }
54}
55
56/// Presentation layer controller for destroy command workflow
57///
58/// Coordinates user interaction, progress reporting, and input validation
59/// before delegating to the application layer `DestroyCommandHandler`.
60///
61/// # Responsibilities
62///
63/// - Validate user input (environment name format)
64/// - Show progress updates to the user
65/// - Format success/error messages for display
66/// - Delegate business logic to application layer
67///
68/// # Architecture
69///
70/// This controller sits in the presentation layer and handles all user-facing
71/// concerns. It delegates actual business logic to the application layer's
72/// `DestroyCommandHandler`, maintaining clear separation of concerns.
73pub struct DestroyCommandController {
74    repository: Arc<dyn EnvironmentRepository + Send + Sync>,
75    clock: Arc<dyn Clock>,
76    progress: ProgressReporter,
77}
78
79impl DestroyCommandController {
80    /// Create a new destroy command controller
81    ///
82    /// Creates a `DestroyCommandController` with direct repository injection.
83    /// This follows the single container architecture pattern.
84    #[allow(clippy::needless_pass_by_value)] // Constructor takes ownership of Arc parameters
85    pub fn new(
86        repository: Arc<dyn EnvironmentRepository + Send + Sync>,
87        clock: Arc<dyn Clock>,
88        user_output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
89    ) -> Self {
90        let progress = ProgressReporter::new(user_output, DestroyStep::count());
91
92        Self {
93            repository,
94            clock,
95            progress,
96        }
97    }
98
99    /// Execute the complete destroy workflow
100    ///
101    /// Orchestrates all steps of the destroy command:
102    /// 1. Validate environment name
103    /// 2. Create command handler
104    /// 3. Tear down infrastructure
105    /// 4. Complete with success message
106    ///
107    /// # Arguments
108    ///
109    /// * `environment_name` - The name of the environment to destroy
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if:
114    /// - Environment name is invalid (format validation fails)
115    /// - Environment cannot be loaded from repository
116    /// - Infrastructure teardown fails
117    /// - Progress reporting encounters a poisoned mutex
118    ///
119    /// # Returns
120    ///
121    /// Returns `Ok(Environment<Destroyed>)` on success, or a `DestroySubcommandError` if any step fails.
122    #[allow(clippy::result_large_err)]
123    #[allow(clippy::unused_async)] // Part of uniform async presentation layer interface
124    pub async fn execute(
125        &mut self,
126        environment_name: &str,
127        output_format: OutputFormat,
128    ) -> Result<(), DestroySubcommandError> {
129        let env_name = self.validate_environment_name(environment_name)?;
130
131        let handler = self.create_command_handler()?;
132
133        let destroyed = self.tear_down_infrastructure(&handler, &env_name)?;
134
135        self.complete_workflow(environment_name, &destroyed, output_format)?;
136
137        Ok(())
138    }
139
140    /// Validate the environment name format
141    ///
142    /// Shows progress to user and validates that the environment name
143    /// meets domain requirements (1-63 chars, alphanumeric + hyphens).
144    #[allow(clippy::result_large_err)]
145    fn validate_environment_name(
146        &mut self,
147        name: &str,
148    ) -> Result<EnvironmentName, DestroySubcommandError> {
149        self.progress
150            .start_step(DestroyStep::ValidateEnvironment.description())?;
151
152        let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
153            DestroySubcommandError::InvalidEnvironmentName {
154                name: name.to_string(),
155                source,
156            }
157        })?;
158
159        self.progress
160            .complete_step(Some(&format!("Environment name validated: {name}")))?;
161
162        Ok(env_name)
163    }
164
165    /// Create application layer command handler
166    ///
167    /// Creates the application layer command handler with all required
168    /// dependencies (repository, clock, etc.).
169    #[allow(clippy::result_large_err)]
170    fn create_command_handler(&mut self) -> Result<DestroyCommandHandler, DestroySubcommandError> {
171        self.progress
172            .start_step(DestroyStep::CreateCommandHandler.description())?;
173        let handler = DestroyCommandHandler::new(self.repository.clone(), self.clock.clone());
174        self.progress.complete_step(None)?;
175
176        Ok(handler)
177    }
178
179    /// Execute infrastructure teardown via application layer
180    ///
181    /// Delegates to the application layer `DestroyCommandHandler` to
182    /// orchestrate the actual infrastructure destruction workflow.
183    #[allow(clippy::result_large_err)]
184    fn tear_down_infrastructure(
185        &mut self,
186        handler: &DestroyCommandHandler,
187        env_name: &EnvironmentName,
188    ) -> Result<Environment<Destroyed>, DestroySubcommandError> {
189        self.progress
190            .start_step(DestroyStep::TearDownInfrastructure.description())?;
191
192        let destroyed = handler.execute(env_name).map_err(|source| {
193            DestroySubcommandError::DestroyOperationFailed {
194                name: env_name.to_string(),
195                source,
196            }
197        })?;
198
199        self.progress
200            .complete_step(Some("Infrastructure torn down"))?;
201        Ok(destroyed)
202    }
203
204    /// Complete the workflow with environment details output
205    ///
206    /// Renders the destroyed environment details using the chosen output format
207    /// (text or JSON) and displays them to the user. In text mode, also shows
208    /// a hint about the purge command for complete cleanup.
209    #[allow(clippy::result_large_err)]
210    fn complete_workflow(
211        &mut self,
212        name: &str,
213        destroyed: &Environment<Destroyed>,
214        output_format: OutputFormat,
215    ) -> Result<(), DestroySubcommandError> {
216        let details = DestroyDetailsData::from(destroyed);
217
218        let output = match output_format {
219            OutputFormat::Text => TextView::render(&details)?,
220            OutputFormat::Json => JsonView::render(&details)?,
221        };
222
223        self.progress.result(&output)?;
224
225        // Purge hint is only shown in text mode — JSON consumers don't need human-readable hints
226        if matches!(output_format, OutputFormat::Text) {
227            self.progress.blank_line()?;
228            self.progress.output().lock().borrow_mut().result(&format!(
229                "💡 Local data preserved for debugging. To completely remove and reuse the name:\n   torrust-tracker-deployer purge {name} --force"
230            ));
231        }
232
233        Ok(())
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
241    use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
242    use crate::presentation::cli::views::testing::TestUserOutput;
243    use crate::presentation::cli::views::VerbosityLevel;
244    use crate::shared::SystemClock;
245    use std::fs;
246    use tempfile::TempDir;
247
248    /// Create test dependencies for destroy command handler tests
249    ///
250    /// Returns the common dependencies needed for testing `handle_destroy_command`:
251    /// - `user_output`: `ReentrantMutex`-wrapped `UserOutput` for thread-safe access
252    /// - `repository`: Environment repository with Send + Sync bounds
253    /// - `clock`: System clock for timing operations
254    #[allow(clippy::type_complexity)] // Test helper with complex but clear types
255    fn create_test_dependencies(
256        temp_dir: &TempDir,
257    ) -> (
258        Arc<ReentrantMutex<RefCell<UserOutput>>>,
259        Arc<dyn EnvironmentRepository + Send + Sync>,
260        Arc<dyn Clock>,
261    ) {
262        let (user_output, _, _) =
263            TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
264        let data_dir = temp_dir.path().join("data");
265        let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
266        let repository = file_repository_factory.create(data_dir);
267        let clock = Arc::new(SystemClock);
268
269        (user_output, repository, clock)
270    }
271
272    #[tokio::test]
273    async fn it_should_return_error_for_invalid_environment_name() {
274        let temp_dir = TempDir::new().unwrap();
275
276        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
277
278        // Test with invalid environment name (contains underscore)
279        let result = DestroyCommandController::new(repository, clock, user_output.clone())
280            .execute("invalid_name", OutputFormat::Text)
281            .await;
282
283        assert!(result.is_err());
284        match result.unwrap_err() {
285            DestroySubcommandError::InvalidEnvironmentName { name, .. } => {
286                assert_eq!(name, "invalid_name");
287            }
288            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
289        }
290    }
291
292    #[tokio::test]
293    async fn it_should_return_error_for_empty_environment_name() {
294        let temp_dir = TempDir::new().unwrap();
295
296        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
297
298        let result = DestroyCommandController::new(repository, clock, user_output.clone())
299            .execute("", OutputFormat::Text)
300            .await;
301
302        assert!(result.is_err());
303        match result.unwrap_err() {
304            DestroySubcommandError::InvalidEnvironmentName { name, .. } => {
305                assert_eq!(name, "");
306            }
307            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
308        }
309    }
310
311    #[tokio::test]
312    async fn it_should_return_error_for_nonexistent_environment() {
313        let temp_dir = TempDir::new().unwrap();
314
315        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
316
317        // Try to destroy an environment that doesn't exist
318        let result = DestroyCommandController::new(repository, clock, user_output.clone())
319            .execute("nonexistent-env", OutputFormat::Text)
320            .await;
321
322        assert!(result.is_err());
323        // Should get DestroyOperationFailed because environment doesn't exist
324        match result.unwrap_err() {
325            DestroySubcommandError::DestroyOperationFailed { name, .. } => {
326                assert_eq!(name, "nonexistent-env");
327            }
328            other => panic!("Expected DestroyOperationFailed, got: {other:?}"),
329        }
330    }
331
332    #[tokio::test]
333    async fn it_should_accept_valid_environment_name() {
334        let temp_dir = TempDir::new().unwrap();
335        let working_dir = temp_dir.path();
336
337        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
338
339        // Create a mock environment directory to test validation
340        let env_dir = working_dir.join("test-env");
341        fs::create_dir_all(&env_dir).unwrap();
342
343        // Valid environment name should pass validation, but will fail
344        // at destroy operation since we don't have a real environment setup
345        let result = DestroyCommandController::new(repository, clock, user_output.clone())
346            .execute("test-env", OutputFormat::Text)
347            .await;
348
349        // Should fail at operation, not at name validation
350        if let Err(DestroySubcommandError::InvalidEnvironmentName { .. }) = result {
351            panic!("Should not fail at name validation for 'test-env'");
352        }
353        // Expected - valid name but operation fails or other errors acceptable in test context
354    }
355}