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 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 if config.compose_files.is_empty() {
43 tracing::debug!("No compose files specified, looking for defaults...");
44 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 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 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 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 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 pub async fn analyze(&mut self) -> ComposerResult<ComposerOutput> {
140 let env_status = self.check_environment_variables().await?;
142
143 if !env_status.is_valid() {
145 return Err(ComposerError::missing_vars(env_status.missing_required));
146 }
147
148 let compose_file = self.get_compose_config().await?;
150
151 let mut output = self.process_compose_file(&compose_file)?;
153
154 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 output.active_profiles = profiles_handler.get_active_profiles();
164
165 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 output
179 .consumed_env
180 .extend(env_status.get_resolved_variables());
181
182 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(); Ok(output)
188 }
189
190 async fn check_environment_variables(&mut self) -> ComposerResult<EnvironmentStatus> {
192 let vars_cmd = self.build_compose_command("config --variables")?;
194 tracing::debug!("Running command: {}", &vars_cmd);
195
196 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 let variables =
211 VariablesParser::parse_variables_output(&result.output.to_stdout_string()?)?;
212
213 let mut checker = EnvironmentChecker::new();
215 checker
216 .check_environment(&variables, &self.config.env_files)
217 .await
218 }
219
220 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 fn process_compose_file(&self, compose_file: &ComposeFile) -> ComposerResult<ComposerOutput> {
241 let mut output = ComposerOutput::new();
242
243 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 let profiles_handler = ProfilesHandler::new();
255 output.active_profiles = profiles_handler.get_active_profiles();
256
257 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 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 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 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 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 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 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 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 self.add_response(
348 "docker-compose --version",
349 Err(ExecutorError::Other("command not found".into())), );
351 }
352
353 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 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 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?; 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 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(); 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 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 assert!(cmd.starts_with(&expected_start));
445 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(); 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 let (temp_dir, mut config) = create_test_environment();
468
469 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 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(); 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)); 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 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 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 let mut services = HashMap::new();
553
554 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 let mut executor = MockExecutor::new();
586 executor.setup_successful_plugin_detection(); let composer = Composer::try_new(executor, config).await.unwrap();
588
589 let output = composer.process_compose_file(&compose_file).unwrap();
590
591 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 assert_eq!(output.volumes.len(), 1);
598 assert_eq!(output.volumes[0].r#type, "bind");
599 assert_eq!(output.volumes[0].target, "/container/path");
601
602 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(); let result = Composer::try_new(executor, config).await;
614
615 assert!(result.is_err());
617 match result.err().unwrap() {
619 ComposerError::CommandExecutionError(_) => {} 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 executor.add_response(
630 "docker compose version --format json",
631 Err(ExecutorError::Other("command not found".into())),
632 );
633 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 let temp_dir = TempDir::new().unwrap();
656
657 let config = ComposerConfig {
658 project_dir: temp_dir.path().to_path_buf(),
659 compose_files: vec![], 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")], 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 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![], env_files: vec![],
709 };
710
711 let mut executor = MockExecutor::new();
712 executor.setup_successful_plugin_detection();
713
714 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}