dcd/composer/
engine.rs

1use crate::composer::{
2    config::parser::ConfigParser,
3    config::ports::PortsParser,
4    config::volumes::VolumesParser,
5    detection::{detect_compose_command, ComposeCommand, DetectionError},
6    errors::ComposerError,
7    types::{ComposeFile, ComposerConfig, ComposerOutput, ComposerResult},
8    variables::availability::EnvironmentChecker,
9    variables::availability::EnvironmentStatus,
10    variables::parser::VariablesParser,
11    variables::profiles::ProfilesHandler,
12};
13use crate::executor::CommandExecutor;
14
15use semver::Version;
16use std::{fs, path::PathBuf};
17
18pub struct Composer<T: CommandExecutor> {
19    executor: T,
20    config: ComposerConfig,
21    pub compose_command: ComposeCommand,
22    pub compose_version: Version,
23}
24
25impl<T: CommandExecutor> Composer<T> {
26    pub async fn try_new(mut executor: T, mut config: ComposerConfig) -> ComposerResult<Self> {
27        // --- Resolve project directory to absolute path ---
28        config.project_dir = fs::canonicalize(&config.project_dir).map_err(|e| {
29            ComposerError::ConfigurationError(format!(
30                "Failed to resolve project directory '{}': {}",
31                config.project_dir.display(),
32                e
33            ))
34        })?;
35        tracing::debug!(
36            "Resolved project directory to: {}",
37            config.project_dir.display()
38        );
39        // --- End resolve project directory ---
40
41        // Validate and handle compose files
42        if config.compose_files.is_empty() {
43            tracing::debug!("No compose files specified, looking for defaults...");
44            // Try docker-compose.yml first, then docker-compose.yaml
45            let yml_path = config.project_dir.join("docker-compose.yml");
46            let yaml_path = config.project_dir.join("docker-compose.yaml");
47
48            if yml_path.exists() {
49                tracing::debug!("Found default docker-compose.yml");
50                config.compose_files.push(yml_path);
51            } else if yaml_path.exists() {
52                tracing::debug!("Found default docker-compose.yaml");
53                config.compose_files.push(yaml_path);
54            } else {
55                return Err(ComposerError::ConfigurationError(
56                    "No compose files specified and no default docker-compose.yml or docker-compose.yaml found".to_string()
57                ));
58            }
59        } else {
60            // Verify all specified compose files exist
61            for file_path in &config.compose_files {
62                if !file_path.exists() {
63                    return Err(ComposerError::ConfigurationError(format!(
64                        "Specified compose file does not exist: {}",
65                        file_path.display()
66                    )));
67                }
68            }
69            tracing::debug!("All specified compose files exist");
70        }
71
72        // Validate and handle env files
73        if config.env_files.is_empty() {
74            tracing::debug!("No env files specified, looking for default '.env'...");
75            let default_env_path = config.project_dir.join(".env");
76            if default_env_path.exists() {
77                tracing::debug!("Found default .env file");
78                config.env_files.push(default_env_path);
79            } else {
80                tracing::debug!("No default .env file found");
81            }
82        } else {
83            // Verify all specified env files exist
84            for file_path in &config.env_files {
85                if !file_path.exists() {
86                    return Err(ComposerError::ConfigurationError(format!(
87                        "Specified env file does not exist: {}",
88                        file_path.display()
89                    )));
90                }
91            }
92            tracing::debug!("All specified env files exist");
93        }
94
95        tracing::debug!("Detecting docker compose command...");
96        let (command, version) =
97            detect_compose_command(&mut executor)
98                .await
99                .map_err(|e| match e {
100                    // Map detection errors to ComposerError
101                    DetectionError::CommandNotFound => ComposerError::CommandNotFound,
102                    DetectionError::VersionTooLow {
103                        command,
104                        version,
105                        required,
106                    } => ComposerError::VersionTooLow {
107                        command,
108                        version,
109                        required,
110                    },
111                    DetectionError::CommandFailed(exec_err) => {
112                        ComposerError::CommandExecutionError(format!(
113                            "Detection command failed: {}",
114                            exec_err
115                        ))
116                    }
117                    DetectionError::OutputParsingError(msg) => ComposerError::ParseError(format!(
118                        "Detection output parsing failed: {}",
119                        msg
120                    )),
121                    DetectionError::VersionParsingError {
122                        version_str,
123                        source,
124                    } => ComposerError::ConfigurationError(format!(
125                        "Version parsing failed for '{}': {}",
126                        version_str, source
127                    )),
128                })?;
129
130        Ok(Self {
131            executor,
132            config,
133            compose_command: command,
134            compose_version: version,
135        })
136    }
137
138    /// Main entry point - analyze docker compose configuration
139    pub async fn analyze(&mut self) -> ComposerResult<ComposerOutput> {
140        // Step 1: Check environment variables
141        let env_status = self.check_environment_variables().await?;
142
143        // If we have missing required variables, return early
144        if !env_status.is_valid() {
145            return Err(ComposerError::missing_vars(env_status.missing_required));
146        }
147
148        // Step 2: Get and parse the full compose config
149        let compose_file = self.get_compose_config().await?;
150
151        // Step 3: Extract all required information
152        let mut output = self.process_compose_file(&compose_file)?;
153
154        // Step 4: Handle profiles with access to env file variables
155        let mut profiles_handler = ProfilesHandler::new();
156        let mut env_checker = EnvironmentChecker::new();
157        env_checker
158            .check_environment(&[], &self.config.env_files)
159            .await?;
160        profiles_handler.set_env_file_vars(&env_checker.get_available_variables());
161
162        // Update profile information in output
163        output.active_profiles = profiles_handler.get_active_profiles();
164
165        // Validate profiles and add COMPOSE_PROFILES to consumed_env if valid
166        let profile_validation = profiles_handler.validate_profiles(&output.available_profiles)?;
167        if profile_validation.is_valid() {
168            if let Some(profiles_value) =
169                profiles_handler.get_env_dcd_value(&output.available_profiles)
170            {
171                output
172                    .consumed_env
173                    .insert("COMPOSE_PROFILES".to_string(), profiles_value);
174            }
175        }
176
177        // Add resolved environment variables to output
178        output
179            .consumed_env
180            .extend(env_status.get_resolved_variables());
181
182        // Add resolved file lists from the config held by the Composer instance
183        output.resolved_compose_files = self.config.compose_files.clone();
184        output.resolved_env_files = self.config.env_files.clone();
185        output.resolved_project_dir = self.config.project_dir.clone(); // Populate the resolved project dir
186
187        Ok(output)
188    }
189
190    /// Check environment variables using docker compose config --variables
191    async fn check_environment_variables(&mut self) -> ComposerResult<EnvironmentStatus> {
192        // Build the variables command
193        let vars_cmd = self.build_compose_command("config --variables")?;
194        tracing::debug!("Running command: {}", &vars_cmd);
195
196        // Execute the command
197        let result = self
198            .executor
199            .execute_command(&vars_cmd)
200            .await
201            .map_err(|e| ComposerError::command_error(e.to_string()))?;
202
203        if !result.is_success() {
204            return Err(ComposerError::command_error(
205                "Failed to get variables configuration",
206            ));
207        }
208
209        // Parse variables output
210        let variables =
211            VariablesParser::parse_variables_output(&result.output.to_stdout_string()?)?;
212
213        // Check environment status
214        let mut checker = EnvironmentChecker::new();
215        checker
216            .check_environment(&variables, &self.config.env_files)
217            .await
218    }
219
220    /// Get and parse the full docker compose config
221    async fn get_compose_config(&mut self) -> ComposerResult<ComposeFile> {
222        let config_cmd = self.build_compose_command("config")?;
223
224        let result = self
225            .executor
226            .execute_command(&config_cmd)
227            .await
228            .map_err(|e| ComposerError::CommandExecutionError(e.to_string()))?;
229
230        if !result.is_success() {
231            return Err(ComposerError::command_error(
232                "Failed to get compose configuration",
233            ));
234        }
235
236        ConfigParser::parse_config(&result.output.to_stdout_string()?)
237    }
238
239    /// Process the compose file to extract all required information
240    fn process_compose_file(&self, compose_file: &ComposeFile) -> ComposerResult<ComposerOutput> {
241        let mut output = ComposerOutput::new();
242
243        // Collect all available profiles from all services
244        let mut all_profiles = std::collections::HashSet::new();
245        for service in compose_file.services.values() {
246            if let Some(profiles) = &service.profiles {
247                all_profiles.extend(profiles.iter().cloned());
248            }
249        }
250        output.available_profiles = all_profiles.into_iter().collect();
251        output.available_profiles.sort();
252
253        // Handle profiles using ProfilesHandler
254        let profiles_handler = ProfilesHandler::new();
255        output.active_profiles = profiles_handler.get_active_profiles();
256
257        // Extract ports and volumes from all services (profiles are handled by docker-compose itself)
258        for service in compose_file.services.values() {
259            if let Some(ports) = &service.ports {
260                let parsed_ports = PortsParser::parse_ports(ports)?;
261                output.exposed_ports.extend(parsed_ports);
262            }
263
264            // Extract volumes
265            if let Some(volumes) = &service.volumes {
266                let parsed_volumes =
267                    VolumesParser::parse_volumes(volumes, &self.config.project_dir)?;
268                output.volumes.extend(parsed_volumes);
269            }
270        }
271
272        // Extract local references
273        let references = ConfigParser::extract_local_references(compose_file);
274        output
275            .local_references
276            .extend(references.into_iter().map(PathBuf::from));
277
278        Ok(output)
279    }
280
281    fn build_compose_command(&self, subcommand: &str) -> ComposerResult<String> {
282        let base_cmd = self.compose_command.command_string();
283        let mut cmd_parts = base_cmd.split_whitespace().collect::<Vec<&str>>();
284
285        // Add compose files
286        for file in &self.config.compose_files {
287            cmd_parts.push("-f");
288            cmd_parts.push(file.to_str().ok_or_else(|| {
289                ComposerError::ConfigurationError("Invalid compose file path".to_string())
290            })?);
291        }
292
293        // Add env files
294        for env_file in &self.config.env_files {
295            cmd_parts.push("--env-file");
296            cmd_parts.push(env_file.to_str().ok_or_else(|| {
297                ComposerError::ConfigurationError("Invalid env file path".to_string())
298            })?);
299        }
300
301        // Add subcommand
302        cmd_parts.extend(subcommand.split_whitespace());
303
304        Ok(cmd_parts.join(" "))
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::composer::{
312        detection::ComposeCommand,
313        types::{PortMapping, Service, VolumeMapping},
314    };
315    use crate::executor::{CommandResult, ExecutorError};
316    use async_trait::async_trait;
317    use std::collections::HashMap;
318    use std::fs;
319    use std::io::Write;
320    use tempfile::TempDir;
321
322    struct MockExecutor {
323        // Store Result directly to simulate execution errors
324        responses: HashMap<String, Result<CommandResult, ExecutorError>>,
325        commands: Vec<String>,
326    }
327
328    impl MockExecutor {
329        fn new() -> Self {
330            Self {
331                responses: HashMap::new(),
332                commands: Vec::new(),
333            }
334        }
335
336        fn add_response(&mut self, command: &str, result: Result<CommandResult, ExecutorError>) {
337            self.responses.insert(command.to_string(), result);
338        }
339
340        // Helper to set up standard successful detection (e.g., plugin)
341        fn setup_successful_plugin_detection(&mut self) {
342            self.add_response(
343                "docker compose version --format json",
344                create_success_result("{\"version\":\"v2.5.1\"}"),
345            );
346            // Optionally add a failure for standalone if needed by a specific test
347            self.add_response(
348                "docker-compose --version",
349                Err(ExecutorError::Other("command not found".into())), // Simulate execution error
350            );
351        }
352
353        // Helper to set up standard successful detection (e.g., standalone)
354        fn setup_successful_standalone_detection(&mut self) {
355            self.add_response(
356                "docker compose version --format json",
357                Err(ExecutorError::Other("command not found".into())),
358            );
359            self.add_response(
360                "docker-compose --version",
361                create_success_result("docker-compose version v1.29.2, build abcdef"),
362            );
363        }
364
365        // Helper to set up failed detection (both commands fail)
366        fn setup_failed_detection(&mut self) {
367            self.add_response(
368                "docker compose version --format json",
369                Err(ExecutorError::Other("command not found".into())),
370            );
371            self.add_response(
372                "docker-compose --version",
373                Err(ExecutorError::Other("command not found".into())),
374            );
375        }
376    }
377
378    #[async_trait]
379    impl CommandExecutor for MockExecutor {
380        async fn execute_command(&mut self, command: &str) -> Result<CommandResult, ExecutorError> {
381            self.commands.push(command.to_string());
382            // Clone the result from the map, or return an error if not found
383            let response = self.responses.get(command).cloned().ok_or_else(|| {
384                ExecutorError::Other(format!("Mock response not found for command: {}", command))
385            })?;
386
387            let result = response?; // Propagate potential ExecutorError stored in the map value
388            Ok(result)
389        }
390
391        async fn close(&mut self) -> Result<(), ExecutorError> {
392            Ok(())
393        }
394    }
395
396    fn create_test_environment() -> (TempDir, ComposerConfig) {
397        let temp_dir = TempDir::new().unwrap();
398        // Create a docker-compose.yml file in the temp directory
399        fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'").unwrap();
400
401        let config = ComposerConfig {
402            project_dir: temp_dir.path().to_path_buf(),
403            compose_files: vec![temp_dir.path().join("docker-compose.yml")],
404            env_files: vec![],
405        };
406
407        (temp_dir, config)
408    }
409
410    fn create_env_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf {
411        let env_path = dir.path().join(filename);
412        let mut file = fs::File::create(&env_path).unwrap();
413        write!(file, "{}", content).unwrap();
414        env_path
415    }
416
417    fn create_success_result(stdout: &str) -> Result<CommandResult, ExecutorError> {
418        let mut result = CommandResult::new("mock_command");
419        result.output.stdout = stdout.as_bytes().to_vec();
420        result.output.exit_code = 0;
421        Ok(result)
422    }
423
424    #[tokio::test]
425    async fn test_composer_try_new_success_plugin() {
426        let (temp_dir, config) = create_test_environment();
427        let mut executor = MockExecutor::new();
428        executor.setup_successful_plugin_detection(); // Setup mock for successful plugin detection
429
430        let composer_result = Composer::try_new(executor, config).await;
431        assert!(composer_result.is_ok());
432        let composer = composer_result.unwrap();
433
434        assert_eq!(composer.compose_command, ComposeCommand::Plugin);
435        assert_eq!(composer.compose_version, Version::parse("2.5.1").unwrap());
436
437        // Test build_compose_command implicitly
438        let cmd = composer.build_compose_command("config").unwrap();
439        let expected_start = format!(
440            "docker compose -f {}",
441            temp_dir.path().join("docker-compose.yml").display()
442        );
443        // let expected_env = format!("--env-file {}", temp_dir.path().join(".env").display()); // No env file in this setup
444        assert!(cmd.starts_with(&expected_start));
445        // assert!(cmd.contains(&expected_env));
446        assert!(cmd.ends_with(" config"));
447    }
448
449    #[tokio::test]
450    async fn test_composer_try_new_success_standalone() {
451        let (_temp_dir, config) = create_test_environment();
452        let mut executor = MockExecutor::new();
453        executor.setup_successful_standalone_detection(); // Setup mock for successful standalone detection
454
455        let composer_result = Composer::try_new(executor, config).await;
456        assert!(composer_result.is_ok());
457        let composer = composer_result.unwrap();
458
459        assert_eq!(composer.compose_command, ComposeCommand::Standalone);
460        assert_eq!(composer.compose_version, Version::parse("1.29.2").unwrap());
461    }
462
463    #[tokio::test]
464    async fn test_build_compose_command() {
465        // Tests that the build_compose_command method correctly constructs a docker compose command
466        // with the appropriate -f and --env-file flags based on the provided configuration
467        let (temp_dir, mut config) = create_test_environment();
468
469        // Add an env file
470        let env_path = create_env_file(&temp_dir, ".env", "TEST=value");
471        config.env_files.push(env_path);
472
473        let mut executor = MockExecutor::new();
474        executor.setup_successful_plugin_detection();
475        let composer = Composer::try_new(executor, config).await.unwrap();
476
477        let cmd = composer.build_compose_command("config").unwrap();
478
479        assert!(cmd.starts_with("docker compose -f "));
480        assert!(cmd.contains(" --env-file "));
481        assert!(cmd.ends_with(" config"));
482    }
483
484    #[tokio::test]
485    async fn test_get_compose_config() {
486        let (_temp_dir, config) = create_test_environment();
487
488        // Mock the config output
489        let config_output = r#"
490services:
491  db:
492    container_name: postgres
493    environment:
494      POSTGRES_PASSWORD: password
495      POSTGRES_USER: user
496    image: postgres:13
497    networks:
498      default: null
499    ports:
500      - mode: ingress
501        target: 5432
502        published: "5432"
503        protocol: tcp
504    volumes:
505      - type: volume
506        source: postgres_data
507        target: /var/lib/postgresql/data
508        volume: {}
509networks:
510  default:
511    name: dcd_default
512volumes:
513  postgres_data:
514    name: dcd_postgres_dat
515"#;
516
517        let mut executor = MockExecutor::new();
518        executor.setup_successful_plugin_detection(); // Need detection to succeed
519                                                      // build_compose_command will construct the command with the absolute path
520        let expected_config_cmd = format!(
521            "docker compose -f {} config",
522            config.compose_files[0].display()
523        );
524        executor.add_response(&expected_config_cmd, create_success_result(config_output)); // Mock the config command
525        let mut composer = Composer::try_new(executor, config).await.unwrap();
526
527        let compose_file = composer.get_compose_config().await.unwrap();
528
529        assert!(compose_file.services.contains_key("db"));
530        let db_service = &compose_file.services["db"];
531        assert_eq!(db_service.image, Some("postgres:13".to_string()));
532        assert_eq!(db_service.container_name, Some("postgres".to_string()));
533
534        // Check ports
535        let ports = db_service.ports.as_ref().unwrap();
536        assert_eq!(ports.len(), 1);
537        assert_eq!(ports[0].published, "5432");
538        assert_eq!(ports[0].target, 5432);
539
540        // Check volumes
541        let volumes = db_service.volumes.as_ref().unwrap();
542        assert_eq!(volumes.len(), 1);
543        assert_eq!(volumes[0].source, Some("postgres_data".to_string()));
544        assert_eq!(volumes[0].target, "/var/lib/postgresql/data");
545    }
546
547    #[tokio::test]
548    async fn test_process_compose_file() {
549        let (_temp_dir, config) = create_test_environment();
550
551        // Create a compose file with services, ports, and volumes
552        let mut services = HashMap::new();
553
554        // Add a service with ports and volumes
555        let db_service = Service {
556            container_name: Some("postgres".to_string()),
557            image: Some("postgres:13".to_string()),
558            build: None,
559            environment: None,
560            ports: Some(vec![PortMapping {
561                mode: None,
562                target: 5432,
563                published: "5432".to_string(),
564                protocol: None,
565            }]),
566            volumes: Some(vec![VolumeMapping {
567                r#type: "bind".to_string(),
568                source: Some("/local/path".to_string()),
569                target: "/container/path".to_string(),
570                read_only: Some(false),
571            }]),
572            configs: None,
573            env_file: None,
574            profiles: None,
575        };
576
577        services.insert("db".to_string(), db_service);
578
579        let compose_file = ComposeFile {
580            services,
581            volumes: None,
582        };
583
584        // Need a Composer instance, detection doesn't matter for this test function itself
585        let mut executor = MockExecutor::new();
586        executor.setup_successful_plugin_detection(); // Provide detection mocks
587        let composer = Composer::try_new(executor, config).await.unwrap();
588
589        let output = composer.process_compose_file(&compose_file).unwrap();
590
591        // Check extracted ports
592        assert_eq!(output.exposed_ports.len(), 1);
593        assert_eq!(output.exposed_ports[0].published, "5432");
594        assert_eq!(output.exposed_ports[0].target, 5432);
595
596        // Check extracted volumes
597        assert_eq!(output.volumes.len(), 1);
598        assert_eq!(output.volumes[0].r#type, "bind");
599        // Source path should be resolved relative to project_dir
600        assert_eq!(output.volumes[0].target, "/container/path");
601
602        // Check local references (from bind mount source)
603        assert_eq!(output.local_references.len(), 1);
604    }
605
606    #[tokio::test]
607    async fn test_composer_try_new_detection_failure() {
608        let (_temp_dir, config) = create_test_environment();
609
610        let mut executor = MockExecutor::new();
611        executor.setup_failed_detection(); // Setup mock for failed detection
612
613        let result = Composer::try_new(executor, config).await;
614
615        // Should return a CommandNotFound error
616        assert!(result.is_err());
617        // Check that the error is specifically CommandNotFound
618        match result.err().unwrap() {
619            ComposerError::CommandExecutionError(_) => {} // Expect CommandExecutionError
620            e => panic!("Expected CommandExecutionError, got {:?}", e),
621        }
622    }
623
624    #[tokio::test]
625    async fn test_composer_try_new_version_too_low() {
626        let (_temp_dir, config) = create_test_environment();
627        let mut executor = MockExecutor::new();
628        // Mock plugin detection failing
629        executor.add_response(
630            "docker compose version --format json",
631            Err(ExecutorError::Other("command not found".into())),
632        );
633        // Mock standalone detection succeeding but with an old version
634        executor.add_response(
635            "docker-compose --version",
636            create_success_result("docker-compose version v1.20.0, build abcdef"),
637        );
638
639        let result = Composer::try_new(executor, config).await;
640        assert!(result.is_err());
641        match result.err().unwrap() {
642            ComposerError::VersionTooLow {
643                command, version, ..
644            } => {
645                assert_eq!(command, "docker-compose");
646                assert_eq!(version, Version::parse("1.20.0").unwrap());
647            }
648            e => panic!("Expected VersionTooLow, got {:?}", e),
649        }
650    }
651
652    #[tokio::test]
653    async fn test_composer_try_new_missing_compose_files() {
654        // Create temp dir but don't create any compose files
655        let temp_dir = TempDir::new().unwrap();
656
657        let config = ComposerConfig {
658            project_dir: temp_dir.path().to_path_buf(),
659            compose_files: vec![], // Empty compose files list
660            env_files: vec![],
661        };
662
663        let mut executor = MockExecutor::new();
664        executor.setup_successful_plugin_detection();
665
666        let result = Composer::try_new(executor, config).await;
667        assert!(result.is_err());
668        match result.err().unwrap() {
669            ComposerError::ConfigurationError(msg) => {
670                assert!(msg.contains("No compose files specified and no default"));
671            }
672            e => panic!("Expected ConfigurationError, got {:?}", e),
673        }
674    }
675
676    #[tokio::test]
677    async fn test_composer_try_new_nonexistent_compose_file() {
678        let temp_dir = TempDir::new().unwrap();
679
680        let config = ComposerConfig {
681            project_dir: temp_dir.path().to_path_buf(),
682            compose_files: vec![temp_dir.path().join("nonexistent-file.yml")], // File doesn't exist
683            env_files: vec![],
684        };
685
686        let mut executor = MockExecutor::new();
687        executor.setup_successful_plugin_detection();
688
689        let result = Composer::try_new(executor, config).await;
690        assert!(result.is_err());
691        match result.err().unwrap() {
692            ComposerError::ConfigurationError(msg) => {
693                assert!(msg.contains("does not exist"));
694            }
695            e => panic!("Expected ConfigurationError, got {:?}", e),
696        }
697    }
698
699    #[tokio::test]
700    async fn test_composer_try_new_default_compose_file() {
701        // Create temp dir with a default docker-compose.yml
702        let temp_dir = TempDir::new().unwrap();
703        fs::write(temp_dir.path().join("docker-compose.yml"), "version: '3'").unwrap();
704
705        let config = ComposerConfig {
706            project_dir: temp_dir.path().to_path_buf(),
707            compose_files: vec![], // Empty compose files list - should find default
708            env_files: vec![],
709        };
710
711        let mut executor = MockExecutor::new();
712        executor.setup_successful_plugin_detection();
713
714        // Mock the config command that will be called after detection
715        let expected_config_cmd = format!(
716            "docker compose -f {} config",
717            temp_dir.path().join("docker-compose.yml").display()
718        );
719        executor.add_response(&expected_config_cmd, create_success_result("services: {}"));
720
721        let result = Composer::try_new(executor, config).await;
722        assert!(result.is_ok());
723    }
724}