Skip to main content

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

1//! Provision Command Handler
2//!
3//! This module handles the provision 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::ProvisionCommandHandler;
12use crate::domain::environment::name::EnvironmentName;
13use crate::domain::environment::repository::EnvironmentRepository;
14use crate::domain::environment::state::Provisioned;
15use crate::domain::environment::Environment;
16use crate::presentation::cli::input::cli::OutputFormat;
17use crate::presentation::cli::views::commands::provision::{
18    JsonView, ProvisionDetailsData, TextView,
19};
20use crate::presentation::cli::views::progress::ProgressReporter;
21use crate::presentation::cli::views::progress::VerboseProgressListener;
22use crate::presentation::cli::views::Render;
23use crate::presentation::cli::views::UserOutput;
24use crate::shared::clock::Clock;
25
26use super::errors::ProvisionSubcommandError;
27
28/// Steps in the provision workflow
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum ProvisionStep {
31    ValidateEnvironment,
32    CreateCommandHandler,
33    ProvisionInfrastructure,
34}
35
36impl ProvisionStep {
37    /// All steps in execution order
38    const ALL: &'static [Self] = &[
39        Self::ValidateEnvironment,
40        Self::CreateCommandHandler,
41        Self::ProvisionInfrastructure,
42    ];
43
44    /// Total number of steps
45    const fn count() -> usize {
46        Self::ALL.len()
47    }
48
49    /// User-facing description for the step
50    fn description(self) -> &'static str {
51        match self {
52            Self::ValidateEnvironment => "Validating environment",
53            Self::CreateCommandHandler => "Creating command handler",
54            Self::ProvisionInfrastructure => "Provisioning infrastructure",
55        }
56    }
57}
58
59/// Presentation layer controller for provision command workflow
60///
61/// Coordinates user interaction, progress reporting, and input validation
62/// before delegating to the application layer `ProvisionCommandHandler`.
63///
64/// # Responsibilities
65///
66/// - Validate user input (environment name format)
67/// - Show progress updates to the user
68/// - Format success/error messages for display
69/// - Delegate business logic to application layer
70///
71/// # Architecture
72///
73/// This controller sits in the presentation layer and handles all user-facing
74/// concerns. It delegates actual business logic to the application layer's
75/// `ProvisionCommandHandler`, maintaining clear separation of concerns.
76pub struct ProvisionCommandController {
77    repository: Arc<dyn EnvironmentRepository + Send + Sync>,
78    clock: Arc<dyn Clock>,
79    progress: ProgressReporter,
80}
81
82impl ProvisionCommandController {
83    /// Create a new provision command controller
84    ///
85    /// Creates a `ProvisionCommandController` with direct service injection.
86    /// This follows the single container architecture pattern.
87    #[allow(clippy::needless_pass_by_value)] // Constructor takes ownership of Arc parameters
88    pub fn new(
89        repository: Arc<dyn EnvironmentRepository + Send + Sync>,
90        clock: Arc<dyn Clock>,
91        user_output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
92    ) -> Self {
93        let progress = ProgressReporter::new(user_output, ProvisionStep::count());
94
95        Self {
96            repository,
97            clock,
98            progress,
99        }
100    }
101
102    /// Execute the complete provision workflow
103    ///
104    /// Orchestrates all steps of the provision command:
105    /// 1. Validate environment name
106    /// 2. Load and validate environment state
107    /// 3. Create command handler
108    /// 4. Provision infrastructure
109    /// 5. Complete with success message
110    /// 6. Display provision results (connection details + DNS reminder)
111    ///
112    /// # Arguments
113    ///
114    /// * `environment_name` - The name of the environment to provision
115    /// * `output_format` - Output format for results (Text or Json)
116    ///
117    /// # Errors
118    ///
119    /// Returns an error if:
120    /// - Environment name is invalid (format validation fails)
121    /// - Environment cannot be loaded from repository
122    /// - Environment is not in "Created" state
123    /// - Infrastructure provisioning fails
124    /// - Progress reporting encounters a poisoned mutex
125    ///
126    /// # Returns
127    ///
128    /// Returns `Ok(Environment<Provisioned>)` on success, or a `ProvisionSubcommandError` if any step fails.
129    #[allow(clippy::result_large_err)]
130    pub async fn execute(
131        &mut self,
132        environment_name: &str,
133        output_format: OutputFormat,
134    ) -> Result<Environment<Provisioned>, ProvisionSubcommandError> {
135        let env_name = self.validate_environment_name(environment_name)?;
136
137        let handler = self.create_command_handler()?;
138
139        let provisioned = self.provision_infrastructure(&handler, &env_name).await?;
140
141        self.complete_workflow(environment_name)?;
142
143        self.display_provision_results(&provisioned, output_format)?;
144
145        Ok(provisioned)
146    }
147
148    /// Validate the environment name format
149    ///
150    /// Shows progress to user and validates that the environment name
151    /// meets domain requirements (1-63 chars, alphanumeric + hyphens).
152    #[allow(clippy::result_large_err)]
153    fn validate_environment_name(
154        &mut self,
155        name: &str,
156    ) -> Result<EnvironmentName, ProvisionSubcommandError> {
157        self.progress
158            .start_step(ProvisionStep::ValidateEnvironment.description())?;
159
160        let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
161            ProvisionSubcommandError::InvalidEnvironmentName {
162                name: name.to_string(),
163                source,
164            }
165        })?;
166
167        self.progress
168            .complete_step(Some(&format!("Environment name validated: {name}")))?;
169
170        Ok(env_name)
171    }
172
173    /// Create application layer command handler
174    ///
175    /// Creates the application layer command handler with all required
176    /// dependencies (repository, clock).
177    #[allow(clippy::result_large_err)]
178    fn create_command_handler(
179        &mut self,
180    ) -> Result<ProvisionCommandHandler, ProvisionSubcommandError> {
181        self.progress
182            .start_step(ProvisionStep::CreateCommandHandler.description())?;
183        let handler = ProvisionCommandHandler::new(self.clock.clone(), self.repository.clone());
184        self.progress.complete_step(None)?;
185
186        Ok(handler)
187    }
188
189    /// Execute infrastructure provisioning via application layer
190    ///
191    /// Delegates to the application layer `ProvisionCommandHandler` to
192    /// orchestrate the actual infrastructure provisioning workflow.
193    ///
194    /// The application layer handles:
195    /// - Loading the environment from repository
196    /// - Validating the environment state (must be Created)
197    /// - Complete provisioning workflow
198    /// - State transitions and persistence
199    #[allow(clippy::result_large_err)]
200    async fn provision_infrastructure(
201        &mut self,
202        handler: &ProvisionCommandHandler,
203        env_name: &EnvironmentName,
204    ) -> Result<Environment<Provisioned>, ProvisionSubcommandError> {
205        self.progress
206            .start_step(ProvisionStep::ProvisionInfrastructure.description())?;
207
208        // Create the listener for verbose progress reporting.
209        // The VerboseProgressListener translates step events into
210        // user-facing detail messages via UserOutput's verbosity filter.
211        let listener = VerboseProgressListener::new(self.progress.output().clone());
212
213        let provisioned = handler
214            .execute(env_name, Some(&listener))
215            .await
216            .map_err(
217                |source| ProvisionSubcommandError::ProvisionOperationFailed {
218                    name: env_name.to_string(),
219                    source: Box::new(source),
220                },
221            )?;
222
223        self.progress
224            .complete_step(Some("Infrastructure provisioned"))?;
225        Ok(provisioned)
226    }
227
228    /// Complete the workflow with success message
229    ///
230    /// Shows final success message to the user with workflow summary.
231    #[allow(clippy::result_large_err)]
232    fn complete_workflow(&mut self, name: &str) -> Result<(), ProvisionSubcommandError> {
233        self.progress
234            .complete(&format!("Environment '{name}' provisioned successfully"))?;
235        Ok(())
236    }
237
238    /// Display the results of successful provisioning
239    ///
240    /// This method outputs:
241    /// - Final completion message with environment name
242    /// - Provision details (IP address, SSH credentials, domains, etc.)
243    ///
244    /// The output formatting is delegated to the view layer (`TextView` or `JsonView`)
245    /// following the MVC pattern and Strategy Pattern. This separates presentation
246    /// concerns from controller logic and allows easy addition of new formats.
247    ///
248    /// # Arguments
249    ///
250    /// * `provisioned` - The successfully provisioned environment
251    /// * `output_format` - The format to use for rendering output (Text or Json)
252    ///
253    /// # Returns
254    ///
255    /// Returns `Ok(())` on success, or `ProvisionSubcommandError` if progress reporting fails.
256    ///
257    /// # Errors
258    ///
259    /// This function will return an error if progress reporting encounters issues,
260    /// which indicates the environment was provisioned but we couldn't display results.
261    #[allow(clippy::result_large_err)]
262    fn display_provision_results(
263        &mut self,
264        provisioned: &Environment<Provisioned>,
265        output_format: OutputFormat,
266    ) -> Result<(), ProvisionSubcommandError> {
267        self.progress.blank_line()?;
268
269        // Convert domain model to presentation DTO
270        let details = ProvisionDetailsData::from(provisioned);
271
272        // Render using appropriate view based on output format (Strategy Pattern)
273        let output = match output_format {
274            OutputFormat::Text => TextView::render(&details)?,
275            OutputFormat::Json => JsonView::render(&details).map_err(|e| {
276                ProvisionSubcommandError::OutputFormatting {
277                    reason: format!("Failed to serialize provision details as JSON: {e}"),
278                }
279            })?,
280        };
281
282        // Output the rendered result
283        self.progress.result(&output)?;
284
285        Ok(())
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
293    use crate::presentation::cli::views::testing::TestUserOutput;
294    use crate::presentation::cli::views::VerbosityLevel;
295    use crate::shared::SystemClock;
296
297    /// Create test dependencies for provision command handler tests
298    ///
299    /// Returns the common dependencies needed for testing `handle_provision_command`:
300    /// - `user_output`: `ReentrantMutex`-wrapped `UserOutput` for thread-safe access
301    /// - `repository`: Environment repository for persistence
302    /// - `clock`: System clock for timing operations
303    #[allow(clippy::type_complexity)] // Test helper with complex but clear types
304    fn create_test_dependencies(
305        temp_dir: &tempfile::TempDir,
306    ) -> (
307        Arc<ReentrantMutex<RefCell<UserOutput>>>,
308        Arc<dyn EnvironmentRepository + Send + Sync>,
309        Arc<dyn Clock>,
310    ) {
311        use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
312        let (user_output, _, _) =
313            TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
314        let data_dir = temp_dir.path().join("data");
315        let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
316        let repository = file_repository_factory.create(data_dir);
317        let clock = Arc::new(SystemClock);
318
319        (user_output, repository, clock)
320    }
321
322    #[tokio::test]
323    async fn it_should_return_error_for_invalid_environment_name() {
324        use tempfile::TempDir;
325
326        let temp_dir = TempDir::new().unwrap();
327
328        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
329
330        // Test with invalid environment name (contains underscore)
331        let result = ProvisionCommandController::new(repository, clock, user_output.clone())
332            .execute("invalid_name", OutputFormat::Text)
333            .await;
334
335        assert!(result.is_err());
336        match result.unwrap_err() {
337            ProvisionSubcommandError::InvalidEnvironmentName { name, .. } => {
338                assert_eq!(name, "invalid_name");
339            }
340            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
341        }
342    }
343
344    #[tokio::test]
345    async fn it_should_return_error_for_empty_environment_name() {
346        use tempfile::TempDir;
347
348        let temp_dir = TempDir::new().unwrap();
349
350        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
351
352        let result = ProvisionCommandController::new(repository, clock, user_output.clone())
353            .execute("", OutputFormat::Text)
354            .await;
355
356        assert!(result.is_err());
357        match result.unwrap_err() {
358            ProvisionSubcommandError::InvalidEnvironmentName { name, .. } => {
359                assert_eq!(name, "");
360            }
361            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
362        }
363    }
364
365    #[tokio::test]
366    async fn it_should_return_error_for_nonexistent_environment() {
367        use tempfile::TempDir;
368
369        let temp_dir = TempDir::new().unwrap();
370
371        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
372
373        // Test environment that doesn't exist yet
374        let result = ProvisionCommandController::new(repository, clock, user_output.clone())
375            .execute("non-existent-env", OutputFormat::Text)
376            .await;
377
378        assert!(result.is_err());
379        // After refactoring, repository NotFound error is wrapped in ProvisionOperationFailed
380        match result.unwrap_err() {
381            ProvisionSubcommandError::ProvisionOperationFailed { name, .. } => {
382                assert_eq!(name, "non-existent-env");
383            }
384            other => panic!("Expected ProvisionOperationFailed, got: {other:?}"),
385        }
386    }
387
388    #[tokio::test]
389    async fn it_should_accept_valid_environment_name() {
390        use std::fs;
391        use tempfile::TempDir;
392
393        let temp_dir = TempDir::new().unwrap();
394        let working_dir = temp_dir.path();
395
396        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
397
398        // Create a mock environment directory to test validation
399        let env_dir = working_dir.join("test-env");
400        fs::create_dir_all(&env_dir).unwrap();
401
402        // Valid environment name should pass validation, but will fail
403        // at provision operation since we don't have a real environment setup
404        let result = ProvisionCommandController::new(repository, clock, user_output.clone())
405            .execute("test-env", OutputFormat::Text)
406            .await;
407
408        // Should fail at operation, not at name validation
409        if let Err(ProvisionSubcommandError::InvalidEnvironmentName { .. }) = result {
410            panic!("Should not fail at name validation for 'test-env'");
411        }
412        // Expected - valid name but operation fails or other errors acceptable in test context
413    }
414}