use crate::manifest::Manifest;
const TEMPLATE: &str = include_str!("../templates/project.compose.yml.tmpl");
pub fn render(m: &Manifest) -> String {
let secret_env_lines = if m.secrets.is_empty() {
String::new()
} else {
let mut lines = String::new();
for s in &m.secrets {
lines.push_str(&format!(" {s}: \"${{{s}:-}}\"\n"));
}
lines.trim_end_matches('\n').to_string()
};
let extra_allow = m.egress.allow.join(",");
TEMPLATE
.replace("{{PROJECT_NAME}}", &m.name)
.replace("{{IMAGE}}", &m.image)
.replace("{{CPU_LIMIT}}", &m.resources.cpu)
.replace("{{MEM_LIMIT}}", &m.resources.mem)
.replace("{{PIDS_LIMIT}}", &m.resources.pids.to_string())
.replace("{{SECRET_ENV_LINES}}", &secret_env_lines)
.replace("{{EXTRA_ALLOW_DOMAINS}}", &extra_allow)
.replace("{{CANARY_TOKEN}}", &m.canary_token)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::manifest::Manifest;
#[test]
fn no_known_placeholder_survives_rendering() {
let m = Manifest::new("sample-app", "moor/node:latest");
let rendered = render(&m);
for token in [
"{{PROJECT_NAME}}",
"{{IMAGE}}",
"{{CPU_LIMIT}}",
"{{MEM_LIMIT}}",
"{{PIDS_LIMIT}}",
"{{SECRET_ENV_LINES}}",
"{{EXTRA_ALLOW_DOMAINS}}",
"{{CANARY_TOKEN}}",
] {
assert!(
!rendered.contains(token),
"unreplaced placeholder {token} left in rendered compose:\n{rendered}"
);
}
}
#[test]
fn project_name_is_substituted_everywhere_it_appears() {
let m = Manifest::new("sample-app", "moor/node:latest");
let rendered = render(&m);
assert!(rendered.contains("sample-app-sandbox"));
assert!(rendered.contains("sample-app-egress"));
assert!(rendered.contains("sample-app-internal"));
assert!(rendered.contains("sample-app-workspace"));
}
#[test]
fn secrets_become_compose_default_empty_env_lines() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.secrets = vec![
"ANTHROPIC_API_KEY".to_string(),
"CLAUDE_CODE_OAUTH_TOKEN".to_string(),
"GITHUB_TOKEN".to_string(),
];
let rendered = render(&m);
assert!(rendered.contains("ANTHROPIC_API_KEY: \"${ANTHROPIC_API_KEY:-}\""));
assert!(rendered.contains("CLAUDE_CODE_OAUTH_TOKEN: \"${CLAUDE_CODE_OAUTH_TOKEN:-}\""));
assert!(rendered.contains("GITHUB_TOKEN: \"${GITHUB_TOKEN:-}\""));
}
#[test]
fn no_secrets_still_renders_valid_yaml_shape() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.secrets = vec![];
let rendered = render(&m);
assert!(!rendered.contains("{{SECRET_ENV_LINES}}"));
}
#[test]
fn extra_allow_domains_joined_with_commas() {
let mut m = Manifest::new("sample-app", "moor/node:latest");
m.egress.allow = vec![
"registry.npmjs.org".to_string(),
"example-registry.dev".to_string(),
];
let rendered = render(&m);
assert!(
rendered.contains("EXTRA_ALLOW_DOMAINS: \"registry.npmjs.org,example-registry.dev\"")
);
}
#[test]
fn sandbox_has_no_bind_mount_volume_entries() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render(&m);
assert!(rendered.contains("workspace:/workspace"));
assert!(rendered.contains("cache:/home/agent/.cache"));
assert!(rendered.contains("claude-state:/home/agent/.claude"));
assert!(!rendered.contains("/Users/"));
assert!(!rendered.contains("${HOME}"));
}
#[test]
fn sandbox_network_is_internal_only() {
let m = Manifest::new("sample-app", "moor/base:latest");
let rendered = render(&m);
assert!(rendered.contains("internal: true"));
}
}