Skip to main content

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

1//! Run Command Handler
2//!
3//! This module handles the run 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;
10
11use crate::application::command_handlers::run::RunCommandHandler;
12use crate::application::command_handlers::show::info::{GrafanaInfo, ServiceInfo};
13use crate::domain::environment::name::EnvironmentName;
14use crate::domain::environment::repository::EnvironmentRepository;
15use crate::domain::environment::state::AnyEnvironmentState;
16use crate::presentation::cli::input::cli::OutputFormat;
17use crate::presentation::cli::views::commands::run::{JsonView, RunDetailsData, 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::RunSubcommandError;
24
25/// Steps in the run workflow
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum RunStep {
28    ValidateEnvironment,
29    RunServices,
30}
31
32impl RunStep {
33    /// All steps in execution order
34    const ALL: &'static [Self] = &[Self::ValidateEnvironment, Self::RunServices];
35
36    /// Total number of steps
37    const fn count() -> usize {
38        Self::ALL.len()
39    }
40
41    /// User-facing description for the step
42    fn description(self) -> &'static str {
43        match self {
44            Self::ValidateEnvironment => "Validating environment",
45            Self::RunServices => "Running application services",
46        }
47    }
48}
49
50/// Presentation layer controller for run command workflow
51///
52/// Coordinates user interaction, progress reporting, and input validation
53/// before delegating to the application layer `RunCommandHandler`.
54///
55/// # Responsibilities
56///
57/// - Validate user input (environment name format)
58/// - Validate environment state (must be Released)
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/// `RunCommandHandler`.
68pub struct RunCommandController {
69    repository: Arc<dyn EnvironmentRepository + Send + Sync>,
70    clock: Arc<dyn Clock>,
71    progress: ProgressReporter,
72}
73
74impl RunCommandController {
75    /// Create a new run command controller
76    ///
77    /// Creates a `RunCommandController` 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, RunStep::count());
86
87        Self {
88            repository,
89            clock,
90            progress,
91        }
92    }
93
94    /// Execute the complete run workflow
95    ///
96    /// Orchestrates all steps of the run command:
97    /// 1. Validate environment name
98    /// 2. Run application services via `RunCommandHandler`
99    /// 3. Complete with success message
100    ///
101    /// # Arguments
102    ///
103    /// * `environment_name` - The name of the environment to run services in
104    /// * `output_format` - Output format (Text or Json)
105    ///
106    /// # Errors
107    ///
108    /// Returns an error if:
109    /// - Environment name is invalid (format validation fails)
110    /// - Environment is not in the Released state
111    /// - Service start fails
112    ///
113    /// # Returns
114    ///
115    /// Returns `Ok(())` on success, or a `RunSubcommandError` if any step fails.
116    #[allow(clippy::result_large_err)]
117    #[allow(clippy::unused_async)] // Part of uniform async presentation layer interface
118    pub async fn execute(
119        &mut self,
120        environment_name: &str,
121        output_format: OutputFormat,
122    ) -> Result<(), RunSubcommandError> {
123        let env_name = self.validate_environment_name(environment_name)?;
124
125        self.run_services(&env_name)?;
126
127        self.complete_workflow(environment_name, output_format)?;
128
129        Ok(())
130    }
131
132    /// Validate the environment name format
133    ///
134    /// Shows progress to user and validates that the environment name
135    /// meets domain requirements (1-63 chars, alphanumeric + hyphens).
136    #[allow(clippy::result_large_err)]
137    fn validate_environment_name(
138        &mut self,
139        name: &str,
140    ) -> Result<EnvironmentName, RunSubcommandError> {
141        self.progress
142            .start_step(RunStep::ValidateEnvironment.description())?;
143
144        let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
145            RunSubcommandError::InvalidEnvironmentName {
146                name: name.to_string(),
147                source,
148            }
149        })?;
150
151        self.progress
152            .complete_step(Some(&format!("Environment name validated: {name}")))?;
153
154        Ok(env_name)
155    }
156
157    /// Run services via the application layer handler
158    ///
159    /// Delegates to `RunCommandHandler` to execute the run workflow:
160    /// 1. Load environment from repository
161    /// 2. Validate environment is in Released state
162    /// 3. Start Docker Compose services via Ansible
163    /// 4. Update environment state to Running
164    #[allow(clippy::result_large_err)]
165    fn run_services(&mut self, env_name: &EnvironmentName) -> Result<(), RunSubcommandError> {
166        self.progress
167            .start_step(RunStep::RunServices.description())?;
168
169        // Cast the repository to the base trait type that RunCommandHandler expects
170        let repository: Arc<dyn crate::domain::environment::repository::EnvironmentRepository> =
171            Arc::clone(&self.repository)
172                as Arc<dyn crate::domain::environment::repository::EnvironmentRepository>;
173
174        let handler = RunCommandHandler::new(repository, Arc::clone(&self.clock));
175
176        handler.execute(env_name)?;
177
178        self.progress.complete_step(Some("Services started"))?;
179
180        Ok(())
181    }
182
183    /// Complete the workflow with success message and service URLs
184    ///
185    /// Loads environment info and displays:
186    /// 1. Service URLs (excluding localhost-only services)
187    /// 2. DNS hint for HTTPS/TLS services
188    /// 3. Tip to use `show` command for full details
189    ///
190    /// Follows the same pattern as the show command for loading environment
191    /// and extracting service information.
192    #[allow(clippy::result_large_err)]
193    fn complete_workflow(
194        &mut self,
195        name: &str,
196        output_format: OutputFormat,
197    ) -> Result<(), RunSubcommandError> {
198        // Load environment to get service information
199        let env_name = EnvironmentName::new(name.to_string()).map_err(|source| {
200            RunSubcommandError::InvalidEnvironmentName {
201                name: name.to_string(),
202                source,
203            }
204        })?;
205
206        let any_env = self.load_environment(&env_name)?;
207
208        // Display success message
209        self.progress
210            .complete(&format!("Run command completed for '{name}'"))?;
211
212        // Display service URLs and hints
213        self.display_service_urls(&any_env, output_format)?;
214
215        Ok(())
216    }
217
218    /// Load environment from repository
219    ///
220    /// Reuses the same loading logic as the show command.
221    #[allow(clippy::result_large_err)]
222    fn load_environment(
223        &self,
224        env_name: &EnvironmentName,
225    ) -> Result<AnyEnvironmentState, RunSubcommandError> {
226        if !self.repository.exists(env_name)? {
227            return Err(RunSubcommandError::EnvironmentNotAccessible {
228                name: env_name.to_string(),
229                data_dir: "data".to_string(),
230            });
231        }
232
233        self.repository.load(env_name)?.ok_or_else(|| {
234            RunSubcommandError::EnvironmentNotAccessible {
235                name: env_name.to_string(),
236                data_dir: "data".to_string(),
237            }
238        })
239    }
240
241    /// Display service URLs and DNS hints
242    ///
243    /// Uses the Strategy Pattern to render output in the requested format:
244    /// - Text format: Uses `TextView` with `CompactServiceUrlsView` and `DnsHintView`
245    /// - JSON format: Uses `JsonView` for machine-readable output
246    ///
247    /// # Architecture
248    ///
249    /// Following the MVC pattern with functional composition:
250    /// - Model: `ServiceInfo` and `GrafanaInfo` (application layer DTOs)
251    /// - View: `TextView::render()` or `JsonView::render()` (formatting)
252    /// - Controller (this method): Orchestrates the pipeline
253    /// - Output: `ProgressReporter::result()` (routing to stdout)
254    #[allow(clippy::result_large_err)]
255    fn display_service_urls(
256        &mut self,
257        any_env: &AnyEnvironmentState,
258        output_format: OutputFormat,
259    ) -> Result<(), RunSubcommandError> {
260        if let Some(instance_ip) = any_env.instance_ip() {
261            let tracker_config = any_env.tracker_config();
262            let grafana_config = any_env.grafana_config();
263
264            let services =
265                ServiceInfo::from_tracker_config(tracker_config, instance_ip, grafana_config);
266
267            let grafana =
268                grafana_config.map(|config| GrafanaInfo::from_config(config, instance_ip));
269
270            let data = RunDetailsData::new(any_env.name().to_string(), services, grafana);
271
272            // Render using appropriate view based on output format (Strategy Pattern)
273            let output = match output_format {
274                OutputFormat::Text => TextView::render(&data)?,
275                OutputFormat::Json => JsonView::render(&data)?,
276            };
277
278            // Pipeline: RunDetailsData → render → output to stdout
279            self.progress.result(&output)?;
280        }
281
282        Ok(())
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::infrastructure::persistence::file_repository_factory::FileRepositoryFactory;
290    use crate::presentation::cli::controllers::constants::DEFAULT_LOCK_TIMEOUT;
291    use crate::presentation::cli::input::cli::OutputFormat;
292    use crate::presentation::cli::views::testing::TestUserOutput;
293    use crate::presentation::cli::views::VerbosityLevel;
294    use crate::shared::SystemClock;
295    use tempfile::TempDir;
296
297    /// Create test dependencies for run command handler tests
298    #[allow(clippy::type_complexity)]
299    fn create_test_dependencies(
300        temp_dir: &TempDir,
301    ) -> (
302        Arc<ReentrantMutex<RefCell<UserOutput>>>,
303        Arc<dyn EnvironmentRepository + Send + Sync>,
304        Arc<dyn Clock>,
305    ) {
306        let (user_output, _, _) =
307            TestUserOutput::new(VerbosityLevel::Normal).into_reentrant_wrapped();
308        let data_dir = temp_dir.path().join("data");
309        let file_repository_factory = FileRepositoryFactory::new(DEFAULT_LOCK_TIMEOUT);
310        let repository = file_repository_factory.create(data_dir);
311        let clock = Arc::new(SystemClock);
312
313        (user_output, repository, clock)
314    }
315
316    #[tokio::test]
317    async fn it_should_return_error_for_invalid_environment_name() {
318        let temp_dir = TempDir::new().unwrap();
319
320        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
321
322        // Test with invalid environment name (contains underscore)
323        let result = RunCommandController::new(repository, clock, user_output.clone())
324            .execute("invalid_name", OutputFormat::Text)
325            .await;
326
327        assert!(result.is_err());
328        match result.unwrap_err() {
329            RunSubcommandError::InvalidEnvironmentName { name, .. } => {
330                assert_eq!(name, "invalid_name");
331            }
332            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
333        }
334    }
335
336    #[tokio::test]
337    async fn it_should_return_error_for_empty_environment_name() {
338        let temp_dir = TempDir::new().unwrap();
339
340        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
341
342        let result = RunCommandController::new(repository, clock, user_output.clone())
343            .execute("", OutputFormat::Text)
344            .await;
345
346        assert!(result.is_err());
347        match result.unwrap_err() {
348            RunSubcommandError::InvalidEnvironmentName { name, .. } => {
349                assert_eq!(name, "");
350            }
351            other => panic!("Expected InvalidEnvironmentName, got: {other:?}"),
352        }
353    }
354
355    #[tokio::test]
356    async fn it_should_return_error_when_environment_not_found() {
357        let temp_dir = TempDir::new().unwrap();
358
359        let (user_output, repository, clock) = create_test_dependencies(&temp_dir);
360
361        // Valid environment name but doesn't exist
362        let result = RunCommandController::new(repository, clock, user_output.clone())
363            .execute("test-env", OutputFormat::Text)
364            .await;
365
366        assert!(result.is_err());
367        match result.unwrap_err() {
368            RunSubcommandError::EnvironmentNotAccessible { name, .. } => {
369                assert_eq!(name, "test-env");
370            }
371            other => panic!("Expected EnvironmentNotAccessible, got: {other:?}"),
372        }
373    }
374}