use std::fs;
use std::path::Path;
use toml::Value;
const DOCKERIGNORE_TPL: &str = include_str!("files/docker/dockerignore.tpl");
const DOCKERFILE_TPL: &str = include_str!("files/docker/Dockerfile.tpl");
pub fn dockerignore_template() -> &'static str {
DOCKERIGNORE_TPL
}
#[derive(Debug, Clone)]
pub struct DockerContext {
pub rust_channel: String,
pub has_frontend: bool,
pub bins: Vec<String>,
pub web_bin: String,
pub copy_dirs_present: Vec<String>,
pub runtime_apt: Vec<String>,
}
pub fn render_dockerfile(ctx: &DockerContext) -> String {
let frontend_stage = if ctx.has_frontend {
FRONTEND_STAGE_BODY.to_string()
} else {
String::new()
};
let bin_copies = ctx
.bins
.iter()
.map(|b| format!("COPY --from=backend-builder /app/target/release/{b} /usr/local/bin/{b}"))
.collect::<Vec<_>>()
.join("\n");
let copy_dirs = ctx
.copy_dirs_present
.iter()
.map(|d| format!("COPY {d} {d}"))
.collect::<Vec<_>>()
.join("\n");
let runtime_apt = if ctx.runtime_apt.is_empty() {
String::new()
} else {
let pkgs = ctx.runtime_apt.join(" ");
format!(
"# ferro:runtime-apt\nRUN apt-get update \\\n && apt-get install -y --no-install-recommends {pkgs} \\\n && rm -rf /var/lib/apt/lists/*"
)
};
let rust_image_tag = if ctx.rust_channel == "stable" {
"slim-bookworm".to_string()
} else {
format!("{}-slim-bookworm", ctx.rust_channel)
};
let entrypoint_block = format!(
"ENTRYPOINT [\"/usr/local/bin/{}\"]\nCMD [\"serve\"]",
ctx.web_bin
);
let rendered = DOCKERFILE_TPL
.replace("{{FRONTEND_STAGE}}", &frontend_stage)
.replace("{{RUST_IMAGE_TAG}}", &rust_image_tag)
.replace("{{ENTRYPOINT}}", &entrypoint_block)
.replace("{{BIN_COPIES}}", &bin_copies)
.replace("{{COPY_DIRS}}", ©_dirs)
.replace("{{RUNTIME_APT}}", &runtime_apt);
debug_assert!(
!rendered.contains("{{"),
"unresolved template token in rendered Dockerfile:\n{rendered}"
);
rendered
}
const FRONTEND_STAGE_BODY: &str = r#"
FROM node:20-bookworm-slim AS frontend-builder
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm ci || npm install
COPY frontend/ ./
RUN npm run build
"#;
pub fn read_rust_channel(project_root: &Path) -> String {
let path = project_root.join("rust-toolchain.toml");
let Ok(content) = fs::read_to_string(&path) else {
return "stable".to_string();
};
let Ok(parsed) = content.parse::<Value>() else {
return "stable".to_string();
};
parsed
.get("toolchain")
.and_then(|t| t.get("channel"))
.and_then(|c| c.as_str())
.map(String::from)
.unwrap_or_else(|| "stable".to_string())
}
pub fn docker_compose_template(
project_name: &str,
include_mailpit: bool,
include_minio: bool,
) -> String {
let mailpit_service = if include_mailpit {
include_str!("files/docker/mailpit.service.tpl").replace("{project_name}", project_name)
} else {
String::new()
};
let minio_service = if include_minio {
include_str!("files/docker/minio.service.tpl").replace("{project_name}", project_name)
} else {
String::new()
};
let additional_volumes = if include_minio {
"\n minio_data:".to_string()
} else {
String::new()
};
include_str!("files/docker/docker-compose.yml.tpl")
.replace("{project_name}", project_name)
.replace("{mailpit_service}", &mailpit_service)
.replace("{minio_service}", &minio_service)
.replace("{additional_volumes}", &additional_volumes)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn ctx() -> DockerContext {
DockerContext {
rust_channel: "stable".to_string(),
has_frontend: false,
bins: vec!["app".to_string()],
web_bin: "app".to_string(),
copy_dirs_present: vec![],
runtime_apt: vec![],
}
}
#[test]
fn frontend_stage_present_only_when_has_frontend() {
let mut c = ctx();
c.has_frontend = false;
assert!(!render_dockerfile(&c).contains("frontend-builder"));
c.has_frontend = true;
assert!(render_dockerfile(&c).contains("frontend-builder"));
}
#[test]
fn base_image_uses_rust_channel() {
let mut c = ctx();
c.rust_channel = "1.90.0".into();
let out = render_dockerfile(&c);
assert!(out.contains("rust:1.90.0-slim-bookworm"));
}
#[test]
fn stable_channel_emits_unversioned_slim_bookworm() {
let mut c = ctx();
c.rust_channel = "stable".into();
let out = render_dockerfile(&c);
assert!(out.contains("FROM rust:slim-bookworm AS chef"));
assert!(!out.contains("rust:stable-slim-bookworm"));
}
#[test]
fn multi_bin_emits_per_bin_copy_without_per_bin_build() {
let mut c = ctx();
c.bins = vec!["web".into(), "worker".into()];
c.web_bin = "web".into();
let out = render_dockerfile(&c);
assert_eq!(out.matches("cargo build --release --bin").count(), 0);
assert!(
out.contains("COPY --from=backend-builder /app/target/release/web /usr/local/bin/web")
);
assert!(out.contains(
"COPY --from=backend-builder /app/target/release/worker /usr/local/bin/worker"
));
}
#[test]
fn copy_dirs_emits_only_present_entries() {
let mut c = ctx();
c.copy_dirs_present = vec!["themes".into(), "migrations".into()];
let out = render_dockerfile(&c);
assert!(out.contains("COPY themes themes"));
assert!(out.contains("COPY migrations migrations"));
assert!(!out.contains("COPY public public"));
}
#[test]
fn runtime_apt_empty_emits_no_block() {
let c = ctx();
let out = render_dockerfile(&c);
assert!(!out.contains("ferro:runtime-apt"));
}
#[test]
fn runtime_apt_nonempty_emits_marker_and_packages() {
let mut c = ctx();
c.runtime_apt = vec!["chromium".into(), "fonts-liberation".into()];
let out = render_dockerfile(&c);
assert!(out.contains("# ferro:runtime-apt"));
assert!(out.contains("chromium fonts-liberation"));
}
#[test]
fn dockerfile_copies_workspace_in_both_stages() {
let out = render_dockerfile(&ctx());
assert_eq!(out.matches("COPY . .").count(), 2);
assert!(!out.contains("Cargo.docker"));
}
#[test]
fn no_obsolete_phase_122_features() {
let out = render_dockerfile(&ctx());
for forbidden in ["GITHUB_TOKEN", "insteadOf", "rewrite-ferro-deps"] {
assert!(
!out.contains(forbidden),
"found forbidden token: {forbidden}"
);
}
}
#[test]
fn read_rust_channel_returns_default_when_missing() {
let tmp = TempDir::new().unwrap();
assert_eq!(read_rust_channel(tmp.path()), "stable");
}
#[test]
fn read_rust_channel_returns_channel_from_toolchain() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.90.0\"\n",
)
.unwrap();
assert_eq!(read_rust_channel(tmp.path()), "1.90.0");
}
#[test]
fn renderer_module_mentions_runtime_apt_marker() {
let mut c = ctx();
c.runtime_apt = vec!["foo".into()];
assert!(render_dockerfile(&c).contains("# ferro:runtime-apt"));
}
#[test]
fn dockerignore_is_static_passthrough() {
assert!(dockerignore_template().contains("database.db"));
}
}
#[cfg(test)]
mod entrypoint_tests {
use super::*;
fn test_ctx_single(bin: &str) -> DockerContext {
DockerContext {
rust_channel: "stable".to_string(),
has_frontend: false,
bins: vec![bin.to_string()],
web_bin: bin.to_string(),
copy_dirs_present: vec![],
runtime_apt: vec![],
}
}
fn test_ctx_multi(web: &str, bins: &[&str]) -> DockerContext {
DockerContext {
rust_channel: "stable".to_string(),
has_frontend: false,
bins: bins.iter().map(|s| s.to_string()).collect(),
web_bin: web.to_string(),
copy_dirs_present: vec![],
runtime_apt: vec![],
}
}
#[test]
fn entrypoint_emitted_for_single_bin() {
let out = render_dockerfile(&test_ctx_single("myapp"));
assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/myapp"]"#));
assert!(out.contains(r#"CMD ["serve"]"#));
}
#[test]
fn entrypoint_emitted_for_multi_bin() {
let out = render_dockerfile(&test_ctx_multi("api", &["api", "worker"]));
assert!(out.contains(r#"ENTRYPOINT ["/usr/local/bin/api"]"#));
}
#[test]
fn cmd_is_serve() {
let out = render_dockerfile(&test_ctx_single("x"));
assert!(out.contains(r#"CMD ["serve"]"#));
}
#[test]
fn dockerfile_single_build_invocation() {
let out = render_dockerfile(&test_ctx_single("x"));
let build_releases = out.matches("cargo build --release").count();
assert_eq!(
build_releases, 1,
"expected exactly one `cargo build --release`"
);
assert_eq!(out.matches("cargo build --release --bin").count(), 0);
}
#[test]
fn no_unresolved_tokens_in_dockerfile() {
let out = render_dockerfile(&test_ctx_single("x"));
assert!(!out.contains("{{"), "unresolved token(s) in output:\n{out}");
}
#[test]
fn dockerignore_whitelists_readme() {
let out = dockerignore_template();
assert!(out.contains("*.md"));
assert!(out.contains("!README.md"));
let idx_whitelist = out.find("!README.md").unwrap();
let before = &out[..idx_whitelist];
let last_line = before
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
assert!(
last_line.trim_start().starts_with('#'),
"expected comment above !README.md, got: {last_line}"
);
}
}