use anyhow::{Context, Result};
use std::collections::HashSet;
use crate::docker::layers::DockerImageConfig;
use crate::docker::template_engine::DockerTemplateEngine;
#[derive(Debug, Default)]
pub struct InlineLayerOverrides {
pub stack_setup: Option<String>,
pub agent_setup: Option<String>,
pub project_setup: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum LayerSource {
Config,
AssetManager,
#[default]
Empty,
}
impl std::fmt::Display for LayerSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LayerSource::Config => write!(f, "config"),
LayerSource::AssetManager => write!(f, "asset"),
LayerSource::Empty => write!(f, "empty"),
}
}
}
#[derive(Debug, Default)]
pub struct LayerSources {
pub stack: LayerSource,
pub agent: LayerSource,
pub project: LayerSource,
}
pub struct DockerComposer;
impl DockerComposer {
pub fn new() -> Self {
Self
}
pub fn compose(
&self,
config: &DockerImageConfig,
overrides: Option<&InlineLayerOverrides>,
) -> Result<ComposedDockerfile> {
let base_dockerfile = crate::assets::embedded::get_dockerfile("base/default")
.context("Failed to get base dockerfile")?;
let base_template = String::from_utf8(base_dockerfile)
.context("Failed to decode base dockerfile as UTF-8")?;
let template_engine = DockerTemplateEngine::new(overrides);
let (dockerfile_content, layer_sources) = template_engine.render_dockerfile(
&base_template,
Some(&config.stack),
Some(&config.agent),
Some(&config.project),
)?;
let build_args = self.extract_build_args(&dockerfile_content)?;
Ok(ComposedDockerfile {
dockerfile_content,
build_args,
image_tag: config.image_tag(),
layer_sources,
})
}
fn extract_build_args(&self, dockerfile_content: &str) -> Result<HashSet<String>> {
let mut build_args = HashSet::new();
for line in dockerfile_content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("ARG ")
&& let Some(arg_def) = trimmed.strip_prefix("ARG ")
{
let arg_name = arg_def
.split_once('=')
.map(|(name, _)| name)
.unwrap_or(arg_def)
.trim();
if !arg_name.is_empty() {
build_args.insert(arg_name.to_string());
}
}
}
Ok(build_args)
}
pub fn validate_dockerfile(&self, content: &str) -> Result<()> {
let mut has_from = false;
let mut has_workdir = false;
let mut has_user = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("FROM ") {
has_from = true;
} else if trimmed.starts_with("WORKDIR ") {
has_workdir = true;
} else if trimmed.starts_with("USER ") {
has_user = true;
}
}
if !has_from {
return Err(anyhow::anyhow!(
"Dockerfile must contain at least one FROM instruction"
));
}
if !has_workdir {
return Err(anyhow::anyhow!(
"Dockerfile should contain a WORKDIR instruction"
));
}
if !has_user {
return Err(anyhow::anyhow!(
"Dockerfile should contain a USER instruction for security"
));
}
Ok(())
}
}
#[derive(Debug)]
pub struct ComposedDockerfile {
pub dockerfile_content: String,
pub build_args: HashSet<String>,
pub image_tag: String,
pub layer_sources: LayerSources,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_composer() -> DockerComposer {
DockerComposer::new()
}
#[test]
fn test_extract_build_args() {
let composer = create_test_composer();
let dockerfile = r#"
FROM ubuntu:24.04
ARG GIT_USER_NAME
ARG GIT_USER_EMAIL=default@example.com
ARG BUILD_VERSION
RUN echo "Building..."
"#;
let args = composer.extract_build_args(dockerfile).unwrap();
assert!(args.contains("GIT_USER_NAME"));
assert!(args.contains("GIT_USER_EMAIL"));
assert!(args.contains("BUILD_VERSION"));
assert_eq!(args.len(), 3);
}
#[test]
fn test_validate_dockerfile() {
let composer = create_test_composer();
let valid = r#"
FROM ubuntu:24.04
WORKDIR /workspace
USER agent
RUN echo "Hello"
"#;
assert!(composer.validate_dockerfile(valid).is_ok());
let no_from = r#"
WORKDIR /workspace
USER agent
RUN echo "Hello"
"#;
assert!(composer.validate_dockerfile(no_from).is_err());
let no_workdir = r#"
FROM ubuntu:24.04
USER agent
RUN echo "Hello"
"#;
assert!(composer.validate_dockerfile(no_workdir).is_err());
let no_user = r#"
FROM ubuntu:24.04
WORKDIR /workspace
RUN echo "Hello"
"#;
assert!(composer.validate_dockerfile(no_user).is_err());
}
#[test]
fn test_compose_with_no_overrides() {
let composer = create_test_composer();
let config = crate::docker::layers::DockerImageConfig::new(
"default".to_string(),
"claude".to_string(),
"default".to_string(),
);
let composed = composer.compose(&config, None).unwrap();
assert!(composed.dockerfile_content.contains("FROM"));
assert!(!composed.image_tag.is_empty());
assert_eq!(composed.layer_sources.stack, LayerSource::AssetManager);
assert_eq!(composed.layer_sources.agent, LayerSource::AssetManager);
}
#[test]
fn test_compose_with_inline_overrides() {
let composer = create_test_composer();
let config = crate::docker::layers::DockerImageConfig::new(
"default".to_string(),
"claude".to_string(),
"default".to_string(),
);
let overrides = InlineLayerOverrides {
stack_setup: Some("RUN echo custom-stack".to_string()),
project_setup: Some("RUN echo custom-project".to_string()),
..Default::default()
};
let composed = composer.compose(&config, Some(&overrides)).unwrap();
assert!(
composed
.dockerfile_content
.contains("RUN echo custom-stack")
);
assert!(
composed
.dockerfile_content
.contains("RUN echo custom-project")
);
assert_eq!(composed.layer_sources.stack, LayerSource::Config);
assert_eq!(composed.layer_sources.agent, LayerSource::AssetManager);
assert_eq!(composed.layer_sources.project, LayerSource::Config);
}
#[test]
fn test_compose_with_config_defined_new_stack() {
let composer = create_test_composer();
let config = crate::docker::layers::DockerImageConfig::new(
"scala".to_string(),
"claude".to_string(),
"default".to_string(),
);
let overrides = InlineLayerOverrides {
stack_setup: Some("RUN apt-get install -y scala".to_string()),
..Default::default()
};
let composed = composer.compose(&config, Some(&overrides)).unwrap();
assert!(
composed
.dockerfile_content
.contains("RUN apt-get install -y scala")
);
assert_eq!(composed.layer_sources.stack, LayerSource::Config);
assert_eq!(composed.layer_sources.agent, LayerSource::AssetManager);
}
}