#[cfg(test)]
#[allow(clippy::module_inception)]
mod tests {
use super::super::*;
#[test]
fn test_join_continuation_simple() {
let input = "RUN apt-get update && \\\n apt-get install -y curl";
let lines = join_continuation_lines(input);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains("apt-get update"));
assert!(lines[0].contains("apt-get install"));
}
#[test]
fn test_join_continuation_no_continuation() {
let input = "FROM alpine:3.19\nRUN echo hello";
let lines = join_continuation_lines(input);
assert_eq!(lines.len(), 2);
}
#[test]
fn test_join_continuation_multiple() {
let input = "RUN a \\\n b \\\n c";
let lines = join_continuation_lines(input);
assert_eq!(lines.len(), 1);
assert!(lines[0].contains('a'));
assert!(lines[0].contains('b'));
assert!(lines[0].contains('c'));
}
#[test]
fn test_parse_from_simple() {
let result = parsers::parse_from("alpine:3.19", 1).unwrap();
assert_eq!(
result,
Instruction::From {
image: "alpine:3.19".to_string(),
alias: None,
}
);
}
#[test]
fn test_parse_from_with_alias() {
let result = parsers::parse_from("golang:1.21 AS builder", 1).unwrap();
assert_eq!(
result,
Instruction::From {
image: "golang:1.21".to_string(),
alias: Some("builder".to_string()),
}
);
}
#[test]
fn test_parse_from_empty() {
assert!(parsers::parse_from("", 1).is_err());
}
#[test]
fn test_parse_run_shell() {
let result = parsers::parse_run("apt-get update && apt-get install -y curl", 1).unwrap();
assert_eq!(
result,
Instruction::Run {
command: "apt-get update && apt-get install -y curl".to_string(),
}
);
}
#[test]
fn test_parse_run_exec_form_rejected() {
let err = parsers::parse_run(r#"["echo", "hello"]"#, 1)
.unwrap_err()
.to_string();
assert!(err.contains("RUN exec form is not supported yet"));
}
#[test]
fn test_parse_run_empty() {
assert!(parsers::parse_run("", 1).is_err());
}
#[test]
fn test_parse_copy_simple() {
let result = parsers::parse_copy("app.py /workspace/", 1).unwrap();
assert_eq!(
result,
Instruction::Copy {
src: vec!["app.py".to_string()],
dst: "/workspace/".to_string(),
from: None,
chown: None,
}
);
}
#[test]
fn test_parse_copy_multiple_sources() {
let result = parsers::parse_copy("file1.txt file2.txt /dest/", 1).unwrap();
assert_eq!(
result,
Instruction::Copy {
src: vec!["file1.txt".to_string(), "file2.txt".to_string()],
dst: "/dest/".to_string(),
from: None,
chown: None,
}
);
}
#[test]
fn test_parse_copy_from_stage() {
let result = parsers::parse_copy("--from=builder /app/bin /usr/local/bin/", 1).unwrap();
assert_eq!(
result,
Instruction::Copy {
src: vec!["/app/bin".to_string()],
dst: "/usr/local/bin/".to_string(),
from: Some("builder".to_string()),
chown: None,
}
);
}
#[test]
fn test_parse_copy_chown() {
let result = parsers::parse_copy("--chown=1000:1000 app.py /workspace/", 1).unwrap();
assert_eq!(
result,
Instruction::Copy {
src: vec!["app.py".to_string()],
dst: "/workspace/".to_string(),
from: None,
chown: Some("1000:1000".to_string()),
}
);
let r2 = parsers::parse_copy("--chown=node app.js /app/", 1).unwrap();
assert_eq!(
r2,
Instruction::Copy {
src: vec!["app.js".to_string()],
dst: "/app/".to_string(),
from: None,
chown: Some("node".to_string()),
}
);
}
#[test]
fn test_parse_copy_rejects_unsupported_flag() {
let err = parsers::parse_copy("--link app.py /workspace/", 1)
.unwrap_err()
.to_string();
assert!(err.contains("COPY flag '--link' is not supported"));
}
#[test]
fn test_parse_copy_json_form_rejected() {
let err = parsers::parse_copy(r#"["app.py", "/workspace/"]"#, 1)
.unwrap_err()
.to_string();
assert!(err.contains("COPY JSON array form is not supported yet"));
}
#[test]
fn test_parse_copy_empty() {
assert!(parsers::parse_copy("", 1).is_err());
}
#[test]
fn test_parse_copy_single_arg() {
assert!(parsers::parse_copy("onlysource", 1).is_err());
}
#[test]
fn test_parse_env_equals() {
let result = parsers::parse_env("PATH=/usr/local/bin:/usr/bin", 1).unwrap();
assert_eq!(
result,
Instruction::Env {
vars: vec![("PATH".to_string(), "/usr/local/bin:/usr/bin".to_string())],
}
);
}
#[test]
fn test_parse_env_quoted() {
let result = parsers::parse_env(r#"MSG="hello world""#, 1).unwrap();
assert_eq!(
result,
Instruction::Env {
vars: vec![("MSG".to_string(), "hello world".to_string())],
}
);
}
#[test]
fn test_parse_env_legacy() {
let result = parsers::parse_env("MY_VAR my_value", 1).unwrap();
assert_eq!(
result,
Instruction::Env {
vars: vec![("MY_VAR".to_string(), "my_value".to_string())],
}
);
}
#[test]
fn test_parse_env_multi_var() {
let result = parsers::parse_env("GREETING=hello APPDIR=/srv DEBUG=1", 1).unwrap();
assert_eq!(
result,
Instruction::Env {
vars: vec![
("GREETING".to_string(), "hello".to_string()),
("APPDIR".to_string(), "/srv".to_string()),
("DEBUG".to_string(), "1".to_string()),
],
}
);
}
#[test]
fn test_parse_env_multi_var_with_quoted_spaces() {
let result = parsers::parse_env(r#"NAME="John Doe" CITY=NYC"#, 1).unwrap();
assert_eq!(
result,
Instruction::Env {
vars: vec![
("NAME".to_string(), "John Doe".to_string()),
("CITY".to_string(), "NYC".to_string()),
],
}
);
}
#[test]
fn test_parse_env_empty() {
assert!(parsers::parse_env("", 1).is_err());
}
#[test]
fn test_parse_entrypoint_exec() {
let result = parsers::parse_entrypoint(r#"["/bin/agent", "--listen"]"#, 1).unwrap();
assert_eq!(
result,
Instruction::Entrypoint {
exec: vec!["/bin/agent".to_string(), "--listen".to_string()],
}
);
}
#[test]
fn test_parse_entrypoint_shell() {
let result = parsers::parse_entrypoint("/bin/agent --listen", 1).unwrap();
assert_eq!(
result,
Instruction::Entrypoint {
exec: vec![
"/bin/sh".to_string(),
"-c".to_string(),
"/bin/agent --listen".to_string(),
],
}
);
}
#[test]
fn test_parse_cmd_exec() {
let result = parsers::parse_cmd(r#"["--port", "8080"]"#, 1).unwrap();
assert_eq!(
result,
Instruction::Cmd {
exec: vec!["--port".to_string(), "8080".to_string()],
}
);
}
#[test]
fn test_parse_cmd_shell() {
let result = parsers::parse_cmd("echo hello", 1).unwrap();
assert_eq!(
result,
Instruction::Cmd {
exec: vec![
"/bin/sh".to_string(),
"-c".to_string(),
"echo hello".to_string(),
],
}
);
}
#[test]
fn test_parse_expose() {
let result = parsers::parse_expose("8080", 1).unwrap();
assert_eq!(
result,
Instruction::Expose {
ports: vec!["8080/tcp".to_string()],
}
);
}
#[test]
fn test_parse_expose_with_proto() {
let result = parsers::parse_expose("8080/tcp", 1).unwrap();
assert_eq!(
result,
Instruction::Expose {
ports: vec!["8080/tcp".to_string()],
}
);
}
#[test]
fn test_parse_expose_multiple_ports() {
let result = parsers::parse_expose("80 443 8080/udp", 1).unwrap();
assert_eq!(
result,
Instruction::Expose {
ports: vec![
"80/tcp".to_string(),
"443/tcp".to_string(),
"8080/udp".to_string(),
],
}
);
}
#[test]
fn test_parse_label_equals() {
let result = parsers::parse_label("version=1.0.0", 1).unwrap();
assert_eq!(
result,
Instruction::Label {
pairs: vec![("version".to_string(), "1.0.0".to_string())],
}
);
}
#[test]
fn test_parse_label_quoted() {
let result = parsers::parse_label(r#"description="My App""#, 1).unwrap();
assert_eq!(
result,
Instruction::Label {
pairs: vec![("description".to_string(), "My App".to_string())],
}
);
}
#[test]
fn test_parse_label_multiple_pairs() {
let result = parsers::parse_label("a=1 b=2 c=3", 1).unwrap();
assert_eq!(
result,
Instruction::Label {
pairs: vec![
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string()),
("c".to_string(), "3".to_string()),
],
}
);
}
#[test]
fn test_parse_user() {
let result = parsers::parse_user("nobody", 1).unwrap();
assert_eq!(
result,
Instruction::User {
user: "nobody".to_string(),
}
);
}
#[test]
fn test_parse_user_with_group() {
let result = parsers::parse_user("1000:1000", 1).unwrap();
assert_eq!(
result,
Instruction::User {
user: "1000:1000".to_string(),
}
);
}
#[test]
fn test_parse_arg_no_default() {
let result = parsers::parse_arg("VERSION", 1).unwrap();
assert_eq!(
result,
Instruction::Arg {
name: "VERSION".to_string(),
default: None,
}
);
}
#[test]
fn test_parse_arg_with_default() {
let result = parsers::parse_arg("VERSION=1.0.0", 1).unwrap();
assert_eq!(
result,
Instruction::Arg {
name: "VERSION".to_string(),
default: Some("1.0.0".to_string()),
}
);
}
#[test]
fn test_parse_minimal_dockerfile() {
let content = "FROM alpine:3.19\nCMD [\"echo\", \"hello\"]";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(
matches!(&df.instructions[0], Instruction::From { image, .. } if image == "alpine:3.19")
);
}
#[test]
fn test_parse_complex_dockerfile() {
let content = r#"
# Build stage
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
LABEL version="1.0.0"
LABEL maintainer="team@example.com"
USER nobody
ENTRYPOINT ["python"]
CMD ["app.py"]
"#;
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 13);
}
#[test]
fn test_parse_with_continuations() {
let content = "FROM alpine:3.19\nRUN apk add --no-cache \\\n curl \\\n wget";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
if let Instruction::Run { command } = &df.instructions[1] {
assert!(command.contains("curl"));
assert!(command.contains("wget"));
} else {
panic!("Expected RUN instruction");
}
}
#[test]
fn test_parse_empty_dockerfile() {
let content = "# just a comment\n\n";
assert!(Dockerfile::parse(content).is_err());
}
#[test]
fn test_parse_no_from() {
let content = "RUN echo hello";
assert!(Dockerfile::parse(content).is_err());
}
#[test]
fn test_parse_arg_before_from() {
let content = "ARG VERSION=3.19\nFROM alpine:${VERSION}";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(matches!(&df.instructions[0], Instruction::Arg { .. }));
assert!(matches!(&df.instructions[1], Instruction::From { .. }));
}
#[test]
fn test_parse_comments_and_blanks() {
let content = "\n# comment\n\nFROM alpine\n\n# another comment\nRUN echo hi\n\n";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
}
#[test]
fn test_unquote_double() {
assert_eq!(utils::unquote(r#""hello world""#), "hello world");
}
#[test]
fn test_unquote_single() {
assert_eq!(utils::unquote("'hello world'"), "hello world");
}
#[test]
fn test_unquote_none() {
assert_eq!(utils::unquote("hello"), "hello");
}
#[test]
fn test_unquote_mismatched() {
assert_eq!(utils::unquote(r#""hello'"#), r#""hello'"#);
}
#[test]
fn test_parse_add_simple() {
let result = parsers::parse_add("app.tar.gz /app/", 1).unwrap();
assert_eq!(
result,
Instruction::Add {
src: vec!["app.tar.gz".to_string()],
dst: "/app/".to_string(),
chown: None,
}
);
}
#[test]
fn test_parse_add_chown() {
let result = parsers::parse_add("--chown=1000:1000 files/ /data/", 1).unwrap();
assert_eq!(
result,
Instruction::Add {
src: vec!["files/".to_string()],
dst: "/data/".to_string(),
chown: Some("1000:1000".to_string()),
}
);
}
#[test]
fn test_parse_add_json_form_rejected() {
let err = parsers::parse_add(r#"["file.txt", "/tmp/file.txt"]"#, 1)
.unwrap_err()
.to_string();
assert!(err.contains("ADD JSON array form is not supported yet"));
}
#[test]
fn test_parse_add_url() {
let result = parsers::parse_add("https://example.com/file.tar.gz /tmp/", 1).unwrap();
assert_eq!(
result,
Instruction::Add {
src: vec!["https://example.com/file.tar.gz".to_string()],
dst: "/tmp/".to_string(),
chown: None,
}
);
}
#[test]
fn test_parse_add_empty() {
assert!(parsers::parse_add("", 1).is_err());
}
#[test]
fn test_parse_add_single_arg() {
assert!(parsers::parse_add("onlysource", 1).is_err());
}
#[test]
fn test_parse_maintainer_accepted_as_label() {
let df = Dockerfile::parse("FROM alpine\nMAINTAINER ops@example.com").unwrap();
assert!(df.instructions.iter().any(|i| matches!(
i,
Instruction::Label { pairs }
if pairs == &vec![("maintainer".to_string(), "ops@example.com".to_string())]
)));
}
#[test]
fn test_parse_shell_bash() {
let result = parsers::parse_shell(r#"["/bin/bash", "-c"]"#, 1).unwrap();
assert_eq!(
result,
Instruction::Shell {
exec: vec!["/bin/bash".to_string(), "-c".to_string()],
}
);
}
#[test]
fn test_parse_shell_powershell() {
let result = parsers::parse_shell(r#"["powershell", "-command"]"#, 1).unwrap();
assert_eq!(
result,
Instruction::Shell {
exec: vec!["powershell".to_string(), "-command".to_string()],
}
);
}
#[test]
fn test_parse_shell_empty() {
assert!(parsers::parse_shell("", 1).is_err());
}
#[test]
fn test_parse_shell_not_json() {
assert!(parsers::parse_shell("/bin/bash -c", 1).is_err());
}
#[test]
fn test_parse_shell_empty_array() {
assert!(parsers::parse_shell("[]", 1).is_err());
}
#[test]
fn test_parse_stopsignal_name() {
let result = parsers::parse_stopsignal("SIGTERM", 1).unwrap();
assert_eq!(
result,
Instruction::StopSignal {
signal: "SIGTERM".to_string(),
}
);
}
#[test]
fn test_parse_stopsignal_number() {
let result = parsers::parse_stopsignal("9", 1).unwrap();
assert_eq!(
result,
Instruction::StopSignal {
signal: "9".to_string(),
}
);
}
#[test]
fn test_parse_stopsignal_empty() {
assert!(parsers::parse_stopsignal("", 1).is_err());
}
#[test]
fn test_parse_healthcheck_none() {
let result = parsers::parse_healthcheck("NONE", 1).unwrap();
assert_eq!(
result,
Instruction::HealthCheck {
cmd: None,
interval: None,
timeout: None,
retries: None,
start_period: None,
}
);
}
#[test]
fn test_parse_healthcheck_simple_cmd() {
let result = parsers::parse_healthcheck("CMD curl -f http://localhost/", 1).unwrap();
assert_eq!(
result,
Instruction::HealthCheck {
cmd: Some(vec![
"/bin/sh".to_string(),
"-c".to_string(),
"curl -f http://localhost/".to_string(),
]),
interval: None,
timeout: None,
retries: None,
start_period: None,
}
);
}
#[test]
fn test_parse_healthcheck_with_options() {
let result = parsers::parse_healthcheck(
"--interval=10s --timeout=5s --retries=5 --start-period=30s CMD curl -f http://localhost/",
1,
)
.unwrap();
assert_eq!(
result,
Instruction::HealthCheck {
cmd: Some(vec![
"/bin/sh".to_string(),
"-c".to_string(),
"curl -f http://localhost/".to_string(),
]),
interval: Some(10),
timeout: Some(5),
retries: Some(5),
start_period: Some(30),
}
);
}
#[test]
fn test_parse_healthcheck_compound_go_duration() {
let result =
parsers::parse_healthcheck("--interval=1m30s --timeout=2s --retries=3 CMD true", 1)
.unwrap();
if let Instruction::HealthCheck {
interval, timeout, ..
} = result
{
assert_eq!(interval, Some(90));
assert_eq!(timeout, Some(2));
} else {
panic!("expected HealthCheck");
}
}
#[test]
fn test_parse_healthcheck_json_cmd() {
let result =
parsers::parse_healthcheck(r#"CMD ["curl", "-f", "http://localhost/"]"#, 1).unwrap();
if let Instruction::HealthCheck { cmd, .. } = &result {
assert_eq!(
cmd.as_ref().unwrap(),
&vec![
"curl".to_string(),
"-f".to_string(),
"http://localhost/".to_string()
]
);
} else {
panic!("Expected HealthCheck");
}
}
#[test]
fn test_parse_healthcheck_empty() {
assert!(parsers::parse_healthcheck("", 1).is_err());
}
#[test]
fn test_parse_healthcheck_no_cmd() {
assert!(parsers::parse_healthcheck("--interval=10s", 1).is_err());
}
#[test]
fn test_parse_onbuild_run() {
let result = parsers::parse_onbuild("RUN echo hello", 1).unwrap();
assert_eq!(
result,
Instruction::OnBuild {
instruction: Box::new(Instruction::Run {
command: "echo hello".to_string(),
}),
}
);
}
#[test]
fn test_parse_onbuild_copy() {
let result = parsers::parse_onbuild("COPY . /app", 1).unwrap();
assert_eq!(
result,
Instruction::OnBuild {
instruction: Box::new(Instruction::Copy {
src: vec![".".to_string()],
dst: "/app".to_string(),
from: None,
chown: None,
}),
}
);
}
#[test]
fn test_parse_onbuild_empty() {
assert!(parsers::parse_onbuild("", 1).is_err());
}
#[test]
fn test_parse_onbuild_onbuild() {
assert!(parsers::parse_onbuild("ONBUILD RUN echo", 1).is_err());
}
#[test]
fn test_parse_onbuild_from() {
assert!(parsers::parse_onbuild("FROM alpine", 1).is_err());
}
#[test]
fn test_parse_volume_single() {
let result = parsers::parse_volume("/data", 1).unwrap();
assert_eq!(
result,
Instruction::Volume {
paths: vec!["/data".to_string()],
}
);
}
#[test]
fn test_parse_volume_multiple() {
let result = parsers::parse_volume("/data /var/log", 1).unwrap();
assert_eq!(
result,
Instruction::Volume {
paths: vec!["/data".to_string(), "/var/log".to_string()],
}
);
}
#[test]
fn test_parse_volume_json() {
let result = parsers::parse_volume(r#"["/data", "/var/log"]"#, 1).unwrap();
assert_eq!(
result,
Instruction::Volume {
paths: vec!["/data".to_string(), "/var/log".to_string()],
}
);
}
#[test]
fn test_parse_volume_empty() {
assert!(parsers::parse_volume("", 1).is_err());
}
#[test]
fn test_parse_duration_seconds() {
assert_eq!(utils::parse_duration_secs("30s", 1).unwrap(), 30);
}
#[test]
fn test_parse_duration_minutes() {
assert_eq!(utils::parse_duration_secs("5m", 1).unwrap(), 300);
}
#[test]
fn test_parse_duration_hours() {
assert_eq!(utils::parse_duration_secs("1h", 1).unwrap(), 3600);
}
#[test]
fn test_parse_duration_plain_number() {
assert_eq!(utils::parse_duration_secs("30", 1).unwrap(), 30);
}
#[test]
fn test_parse_duration_empty() {
assert_eq!(utils::parse_duration_secs("", 1).unwrap(), 0);
}
#[test]
fn test_parse_dockerfile_with_shell() {
let content = "FROM alpine\nSHELL [\"/bin/bash\", \"-c\"]\nRUN echo hello";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 3);
assert!(matches!(&df.instructions[1], Instruction::Shell { exec } if exec.len() == 2));
}
#[test]
fn test_parse_dockerfile_with_healthcheck() {
let content = "FROM alpine\nHEALTHCHECK CMD curl -f http://localhost/";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(matches!(
&df.instructions[1],
Instruction::HealthCheck { cmd: Some(_), .. }
));
}
#[test]
fn test_parse_dockerfile_with_volume() {
let content = "FROM alpine\nVOLUME /data";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(
matches!(&df.instructions[1], Instruction::Volume { paths } if paths == &["/data".to_string()])
);
}
#[test]
fn test_parse_dockerfile_with_add() {
let content = "FROM alpine\nADD app.tar.gz /app/";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(matches!(&df.instructions[1], Instruction::Add { .. }));
}
#[test]
fn test_parse_dockerfile_with_stopsignal() {
let content = "FROM alpine\nSTOPSIGNAL SIGKILL";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(
matches!(&df.instructions[1], Instruction::StopSignal { signal } if signal == "SIGKILL")
);
}
#[test]
fn test_parse_dockerfile_with_onbuild() {
let content = "FROM alpine\nONBUILD RUN echo triggered";
let df = Dockerfile::parse(content).unwrap();
assert_eq!(df.instructions.len(), 2);
assert!(matches!(&df.instructions[1], Instruction::OnBuild { .. }));
}
}