Generate compose YAML programmatically via serde
Fragile string replacement and env var indirection replaced with typed structs.

Assisted-by: Claude Opus 4.6 via pi
change qpsltolyxoqrnlvvxzuupnlnkxsomxlx
commit 46b22a2deeb13fb592cb2377436fb3ef325f7025
author Alpha Chen <alpha@kejadlen.dev>
date
parent ymkslxqm
diff --git a/Cargo.lock b/Cargo.lock
index 47618f2..983f071 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -176,6 +176,12 @@ version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
 
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
 [[package]]
 name = "eyre"
 version = "0.6.12"
@@ -201,6 +207,12 @@ version = "0.32.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
 
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
 [[package]]
 name = "heck"
 version = "0.5.0"
@@ -213,12 +225,28 @@ version = "0.3.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
 
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
 [[package]]
 name = "is_terminal_polyfill"
 version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
 [[package]]
 name = "lazy_static"
 version = "1.5.0"
@@ -328,6 +356,8 @@ dependencies = [
  "clap",
  "color-eyre",
  "fs-err",
+ "serde",
+ "serde_yaml",
  "tracing",
  "tracing-subscriber",
  "xdg",
@@ -356,6 +386,55 @@ version = "0.1.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
 
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -474,6 +553,12 @@ version = "1.0.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
 
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
 [[package]]
 name = "utf8parse"
 version = "0.2.2"
diff --git a/Cargo.toml b/Cargo.toml
index f3164b7..7c86d60 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,8 @@ edition = "2024"
 clap = { version = "*", features = ["derive"] }
 color-eyre = "*"
 fs-err = "*"
+serde = { version = "*", features = ["derive"] }
+serde_yaml = "*"
 tracing = "*"
 tracing-subscriber = { version = "*", features = ["env-filter"] }
 xdg = "*"
diff --git a/assets/compose.yml b/assets/compose.yml
deleted file mode 100644
index b9bd9a2..0000000
--- a/assets/compose.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-services:
-  agent:
-    build:
-      context: "${RAMEKIN_BUILD_CONTEXT:-.}"
-      dockerfile: "${RAMEKIN_DOCKERFILE:-Dockerfile}"
-    image: ramekin-agent
-    stdin_open: true
-    tty: true
-    environment:
-      - RAMEKIN_FIREWALL=${RAMEKIN_FIREWALL:-true}
-    volumes:
-      - "${RAMEKIN_WORKSPACE:-.}:/workspace"
-      - "${RAMEKIN_DATA_DIR}:/root/.pi"
-      - "${RAMEKIN_CONFIG_DIR}/settings.json:/root/.pi/agent/settings.json"
-      - "${RAMEKIN_CONFIG_DIR}/keybindings.json:/root/.pi/agent/keybindings.json"
-      - "${RAMEKIN_CONFIG_DIR}/AGENTS.md:/root/.pi/agent/AGENTS.md"
-      - type: bind
-        source: "${RAMEKIN_EXTENSION}"
-        target: /root/.pi/agent/extensions/ramekin.ts
-        read_only: true
diff --git a/src/main.rs b/src/main.rs
index d9f0822..50444c0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,12 +1,12 @@
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use std::process::{Command, ExitCode, Stdio};
 
 use clap::Parser;
 use color_eyre::eyre::{Context, Result, bail};
+use serde::Serialize;
 use tracing::{error, info};
 use tracing_subscriber::{EnvFilter, fmt, prelude::*};
 
-const COMPOSE_YML: &str = include_str!("../assets/compose.yml");
 const DOCKERFILE: &str = include_str!("../assets/Dockerfile");
 const RAMEKIN_EXTENSION: &str = include_str!("../assets/ramekin.ts");
 
@@ -70,13 +70,9 @@ fn run() -> Result<()> {
         .create_cache_directory("")
         .wrap_err("failed to create cache directory")?;
 
-    // Inject host git/jj config mounts into compose template
-    let compose = inject_vcs_config_mounts(COMPOSE_YML);
-    fs_err::write(cache_dir.join("compose.yml"), &compose)?;
     fs_err::write(cache_dir.join("Dockerfile"), DOCKERFILE)?;
-    fs_err::write(cache_dir.join("ramekin.ts"), RAMEKIN_EXTENSION)?;
-
-    let compose_file = cache_dir.join("compose.yml");
+    let extension_path = cache_dir.join("ramekin.ts");
+    fs_err::write(&extension_path, RAMEKIN_EXTENSION)?;
 
     // Always build the base image first
     info!("building base image");
@@ -101,17 +97,22 @@ fn run() -> Result<()> {
         (base_dockerfile, cache_dir.clone())
     };
 
+    let compose = generate_compose(
+        &workspace,
+        &dockerfile,
+        &build_context,
+        &pi_data_dir,
+        &pi_config_dir,
+        &extension_path,
+    );
+    let compose_file = cache_dir.join("compose.yml");
+    fs_err::write(&compose_file, &compose)?;
+
     let docker_compose = |args: &[&str]| -> Result<Command> {
         let mut cmd = Command::new("docker");
         cmd.args(["compose", "-f"])
             .arg(&compose_file)
-            .args(args)
-            .env("RAMEKIN_WORKSPACE", &workspace)
-            .env("RAMEKIN_DATA_DIR", &pi_data_dir)
-            .env("RAMEKIN_DOCKERFILE", &dockerfile)
-            .env("RAMEKIN_BUILD_CONTEXT", &build_context)
-            .env("RAMEKIN_CONFIG_DIR", &pi_config_dir)
-            .env("RAMEKIN_EXTENSION", cache_dir.join("ramekin.ts"));
+            .args(args);
         Ok(cmd)
     };
 
@@ -146,31 +147,81 @@ fn run() -> Result<()> {
     Ok(())
 }
 
-/// Insert read-only volume mounts for host git/jj config into the compose YAML.
-fn inject_vcs_config_mounts(template: &str) -> String {
-    let git_config_dir = xdg::BaseDirectories::with_prefix("git").get_config_home();
-    let jj_config_dir = xdg::BaseDirectories::with_prefix("jj").get_config_home();
+#[derive(Serialize)]
+struct ComposeConfig {
+    services: Services,
+}
 
-    if git_config_dir.is_none() && jj_config_dir.is_none() {
-        return template.to_string();
-    }
+#[derive(Serialize)]
+struct Services {
+    agent: AgentService,
+}
 
-    let mut extra = String::new();
-    if let Some(ref path) = git_config_dir {
-        info!(path = %path.display(), "mounting git config dir");
-        extra.push_str(&format!(
-            "      - \"{}:/root/.config/git:ro\"\n",
-            path.display()
-        ));
+#[derive(Serialize)]
+struct AgentService {
+    build: BuildConfig,
+    image: String,
+    stdin_open: bool,
+    tty: bool,
+    volumes: Vec<String>,
+}
+
+#[derive(Serialize)]
+struct BuildConfig {
+    context: String,
+    dockerfile: String,
+}
+
+/// Generate a Docker Compose config with all volume mounts baked in.
+fn generate_compose(
+    workspace: &Path,
+    dockerfile: &Path,
+    build_context: &Path,
+    data_dir: &Path,
+    config_dir: &Path,
+    extension_path: &Path,
+) -> String {
+    let mut volumes = vec![
+        format!("{}:/workspace", workspace.display()),
+        format!("{}:/root/.pi", data_dir.display()),
+        format!(
+            "{}/settings.json:/root/.pi/agent/settings.json",
+            config_dir.display()
+        ),
+        format!(
+            "{}/keybindings.json:/root/.pi/agent/keybindings.json",
+            config_dir.display()
+        ),
+        format!("{}/AGENTS.md:/root/.pi/agent/AGENTS.md", config_dir.display()),
+        format!(
+            "{}:/root/.pi/agent/extensions/ramekin.ts:ro",
+            extension_path.display()
+        ),
+    ];
+
+    if let Some(dir) = xdg::BaseDirectories::with_prefix("git").get_config_home() {
+        info!(path = %dir.display(), "mounting git config dir");
+        volumes.push(format!("{}:/root/.config/git:ro", dir.display()));
     }
-    if let Some(ref path) = jj_config_dir {
-        info!(path = %path.display(), "mounting jj config dir");
-        extra.push_str(&format!(
-            "      - \"{}:/root/.config/jj:ro\"\n",
-            path.display()
-        ));
+    if let Some(dir) = xdg::BaseDirectories::with_prefix("jj").get_config_home() {
+        info!(path = %dir.display(), "mounting jj config dir");
+        volumes.push(format!("{}:/root/.config/jj:ro", dir.display()));
     }
 
-    // Insert before the extension bind mount
-    template.replace("      - type: bind", &format!("{extra}      - type: bind"))
+    let config = ComposeConfig {
+        services: Services {
+            agent: AgentService {
+                build: BuildConfig {
+                    context: build_context.display().to_string(),
+                    dockerfile: dockerfile.display().to_string(),
+                },
+                image: "ramekin-agent".into(),
+                stdin_open: true,
+                tty: true,
+                volumes,
+            },
+        },
+    };
+
+    serde_yaml::to_string(&config).expect("failed to serialize compose config")
 }