1use anyhow::{Context, Result};
7use std::collections::{HashMap, HashSet};
8use std::path::Path;
9
10use crate::docker::layers::DockerImageConfig;
11use crate::docker::template_manager::DockerTemplateManager;
12
13pub struct DockerComposer {
15 template_manager: DockerTemplateManager,
16}
17
18impl DockerComposer {
19 pub fn new(template_manager: DockerTemplateManager) -> Self {
21 Self { template_manager }
22 }
23
24 pub fn compose(
26 &self,
27 config: &DockerImageConfig,
28 project_root: Option<&Path>,
29 ) -> Result<ComposedDockerfile> {
30 let dockerfile_content = self
32 .template_manager
33 .compose_dockerfile(config, project_root)?;
34
35 let mut additional_files = HashMap::new();
37 let layers = config.get_layers();
38
39 for layer in &layers {
40 if let Ok(layer_content) = self.template_manager.get_layer_content(layer, project_root)
41 {
42 for (filename, content) in layer_content.additional_files {
43 additional_files.insert(filename, content);
45 }
46 }
47 }
48
49 let build_args = self.extract_build_args(&dockerfile_content)?;
51
52 Ok(ComposedDockerfile {
53 dockerfile_content,
54 additional_files,
55 build_args,
56 image_tag: config.image_tag(),
57 })
58 }
59
60 fn extract_build_args(&self, dockerfile_content: &str) -> Result<HashSet<String>> {
62 let mut build_args = HashSet::new();
63
64 for line in dockerfile_content.lines() {
65 let trimmed = line.trim();
66 if trimmed.starts_with("ARG ") {
67 if let Some(arg_def) = trimmed.strip_prefix("ARG ") {
68 let arg_name = arg_def
70 .split_once('=')
71 .map(|(name, _)| name)
72 .unwrap_or(arg_def)
73 .trim();
74
75 if !arg_name.is_empty() {
76 build_args.insert(arg_name.to_string());
77 }
78 }
79 }
80 }
81
82 Ok(build_args)
83 }
84
85 pub fn validate_dockerfile(&self, content: &str) -> Result<()> {
87 let mut has_from = false;
88 let mut has_workdir = false;
89 let mut has_user = false;
90
91 for line in content.lines() {
92 let trimmed = line.trim();
93
94 if trimmed.starts_with("FROM ") {
95 has_from = true;
96 } else if trimmed.starts_with("WORKDIR ") {
97 has_workdir = true;
98 } else if trimmed.starts_with("USER ") {
99 has_user = true;
100 }
101 }
102
103 if !has_from {
104 return Err(anyhow::anyhow!(
105 "Dockerfile must contain at least one FROM instruction"
106 ));
107 }
108
109 if !has_workdir {
110 return Err(anyhow::anyhow!(
111 "Dockerfile should contain a WORKDIR instruction"
112 ));
113 }
114
115 if !has_user {
116 return Err(anyhow::anyhow!(
117 "Dockerfile should contain a USER instruction for security"
118 ));
119 }
120
121 Ok(())
122 }
123
124 pub fn write_to_directory(
126 &self,
127 composed: &ComposedDockerfile,
128 output_dir: &Path,
129 ) -> Result<()> {
130 std::fs::create_dir_all(output_dir)
132 .with_context(|| format!("Failed to create output directory: {output_dir:?}"))?;
133
134 let dockerfile_path = output_dir.join("Dockerfile");
136 std::fs::write(&dockerfile_path, &composed.dockerfile_content)
137 .with_context(|| format!("Failed to write Dockerfile to {dockerfile_path:?}"))?;
138
139 for (filename, content) in &composed.additional_files {
141 let file_path = output_dir.join(filename);
142
143 if let Some(parent) = file_path.parent() {
145 std::fs::create_dir_all(parent)
146 .with_context(|| format!("Failed to create directory for {file_path:?}"))?;
147 }
148
149 std::fs::write(&file_path, content)
150 .with_context(|| format!("Failed to write file {file_path:?}"))?;
151 }
152
153 Ok(())
154 }
155}
156
157#[derive(Debug)]
159pub struct ComposedDockerfile {
160 pub dockerfile_content: String,
162 pub additional_files: HashMap<String, Vec<u8>>,
164 pub build_args: HashSet<String>,
166 pub image_tag: String,
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::assets::embedded::EmbeddedAssetManager;
174 use crate::storage::xdg::XdgDirectories;
175 use std::sync::Arc;
176 use tempfile::TempDir;
177
178 fn create_test_composer() -> DockerComposer {
179 let temp_dir = TempDir::new().unwrap();
180 let xdg_dirs = XdgDirectories::new_with_paths(
181 temp_dir.path().to_path_buf(),
182 temp_dir.path().to_path_buf(),
183 temp_dir.path().to_path_buf(),
184 temp_dir.path().to_path_buf(),
185 );
186
187 let template_manager =
188 DockerTemplateManager::new(Arc::new(EmbeddedAssetManager), Arc::new(xdg_dirs));
189
190 DockerComposer::new(template_manager)
191 }
192
193 #[test]
194 fn test_extract_build_args() {
195 let composer = create_test_composer();
196
197 let dockerfile = r#"
198FROM ubuntu:22.04
199ARG GIT_USER_NAME
200ARG GIT_USER_EMAIL=default@example.com
201ARG BUILD_VERSION
202RUN echo "Building..."
203"#;
204
205 let args = composer.extract_build_args(dockerfile).unwrap();
206 assert!(args.contains("GIT_USER_NAME"));
207 assert!(args.contains("GIT_USER_EMAIL"));
208 assert!(args.contains("BUILD_VERSION"));
209 assert_eq!(args.len(), 3);
210 }
211
212 #[test]
213 fn test_validate_dockerfile() {
214 let composer = create_test_composer();
215
216 let valid = r#"
218FROM ubuntu:22.04
219WORKDIR /workspace
220USER agent
221RUN echo "Hello"
222"#;
223 assert!(composer.validate_dockerfile(valid).is_ok());
224
225 let no_from = r#"
227WORKDIR /workspace
228USER agent
229RUN echo "Hello"
230"#;
231 assert!(composer.validate_dockerfile(no_from).is_err());
232
233 let no_workdir = r#"
235FROM ubuntu:22.04
236USER agent
237RUN echo "Hello"
238"#;
239 assert!(composer.validate_dockerfile(no_workdir).is_err());
240
241 let no_user = r#"
243FROM ubuntu:22.04
244WORKDIR /workspace
245RUN echo "Hello"
246"#;
247 assert!(composer.validate_dockerfile(no_user).is_err());
248 }
249
250 #[test]
251 fn test_write_to_directory() {
252 let composer = create_test_composer();
253 let temp_dir = TempDir::new().unwrap();
254
255 let composed = ComposedDockerfile {
256 dockerfile_content: "FROM ubuntu:22.04\nRUN echo 'test'".to_string(),
257 additional_files: {
258 let mut files = HashMap::new();
259 files.insert("requirements.txt".to_string(), b"pytest==7.0.0\n".to_vec());
260 files.insert("config/app.json".to_string(), b"{\"test\": true}".to_vec());
261 files
262 },
263 build_args: HashSet::new(),
264 image_tag: "tsk/test/test/test".to_string(),
265 };
266
267 composer
268 .write_to_directory(&composed, temp_dir.path())
269 .unwrap();
270
271 let dockerfile_path = temp_dir.path().join("Dockerfile");
273 assert!(dockerfile_path.exists());
274 let content = std::fs::read_to_string(&dockerfile_path).unwrap();
275 assert!(content.contains("FROM ubuntu:22.04"));
276
277 let requirements_path = temp_dir.path().join("requirements.txt");
279 assert!(requirements_path.exists());
280 let requirements = std::fs::read_to_string(&requirements_path).unwrap();
281 assert_eq!(requirements, "pytest==7.0.0\n");
282
283 let config_path = temp_dir.path().join("config/app.json");
284 assert!(config_path.exists());
285 let config = std::fs::read_to_string(&config_path).unwrap();
286 assert_eq!(config, "{\"test\": true}");
287 }
288}