use std::collections::BTreeSet;
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Family {
Debian,
Alpine,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaseImage {
DebianBookwormSlim,
Ubuntu24_04,
AlpineLatest,
Node20BookwormSlim,
Python3_12Slim,
}
impl BaseImage {
pub const ALL: &'static [BaseImage] = &[
Self::DebianBookwormSlim,
Self::Ubuntu24_04,
Self::AlpineLatest,
Self::Node20BookwormSlim,
Self::Python3_12Slim,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::DebianBookwormSlim => "debian:bookworm-slim",
Self::Ubuntu24_04 => "ubuntu:24.04",
Self::AlpineLatest => "alpine:latest",
Self::Node20BookwormSlim => "node:20-bookworm-slim",
Self::Python3_12Slim => "python:3.12-slim",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::DebianBookwormSlim => "Debian 12 slim. Apt-based; small but full-featured.",
Self::Ubuntu24_04 => "Ubuntu 24.04 LTS. Apt-based; superset of Debian.",
Self::AlpineLatest => "Alpine. Apk + musl; smallest footprint.",
Self::Node20BookwormSlim => "Debian-slim with Node 20 LTS preinstalled.",
Self::Python3_12Slim => "Debian-slim with CPython 3.12 + pip preinstalled.",
}
}
pub fn family(self) -> Family {
match self {
Self::AlpineLatest => Family::Alpine,
_ => Family::Debian,
}
}
fn has_node(self) -> bool {
matches!(self, Self::Node20BookwormSlim)
}
fn has_python(self) -> bool {
matches!(self, Self::Python3_12Slim)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Toolchain {
Rust,
Node,
Python,
Go,
None,
}
impl Toolchain {
pub const ALL: &'static [Toolchain] =
&[Self::Rust, Self::Node, Self::Python, Self::Go, Self::None];
pub fn as_str(self) -> &'static str {
match self {
Self::Rust => "rust",
Self::Node => "node",
Self::Python => "python",
Self::Go => "go",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum McpServer {
Fs,
Git,
}
impl McpServer {
pub const ALL: &'static [McpServer] = &[Self::Fs, Self::Git];
pub const fn as_str(self) -> &'static str {
match self {
Self::Fs => "fs",
Self::Git => "git",
}
}
pub const fn description(self) -> &'static str {
match self {
Self::Fs => "Filesystem MCP server (npm @modelcontextprotocol/server-filesystem).",
Self::Git => "Git MCP server (PyPI mcp-server-git).",
}
}
pub fn command_args(self) -> &'static [&'static str] {
match self {
Self::Fs => &["mcp-server-filesystem", "/workspace"],
Self::Git => &["mcp-server-git", "--repository", "/workspace"],
}
}
fn needs_node(self) -> bool {
matches!(self, Self::Fs)
}
fn needs_python(self) -> bool {
matches!(self, Self::Git)
}
pub(crate) fn install_cmd(self) -> &'static str {
match self {
Self::Fs => "npm install -g @modelcontextprotocol/server-filesystem",
Self::Git => "pip install --break-system-packages mcp-server-git",
}
}
}
const HEADER_DEBIAN: &str = include_str!("templates/header.debian.dockerfile");
const HEADER_ALPINE: &str = include_str!("templates/header.alpine.dockerfile");
const RUST_DEBIAN: &str = include_str!("templates/rust.debian.dockerfile");
const RUST_ALPINE: &str = include_str!("templates/rust.alpine.dockerfile");
const NODE_DEBIAN: &str = include_str!("templates/node.debian.dockerfile");
const NODE_ALPINE: &str = include_str!("templates/node.alpine.dockerfile");
const PYTHON_DEBIAN: &str = include_str!("templates/python.debian.dockerfile");
const PYTHON_ALPINE: &str = include_str!("templates/python.alpine.dockerfile");
const GO_DEBIAN: &str = include_str!("templates/go.debian.dockerfile");
const GO_ALPINE: &str = include_str!("templates/go.alpine.dockerfile");
const FOOTER: &str = include_str!("templates/footer.dockerfile");
fn header_template(family: Family) -> &'static str {
match family {
Family::Debian => HEADER_DEBIAN,
Family::Alpine => HEADER_ALPINE,
}
}
fn toolchain_fragment(t: Toolchain, family: Family) -> Option<&'static str> {
let frag = match (t, family) {
(Toolchain::Rust, Family::Debian) => RUST_DEBIAN,
(Toolchain::Rust, Family::Alpine) => RUST_ALPINE,
(Toolchain::Node, Family::Debian) => NODE_DEBIAN,
(Toolchain::Node, Family::Alpine) => NODE_ALPINE,
(Toolchain::Python, Family::Debian) => PYTHON_DEBIAN,
(Toolchain::Python, Family::Alpine) => PYTHON_ALPINE,
(Toolchain::Go, Family::Debian) => GO_DEBIAN,
(Toolchain::Go, Family::Alpine) => GO_ALPINE,
(Toolchain::None, _) => return None,
};
Some(frag)
}
fn ensure_pkgs(family: Family, pkgs: &str) -> String {
match family {
Family::Debian => format!(
"apt-get update && apt-get install -y --no-install-recommends {pkgs} \
&& rm -rf /var/lib/apt/lists/*"
),
Family::Alpine => format!("apk add --no-cache {pkgs}"),
}
}
fn mcp_block(
base: BaseImage,
toolchains: &BTreeSet<Toolchain>,
mcps: &BTreeSet<McpServer>,
) -> String {
if mcps.is_empty() {
return String::new();
}
let family = base.family();
let need_node = mcps.iter().any(|m| m.needs_node())
&& !base.has_node()
&& !toolchains.contains(&Toolchain::Node);
let need_python = mcps.iter().any(|m| m.needs_python())
&& !base.has_python()
&& !toolchains.contains(&Toolchain::Python);
let mut lines: Vec<String> = Vec::new();
if need_node {
lines.push(ensure_pkgs(family, "nodejs npm"));
}
if need_python {
let pkgs = match family {
Family::Debian => "python3 python3-pip",
Family::Alpine => "python3 py3-pip",
};
lines.push(ensure_pkgs(family, pkgs));
}
for m in mcps {
lines.push(m.install_cmd().to_string());
}
let mut out = String::from("# MCP servers\nRUN ");
for (i, line) in lines.iter().enumerate() {
if i > 0 {
out.push_str(" \\\n && ");
}
out.push_str(line);
}
out.push('\n');
out
}
fn push_section(out: &mut String, section: &str) {
out.push_str(section);
if !out.ends_with('\n') {
out.push('\n');
}
}
pub fn render(base: BaseImage, toolchains: &[Toolchain], mcps: &[McpServer]) -> String {
let toolchains: BTreeSet<Toolchain> = toolchains
.iter()
.copied()
.filter(|t| *t != Toolchain::None)
.collect();
let mcps: BTreeSet<McpServer> = mcps.iter().copied().collect();
let family = base.family();
let mut out = String::new();
let header = header_template(family).replace("{IMAGE}", base.as_str());
push_section(&mut out, &header);
for &t in Toolchain::ALL {
if t == Toolchain::None || !toolchains.contains(&t) {
continue;
}
let frag = toolchain_fragment(t, family).expect("non-None toolchain has a fragment");
out.push('\n');
let _ = writeln!(out, "# {} toolchain", t.as_str());
push_section(&mut out, frag);
}
let block = mcp_block(base, &toolchains, &mcps);
if !block.is_empty() {
out.push('\n');
out.push_str(&block);
}
out.push('\n');
push_section(&mut out, FOOTER);
out
}