Skip to main content

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

1//! Release Command Handler
2//!
3//! This module handles the release command execution at the presentation layer,
4//! including environment validation, state validation, and user interaction.
5
6use std::cell::RefCell;
7use std::sync::Arc;
8
9use parking_lot::ReentrantMutex;
10use tracing::info;
11
12use crate::application::command_handlers::release::ReleaseCommandHandler;
13use crate::domain::environment::name::EnvironmentName;
14use crate::domain::environment::repository::EnvironmentRepository;
15use crate::domain::environment::state::Released;
16use crate::domain::environment::Environment;
17use crate::presentation::cli::input::cli::OutputFormat;
18use crate::presentation::cli::views::commands::release::{JsonView, ReleaseDetailsData, TextView};
19use crate::presentation::cli::views::progress::{ProgressReporter, VerboseProgressListener};
20use crate::presentation::cli::views::Render;
21use crate::presentation::cli::views::UserOutput;
22use crate::shared::clock::Clock;
23
24use super::errors::ReleaseSubcommandError;
25
26/// Steps in the release workflow
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28enum ReleaseStep {
29    ValidateEnvironment,
30    ReleaseApplication,
31}
32
33impl ReleaseStep {
34    /// All steps in execution order
35    const ALL: &'static [Self] = &[Self::ValidateEnvironment, Self::ReleaseApplication];
36
37    /// Total number of steps
38    const fn count() -> usize {
39        Self::ALL.len()
40    }
41
42    /// User-facing description for the step
43    fn description(self) -> &'static str {
44        match self {
45            Self::ValidateEnvironment => "Validating environment",
46            Self::ReleaseApplication => "Releasing application",
47        }
48    }
49}
50
51/// Presentation layer controller for release command workflow
52///
53/// Coordinates user interaction, progress reporting, and input validation
54/// before delegating to the application layer `ReleaseCommandHandler`.
55///
56/// # Responsibilities
57///
58/// - Validate user input (environment name format)
59/// - Show progress updates to the user
60/// - Format success/error messages for display
61/// - Delegate business logic to application layer
62///
63/// # Architecture
64///
65/// This controller sits in the presentation layer and handles all user-facing
66/// concerns. It delegates actual business logic to the application layer's
67/// `ReleaseCommandHandler`.
68pub struct ReleaseCommandController {
69    repository: Arc<dyn EnvironmentRepository + Send + Sync>,
70    clock: Arc<dyn Clock>,
71    progress: ProgressReporter,
72}
73
74impl ReleaseCommandController {
75    /// Create a new release command controller
76    ///
77    /// Creates a `ReleaseCommandController` with direct repository injection.
78    /// This follows the single container architecture pattern.
79    #[allow(clippy::needless_pass_by_value)] // Constructor takes ownership of Arc parameters
80    pub fn new(
81        repository: Arc<dyn EnvironmentRepository + Send + Sync>,
82        clock: Arc<dyn Clock>,
83        user_output: Arc<ReentrantMutex<RefCell<UserOutput>>>,
84    ) -> Self {
85        let progress = ProgressReporter::new(user_output, ReleaseStep::count());
86
87        Self {
88            repository,
89            clock,
90            progress,
91        }
92    }
93
94    /// Execute the complete release workflow
95    ///
96    /// Orchestrates all steps of the release command:
97    /// 1. Validate environment name
98    /// 2. Execute release via application handler
99    /// 3. Complete with success message
100    ///
101    /// # Arguments
102    ///
103    /// * `environment_name` - The name of the environment to release to
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if:
108    /// - Environment name is invalid (format validation fails)
109    /// - Environment is not in the Configured state
110    /// - Docker Compose file preparation fails
111    /// - State persistence fails
112    ///
113    /// # Returns
114    ///
115    /// Returns `Ok(())` on success, or a `ReleaseSubcommandError` if any step fails.
116    #[allow(clippy::result_large_err)]
117    pub async fn execute(
118        &mut self,
119        environment_name: &str,
120        output_format: OutputFormat,
121    ) -> Result<(), ReleaseSubcommandError> {
122        let env_name = self.validate_environment_name(environment_name)?;
123
124        let released_env = self.release_application(&env_name).await?;
125
126        self.complete_workflow(&released_env, output_format)?;
127
128        Ok(())
129    }
130
131    /// Validate the environment name format
132    ///
133    /// Shows progress to user and validates that the environment name
134    /// meets domain requirements (1-63 chars, alphanumeric + hyphens).
135    #[allow(clippy::result_large_err)]
136    fn validate_environment_name(
137        &mut self,
138        name: &str,
139    ) -> Result<EnvironmentName, ReleaseSubcommandError> {
140        self.progress
141            .start_step(ReleaseStep::ValidateEnvironment.description())?;
142
143        let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
144            ReleaseSubcommandError::InvalidEnvironmentName {
145                name: name.to_string(),
146                source,
147            }
148        })?;
149
150        self.progress
151            .complete_step(Some(&format!("Environment name validated: {name}")))?;
152
153        Ok(env_name)
154    }
155
156    /// Release application to the environment
157    ///
158    /// Calls the application layer handler to execute the release workflow.
159    #[allow(clippy::result_large_err)]
160    async fn release_application(
161        &mut self,
162        env_name: &EnvironmentName,
163    ) -> Result<Environment<Released>, ReleaseSubcommandError> {
164        self.progress
165            .start_step(ReleaseStep::ReleaseApplication.description())?;
166
167        let handler = ReleaseCommandHandler::new(self.repository.clone(), self.clock.clone());
168
169        // Create the listener for verbose progress reporting.
170        // The VerboseProgressListener translates step events into
171        // user-facing detail messages via UserOutput's verbosity filter.
172        let listener = VerboseProgressListener::new(self.progress.output().clone());
173
174        let released_env = handler
175            .execute(env_name, Some(&listener))
176            .await
177            .map_err(|source| ReleaseSubcommandError::ApplicationLayerError { source })?;
178
179        info!(
180            environment = %env_name,
181            final_state = "Released",
182            "Application released successfully"
183        );
184
185        self.progress
186            .complete_step(Some("Application released successfully"))?;
187
188        Ok(released_env)
189    }
190
191    /// Complete the workflow with environment details output
192    ///
193    /// Renders the released environment details using the chosen output format
194    /// (text or JSON) and displays them to the user.
195    #[allow(clippy::result_large_err)]
196    fn complete_workflow(
197        &mut self,
198        released_env: &Environment<Released>,
199        output_format: OutputFormat,
200    ) -> Result<(), ReleaseSubcommandError> {
201        let details = ReleaseDetailsData::from(released_env);
202
203        let output = match output_format {
204            OutputFormat::Text => TextView::render(&details)?,
205            OutputFormat::Json => JsonView::render(&details)?,
206        };
207
208        self.progress.result(&output)?;
209
210        Ok(())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
218    use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
219    use crate::presentation::cli::views::testing::TestUserOutput;
220    use crate::presentation::cli::views::VerbosityLevel;
221    use crate::shared::SystemClock;
222    use tempfile::TempDir;
223
224    /// Create test dependencies for release command handler tests
225    #[allow(clippy::type_complexity)]
226    fn create_test_dependencies(
227        temp_dir: &TempDir,
228    ) -> (
229        Arc<ReentrantMutex<RefCell<UserOutput>>>,
230        Arc<dyn EnvironmentRepository + Send + Sync>,
231        Arc<dyn Clock>,
232    ) {
233        let (user_output, _, _) =
234            TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
235        let data_dir = temp_dir.path().join("data");
236        let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
237        let repository = file_repository_factory.create(data_dir);
238        let clock = Arc::new(SystemClock);
239
240        (user_output, repository, clock)
241    }
242
243    #[tokio::test]
244    async fn it_should_return_error_for_invalid_environment_name() {
245        let temp_dir = TempDir::new().unwrap();
246
247        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
248
249        // Test with invalid environment name (contains underscore)
250        let result = ReleaseCommandController::new(repository, clock, user_output.clone())
251            .execute("invalid_name", OutputFormat::Text)
252            .await;
253
254        assert!(result.is_err());
255        match result.unwrap_err() {
256            ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => {
257                assert_eq!(name, "invalid_name");
258            }
259            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
260        }
261    }
262
263    #[tokio::test]
264    async fn it_should_return_error_for_empty_environment_name() {
265        let temp_dir = TempDir::new().unwrap();
266
267        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
268
269        let result = ReleaseCommandController::new(repository, clock, user_output.clone())
270            .execute("", OutputFormat::Text)
271            .await;
272
273        assert!(result.is_err());
274        match result.unwrap_err() {
275            ReleaseSubcommandError::InvalidEnvironmentName { name, .. } => {
276                assert_eq!(name, "");
277            }
278            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
279        }
280    }
281
282    #[tokio::test]
283    async fn it_should_return_error_for_nonexistent_environment() {
284        let temp_dir = TempDir::new().unwrap();
285
286        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
287
288        // Valid environment name but environment doesn't exist
289        let result = ReleaseCommandController::new(repository, clock, user_output.clone())
290            .execute("test-env", OutputFormat::Text)
291            .await;
292
293        // Should fail because environment doesn't exist
294        assert!(result.is_err());
295        match result.unwrap_err() {
296            ReleaseSubcommandError::ApplicationLayerError { .. } => (),
297            other => panic!("Expected ApplicationLayerError, got: {other:?}"),
298        }
299    }
300}