Skip to main content

hd_oci/
dockerfile.rs

1// Dockerfile parsing and translation to hd.toml.
2
3use 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
18/// Translate a Dockerfile into an EnvSpec (hd.toml equivalent).
19/// This is a best-effort translation -- arbitrary shell commands become build steps.
20pub 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            // Derive name from base image
41            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            // Parse JSON array format: ["node", "server.js"] -> "node server.js"
49            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        // COPY, WORKDIR, ENV, EXPOSE etc. are noted but don't map cleanly to hd.toml
62        // They become part of the build context
63    }
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}