Skip to main content

outrig_cli/image_setup/
render.rs

1//! Pure Dockerfile assembly for `outrig image add`.
2//!
3//! `render(base, toolchains, mcps)` stitches a header (per base-family) plus
4//! per-(toolchain, family) fragments plus an MCP-server install block plus a
5//! universal footer into a single string. No I/O. The fragments are
6//! `include_str!`'d from `templates/`; the MCP install lines are inline
7//! string literals because they're short and need to be family-aware.
8
9use std::collections::BTreeSet;
10use std::fmt::Write as _;
11
12/// Apt vs apk: drives every install line below.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Family {
15    Debian,
16    Alpine,
17}
18
19/// One of the curated base images offered by `image add`. Each carries
20/// the family discriminant and the full image string used in `FROM
21/// docker.io/library/<image>`.
22#[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    /// `true` if the base image already ships a working `node + npm`. Used
68    /// by the MCP install block to skip a redundant runtime-install step.
69    fn has_node(self) -> bool {
70        matches!(self, Self::Node20BookwormSlim)
71    }
72
73    /// `true` if the base image already ships a working `python3 + pip`.
74    fn has_python(self) -> bool {
75        matches!(self, Self::Python3_12Slim)
76    }
77}
78
79/// One of the curated language toolchains offered by the toolchain
80/// multi-select. Canonical render order: `Rust, Node, Python, Go`.
81#[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    /// Order matters here: it's the canonical (deterministic) render order
92    /// and the order shown in the prompt's option list.
93    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/// Curated MCP server package recipes rendered by `image add`.
108/// Config can still declare any MCP command, including shell servers; this
109/// enum only covers recipes OutRig can install without more user input.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
111pub enum McpServer {
112    Fs,
113    Git,
114}
115
116impl McpServer {
117    /// Canonical render order matches prompt order.
118    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    /// argv of the runtime `command` for this server's
135    /// `[images.<name>.mcp.<server>]` entry. The first element is the
136    /// binary name, the rest are arguments.
137    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    /// `true` if this server is installed via npm (and so needs node + npm
145    /// available at build time).
146    fn needs_node(self) -> bool {
147        matches!(self, Self::Fs)
148    }
149
150    /// `true` if this server is installed via pip (and so needs python3 +
151    /// pip available at build time).
152    fn needs_python(self) -> bool {
153        matches!(self, Self::Git)
154    }
155
156    /// The bare `<package-manager> install ...` line for this server. The
157    /// caller is responsible for ensuring the package manager is available.
158    pub(crate) fn install_cmd(self) -> &'static str {
159        match self {
160            Self::Fs => "npm install -g @modelcontextprotocol/server-filesystem",
161            // `--break-system-packages` lets pip write into the system
162            // site-packages on Debian/Python 3.11+; cheaper than a venv for
163            // a one-binary install. Alpine's `py3-pip` accepts it too.
164            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
205/// Family-aware `apt-get install` / `apk add` for the named system
206/// packages. Packages are space-separated.
207fn 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
217/// Build the `# MCP servers` block: a `RUN` that ensures any required
218/// runtimes are present, then runs each server's install command.
219fn 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        // Bookworm and Alpine both ship a usable `nodejs npm` in the
239        // default repos -- older than NodeSource's setup_20.x but enough
240        // to run `npm install -g`. Saves the curl-and-pipe-bash dance.
241        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
265/// Append `section` to `out`, ensuring it ends in exactly one `\n`. Used
266/// to normalize the trailing newline of `include_str!`'d templates that
267/// may or may not have one depending on editor settings.
268fn 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
275/// Stitch the header, toolchain fragments, MCP block, and footer into a
276/// final Dockerfile string.
277///
278/// `toolchains` and `mcps` are deduped + sorted into canonical order so
279/// output is byte-stable regardless of multi-select input order.
280/// `Toolchain::None` in the toolchain set is treated as "no toolchain
281/// section" (skipped silently).
282pub 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}