1use std::collections::HashMap;
4
5use hd_spec::{
6 BuildConfig, EnvironmentConfig, EnvSpec, FilesConfig, OptionsConfig, RestartPolicy,
7 ServiceConfig,
8};
9
10#[derive(Debug, thiserror::Error)]
11pub enum DockerfileError {
12 #[error("no FROM instruction found")]
13 NoFrom,
14 #[error("parse error: {0}")]
15 ParseError(String),
16}
17
18pub fn translate_dockerfile(content: &str) -> Result<EnvSpec, DockerfileError> {
21 let lines: Vec<&str> = content
22 .lines()
23 .map(|l| l.trim())
24 .filter(|l| !l.is_empty() && !l.starts_with('#'))
25 .collect();
26
27 if lines.is_empty() {
28 return Err(DockerfileError::NoFrom);
29 }
30
31 let mut base = String::new();
32 let mut build_steps = Vec::new();
33 let mut cmd = Vec::new();
34 let mut name = "app".to_string();
35
36 for line in &lines {
37 let upper = line.to_uppercase();
38 if upper.starts_with("FROM ") {
39 base = line[5..].trim().to_string();
40 if let Some(img_name) = base.split('/').next_back() {
42 name = img_name.split(':').next().unwrap_or("app").to_string();
43 }
44 } else if upper.starts_with("RUN ") {
45 build_steps.push(line[4..].trim().to_string());
46 } else if upper.starts_with("CMD ") {
47 let cmd_str = line[4..].trim();
48 if cmd_str.starts_with('[') {
50 let parsed: Vec<String> = cmd_str
51 .trim_start_matches('[')
52 .trim_end_matches(']')
53 .split(',')
54 .map(|s| s.trim().trim_matches('"').to_string())
55 .collect();
56 cmd = parsed;
57 } else {
58 cmd = vec![cmd_str.to_string()];
59 }
60 }
61 }
64
65 if base.is_empty() {
66 return Err(DockerfileError::NoFrom);
67 }
68
69 let mut services = HashMap::new();
70 if !cmd.is_empty() {
71 services.insert(
72 "app".to_string(),
73 ServiceConfig {
74 command: cmd.join(" "),
75 watch: vec![],
76 port: None,
77 depends_on: vec![],
78 restart_policy: RestartPolicy::Always,
79 },
80 );
81 }
82
83 Ok(EnvSpec {
84 environment: EnvironmentConfig { name, base },
85 dependencies: HashMap::new(),
86 build: BuildConfig {
87 steps: build_steps,
88 cache: vec![],
89 },
90 services,
91 files: FilesConfig::default(),
92 options: OptionsConfig::default(),
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn translate_simple_dockerfile() {
102 let dockerfile = r#"
103FROM node:20-alpine
104RUN npm install
105COPY . .
106CMD ["node", "server.js"]
107"#;
108 let spec = translate_dockerfile(dockerfile).unwrap();
109 assert_eq!(spec.environment.base, "node:20-alpine");
110 assert!(spec.build.steps.contains(&"npm install".to_string()));
111 assert!(!spec.services.is_empty());
112 }
113
114 #[test]
115 fn translate_with_dependencies() {
116 let dockerfile = r#"
117FROM ubuntu:22.04
118RUN apt-get update && apt-get install -y curl git
119RUN npm install
120"#;
121 let spec = translate_dockerfile(dockerfile).unwrap();
122 assert_eq!(spec.environment.base, "ubuntu:22.04");
123 assert_eq!(spec.build.steps.len(), 2);
124 }
125
126 #[test]
127 fn translate_workdir_and_env() {
128 let dockerfile = r#"
129FROM python:3.11
130WORKDIR /app
131ENV PORT=8080
132RUN pip install flask
133CMD ["python", "app.py"]
134"#;
135 let spec = translate_dockerfile(dockerfile).unwrap();
136 assert_eq!(spec.environment.base, "python:3.11");
137 }
138
139 #[test]
140 fn translate_empty_dockerfile_errors() {
141 let result = translate_dockerfile("");
142 assert!(result.is_err());
143 }
144}