1use std::collections::BTreeSet;
10use std::fmt::Write as _;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Family {
15 Debian,
16 Alpine,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum BaseImage {
24 DebianBookwormSlim,
25 Ubuntu24_04,
26 AlpineLatest,
27 Node20BookwormSlim,
28 Python3_12Slim,
29}
30
31impl BaseImage {
32 pub const ALL: &'static [BaseImage] = &[
33 Self::DebianBookwormSlim,
34 Self::Ubuntu24_04,
35 Self::AlpineLatest,
36 Self::Node20BookwormSlim,
37 Self::Python3_12Slim,
38 ];
39
40 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::DebianBookwormSlim => "debian:bookworm-slim",
43 Self::Ubuntu24_04 => "ubuntu:24.04",
44 Self::AlpineLatest => "alpine:latest",
45 Self::Node20BookwormSlim => "node:20-bookworm-slim",
46 Self::Python3_12Slim => "python:3.12-slim",
47 }
48 }
49
50 pub const fn description(self) -> &'static str {
51 match self {
52 Self::DebianBookwormSlim => "Debian 12 slim. Apt-based; small but full-featured.",
53 Self::Ubuntu24_04 => "Ubuntu 24.04 LTS. Apt-based; superset of Debian.",
54 Self::AlpineLatest => "Alpine. Apk + musl; smallest footprint.",
55 Self::Node20BookwormSlim => "Debian-slim with Node 20 LTS preinstalled.",
56 Self::Python3_12Slim => "Debian-slim with CPython 3.12 + pip preinstalled.",
57 }
58 }
59
60 pub fn family(self) -> Family {
61 match self {
62 Self::AlpineLatest => Family::Alpine,
63 _ => Family::Debian,
64 }
65 }
66
67 fn has_node(self) -> bool {
70 matches!(self, Self::Node20BookwormSlim)
71 }
72
73 fn has_python(self) -> bool {
75 matches!(self, Self::Python3_12Slim)
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
82pub enum Toolchain {
83 Rust,
84 Node,
85 Python,
86 Go,
87 None,
88}
89
90impl Toolchain {
91 pub const ALL: &'static [Toolchain] =
94 &[Self::Rust, Self::Node, Self::Python, Self::Go, Self::None];
95
96 pub fn as_str(self) -> &'static str {
97 match self {
98 Self::Rust => "rust",
99 Self::Node => "node",
100 Self::Python => "python",
101 Self::Go => "go",
102 Self::None => "none",
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
111pub enum McpServer {
112 Fs,
113 Git,
114}
115
116impl McpServer {
117 pub const ALL: &'static [McpServer] = &[Self::Fs, Self::Git];
119
120 pub const fn as_str(self) -> &'static str {
121 match self {
122 Self::Fs => "fs",
123 Self::Git => "git",
124 }
125 }
126
127 pub const fn description(self) -> &'static str {
128 match self {
129 Self::Fs => "Filesystem MCP server (npm @modelcontextprotocol/server-filesystem).",
130 Self::Git => "Git MCP server (PyPI mcp-server-git).",
131 }
132 }
133
134 pub fn command_args(self) -> &'static [&'static str] {
138 match self {
139 Self::Fs => &["mcp-server-filesystem", "/workspace"],
140 Self::Git => &["mcp-server-git", "--repository", "/workspace"],
141 }
142 }
143
144 fn needs_node(self) -> bool {
147 matches!(self, Self::Fs)
148 }
149
150 fn needs_python(self) -> bool {
153 matches!(self, Self::Git)
154 }
155
156 pub(crate) fn install_cmd(self) -> &'static str {
159 match self {
160 Self::Fs => "npm install -g @modelcontextprotocol/server-filesystem",
161 Self::Git => "pip install --break-system-packages mcp-server-git",
165 }
166 }
167}
168
169const HEADER_DEBIAN: &str = include_str!("templates/header.debian.dockerfile");
170const HEADER_ALPINE: &str = include_str!("templates/header.alpine.dockerfile");
171
172const RUST_DEBIAN: &str = include_str!("templates/rust.debian.dockerfile");
173const RUST_ALPINE: &str = include_str!("templates/rust.alpine.dockerfile");
174const NODE_DEBIAN: &str = include_str!("templates/node.debian.dockerfile");
175const NODE_ALPINE: &str = include_str!("templates/node.alpine.dockerfile");
176const PYTHON_DEBIAN: &str = include_str!("templates/python.debian.dockerfile");
177const PYTHON_ALPINE: &str = include_str!("templates/python.alpine.dockerfile");
178const GO_DEBIAN: &str = include_str!("templates/go.debian.dockerfile");
179const GO_ALPINE: &str = include_str!("templates/go.alpine.dockerfile");
180
181const FOOTER: &str = include_str!("templates/footer.dockerfile");
182
183fn header_template(family: Family) -> &'static str {
184 match family {
185 Family::Debian => HEADER_DEBIAN,
186 Family::Alpine => HEADER_ALPINE,
187 }
188}
189
190fn toolchain_fragment(t: Toolchain, family: Family) -> Option<&'static str> {
191 let frag = match (t, family) {
192 (Toolchain::Rust, Family::Debian) => RUST_DEBIAN,
193 (Toolchain::Rust, Family::Alpine) => RUST_ALPINE,
194 (Toolchain::Node, Family::Debian) => NODE_DEBIAN,
195 (Toolchain::Node, Family::Alpine) => NODE_ALPINE,
196 (Toolchain::Python, Family::Debian) => PYTHON_DEBIAN,
197 (Toolchain::Python, Family::Alpine) => PYTHON_ALPINE,
198 (Toolchain::Go, Family::Debian) => GO_DEBIAN,
199 (Toolchain::Go, Family::Alpine) => GO_ALPINE,
200 (Toolchain::None, _) => return None,
201 };
202 Some(frag)
203}
204
205fn ensure_pkgs(family: Family, pkgs: &str) -> String {
208 match family {
209 Family::Debian => format!(
210 "apt-get update && apt-get install -y --no-install-recommends {pkgs} \
211 && rm -rf /var/lib/apt/lists/*"
212 ),
213 Family::Alpine => format!("apk add --no-cache {pkgs}"),
214 }
215}
216
217fn mcp_block(
220 base: BaseImage,
221 toolchains: &BTreeSet<Toolchain>,
222 mcps: &BTreeSet<McpServer>,
223) -> String {
224 if mcps.is_empty() {
225 return String::new();
226 }
227 let family = base.family();
228
229 let need_node = mcps.iter().any(|m| m.needs_node())
230 && !base.has_node()
231 && !toolchains.contains(&Toolchain::Node);
232 let need_python = mcps.iter().any(|m| m.needs_python())
233 && !base.has_python()
234 && !toolchains.contains(&Toolchain::Python);
235
236 let mut lines: Vec<String> = Vec::new();
237 if need_node {
238 lines.push(ensure_pkgs(family, "nodejs npm"));
242 }
243 if need_python {
244 let pkgs = match family {
245 Family::Debian => "python3 python3-pip",
246 Family::Alpine => "python3 py3-pip",
247 };
248 lines.push(ensure_pkgs(family, pkgs));
249 }
250 for m in mcps {
251 lines.push(m.install_cmd().to_string());
252 }
253
254 let mut out = String::from("# MCP servers\nRUN ");
255 for (i, line) in lines.iter().enumerate() {
256 if i > 0 {
257 out.push_str(" \\\n && ");
258 }
259 out.push_str(line);
260 }
261 out.push('\n');
262 out
263}
264
265fn push_section(out: &mut String, section: &str) {
269 out.push_str(section);
270 if !out.ends_with('\n') {
271 out.push('\n');
272 }
273}
274
275pub fn render(base: BaseImage, toolchains: &[Toolchain], mcps: &[McpServer]) -> String {
283 let toolchains: BTreeSet<Toolchain> = toolchains
284 .iter()
285 .copied()
286 .filter(|t| *t != Toolchain::None)
287 .collect();
288 let mcps: BTreeSet<McpServer> = mcps.iter().copied().collect();
289
290 let family = base.family();
291 let mut out = String::new();
292
293 let header = header_template(family).replace("{IMAGE}", base.as_str());
294 push_section(&mut out, &header);
295
296 for &t in Toolchain::ALL {
297 if t == Toolchain::None || !toolchains.contains(&t) {
298 continue;
299 }
300 let frag = toolchain_fragment(t, family).expect("non-None toolchain has a fragment");
301 out.push('\n');
302 let _ = writeln!(out, "# {} toolchain", t.as_str());
303 push_section(&mut out, frag);
304 }
305
306 let block = mcp_block(base, &toolchains, &mcps);
307 if !block.is_empty() {
308 out.push('\n');
309 out.push_str(&block);
310 }
311
312 out.push('\n');
313 push_section(&mut out, FOOTER);
314
315 out
316}