syncable_cli/analyzer/
docker_analyzer.rs

1//! # Docker Analyzer Module
2//!
3//! This module provides Docker infrastructure analysis capabilities for detecting:
4//! - Dockerfiles and their variants (dockerfile.dev, dockerfile.prod, etc.)
5//! - Docker Compose files and their variants (docker-compose.dev.yaml, etc.)
6//! - Port mappings and networking configuration
7//! - Service discovery and inter-service communication
8//! - Container orchestration patterns
9
10use crate::error::Result;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::fs;
15use std::path::{Path, PathBuf};
16
17/// Represents a Docker infrastructure analysis
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct DockerAnalysis {
20    /// All Dockerfiles found in the project
21    pub dockerfiles: Vec<DockerfileInfo>,
22    /// All Docker Compose files found in the project
23    pub compose_files: Vec<ComposeFileInfo>,
24    /// Analyzed services from compose files
25    pub services: Vec<DockerService>,
26    /// Network configuration and service discovery
27    pub networking: NetworkingConfig,
28    /// Overall container orchestration pattern
29    pub orchestration_pattern: OrchestrationPattern,
30    /// Environment-specific configurations
31    pub environments: Vec<DockerEnvironment>,
32}
33
34/// Information about a Dockerfile
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DockerfileInfo {
37    /// Path to the Dockerfile
38    pub path: PathBuf,
39    /// Environment this Dockerfile is for (dev, prod, staging, etc.)
40    pub environment: Option<String>,
41    /// Base image used
42    pub base_image: Option<String>,
43    /// Exposed ports from EXPOSE instructions
44    pub exposed_ports: Vec<u16>,
45    /// Working directory
46    pub workdir: Option<String>,
47    /// Entry point or CMD
48    pub entrypoint: Option<String>,
49    /// Environment variables defined
50    pub env_vars: Vec<String>,
51    /// Multi-stage build stages
52    pub build_stages: Vec<String>,
53    /// Whether it's a multi-stage build
54    pub is_multistage: bool,
55    /// Dockerfile instructions count (complexity indicator)
56    pub instruction_count: usize,
57}
58
59/// Dockerfile discovery result for deployment wizard
60///
61/// Provides deployment-focused metadata about a Dockerfile including
62/// build context path, suggested service name, and port configuration.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct DiscoveredDockerfile {
65    /// Absolute path to the Dockerfile
66    pub path: PathBuf,
67    /// Relative path from project root to Dockerfile directory (build context)
68    pub build_context: String,
69    /// Suggested service name based on directory structure
70    pub suggested_service_name: String,
71    /// Suggested port for deployment (from EXPOSE or default)
72    pub suggested_port: Option<u16>,
73    /// Base image from Dockerfile
74    pub base_image: Option<String>,
75    /// Whether this is a multi-stage build
76    pub is_multistage: bool,
77    /// Environment type (dev, prod, staging) from filename
78    pub environment: Option<String>,
79}
80
81/// Information about a Docker Compose file
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
83pub struct ComposeFileInfo {
84    /// Path to the compose file
85    pub path: PathBuf,
86    /// Environment this compose file is for
87    pub environment: Option<String>,
88    /// Compose file version
89    pub version: Option<String>,
90    /// Services defined in the compose file
91    pub service_names: Vec<String>,
92    /// Networks defined
93    pub networks: Vec<String>,
94    /// Volumes defined
95    pub volumes: Vec<String>,
96    /// External dependencies (external networks, volumes)
97    pub external_dependencies: Vec<String>,
98}
99
100/// Container orchestration patterns
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
102pub enum OrchestrationPattern {
103    /// Single container application
104    SingleContainer,
105    /// Multiple containers with docker-compose
106    DockerCompose,
107    /// Microservices architecture
108    Microservices,
109    /// Event-driven architecture
110    EventDriven,
111    /// Service mesh
112    ServiceMesh,
113    /// Mixed or complex pattern
114    Mixed,
115}
116
117/// Represents a Docker service from compose files
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119pub struct DockerService {
120    /// Service name
121    pub name: String,
122    /// Which compose file this service is defined in
123    pub compose_file: PathBuf,
124    /// Docker image or build context
125    pub image_or_build: ImageOrBuild,
126    /// Port mappings
127    pub ports: Vec<PortMapping>,
128    /// Environment variables
129    pub environment: HashMap<String, String>,
130    /// Service dependencies
131    pub depends_on: Vec<String>,
132    /// Networks this service is connected to
133    pub networks: Vec<String>,
134    /// Volumes mounted
135    pub volumes: Vec<VolumeMount>,
136    /// Health check configuration
137    pub health_check: Option<HealthCheck>,
138    /// Restart policy
139    pub restart_policy: Option<String>,
140    /// Resource limits
141    pub resource_limits: Option<ResourceLimits>,
142}
143
144/// Image or build configuration for a service
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub enum ImageOrBuild {
147    /// Uses a pre-built image
148    Image(String),
149    /// Builds from a Dockerfile
150    Build {
151        context: String,
152        dockerfile: Option<String>,
153        args: HashMap<String, String>,
154    },
155}
156
157/// Port mapping configuration
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct PortMapping {
160    /// Host port (external)
161    pub host_port: Option<u16>,
162    /// Container port (internal)
163    pub container_port: u16,
164    /// Protocol (tcp, udp)
165    pub protocol: String,
166    /// Whether this port is exposed to the host
167    pub exposed_to_host: bool,
168}
169
170/// Volume mount configuration
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct VolumeMount {
173    /// Source (host path or volume name)
174    pub source: String,
175    /// Target path in container
176    pub target: String,
177    /// Mount type (bind, volume, tmpfs)
178    pub mount_type: String,
179    /// Whether it's read-only
180    pub read_only: bool,
181}
182
183/// Health check configuration
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185pub struct HealthCheck {
186    /// Test command
187    pub test: String,
188    /// Interval between checks
189    pub interval: Option<String>,
190    /// Timeout for each check
191    pub timeout: Option<String>,
192    /// Number of retries
193    pub retries: Option<u32>,
194}
195
196/// Resource limits configuration
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct ResourceLimits {
199    /// CPU limit
200    pub cpu_limit: Option<String>,
201    /// Memory limit
202    pub memory_limit: Option<String>,
203    /// CPU reservation
204    pub cpu_reservation: Option<String>,
205    /// Memory reservation
206    pub memory_reservation: Option<String>,
207}
208
209/// Networking configuration analysis
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211pub struct NetworkingConfig {
212    /// Custom networks defined
213    pub custom_networks: Vec<NetworkInfo>,
214    /// Service discovery patterns
215    pub service_discovery: ServiceDiscoveryConfig,
216    /// Load balancing configuration
217    pub load_balancing: Vec<LoadBalancerConfig>,
218    /// External connectivity patterns
219    pub external_connectivity: ExternalConnectivity,
220}
221
222/// Network information
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
224pub struct NetworkInfo {
225    /// Network name
226    pub name: String,
227    /// Network driver
228    pub driver: Option<String>,
229    /// Whether it's external
230    pub external: bool,
231    /// Connected services
232    pub connected_services: Vec<String>,
233}
234
235/// Service discovery configuration
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
237pub struct ServiceDiscoveryConfig {
238    /// Whether services can discover each other by name
239    pub internal_dns: bool,
240    /// External service discovery tools
241    pub external_tools: Vec<String>,
242    /// Service mesh indicators
243    pub service_mesh: bool,
244}
245
246/// Load balancer configuration
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
248pub struct LoadBalancerConfig {
249    /// Service name
250    pub service: String,
251    /// Load balancer type (nginx, traefik, etc.)
252    pub lb_type: String,
253    /// Backend services
254    pub backends: Vec<String>,
255}
256
257/// External connectivity patterns
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259pub struct ExternalConnectivity {
260    /// Services exposed to external traffic
261    pub exposed_services: Vec<ExposedService>,
262    /// Ingress patterns
263    pub ingress_patterns: Vec<String>,
264    /// API gateways
265    pub api_gateways: Vec<String>,
266}
267
268/// Service exposed to external traffic
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
270pub struct ExposedService {
271    /// Service name
272    pub service: String,
273    /// External ports
274    pub external_ports: Vec<u16>,
275    /// Protocols
276    pub protocols: Vec<String>,
277    /// Whether it has SSL/TLS
278    pub ssl_enabled: bool,
279}
280
281/// Environment-specific Docker configuration
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
283pub struct DockerEnvironment {
284    /// Environment name (dev, prod, staging, etc.)
285    pub name: String,
286    /// Dockerfiles for this environment
287    pub dockerfiles: Vec<PathBuf>,
288    /// Compose files for this environment
289    pub compose_files: Vec<PathBuf>,
290    /// Environment-specific configurations
291    pub config_overrides: HashMap<String, String>,
292}
293
294/// Analyzes Docker infrastructure in a project
295pub fn analyze_docker_infrastructure(project_root: &Path) -> Result<DockerAnalysis> {
296    log::info!(
297        "Starting Docker infrastructure analysis for: {}",
298        project_root.display()
299    );
300
301    // Find all Docker-related files
302    let dockerfiles = find_dockerfiles(project_root)?;
303    let compose_files = find_compose_files(project_root)?;
304
305    log::debug!(
306        "Found {} Dockerfiles and {} Compose files",
307        dockerfiles.len(),
308        compose_files.len()
309    );
310
311    // Parse Dockerfiles
312    let parsed_dockerfiles: Vec<DockerfileInfo> = dockerfiles
313        .into_iter()
314        .filter_map(|path| parse_dockerfile(&path).ok())
315        .collect();
316
317    // Parse Compose files
318    let parsed_compose_files: Vec<ComposeFileInfo> = compose_files
319        .into_iter()
320        .filter_map(|path| parse_compose_file(&path).ok())
321        .collect();
322
323    // Extract services from compose files
324    let services = extract_services_from_compose(&parsed_compose_files)?;
325
326    // Analyze networking
327    let networking = analyze_networking(&services, &parsed_compose_files)?;
328
329    // Determine orchestration pattern
330    let orchestration_pattern = determine_orchestration_pattern(&services, &networking);
331
332    // Analyze environments
333    let environments = analyze_environments(&parsed_dockerfiles, &parsed_compose_files);
334
335    Ok(DockerAnalysis {
336        dockerfiles: parsed_dockerfiles,
337        compose_files: parsed_compose_files,
338        services,
339        networking,
340        orchestration_pattern,
341        environments,
342    })
343}
344
345/// Finds all Dockerfiles in the project, including variants
346fn find_dockerfiles(project_root: &Path) -> Result<Vec<PathBuf>> {
347    let mut dockerfiles = Vec::new();
348
349    fn collect_dockerfiles_recursive(dir: &Path, dockerfiles: &mut Vec<PathBuf>) -> Result<()> {
350        if dir.file_name().is_some_and(|name| {
351            name == "node_modules" || name == ".git" || name == "target" || name == ".next"
352        }) {
353            return Ok(());
354        }
355
356        for entry in fs::read_dir(dir)? {
357            let entry = entry?;
358            let path = entry.path();
359
360            if path.is_dir() {
361                collect_dockerfiles_recursive(&path, dockerfiles)?;
362            } else if let Some(filename) = path.file_name().and_then(|n| n.to_str())
363                && is_dockerfile_name(filename)
364            {
365                dockerfiles.push(path);
366            }
367        }
368        Ok(())
369    }
370
371    collect_dockerfiles_recursive(project_root, &mut dockerfiles)?;
372
373    Ok(dockerfiles)
374}
375
376/// Checks if a filename matches Dockerfile patterns
377fn is_dockerfile_name(filename: &str) -> bool {
378    let filename_lower = filename.to_lowercase();
379
380    // Exact matches
381    if filename_lower == "dockerfile" {
382        return true;
383    }
384
385    // Pattern matches
386    if filename_lower.starts_with("dockerfile.") {
387        return true;
388    }
389
390    if filename_lower.ends_with(".dockerfile") {
391        return true;
392    }
393
394    false
395}
396
397/// Finds all Docker Compose files in the project
398fn find_compose_files(project_root: &Path) -> Result<Vec<PathBuf>> {
399    let mut compose_files = Vec::new();
400
401    fn collect_compose_files_recursive(dir: &Path, compose_files: &mut Vec<PathBuf>) -> Result<()> {
402        if dir.file_name().is_some_and(|name| {
403            name == "node_modules" || name == ".git" || name == "target" || name == ".next"
404        }) {
405            return Ok(());
406        }
407
408        for entry in fs::read_dir(dir)? {
409            let entry = entry?;
410            let path = entry.path();
411
412            if path.is_dir() {
413                collect_compose_files_recursive(&path, compose_files)?;
414            } else if let Some(filename) = path.file_name().and_then(|n| n.to_str())
415                && is_compose_file_name(filename)
416            {
417                compose_files.push(path);
418            }
419        }
420        Ok(())
421    }
422
423    collect_compose_files_recursive(project_root, &mut compose_files)?;
424
425    Ok(compose_files)
426}
427
428/// Checks if a filename matches Docker Compose patterns
429fn is_compose_file_name(filename: &str) -> bool {
430    let filename_lower = filename.to_lowercase();
431
432    // Common compose file patterns
433    let patterns = [
434        "docker-compose.yml",
435        "docker-compose.yaml",
436        "compose.yml",
437        "compose.yaml",
438    ];
439
440    // Exact matches
441    for pattern in &patterns {
442        if filename_lower == *pattern {
443            return true;
444        }
445    }
446
447    // Environment-specific patterns
448    if filename_lower.starts_with("docker-compose.")
449        && (filename_lower.ends_with(".yml") || filename_lower.ends_with(".yaml"))
450    {
451        return true;
452    }
453
454    if filename_lower.starts_with("compose.")
455        && (filename_lower.ends_with(".yml") || filename_lower.ends_with(".yaml"))
456    {
457        return true;
458    }
459
460    false
461}
462
463/// Parses a Dockerfile and extracts information
464fn parse_dockerfile(path: &PathBuf) -> Result<DockerfileInfo> {
465    let content = fs::read_to_string(path)?;
466    let lines: Vec<&str> = content.lines().collect();
467
468    let mut info = DockerfileInfo {
469        path: path.clone(),
470        environment: extract_environment_from_filename(path),
471        base_image: None,
472        exposed_ports: Vec::new(),
473        workdir: None,
474        entrypoint: None,
475        env_vars: Vec::new(),
476        build_stages: Vec::new(),
477        is_multistage: false,
478        instruction_count: 0,
479    };
480
481    // Regex patterns for Dockerfile instructions
482    let from_regex = Regex::new(r"(?i)^FROM\s+(.+?)(?:\s+AS\s+(.+))?$").unwrap();
483    let expose_regex = Regex::new(r"(?i)^EXPOSE\s+(.+)$").unwrap();
484    let workdir_regex = Regex::new(r"(?i)^WORKDIR\s+(.+)$").unwrap();
485    let cmd_regex = Regex::new(r"(?i)^CMD\s+(.+)$").unwrap();
486    let entrypoint_regex = Regex::new(r"(?i)^ENTRYPOINT\s+(.+)$").unwrap();
487    let env_regex = Regex::new(r"(?i)^ENV\s+(.+)$").unwrap();
488
489    for line in lines {
490        let line = line.trim();
491        if line.is_empty() || line.starts_with('#') {
492            continue;
493        }
494
495        info.instruction_count += 1;
496
497        // Parse FROM instructions
498        if let Some(captures) = from_regex.captures(line) {
499            if info.base_image.is_none() {
500                info.base_image = Some(captures.get(1).unwrap().as_str().trim().to_string());
501            }
502            if let Some(stage_name) = captures.get(2) {
503                info.build_stages
504                    .push(stage_name.as_str().trim().to_string());
505                info.is_multistage = true;
506            }
507        }
508
509        // Parse EXPOSE instructions
510        if let Some(captures) = expose_regex.captures(line) {
511            let ports_str = captures.get(1).unwrap().as_str();
512            for port in ports_str.split_whitespace() {
513                if let Ok(port_num) = port.parse::<u16>() {
514                    info.exposed_ports.push(port_num);
515                }
516            }
517        }
518
519        // Parse WORKDIR
520        if let Some(captures) = workdir_regex.captures(line) {
521            info.workdir = Some(captures.get(1).unwrap().as_str().trim().to_string());
522        }
523
524        // Parse CMD and ENTRYPOINT
525        if let Some(captures) = cmd_regex.captures(line)
526            && info.entrypoint.is_none()
527        {
528            info.entrypoint = Some(captures.get(1).unwrap().as_str().trim().to_string());
529        }
530
531        if let Some(captures) = entrypoint_regex.captures(line) {
532            info.entrypoint = Some(captures.get(1).unwrap().as_str().trim().to_string());
533        }
534
535        // Parse ENV
536        if let Some(captures) = env_regex.captures(line) {
537            info.env_vars
538                .push(captures.get(1).unwrap().as_str().trim().to_string());
539        }
540    }
541
542    Ok(info)
543}
544
545/// Parses a Docker Compose file and extracts information
546fn parse_compose_file(path: &PathBuf) -> Result<ComposeFileInfo> {
547    let content = fs::read_to_string(path)?;
548
549    // Parse YAML content
550    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
551        crate::error::AnalysisError::DependencyParsing {
552            file: path.display().to_string(),
553            reason: format!("YAML parsing error: {}", e),
554        }
555    })?;
556
557    let mut info = ComposeFileInfo {
558        path: path.clone(),
559        environment: extract_environment_from_filename(path),
560        version: None,
561        service_names: Vec::new(),
562        networks: Vec::new(),
563        volumes: Vec::new(),
564        external_dependencies: Vec::new(),
565    };
566
567    // Extract version
568    if let Some(version) = yaml_value.get("version").and_then(|v| v.as_str()) {
569        info.version = Some(version.to_string());
570    }
571
572    // Extract service names
573    if let Some(services) = yaml_value.get("services").and_then(|s| s.as_mapping()) {
574        for (service_name, _) in services {
575            if let Some(name) = service_name.as_str() {
576                info.service_names.push(name.to_string());
577            }
578        }
579    }
580
581    // Extract networks
582    if let Some(networks) = yaml_value.get("networks").and_then(|n| n.as_mapping()) {
583        for (network_name, network_config) in networks {
584            if let Some(name) = network_name.as_str() {
585                info.networks.push(name.to_string());
586
587                // Check if it's external
588                if let Some(config) = network_config.as_mapping()
589                    && config
590                        .get("external")
591                        .and_then(|e| e.as_bool())
592                        .unwrap_or(false)
593                {
594                    info.external_dependencies.push(format!("network:{}", name));
595                }
596            }
597        }
598    }
599
600    // Extract volumes
601    if let Some(volumes) = yaml_value.get("volumes").and_then(|v| v.as_mapping()) {
602        for (volume_name, volume_config) in volumes {
603            if let Some(name) = volume_name.as_str() {
604                info.volumes.push(name.to_string());
605
606                // Check if it's external
607                if let Some(config) = volume_config.as_mapping()
608                    && config
609                        .get("external")
610                        .and_then(|e| e.as_bool())
611                        .unwrap_or(false)
612                {
613                    info.external_dependencies.push(format!("volume:{}", name));
614                }
615            }
616        }
617    }
618
619    Ok(info)
620}
621
622/// Extracts environment from filename (e.g., "dev" from "dockerfile.dev")
623fn extract_environment_from_filename(path: &Path) -> Option<String> {
624    if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
625        let filename_lower = filename.to_lowercase();
626
627        // Helper to map env shorthand to full name
628        let map_env = |env: &str| -> Option<String> {
629            match env {
630                "dev" | "development" | "local" => Some("development".to_string()),
631                "prod" | "production" => Some("production".to_string()),
632                "test" | "testing" => Some("test".to_string()),
633                "stage" | "staging" => Some("staging".to_string()),
634                _ if env.len() <= 10 && !env.is_empty() => Some(env.to_string()),
635                _ => None,
636            }
637        };
638
639        // Handle patterns like "docker-compose.prod.yml" (env between two dots)
640        if let Some(last_dot) = filename_lower.rfind('.')
641            && let Some(env_dot_pos) = filename_lower[..last_dot].rfind('.')
642        {
643            let env = &filename_lower[env_dot_pos + 1..last_dot];
644            if let Some(result) = map_env(env) {
645                return Some(result);
646            }
647        }
648
649        // Handle patterns like "Dockerfile.dev" (env is the extension itself)
650        if let Some(dot_pos) = filename_lower.rfind('.') {
651            let ext = &filename_lower[dot_pos + 1..];
652            // Only if the base is dockerfile/docker-compose related
653            let base = &filename_lower[..dot_pos];
654            if (base.contains("dockerfile") || base.contains("docker-compose") || base == "compose")
655                && let Some(result) = map_env(ext)
656            {
657                return Some(result);
658            }
659        }
660    }
661    None
662}
663
664/// Helper functions for parsing compose files
665fn extract_services_from_compose(compose_files: &[ComposeFileInfo]) -> Result<Vec<DockerService>> {
666    let mut services = Vec::new();
667
668    for compose_file in compose_files {
669        let content = fs::read_to_string(&compose_file.path)?;
670        let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content).map_err(|e| {
671            crate::error::AnalysisError::DependencyParsing {
672                file: compose_file.path.display().to_string(),
673                reason: format!("YAML parsing error: {}", e),
674            }
675        })?;
676
677        if let Some(services_yaml) = yaml_value.get("services").and_then(|s| s.as_mapping()) {
678            for (service_name, service_config) in services_yaml {
679                if let (Some(name), Some(config)) =
680                    (service_name.as_str(), service_config.as_mapping())
681                {
682                    let service = parse_docker_service(name, config, &compose_file.path)?;
683                    services.push(service);
684                }
685            }
686        }
687    }
688
689    Ok(services)
690}
691
692/// Parses a Docker service from compose configuration
693fn parse_docker_service(
694    name: &str,
695    config: &serde_yaml::Mapping,
696    compose_file: &Path,
697) -> Result<DockerService> {
698    let mut service = DockerService {
699        name: name.to_string(),
700        compose_file: compose_file.to_path_buf(),
701        image_or_build: ImageOrBuild::Image("unknown".to_string()),
702        ports: Vec::new(),
703        environment: HashMap::new(),
704        depends_on: Vec::new(),
705        networks: Vec::new(),
706        volumes: Vec::new(),
707        health_check: None,
708        restart_policy: None,
709        resource_limits: None,
710    };
711
712    // Parse image or build
713    if let Some(image) = config.get("image").and_then(|i| i.as_str()) {
714        service.image_or_build = ImageOrBuild::Image(image.to_string());
715    } else if let Some(build_config) = config.get("build") {
716        if let Some(context) = build_config.as_str() {
717            service.image_or_build = ImageOrBuild::Build {
718                context: context.to_string(),
719                dockerfile: None,
720                args: HashMap::new(),
721            };
722        } else if let Some(build_mapping) = build_config.as_mapping() {
723            let context = build_mapping
724                .get("context")
725                .and_then(|c| c.as_str())
726                .unwrap_or(".")
727                .to_string();
728
729            let dockerfile = build_mapping
730                .get("dockerfile")
731                .and_then(|d| d.as_str())
732                .map(|s| s.to_string());
733
734            let mut args = HashMap::new();
735            if let Some(args_config) = build_mapping.get("args").and_then(|a| a.as_mapping()) {
736                for (key, value) in args_config {
737                    if let (Some(k), Some(v)) = (key.as_str(), value.as_str()) {
738                        args.insert(k.to_string(), v.to_string());
739                    }
740                }
741            }
742
743            service.image_or_build = ImageOrBuild::Build {
744                context,
745                dockerfile,
746                args,
747            };
748        }
749    }
750
751    // Parse ports
752    if let Some(ports_config) = config.get("ports").and_then(|p| p.as_sequence()) {
753        for port_item in ports_config {
754            if let Some(port_mapping) = parse_port_mapping(port_item) {
755                service.ports.push(port_mapping);
756            }
757        }
758    }
759
760    // Parse environment variables
761    if let Some(env_config) = config.get("environment") {
762        parse_environment_variables(env_config, &mut service.environment);
763    }
764
765    // Parse depends_on
766    if let Some(depends_config) = config.get("depends_on") {
767        if let Some(depends_sequence) = depends_config.as_sequence() {
768            for dep in depends_sequence {
769                if let Some(dep_name) = dep.as_str() {
770                    service.depends_on.push(dep_name.to_string());
771                }
772            }
773        } else if let Some(depends_mapping) = depends_config.as_mapping() {
774            for (dep_name, _) in depends_mapping {
775                if let Some(name) = dep_name.as_str() {
776                    service.depends_on.push(name.to_string());
777                }
778            }
779        }
780    }
781
782    // Parse networks
783    if let Some(networks_config) = config.get("networks") {
784        if let Some(networks_sequence) = networks_config.as_sequence() {
785            for network in networks_sequence {
786                if let Some(network_name) = network.as_str() {
787                    service.networks.push(network_name.to_string());
788                }
789            }
790        } else if let Some(networks_mapping) = networks_config.as_mapping() {
791            for (network_name, _) in networks_mapping {
792                if let Some(name) = network_name.as_str() {
793                    service.networks.push(name.to_string());
794                }
795            }
796        }
797    }
798
799    // Parse volumes
800    if let Some(volumes_config) = config.get("volumes").and_then(|v| v.as_sequence()) {
801        for volume_item in volumes_config {
802            if let Some(volume_mount) = parse_volume_mount(volume_item) {
803                service.volumes.push(volume_mount);
804            }
805        }
806    }
807
808    // Parse restart policy
809    if let Some(restart) = config.get("restart").and_then(|r| r.as_str()) {
810        service.restart_policy = Some(restart.to_string());
811    }
812
813    // Parse health check
814    if let Some(healthcheck_config) = config.get("healthcheck").and_then(|h| h.as_mapping())
815        && let Some(test) = healthcheck_config.get("test").and_then(|t| t.as_str())
816    {
817        service.health_check = Some(HealthCheck {
818            test: test.to_string(),
819            interval: healthcheck_config
820                .get("interval")
821                .and_then(|i| i.as_str())
822                .map(|s| s.to_string()),
823            timeout: healthcheck_config
824                .get("timeout")
825                .and_then(|t| t.as_str())
826                .map(|s| s.to_string()),
827            retries: healthcheck_config
828                .get("retries")
829                .and_then(|r| r.as_u64())
830                .map(|r| r as u32),
831        });
832    }
833
834    Ok(service)
835}
836
837/// Parses port mapping from YAML value
838fn parse_port_mapping(port_value: &serde_yaml::Value) -> Option<PortMapping> {
839    if let Some(port_str) = port_value.as_str() {
840        // Handle string format like "8080:80" or "80"
841        if let Some(colon_pos) = port_str.find(':') {
842            let host_part = &port_str[..colon_pos];
843            let container_part = &port_str[colon_pos + 1..];
844
845            if let (Ok(host_port), Ok(container_port)) =
846                (host_part.parse::<u16>(), container_part.parse::<u16>())
847            {
848                return Some(PortMapping {
849                    host_port: Some(host_port),
850                    container_port,
851                    protocol: "tcp".to_string(),
852                    exposed_to_host: true,
853                });
854            }
855        } else if let Ok(container_port) = port_str.parse::<u16>() {
856            return Some(PortMapping {
857                host_port: None,
858                container_port,
859                protocol: "tcp".to_string(),
860                exposed_to_host: false,
861            });
862        }
863    } else if let Some(port_num) = port_value.as_u64() {
864        return Some(PortMapping {
865            host_port: None,
866            container_port: port_num as u16,
867            protocol: "tcp".to_string(),
868            exposed_to_host: false,
869        });
870    }
871
872    None
873}
874
875/// Parses volume mount from YAML value
876fn parse_volume_mount(volume_value: &serde_yaml::Value) -> Option<VolumeMount> {
877    if let Some(volume_str) = volume_value.as_str() {
878        // Handle string format like "./data:/app/data:ro" or "./data:/app/data"
879        let parts: Vec<&str> = volume_str.split(':').collect();
880        if parts.len() >= 2 {
881            return Some(VolumeMount {
882                source: parts[0].to_string(),
883                target: parts[1].to_string(),
884                mount_type: if parts[0].starts_with('/') || parts[0].starts_with('.') {
885                    "bind".to_string()
886                } else {
887                    "volume".to_string()
888                },
889                read_only: parts.get(2).is_some_and(|&opt| opt == "ro"),
890            });
891        }
892    }
893    None
894}
895
896/// Parses environment variables from YAML
897fn parse_environment_variables(
898    env_value: &serde_yaml::Value,
899    env_map: &mut HashMap<String, String>,
900) {
901    if let Some(env_mapping) = env_value.as_mapping() {
902        for (key, value) in env_mapping {
903            if let Some(key_str) = key.as_str() {
904                let value_str = value.as_str().unwrap_or("").to_string();
905                env_map.insert(key_str.to_string(), value_str);
906            }
907        }
908    } else if let Some(env_sequence) = env_value.as_sequence() {
909        for env_item in env_sequence {
910            if let Some(env_str) = env_item.as_str() {
911                if let Some(eq_pos) = env_str.find('=') {
912                    let key = env_str[..eq_pos].to_string();
913                    let value = env_str[eq_pos + 1..].to_string();
914                    env_map.insert(key, value);
915                } else {
916                    env_map.insert(env_str.to_string(), String::new());
917                }
918            }
919        }
920    }
921}
922
923fn analyze_networking(
924    services: &[DockerService],
925    compose_files: &[ComposeFileInfo],
926) -> Result<NetworkingConfig> {
927    let mut custom_networks = Vec::new();
928    let mut connected_services: HashMap<String, Vec<String>> = HashMap::new();
929
930    // Collect networks from compose files
931    for compose_file in compose_files {
932        for network_name in &compose_file.networks {
933            let network_info = NetworkInfo {
934                name: network_name.clone(),
935                driver: None, // TODO: Parse driver from compose file
936                external: compose_file
937                    .external_dependencies
938                    .contains(&format!("network:{}", network_name)),
939                connected_services: Vec::new(),
940            };
941            custom_networks.push(network_info);
942        }
943    }
944
945    // Map services to networks
946    for service in services {
947        for network in &service.networks {
948            connected_services
949                .entry(network.clone())
950                .or_default()
951                .push(service.name.clone());
952        }
953    }
954
955    // Update network info with connected services
956    for network in &mut custom_networks {
957        if let Some(services) = connected_services.get(&network.name) {
958            network.connected_services = services.clone();
959        }
960    }
961
962    // Analyze service discovery
963    let service_discovery = ServiceDiscoveryConfig {
964        internal_dns: !services.is_empty(), // Docker Compose provides internal DNS
965        external_tools: detect_service_discovery_tools(services),
966        service_mesh: detect_service_mesh(services),
967    };
968
969    // Analyze load balancing
970    let load_balancing = detect_load_balancers(services);
971
972    // Analyze external connectivity
973    let external_connectivity = analyze_external_connectivity(services);
974
975    Ok(NetworkingConfig {
976        custom_networks,
977        service_discovery,
978        load_balancing,
979        external_connectivity,
980    })
981}
982
983fn determine_orchestration_pattern(
984    services: &[DockerService],
985    networking: &NetworkingConfig,
986) -> OrchestrationPattern {
987    if services.is_empty() {
988        return OrchestrationPattern::SingleContainer;
989    }
990
991    if services.len() == 1 {
992        return OrchestrationPattern::SingleContainer;
993    }
994
995    // Check for microservices patterns
996    let has_multiple_backends = services
997        .iter()
998        .filter(|s| match &s.image_or_build {
999            ImageOrBuild::Image(img) => {
1000                !img.contains("nginx") && !img.contains("proxy") && !img.contains("traefik")
1001            }
1002            _ => true,
1003        })
1004        .count()
1005        > 2;
1006
1007    let has_service_discovery = networking.service_discovery.internal_dns
1008        || !networking.service_discovery.external_tools.is_empty();
1009
1010    let _has_load_balancing = !networking.load_balancing.is_empty();
1011
1012    let has_message_queues = services.iter().any(|s| match &s.image_or_build {
1013        ImageOrBuild::Image(img) => {
1014            img.contains("redis")
1015                || img.contains("rabbitmq")
1016                || img.contains("kafka")
1017                || img.contains("nats")
1018        }
1019        _ => false,
1020    });
1021
1022    if networking.service_discovery.service_mesh {
1023        OrchestrationPattern::ServiceMesh
1024    } else if has_message_queues && has_multiple_backends {
1025        OrchestrationPattern::EventDriven
1026    } else if has_multiple_backends && has_service_discovery {
1027        OrchestrationPattern::Microservices
1028    } else {
1029        OrchestrationPattern::DockerCompose
1030    }
1031}
1032
1033/// Detects service discovery tools in the services
1034fn detect_service_discovery_tools(services: &[DockerService]) -> Vec<String> {
1035    let mut tools = Vec::new();
1036
1037    for service in services {
1038        if let ImageOrBuild::Image(image) = &service.image_or_build {
1039            if image.contains("consul") {
1040                tools.push("consul".to_string());
1041            }
1042            if image.contains("etcd") {
1043                tools.push("etcd".to_string());
1044            }
1045            if image.contains("zookeeper") {
1046                tools.push("zookeeper".to_string());
1047            }
1048        }
1049    }
1050
1051    tools.sort();
1052    tools.dedup();
1053    tools
1054}
1055
1056/// Detects service mesh presence
1057fn detect_service_mesh(services: &[DockerService]) -> bool {
1058    services.iter().any(|s| {
1059        if let ImageOrBuild::Image(image) = &s.image_or_build {
1060            image.contains("istio")
1061                || image.contains("linkerd")
1062                || image.contains("envoy")
1063                || image.contains("consul-connect")
1064        } else {
1065            false
1066        }
1067    })
1068}
1069
1070/// Detects load balancers in the services
1071fn detect_load_balancers(services: &[DockerService]) -> Vec<LoadBalancerConfig> {
1072    let mut load_balancers = Vec::new();
1073
1074    for service in services {
1075        // Check if service image indicates a load balancer
1076        let is_load_balancer = match &service.image_or_build {
1077            ImageOrBuild::Image(image) => {
1078                image.contains("nginx")
1079                    || image.contains("traefik")
1080                    || image.contains("haproxy")
1081                    || image.contains("envoy")
1082                    || image.contains("kong")
1083            }
1084            _ => false,
1085        };
1086
1087        if is_load_balancer {
1088            // Find potential backend services (services this one doesn't depend on)
1089            let backends: Vec<String> = services
1090                .iter()
1091                .filter(|s| s.name != service.name && !service.depends_on.contains(&s.name))
1092                .map(|s| s.name.clone())
1093                .collect();
1094
1095            if !backends.is_empty() {
1096                let lb_type = match &service.image_or_build {
1097                    ImageOrBuild::Image(image) => {
1098                        if image.contains("nginx") {
1099                            "nginx"
1100                        } else if image.contains("traefik") {
1101                            "traefik"
1102                        } else if image.contains("haproxy") {
1103                            "haproxy"
1104                        } else if image.contains("envoy") {
1105                            "envoy"
1106                        } else if image.contains("kong") {
1107                            "kong"
1108                        } else {
1109                            "unknown"
1110                        }
1111                    }
1112                    _ => "unknown",
1113                };
1114
1115                load_balancers.push(LoadBalancerConfig {
1116                    service: service.name.clone(),
1117                    lb_type: lb_type.to_string(),
1118                    backends,
1119                });
1120            }
1121        }
1122    }
1123
1124    load_balancers
1125}
1126
1127/// Analyzes external connectivity patterns
1128fn analyze_external_connectivity(services: &[DockerService]) -> ExternalConnectivity {
1129    let mut exposed_services = Vec::new();
1130    let mut ingress_patterns = Vec::new();
1131    let mut api_gateways = Vec::new();
1132
1133    for service in services {
1134        let mut external_ports = Vec::new();
1135        let mut protocols = Vec::new();
1136
1137        // Check for exposed ports
1138        for port in &service.ports {
1139            if port.exposed_to_host {
1140                if let Some(host_port) = port.host_port {
1141                    external_ports.push(host_port);
1142                }
1143                protocols.push(port.protocol.clone());
1144            }
1145        }
1146
1147        if !external_ports.is_empty() {
1148            // Check for SSL/TLS indicators
1149            let ssl_enabled = external_ports.contains(&443)
1150                || external_ports.contains(&8443)
1151                || service
1152                    .environment
1153                    .keys()
1154                    .any(|k| k.to_lowercase().contains("ssl") || k.to_lowercase().contains("tls"));
1155
1156            exposed_services.push(ExposedService {
1157                service: service.name.clone(),
1158                external_ports,
1159                protocols: protocols
1160                    .into_iter()
1161                    .collect::<std::collections::HashSet<_>>()
1162                    .into_iter()
1163                    .collect(),
1164                ssl_enabled,
1165            });
1166        }
1167
1168        // Detect API gateways
1169        if service.name.to_lowercase().contains("gateway")
1170            || service.name.to_lowercase().contains("api")
1171            || service.name.to_lowercase().contains("proxy")
1172        {
1173            api_gateways.push(service.name.clone());
1174        }
1175
1176        // Also check image for API gateway patterns
1177        if let ImageOrBuild::Image(image) = &service.image_or_build
1178            && (image.contains("kong")
1179                || image.contains("zuul")
1180                || image.contains("ambassador")
1181                || image.contains("traefik"))
1182            && !api_gateways.contains(&service.name)
1183        {
1184            api_gateways.push(service.name.clone());
1185        }
1186    }
1187
1188    // Detect ingress patterns
1189    if exposed_services.len() == 1 && api_gateways.len() == 1 {
1190        ingress_patterns.push("Single API Gateway".to_string());
1191    } else if exposed_services.len() > 1 && api_gateways.is_empty() {
1192        ingress_patterns.push("Multiple Direct Entry Points".to_string());
1193    } else if !api_gateways.is_empty() {
1194        ingress_patterns.push("API Gateway Pattern".to_string());
1195    }
1196
1197    // Detect reverse proxy patterns
1198    let has_reverse_proxy = services.iter().any(|s| {
1199        if let ImageOrBuild::Image(image) = &s.image_or_build {
1200            image.contains("nginx") || image.contains("apache") || image.contains("caddy")
1201        } else {
1202            false
1203        }
1204    });
1205
1206    if has_reverse_proxy {
1207        ingress_patterns.push("Reverse Proxy".to_string());
1208    }
1209
1210    ExternalConnectivity {
1211        exposed_services,
1212        ingress_patterns,
1213        api_gateways,
1214    }
1215}
1216
1217fn analyze_environments(
1218    dockerfiles: &[DockerfileInfo],
1219    compose_files: &[ComposeFileInfo],
1220) -> Vec<DockerEnvironment> {
1221    let mut environments: HashMap<String, DockerEnvironment> = HashMap::new();
1222
1223    // Collect environments from Dockerfiles
1224    for dockerfile in dockerfiles {
1225        let env_name = dockerfile
1226            .environment
1227            .clone()
1228            .unwrap_or_else(|| "default".to_string());
1229        environments
1230            .entry(env_name.clone())
1231            .or_insert_with(|| DockerEnvironment {
1232                name: env_name,
1233                dockerfiles: Vec::new(),
1234                compose_files: Vec::new(),
1235                config_overrides: HashMap::new(),
1236            })
1237            .dockerfiles
1238            .push(dockerfile.path.clone());
1239    }
1240
1241    // Collect environments from Compose files
1242    for compose_file in compose_files {
1243        let env_name = compose_file
1244            .environment
1245            .clone()
1246            .unwrap_or_else(|| "default".to_string());
1247        environments
1248            .entry(env_name.clone())
1249            .or_insert_with(|| DockerEnvironment {
1250                name: env_name,
1251                dockerfiles: Vec::new(),
1252                compose_files: Vec::new(),
1253                config_overrides: HashMap::new(),
1254            })
1255            .compose_files
1256            .push(compose_file.path.clone());
1257    }
1258
1259    environments.into_values().collect()
1260}
1261
1262// =============================================================================
1263// Dockerfile Discovery for Deployment Wizard
1264// =============================================================================
1265
1266/// Suggests a service name based on Dockerfile path and project structure.
1267///
1268/// Uses the parent directory name if not at project root, otherwise uses
1269/// the project root's directory name. The name is sanitized to be lowercase
1270/// with hyphens (suitable for Kubernetes service names).
1271fn suggest_service_name(dockerfile_path: &Path, project_root: &Path) -> String {
1272    // Get parent directory of Dockerfile
1273    let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1274
1275    // Determine which directory name to use
1276    let name = if dockerfile_dir == project_root {
1277        // Dockerfile is in project root - use project root's directory name
1278        project_root
1279            .file_name()
1280            .and_then(|n| n.to_str())
1281            .unwrap_or("app")
1282    } else {
1283        // Use the immediate parent directory name
1284        dockerfile_dir
1285            .file_name()
1286            .and_then(|n| n.to_str())
1287            .unwrap_or("app")
1288    };
1289
1290    // Sanitize: lowercase, replace underscores/spaces with hyphens, remove non-alphanumeric
1291    sanitize_service_name(name)
1292}
1293
1294/// Sanitizes a string to be a valid Kubernetes service name.
1295/// Lowercase, alphanumeric with hyphens, no leading/trailing hyphens.
1296fn sanitize_service_name(name: &str) -> String {
1297    let sanitized: String = name
1298        .to_lowercase()
1299        .chars()
1300        .map(|c| {
1301            if c.is_ascii_alphanumeric() {
1302                c
1303            } else {
1304                '-'
1305            }
1306        })
1307        .collect();
1308
1309    // Remove consecutive hyphens and trim hyphens from ends
1310    let mut result = String::new();
1311    let mut prev_hyphen = true; // Start true to skip leading hyphens
1312
1313    for c in sanitized.chars() {
1314        if c == '-' {
1315            if !prev_hyphen {
1316                result.push(c);
1317                prev_hyphen = true;
1318            }
1319        } else {
1320            result.push(c);
1321            prev_hyphen = false;
1322        }
1323    }
1324
1325    // Remove trailing hyphen
1326    if result.ends_with('-') {
1327        result.pop();
1328    }
1329
1330    if result.is_empty() {
1331        "app".to_string()
1332    } else {
1333        result
1334    }
1335}
1336
1337/// Computes build context path relative to project root.
1338///
1339/// Returns the relative path from project root to the Dockerfile's directory,
1340/// suitable for use as a Docker build context path.
1341fn compute_build_context(dockerfile_path: &Path, project_root: &Path) -> String {
1342    let dockerfile_dir = dockerfile_path.parent().unwrap_or(dockerfile_path);
1343
1344    // Try to get relative path from project root to dockerfile directory
1345    if let Ok(relative) = dockerfile_dir.strip_prefix(project_root) {
1346        let path_str = relative.to_string_lossy().to_string();
1347        if path_str.is_empty() {
1348            ".".to_string()
1349        } else {
1350            path_str
1351        }
1352    } else {
1353        // Fallback: use "." if we can't compute relative path
1354        ".".to_string()
1355    }
1356}
1357
1358/// Infers default port based on base image.
1359///
1360/// Returns a common default port for well-known base images.
1361fn infer_default_port(base_image: &Option<String>) -> Option<u16> {
1362    let image = base_image.as_ref()?;
1363    let image_lower = image.to_lowercase();
1364
1365    // Extract image name without registry/tag
1366    let image_name = image_lower
1367        .split('/')
1368        .last()
1369        .unwrap_or(&image_lower)
1370        .split(':')
1371        .next()
1372        .unwrap_or(&image_lower);
1373
1374    match image_name {
1375        // Node.js
1376        s if s.starts_with("node") => Some(3000),
1377        // Python web frameworks
1378        s if s.contains("python") => Some(8000),
1379        s if s.contains("flask") => Some(5000),
1380        s if s.contains("django") => Some(8000),
1381        s if s.contains("fastapi") => Some(8000),
1382        // Go
1383        s if s.starts_with("golang") || s.starts_with("go") => Some(8080),
1384        // Rust
1385        s if s.starts_with("rust") => Some(8080),
1386        // Web servers
1387        s if s.starts_with("nginx") => Some(80),
1388        s if s.starts_with("httpd") || s.starts_with("apache") => Some(80),
1389        s if s.starts_with("caddy") => Some(80),
1390        // Java
1391        s if s.contains("openjdk") || s.contains("java") => Some(8080),
1392        s if s.contains("tomcat") => Some(8080),
1393        s if s.contains("spring") => Some(8080),
1394        // Ruby
1395        s if s.starts_with("ruby") => Some(3000),
1396        s if s.contains("rails") => Some(3000),
1397        // PHP
1398        s if s.starts_with("php") => Some(80),
1399        // .NET
1400        s if s.contains("dotnet") || s.contains("aspnet") => Some(80),
1401        // Elixir/Phoenix
1402        s if s.contains("elixir") || s.contains("phoenix") => Some(4000),
1403        // Default: no inference
1404        _ => None,
1405    }
1406}
1407
1408/// Discovers Dockerfiles in a project and returns deployment-focused metadata.
1409///
1410/// This function finds all Dockerfiles in the project, parses them, and returns
1411/// deployment-relevant information including build context paths, suggested
1412/// service names, and port configurations.
1413///
1414/// # Arguments
1415///
1416/// * `project_root` - The root directory of the project to analyze
1417///
1418/// # Returns
1419///
1420/// A vector of `DiscoveredDockerfile` structs, one for each Dockerfile found
1421pub fn discover_dockerfiles_for_deployment(
1422    project_root: &Path,
1423) -> Result<Vec<DiscoveredDockerfile>> {
1424    let dockerfiles = find_dockerfiles(project_root)?;
1425
1426    let discovered: Vec<DiscoveredDockerfile> = dockerfiles
1427        .into_iter()
1428        .filter_map(|path| {
1429            let info = parse_dockerfile(&path).ok()?;
1430            let build_context = compute_build_context(&path, project_root);
1431            let suggested_name = suggest_service_name(&path, project_root);
1432
1433            // Get port from EXPOSE instruction or infer from base image
1434            let suggested_port = info
1435                .exposed_ports
1436                .first()
1437                .copied()
1438                .or_else(|| infer_default_port(&info.base_image));
1439
1440            Some(DiscoveredDockerfile {
1441                path,
1442                build_context,
1443                suggested_service_name: suggested_name,
1444                suggested_port,
1445                base_image: info.base_image,
1446                is_multistage: info.is_multistage,
1447                environment: info.environment,
1448            })
1449        })
1450        .collect();
1451
1452    Ok(discovered)
1453}
1454
1455#[cfg(test)]
1456mod tests {
1457    use super::*;
1458
1459    #[test]
1460    fn test_is_dockerfile_name() {
1461        assert!(is_dockerfile_name("Dockerfile"));
1462        assert!(is_dockerfile_name("dockerfile"));
1463        assert!(is_dockerfile_name("Dockerfile.dev"));
1464        assert!(is_dockerfile_name("dockerfile.prod"));
1465        assert!(is_dockerfile_name("api.dockerfile"));
1466        assert!(!is_dockerfile_name("README.md"));
1467        assert!(!is_dockerfile_name("package.json"));
1468    }
1469
1470    #[test]
1471    fn test_is_compose_file_name() {
1472        assert!(is_compose_file_name("docker-compose.yml"));
1473        assert!(is_compose_file_name("docker-compose.yaml"));
1474        assert!(is_compose_file_name("docker-compose.dev.yml"));
1475        assert!(is_compose_file_name("docker-compose.prod.yaml"));
1476        assert!(is_compose_file_name("compose.yml"));
1477        assert!(is_compose_file_name("compose.yaml"));
1478        assert!(!is_compose_file_name("README.md"));
1479        assert!(!is_compose_file_name("package.json"));
1480    }
1481
1482    #[test]
1483    fn test_extract_environment_from_filename() {
1484        assert_eq!(
1485            extract_environment_from_filename(&PathBuf::from("Dockerfile.dev")),
1486            Some("development".to_string())
1487        );
1488        assert_eq!(
1489            extract_environment_from_filename(&PathBuf::from("docker-compose.prod.yml")),
1490            Some("production".to_string())
1491        );
1492        assert_eq!(
1493            extract_environment_from_filename(&PathBuf::from("Dockerfile")),
1494            None
1495        );
1496    }
1497
1498    // =============================================================================
1499    // Dockerfile Discovery Tests
1500    // =============================================================================
1501
1502    #[test]
1503    fn test_suggest_service_name_from_subdirectory() {
1504        let path = PathBuf::from("/project/services/api/Dockerfile");
1505        let root = PathBuf::from("/project");
1506        assert_eq!(suggest_service_name(&path, &root), "api");
1507    }
1508
1509    #[test]
1510    fn test_suggest_service_name_from_root() {
1511        let path = PathBuf::from("/project/Dockerfile");
1512        let root = PathBuf::from("/project");
1513        assert_eq!(suggest_service_name(&path, &root), "project");
1514    }
1515
1516    #[test]
1517    fn test_suggest_service_name_nested() {
1518        let path = PathBuf::from("/myapp/apps/web-frontend/Dockerfile");
1519        let root = PathBuf::from("/myapp");
1520        assert_eq!(suggest_service_name(&path, &root), "web-frontend");
1521    }
1522
1523    #[test]
1524    fn test_suggest_service_name_sanitizes() {
1525        // Underscores become hyphens
1526        let path = PathBuf::from("/project/my_service_api/Dockerfile");
1527        let root = PathBuf::from("/project");
1528        assert_eq!(suggest_service_name(&path, &root), "my-service-api");
1529    }
1530
1531    #[test]
1532    fn test_sanitize_service_name() {
1533        assert_eq!(sanitize_service_name("My_Service"), "my-service");
1534        assert_eq!(sanitize_service_name("api-v2"), "api-v2");
1535        assert_eq!(sanitize_service_name("__leading__"), "leading");
1536        assert_eq!(sanitize_service_name("trailing--"), "trailing");
1537        assert_eq!(sanitize_service_name("multi---hyphens"), "multi-hyphens");
1538        assert_eq!(sanitize_service_name("special@#chars!"), "special-chars");
1539        assert_eq!(sanitize_service_name(""), "app"); // Empty defaults to "app"
1540    }
1541
1542    #[test]
1543    fn test_compute_build_context_subdirectory() {
1544        let path = PathBuf::from("/project/services/api/Dockerfile");
1545        let root = PathBuf::from("/project");
1546        assert_eq!(compute_build_context(&path, &root), "services/api");
1547    }
1548
1549    #[test]
1550    fn test_compute_build_context_root() {
1551        let path = PathBuf::from("/project/Dockerfile");
1552        let root = PathBuf::from("/project");
1553        assert_eq!(compute_build_context(&path, &root), ".");
1554    }
1555
1556    #[test]
1557    fn test_compute_build_context_deep_nested() {
1558        let path = PathBuf::from("/myapp/packages/frontend/apps/web/Dockerfile");
1559        let root = PathBuf::from("/myapp");
1560        assert_eq!(
1561            compute_build_context(&path, &root),
1562            "packages/frontend/apps/web"
1563        );
1564    }
1565
1566    #[test]
1567    fn test_infer_default_port_node() {
1568        assert_eq!(infer_default_port(&Some("node:18".to_string())), Some(3000));
1569        assert_eq!(
1570            infer_default_port(&Some("node:18-alpine".to_string())),
1571            Some(3000)
1572        );
1573    }
1574
1575    #[test]
1576    fn test_infer_default_port_nginx() {
1577        assert_eq!(
1578            infer_default_port(&Some("nginx:latest".to_string())),
1579            Some(80)
1580        );
1581        assert_eq!(
1582            infer_default_port(&Some("nginx:1.25-alpine".to_string())),
1583            Some(80)
1584        );
1585    }
1586
1587    #[test]
1588    fn test_infer_default_port_python() {
1589        assert_eq!(
1590            infer_default_port(&Some("python:3.11".to_string())),
1591            Some(8000)
1592        );
1593    }
1594
1595    #[test]
1596    fn test_infer_default_port_go() {
1597        assert_eq!(
1598            infer_default_port(&Some("golang:1.21".to_string())),
1599            Some(8080)
1600        );
1601    }
1602
1603    #[test]
1604    fn test_infer_default_port_java() {
1605        assert_eq!(
1606            infer_default_port(&Some("openjdk:17".to_string())),
1607            Some(8080)
1608        );
1609    }
1610
1611    #[test]
1612    fn test_infer_default_port_ruby() {
1613        assert_eq!(
1614            infer_default_port(&Some("ruby:3.2".to_string())),
1615            Some(3000)
1616        );
1617    }
1618
1619    #[test]
1620    fn test_infer_default_port_with_registry() {
1621        // Should handle images with registry prefix
1622        assert_eq!(
1623            infer_default_port(&Some("gcr.io/my-project/node:18".to_string())),
1624            Some(3000)
1625        );
1626        assert_eq!(
1627            infer_default_port(&Some("docker.io/library/nginx:latest".to_string())),
1628            Some(80)
1629        );
1630    }
1631
1632    #[test]
1633    fn test_infer_default_port_unknown() {
1634        assert_eq!(
1635            infer_default_port(&Some("custom-base:latest".to_string())),
1636            None
1637        );
1638        assert_eq!(infer_default_port(&None), None);
1639    }
1640}