use crate::composer::{
errors::ComposerError,
types::{ComposeFile, ComposerResult, EnvFiles, Service, Volume},
};
use serde_yaml::Value;
use std::collections::HashMap;
pub struct ConfigParser;
impl ConfigParser {
pub fn parse_config(config_output: &str) -> ComposerResult<ComposeFile> {
let yaml: Value = serde_yaml::from_str(config_output).map_err(ComposerError::YamlError)?;
let mapping = yaml.as_mapping().ok_or_else(|| {
ComposerError::parse_error("Docker Compose config must be a YAML mapping")
})?;
let services = mapping
.get("services")
.ok_or_else(|| ComposerError::parse_error("No services found in config"))?;
let services_map = Self::parse_services(services)?;
let volumes = mapping
.get("volumes")
.map(Self::parse_volumes)
.transpose()?;
Ok(ComposeFile {
services: services_map,
volumes,
})
}
fn parse_services(services: &Value) -> ComposerResult<HashMap<String, Service>> {
let services_mapping = services
.as_mapping()
.ok_or_else(|| ComposerError::parse_error("Services must be a YAML mapping"))?;
let mut result = HashMap::new();
for (name, service_value) in services_mapping {
let service_name = name
.as_str()
.ok_or_else(|| ComposerError::parse_error("Service name must be a string"))?;
let service = serde_yaml::from_value(service_value.clone()).map_err(|e| {
ComposerError::parse_error(format!(
"Failed to parse service '{}': {}",
service_name, e
))
})?;
result.insert(service_name.to_string(), service);
}
Ok(result)
}
fn parse_volumes(volumes: &Value) -> ComposerResult<HashMap<String, Volume>> {
let volumes_mapping = volumes
.as_mapping()
.ok_or_else(|| ComposerError::parse_error("Volumes must be a YAML mapping"))?;
let mut result = HashMap::new();
for (name, volume_value) in volumes_mapping {
let volume_name = name
.as_str()
.ok_or_else(|| ComposerError::parse_error("Volume name must be a string"))?;
let volume = serde_yaml::from_value(volume_value.clone()).map_err(|e| {
ComposerError::parse_error(format!(
"Failed to parse volume '{}': {}",
volume_name, e
))
})?;
result.insert(volume_name.to_string(), volume);
}
Ok(result)
}
pub fn extract_local_references(compose_file: &ComposeFile) -> Vec<String> {
let mut references = Vec::new();
for service in compose_file.services.values() {
if let Some(build) = &service.build {
references.push(build.context.to_string_lossy().into_owned());
if let Some(dockerfile) = &build.dockerfile {
references.push(dockerfile.to_string_lossy().into_owned());
}
}
if let Some(configs) = &service.configs {
for config in configs {
if let Some(source) = &config.source {
references.push(source.to_string_lossy().into_owned());
}
}
}
if let Some(env_files) = &service.env_file {
match env_files {
EnvFiles::Single(file) => {
references.push(file.clone());
}
EnvFiles::Multiple(files) => {
references.extend(files.iter().cloned());
}
}
}
if let Some(volumes) = &service.volumes {
for volume in volumes {
if volume.r#type == "bind" {
if let Some(source) = &volume.source {
if !source.is_empty() {
references.push(source.clone());
}
}
}
}
}
}
references
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_config() {
let config = r#"
services:
db:
build:
context: /Users/user/code/tools/dcd/db
dockerfile: Dockerfile.db
networks:
default: null
volumes:
- type: volume
source: data
target: /var/lib/postgresql/data
volume: {}
web:
environment:
NODE_ENV: production
image: nginx:latest
networks:
default: null
ports:
- mode: ingress
target: 80
published: "80"
protocol: tcp
networks:
default:
name: dcd_default
volumes:
data:
name: data
external: true
"#;
let result = ConfigParser::parse_config(config).unwrap();
assert_eq!(result.services.len(), 2);
let web = result.services.get("web").unwrap();
assert_eq!(web.image, Some("nginx:latest".to_string()));
let db = result.services.get("db").unwrap();
assert!(db.build.is_some());
assert!(result.volumes.is_some());
let volumes = result.volumes.unwrap();
assert!(volumes.contains_key("data"));
}
#[test]
fn test_parse_config_with_local_references() {
let config = r#"
services:
app:
build:
context: ./app
dockerfile: Dockerfile.dev
configs:
- source: ./config/app.conf
target: /etc/app/config.conf
api:
build:
context: ../api
configs:
- source: ./config/api.yaml
target: /etc/api/config.yaml
"#;
let compose_file = ConfigParser::parse_config(config).unwrap();
let references = ConfigParser::extract_local_references(&compose_file);
assert!(references.contains(&"./app".to_string()));
assert!(references.contains(&"Dockerfile.dev".to_string()));
assert!(references.contains(&"../api".to_string()));
assert!(references.contains(&"./config/app.conf".to_string()));
assert!(references.contains(&"./config/api.yaml".to_string()));
}
#[test]
fn test_parse_config_with_volumes() {
let config = r#"
services:
app:
volumes:
- type: bind
source: ./data
target: /app/data
- type: volume
source: mydata
target: /data
volumes:
mydata:
name: external_volume
"#;
let result = ConfigParser::parse_config(config).unwrap();
let app = result.services.get("app").unwrap();
let volumes = app.volumes.as_ref().unwrap();
assert_eq!(volumes.len(), 2);
let named_volumes = result.volumes.unwrap();
assert_eq!(named_volumes.len(), 1);
assert!(named_volumes.contains_key("mydata"));
}
#[test]
fn test_parse_invalid_config() {
let invalid_configs = vec![
"",
"volumes:\n data:",
"services: - invalid",
r#"
services:
web: invalid
"#,
];
for config in invalid_configs {
assert!(ConfigParser::parse_config(config).is_err());
}
}
#[test]
fn test_extract_local_references_empty() {
let config = r#"
services:
web:
image: nginx:latest
"#;
let compose_file = ConfigParser::parse_config(config).unwrap();
let references = ConfigParser::extract_local_references(&compose_file);
assert!(references.is_empty());
}
#[test]
fn test_parse_config_with_complex_volumes() {
let config = r#"
services:
app:
volumes:
- type: bind
source: ./src
target: /app/src
read_only: true
- type: volume
source: node_modules
target: /app/node_modules
- type: tmpfs
target: /app/temp
volumes:
node_modules:
driver: local
driver_opts:
type: none
device: ./node_modules
o: bind
"#;
let result = ConfigParser::parse_config(config).unwrap();
let app = result.services.get("app").unwrap();
let volumes = app.volumes.as_ref().unwrap();
assert_eq!(volumes.len(), 3);
let named_volumes = result.volumes.unwrap();
assert_eq!(named_volumes.len(), 1);
assert!(named_volumes.contains_key("node_modules"));
}
}