use std::fs;
use std::path::Path;
pub fn generate_dockerfile(
project_name: &str,
includes: &[String],
additional_tools: &[String],
runtime_deps: &[String],
) -> String {
let mut dockerfile = String::new();
dockerfile.push_str("# Base builder stage with cargo-chef for dependency caching\n");
dockerfile.push_str("FROM rust:slim-bookworm AS base-builder\n\n");
dockerfile.push_str("WORKDIR /app\n\n");
dockerfile.push_str("# Install cargo-chef for dependency caching\n");
dockerfile.push_str("RUN cargo install cargo-chef\n\n");
dockerfile.push_str("# Install system dependencies\n");
dockerfile.push_str("RUN apt-get update && apt-get install -y \\\n");
dockerfile.push_str(" pkg-config \\\n");
dockerfile.push_str(" libssl-dev \\\n");
for tool in additional_tools {
dockerfile.push_str(&format!(" {} \\\n", tool));
}
dockerfile.push_str(" && rm -rf /var/lib/apt/lists/*\n\n");
dockerfile.push_str("# Planner stage - analyzes dependencies\n");
dockerfile.push_str("FROM base-builder AS planner\n\n");
dockerfile.push_str("COPY . .\n");
dockerfile.push_str("RUN cargo chef prepare --recipe-path recipe.json\n\n");
dockerfile.push_str("# Builder stage - builds with cached dependencies\n");
dockerfile.push_str("FROM base-builder AS builder\n\n");
dockerfile
.push_str("# Copy dependency recipe (cached layer if dependencies haven't changed)\n");
dockerfile.push_str("COPY --from=planner /app/recipe.json recipe.json\n\n");
dockerfile.push_str("# Build dependencies (cached if recipe.json is unchanged)\n");
dockerfile.push_str("RUN cargo chef cook --release --recipe-path recipe.json\n\n");
if !includes.is_empty() {
dockerfile.push_str("# Copy source files (invalidates cache only when source changes)\n");
for item in includes {
dockerfile.push_str(&format!("COPY {} ./{}\n", item, item));
}
dockerfile.push('\n');
}
dockerfile.push_str("# Copy dependency files\n");
dockerfile.push_str("COPY Cargo.toml Cargo.lock ./\n\n");
dockerfile.push_str("# Build the project (only compiles project code, not dependencies)\n");
dockerfile.push_str("RUN cargo build --release\n\n");
dockerfile.push_str("# Runtime stage\n");
dockerfile.push_str("FROM debian:bookworm-slim\n\n");
dockerfile.push_str("WORKDIR /app\n\n");
dockerfile.push_str("# Install runtime dependencies\n");
dockerfile.push_str("RUN apt-get update && apt-get install -y \\\n");
dockerfile.push_str(" ca-certificates \\\n");
dockerfile.push_str(" libssl3 \\\n");
for dep in runtime_deps {
dockerfile.push_str(&format!(" {} \\\n", dep));
}
dockerfile.push_str(" && rm -rf /var/lib/apt/lists/*\n\n");
dockerfile.push_str("# Copy the binary from builder\n");
dockerfile.push_str(&format!(
"COPY --from=builder /app/target/release/{} /app/\n\n",
project_name
));
dockerfile.push_str("# Expose port 80\n");
dockerfile.push_str("EXPOSE 80\n\n");
dockerfile.push_str("# Run the binary\n");
dockerfile.push_str(&format!("ENTRYPOINT [\"./{}\"]\n", project_name));
dockerfile
}
pub fn generate_dockerignore(excludes: &[String]) -> String {
let mut content = String::new();
content.push_str("# Auto-generated by spruce\n");
content.push_str("# Files and directories to exclude from Docker build context\n\n");
content.push_str("# System files\n");
content.push_str(".git\n");
content.push_str(".gitignore\n");
content.push_str(".github\n");
content.push_str(".DS_Store\n");
content.push_str("*.swp\n");
content.push_str("*.swo\n");
content.push_str("*~\n\n");
content.push_str("# Rust build artifacts\n");
content.push_str("target/\n");
content.push_str("Cargo.lock\n");
content.push_str("**/*.rs.bk\n\n");
content.push_str("# Docker files\n");
content.push_str("Dockerfile\n");
content.push_str("Dockerfile.*\n");
content.push_str("docker-compose*.yml\n");
content.push_str("docker-compose*.yaml\n");
content.push_str("compose*.yml\n");
content.push_str("compose*.yaml\n");
content.push_str(".dockerignore\n\n");
if !excludes.is_empty() {
content.push_str("# Project-specific excludes\n");
for exclude in excludes {
content.push_str(exclude);
content.push('\n');
}
content.push('\n');
}
content
}
pub fn handle_dockerignore(excludes: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let dockerignore_exists = Path::new(".dockerignore").exists();
let dockerignore_content = generate_dockerignore(excludes);
if dockerignore_exists {
let overwrite = inquire::Confirm::new(
".dockerignore already exists. Overwrite it with spruce defaults?",
)
.with_default(false)
.with_help_message("This will replace your existing .dockerignore")
.prompt()?;
if overwrite {
fs::write(".dockerignore", dockerignore_content)?;
println!("[OK] Overwrote .dockerignore");
} else {
println!(" Skipped .dockerignore (kept existing)");
}
} else {
fs::write(".dockerignore", dockerignore_content)?;
println!("[OK] Created .dockerignore");
}
Ok(())
}
const ALWAYS_IGNORED: &[&str] = &[
".git",
".gitignore",
".github",
".gitlab-ci.yml",
".travis.yml",
".circleci",
"target",
"Dockerfile",
"dockerfile",
"Dockerfile.",
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
".dockerignore",
];
fn read_dockerignore_patterns() -> Vec<String> {
let mut patterns = Vec::new();
if let Ok(content) = fs::read_to_string(".dockerignore") {
for line in content.lines() {
let line = line.trim();
if !line.is_empty() && !line.starts_with('#') {
patterns.push(line.to_string());
}
}
}
patterns
}
fn matches_dockerignore_pattern(name: &str, pattern: &str) -> bool {
let pattern = pattern.trim();
if let Some(dir_pattern) = pattern.strip_suffix('/') {
return name == dir_pattern;
}
if pattern.starts_with("*.") {
let ext = &pattern[1..];
return name.ends_with(ext);
}
if pattern.contains('*') {
if let Some(suffix) = pattern.strip_prefix("*") {
return name.ends_with(suffix);
}
if let Some(prefix) = pattern.strip_suffix("*") {
return name.starts_with(prefix);
}
if let Some(pos) = pattern.find("*") {
let prefix = &pattern[..pos];
let suffix = &pattern[pos + 1..];
return name.starts_with(prefix) && name.ends_with(suffix);
}
}
name == pattern
}
fn should_exclude_by_dockerignore(name: &str) -> bool {
let patterns = read_dockerignore_patterns();
for pattern in patterns {
if matches_dockerignore_pattern(name, &pattern) {
return true;
}
}
false
}
pub fn discover_files() -> (Vec<String>, Vec<String>) {
let mut includes = Vec::new();
let mut excludes = Vec::new();
if let Ok(entries) = fs::read_dir(".") {
for entry in entries.flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if name.starts_with('.') {
excludes.push(name);
continue;
}
if ALWAYS_IGNORED.iter().any(|&ignored| {
name == ignored
|| name.starts_with("Dockerfile.")
|| name.starts_with("dockerfile.")
}) {
excludes.push(name);
continue;
}
if should_exclude_by_dockerignore(&name) {
excludes.push(name);
continue;
}
includes.push(name);
}
}
includes.sort();
excludes.sort();
(includes, excludes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_dockerfile_without_additional_tools() {
let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &[], &[]);
assert!(dockerfile.contains("pkg-config"));
assert!(dockerfile.contains("libssl-dev"));
assert!(!dockerfile.contains("curl"));
assert!(!dockerfile.contains("jq"));
assert!(dockerfile.contains("cargo-chef"));
assert!(dockerfile.contains("cargo chef prepare"));
assert!(dockerfile.contains("cargo chef cook"));
assert!(dockerfile.contains("recipe.json"));
assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
assert!(dockerfile.contains("FROM base-builder AS builder"));
assert!(dockerfile.contains("FROM debian:bookworm-slim"));
assert!(dockerfile.contains("COPY --from=builder /app/target/release/myapp"));
}
#[test]
fn test_generate_dockerfile_with_additional_tools() {
let tools = vec!["curl".to_string(), "jq".to_string(), "git".to_string()];
let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &tools, &[]);
assert!(dockerfile.contains("pkg-config"));
assert!(dockerfile.contains("libssl-dev"));
assert!(dockerfile.contains("curl"));
assert!(dockerfile.contains("jq"));
assert!(dockerfile.contains("git"));
assert!(dockerfile.contains("cargo-chef"));
assert!(dockerfile.contains("cargo chef prepare"));
assert!(dockerfile.contains("cargo chef cook"));
assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
assert!(dockerfile.contains("FROM base-builder AS builder"));
assert!(dockerfile.contains("FROM debian:bookworm-slim"));
}
#[test]
fn test_generate_dockerfile_with_runtime_deps() {
let runtime_deps = vec!["postgresql-client".to_string(), "redis-tools".to_string()];
let dockerfile = generate_dockerfile("myapp", &["src".to_string()], &[], &runtime_deps);
assert!(dockerfile.contains("ca-certificates"));
assert!(dockerfile.contains("libssl3"));
assert!(dockerfile.contains("postgresql-client"));
assert!(dockerfile.contains("redis-tools"));
assert!(dockerfile.contains("cargo-chef"));
assert!(dockerfile.contains("cargo chef prepare"));
assert!(dockerfile.contains("cargo chef cook"));
assert!(dockerfile.contains("FROM rust:slim-bookworm AS base-builder"));
assert!(dockerfile.contains("FROM base-builder AS builder"));
assert!(dockerfile.contains("FROM debian:bookworm-slim"));
}
#[test]
fn test_generate_dockerignore_default() {
let dockerignore = generate_dockerignore(&[]);
assert!(dockerignore.contains(".git"));
assert!(dockerignore.contains(".gitignore"));
assert!(dockerignore.contains(".github"));
assert!(dockerignore.contains(".DS_Store"));
assert!(dockerignore.contains("target/"));
assert!(dockerignore.contains("Cargo.lock"));
assert!(dockerignore.contains("**/*.rs.bk"));
assert!(dockerignore.contains("Dockerfile"));
assert!(dockerignore.contains("Dockerfile.*"));
assert!(dockerignore.contains("docker-compose*.yml"));
assert!(dockerignore.contains(".dockerignore"));
assert!(dockerignore.contains("# Auto-generated by spruce"));
}
#[test]
fn test_generate_dockerignore_with_excludes() {
let excludes = vec!["tests".to_string(), "*.log".to_string(), "docs".to_string()];
let dockerignore = generate_dockerignore(&excludes);
assert!(dockerignore.contains(".git"));
assert!(dockerignore.contains("target/"));
assert!(dockerignore.contains("tests"));
assert!(dockerignore.contains("*.log"));
assert!(dockerignore.contains("docs"));
assert!(dockerignore.contains("# Project-specific excludes"));
}
#[test]
fn test_generate_dockerignore_format() {
let excludes = vec!["node_modules".to_string()];
let dockerignore = generate_dockerignore(&excludes);
let lines: Vec<&str> = dockerignore.lines().collect();
assert!(lines.iter().any(|&line| line == "node_modules"));
assert!(lines.iter().any(|&line| line == ".git"));
assert!(lines.iter().any(|&line| line == "target/"));
}
}