use crate::config::ComposeConfig;
use anyhow::{Context, Result};
use std::path::Path;
pub const OVERLAY_FILENAME: &str = "docker-compose.kap.yml";
pub fn run(project_dir: &str, yes: bool) -> Result<()> {
let project = Path::new(project_dir);
let devcontainer_dir = project.join(".devcontainer");
if devcontainer_dir.exists() {
run_existing(project, &devcontainer_dir, yes)
} else {
run_new(project, &devcontainer_dir)
}
}
fn confirm(prompt: &str) -> bool {
use std::io::Write;
print!("{prompt} [Y/n] ");
std::io::stdout().flush().ok();
let mut input = String::new();
if std::io::stdin().read_line(&mut input).is_err() {
return false;
}
let input = input.trim().to_lowercase();
input.is_empty() || input == "y" || input == "yes"
}
pub fn read_project_name(devcontainer_dir: &Path) -> String {
let path = devcontainer_dir.join("devcontainer.json");
if let Ok(content) = std::fs::read_to_string(&path)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(name) = json["name"].as_str()
{
return name.to_string();
}
devcontainer_dir
.parent()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "dev".to_string())
}
pub fn read_service_name(devcontainer_dir: &Path) -> Result<String> {
let path = devcontainer_dir.join("devcontainer.json");
let content =
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let json: serde_json::Value =
serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
Ok(json["service"].as_str().unwrap_or("app").to_string())
}
pub fn derive_subnet(project_dir: &Path) -> String {
let path = project_dir
.canonicalize()
.unwrap_or_else(|_| project_dir.to_path_buf());
let path_str = path.to_string_lossy();
let mut hash: u32 = 0;
for byte in path_str.bytes() {
hash = hash.wrapping_mul(31).wrapping_add(byte as u32);
}
let second = 18 + (hash % 14) as u8; let third = ((hash >> 8) % 256) as u8; format!("172.{second}.{third}")
}
pub fn generate_overlay(
service_name: &str,
compose: &ComposeConfig,
cli_tools: &[String],
subnet_prefix: &str,
project_name: &str,
) -> String {
let image_yaml = compose.image_yaml(" ");
let cli_volumes = if cli_tools.is_empty() {
String::new()
} else {
let mut mounts: Vec<String> = cli_tools
.iter()
.map(|name| format!(" - ./{name}-shim.sh:/usr/local/bin/{name}:ro"))
.collect();
mounts.push(" - kap-bin:/opt/kap:ro".to_string());
format!("\n volumes:\n{}", mounts.join("\n"))
};
let app_ip = format!("{subnet_prefix}.2");
let sidecar_ip = format!("{subnet_prefix}.3");
let subnet = format!("{subnet_prefix}.0/24");
format!(
r#"# Generated by kap — DO NOT EDIT. Regenerated on each `kap sidecar-init` run.
# Adds network isolation, DNS filtering, and MCP proxy.
# Merged with your existing docker-compose via dockerComposeFile array in devcontainer.json.
# This file MUST be last in the array so its settings take precedence.
services:
# Adds proxy env vars and DNS to your existing service
{service_name}:
hostname: {project_name}
environment:
# Use static IP because app DNS goes through kap (hostnames won't resolve)
HTTP_PROXY: http://{sidecar_ip}:3128
HTTPS_PROXY: http://{sidecar_ip}:3128
http_proxy: http://{sidecar_ip}:3128
https_proxy: http://{sidecar_ip}:3128
NO_PROXY: localhost,127.0.0.1
no_proxy: localhost,127.0.0.1
# DNS goes through kap's filtered forwarder (only resolves allowed domains)
dns:
- {sidecar_ip}
networks:
kap_sandbox:
ipv4_address: {app_ip}{cli_volumes}
depends_on:
kap:
condition: service_healthy
# Proxy sidecar: domain proxy (:3128), DNS forwarder (:53), MCP proxy (:3129)
kap:
{image_yaml}
volumes:
- ./kap.toml:/etc/kap/config.toml:ro
- ${{HOME}}/.kap/auth:/etc/kap/auth
- proxy-logs:/var/log/kap
- kap-bin:/opt/kap
- ..:/workspace:ro
entrypoint: ["sh", "-c", "cp /usr/local/bin/kap /opt/kap/kap && exec kap sidecar-proxy"]
env_file:
- path: .env
required: false
networks:
kap_sandbox:
ipv4_address: {sidecar_ip}
kap_external:
restart: unless-stopped
healthcheck:
test: ["CMD", "kap", "sidecar-check", "--proxy"]
interval: 2s
timeout: 2s
retries: 10
volumes:
proxy-logs:
kap-bin:
# Static subnet so we can use fixed IPs for DNS and proxy references.
# Internal to Docker, not your host network.
networks:
kap_sandbox:
internal: true # no default gateway, no route to internet
ipam:
config:
- subnet: {subnet}
kap_external:
driver: bridge
"#
)
}
pub fn gitignore_overlay(project_dir: &Path) -> Result<()> {
let gitignore_path = project_dir.join(".gitignore");
let entries = [
format!(".devcontainer/{OVERLAY_FILENAME}"),
".devcontainer/.env".to_string(),
];
let existing = if gitignore_path.exists() {
std::fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
let new_entries: Vec<&str> = entries
.iter()
.filter(|e| !existing.lines().any(|line| line.trim() == e.as_str()))
.map(|e| e.as_str())
.collect();
if new_entries.is_empty() {
return Ok(());
}
let separator = if existing.is_empty() || existing.ends_with('\n') {
""
} else {
"\n"
};
let block = new_entries.join("\n");
std::fs::write(
&gitignore_path,
format!("{existing}{separator}\n# Generated by kap\n{block}\n"),
)?;
Ok(())
}
fn run_existing(project: &Path, devcontainer_dir: &Path, yes: bool) -> Result<()> {
let devcontainer_json_path = devcontainer_dir.join("devcontainer.json");
if !devcontainer_json_path.exists() {
anyhow::bail!(
".devcontainer/ exists but has no devcontainer.json at {}",
devcontainer_json_path.display()
);
}
let kap_toml_path = devcontainer_dir.join("kap.toml");
if kap_toml_path.exists() {
anyhow::bail!(
"kap.toml already exists at {}. Remove it to re-initialize.",
kap_toml_path.display()
);
}
let dc_content = std::fs::read_to_string(&devcontainer_json_path)?;
let dc_json: serde_json::Value = serde_json::from_str(&dc_content)?;
let image_based = dc_json.get("image").is_some() && dc_json.get("dockerComposeFile").is_none();
if image_based {
let image = dc_json["image"]
.as_str()
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
println!();
println!(
" Your devcontainer uses \x1b[1mimage\x1b[0m mode, but kap requires Docker Compose."
);
println!(" I'll convert it for you:");
println!();
println!(" \x1b[32m+\x1b[0m Create docker-compose.yml (image: {image})");
println!(
" \x1b[33m~\x1b[0m Update devcontainer.json (add service, workspaceFolder, dockerComposeFile)"
);
println!(
" \x1b[31m-\x1b[0m Remove \"image\" field (moved to docker-compose.yml)"
);
println!();
if !yes && !confirm(" Proceed?") {
anyhow::bail!("aborted");
}
let compose_path = devcontainer_dir.join("docker-compose.yml");
if !compose_path.exists() {
write_file(
&compose_path,
&format!(
"services:\n app:\n image: {image}\n volumes:\n - ..:/workspace:cached\n command: sleep infinity\n"
),
)?;
}
}
let service_name = if image_based {
"app".to_string()
} else {
read_service_name(devcontainer_dir)?
};
let compose_files: Vec<String> = if image_based {
vec!["docker-compose.yml".to_string()]
} else if let Some(arr) = dc_json["dockerComposeFile"].as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
} else if let Some(s) = dc_json["dockerComposeFile"].as_str() {
vec![s.to_string()]
} else {
vec!["docker-compose.yml".to_string()]
};
let config_content = generate_config();
write_file(&kap_toml_path, &config_content)?;
let detected = detect_cli_tools();
let detected_tool_names: Vec<String> = detected.iter().map(|t| t.name.to_string()).collect();
let overlay_path = devcontainer_dir.join(OVERLAY_FILENAME);
let compose_config = ComposeConfig::default();
let subnet_prefix = derive_subnet(project);
let project_name = read_project_name(devcontainer_dir);
write_file(
&overlay_path,
&generate_overlay(
&service_name,
&compose_config,
&detected_tool_names,
&subnet_prefix,
&project_name,
),
)?;
let env_path = devcontainer_dir.join(".env");
if !env_path.exists() {
let env_content = generate_env_file(&detected);
write_file(&env_path, &env_content)?;
}
let mut dc_obj = dc_json.clone();
if image_based {
dc_obj.as_object_mut().unwrap().shift_remove("image");
}
let mut all_compose: Vec<serde_json::Value> = compose_files
.iter()
.map(|f| serde_json::Value::String(f.clone()))
.collect();
all_compose.push(serde_json::Value::String(OVERLAY_FILENAME.to_string()));
dc_obj["dockerComposeFile"] = serde_json::Value::Array(all_compose);
dc_obj["service"] = serde_json::Value::String(service_name);
dc_obj["workspaceFolder"] = serde_json::Value::String("/workspace".to_string());
let mut notes: Vec<String> = Vec::new();
if dc_obj.get("initializeCommand").is_some() {
notes.push(
"initializeCommand already set. Add `kap sidecar-init` to your existing command."
.to_string(),
);
} else {
dc_obj["initializeCommand"] = serde_json::Value::String("kap sidecar-init".to_string());
}
let updated = serde_json::to_string_pretty(&dc_obj)?;
write_file(&devcontainer_json_path, &format!("{updated}\n"))?;
gitignore_overlay(project)?;
println!();
println!("Created .devcontainer/kap.toml");
println!("Created .devcontainer/{OVERLAY_FILENAME} (generated, gitignored)");
println!("Updated .devcontainer/devcontainer.json");
for note in ¬es {
println!();
println!(" NOTE: {note}");
}
println!();
println!("Next:");
println!(" kap up");
Ok(())
}
fn run_new(project: &Path, devcontainer_dir: &Path) -> Result<()> {
std::fs::create_dir_all(devcontainer_dir)?;
let project_name = project
.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-project".to_string());
write_file(&devcontainer_dir.join("kap.toml"), &generate_config())?;
write_file(
&devcontainer_dir.join("docker-compose.yml"),
&generate_app_compose(&project_name),
)?;
write_file(
&devcontainer_dir.join("devcontainer.json"),
&generate_devcontainer_json(&project_name),
)?;
let compose_config = ComposeConfig::default();
let subnet_prefix = derive_subnet(project);
let detected = detect_cli_tools();
let detected_tool_names: Vec<String> = detected.iter().map(|t| t.name.to_string()).collect();
write_file(
&devcontainer_dir.join(OVERLAY_FILENAME),
&generate_overlay(
"app",
&compose_config,
&detected_tool_names,
&subnet_prefix,
&project_name,
),
)?;
let env_content = generate_env_file(&detected);
write_file(&devcontainer_dir.join(".env"), &env_content)?;
gitignore_overlay(project)?;
println!("Created .devcontainer/ with:");
println!(" kap.toml - kap config (edit allowed domains here)");
println!(" docker-compose.yml - app container definition");
println!(" devcontainer.json - devcontainer config");
println!(" {OVERLAY_FILENAME} - kap sidecar (generated, gitignored)");
println!();
println!("Next steps:");
println!(" 1. Review kap.toml and adjust allowed domains");
println!(" 2. Run: kap up");
Ok(())
}
struct DomainGroup {
label: &'static str,
domains: &'static [&'static str],
}
const DEFAULT_DOMAIN_GROUPS: &[DomainGroup] = &[
DomainGroup {
label: "GitHub",
domains: &["github.com", "*.github.com", "*.githubusercontent.com"],
},
DomainGroup {
label: "AI providers",
domains: &[
"anthropic.com",
"*.anthropic.com",
"claude.ai",
"*.claude.ai",
"claude.com",
"*.claude.com",
"openai.com",
"*.openai.com",
"generativelanguage.googleapis.com",
"storage.googleapis.com",
],
},
DomainGroup {
label: "APT",
domains: &["*.ubuntu.com", "*.debian.org"],
},
DomainGroup {
label: "Dev tools",
domains: &["mise.jdx.dev"],
},
DomainGroup {
label: "Ruby",
domains: &[
"rubygems.org",
"*.rubygems.org",
"bundler.io",
"*.ruby-lang.org",
"rubyonrails.org",
"*.rubyonrails.org",
],
},
DomainGroup {
label: "Node",
domains: &["*.npmjs.org", "*.npmjs.com", "nodejs.org", "*.yarnpkg.com"],
},
DomainGroup {
label: "Rust",
domains: &["crates.io", "*.crates.io", "rustup.rs", "*.rust-lang.org"],
},
DomainGroup {
label: "Python",
domains: &["pypi.org", "*.pypi.org", "*.pythonhosted.org"],
},
DomainGroup {
label: "Go",
domains: &["proxy.golang.org", "sum.golang.org"],
},
DomainGroup {
label: "Java",
domains: &[
"repo.maven.apache.org",
"*.maven.org",
"plugins.gradle.org",
"services.gradle.org",
"downloads.gradle-dn.com",
],
},
DomainGroup {
label: "CocoaPods",
domains: &["cocoapods.org", "*.cocoapods.org"],
},
];
#[cfg(test)]
fn all_default_domains() -> Vec<&'static str> {
DEFAULT_DOMAIN_GROUPS
.iter()
.flat_map(|g| g.domains.iter().copied())
.collect()
}
struct DetectedTool {
name: &'static str,
env: &'static [&'static str],
allow: &'static [&'static str],
env_defaults: &'static [(&'static str, &'static str)],
}
const DETECTABLE_TOOLS: &[DetectedTool] = &[DetectedTool {
name: "gh",
env: &["GH_TOKEN"],
allow: &["*"],
env_defaults: &[("GH_TOKEN", "$(gh auth token)")],
}];
pub fn env_var_default(var: &str) -> Option<&'static str> {
DETECTABLE_TOOLS
.iter()
.flat_map(|t| t.env_defaults.iter())
.find(|(name, _)| *name == var)
.map(|(_, expr)| *expr)
}
fn detect_cli_tools() -> Vec<&'static DetectedTool> {
DETECTABLE_TOOLS
.iter()
.filter(|t| {
std::process::Command::new("which")
.arg(t.name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
.collect()
}
fn generate_config() -> String {
let mut allow_lines: Vec<String> = Vec::new();
for (i, group) in DEFAULT_DOMAIN_GROUPS.iter().enumerate() {
if i > 0 {
allow_lines.push(String::new()); }
allow_lines.push(format!(" # {}", group.label));
for (j, domain) in group.domains.iter().enumerate() {
let comma = if i == DEFAULT_DOMAIN_GROUPS.len() - 1 && j == group.domains.len() - 1 {
"" } else {
","
};
allow_lines.push(format!(" \"{domain}\"{comma}"));
}
}
let allow_toml = allow_lines.join("\n");
let detected = detect_cli_tools();
let cli_section = if detected.is_empty() {
r#"
# --- CLI tool proxying (credentials stay on sidecar, never enter app container) ---
# Uncomment to proxy a CLI tool:
# [cli]
# [[cli.tools]]
# name = "gh"
# env = ["GH_TOKEN"]
# allow = ["*"]
"#
.to_string()
} else {
let tools: Vec<String> = detected
.iter()
.map(|t| {
let env = t
.env
.iter()
.map(|e| format!("\"{e}\""))
.collect::<Vec<_>>()
.join(", ");
let allow = t
.allow
.iter()
.map(|a| format!("\"{a}\""))
.collect::<Vec<_>>()
.join(", ");
format!(
"\n[[cli.tools]]\nname = \"{}\"\nenv = [{}]\nallow = [{}]",
t.name, env, allow
)
})
.collect();
format!(
"\n# --- CLI tool proxying (credentials stay on sidecar, never enter app container) ---\n[cli]{}\n",
tools.join("\n")
)
};
format!(
r#"# kap.toml — network and tool policy for this devcontainer
[proxy.network]
# Domains the container can reach. Wildcards supported (*.example.com).
# Everything else is blocked — both HTTP/HTTPS and DNS.
allow = [
{allow_toml}
]
# deny overrides allow:
# deny = ["gist.github.com"]
# --- MCP servers (tool-level filtering for remote MCP) ---
# Register with `kap mcp add <url>`, then configure:
# [mcp]
# [[mcp.servers]]
# name = "github"
# upstream = "https://api.githubcopilot.com/mcp/"
# token_env = "GH_TOKEN"
# allow_tools = ["get_pull_request", "list_issues"]
{cli_section}"#
)
}
fn generate_app_compose(project_name: &str) -> String {
format!(
r#"services:
app:
image: mcr.microsoft.com/devcontainers/base:ubuntu
volumes:
- ..:/workspaces/{project_name}:cached
# 1Password SSH agent (macOS). On Linux, use $SSH_AUTH_SOCK instead.
- ${{HOME}}/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock:/ssh-agent:ro
environment:
SSH_AUTH_SOCK: /ssh-agent
command: sleep infinity
"#
)
}
fn generate_devcontainer_json(project_name: &str) -> String {
format!(
r#"{{
"name": "{project_name}",
"dockerComposeFile": ["docker-compose.yml", "{OVERLAY_FILENAME}"],
"service": "app",
"workspaceFolder": "/workspaces/{project_name}",
"initializeCommand": "kap sidecar-init",
"remoteUser": "vscode"
}}
"#
)
}
fn generate_env_file(detected: &[&DetectedTool]) -> String {
let mut lines: Vec<String> = Vec::new();
for tool in detected {
for (var, expr) in tool.env_defaults {
lines.push(format!("{var}={expr}"));
}
}
lines.join("\n")
}
fn write_file(path: &Path, content: &str) -> Result<()> {
std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn default_domains_covers_all_ecosystems() {
let domains = all_default_domains();
assert!(domains.contains(&"github.com"));
assert!(domains.contains(&"anthropic.com"));
assert!(domains.contains(&"crates.io"));
assert!(domains.contains(&"*.npmjs.org"));
assert!(domains.contains(&"pypi.org"));
assert!(domains.contains(&"proxy.golang.org"));
assert!(domains.contains(&"rubygems.org"));
assert!(domains.contains(&"cocoapods.org"));
assert!(domains.contains(&"repo.maven.apache.org"));
assert!(domains.contains(&"mise.jdx.dev"));
}
#[test]
fn generate_config_has_category_comments() {
let config = generate_config();
assert!(config.contains("# GitHub"));
assert!(config.contains("# AI providers"));
assert!(config.contains("# APT"));
assert!(config.contains("# Dev tools"));
assert!(config.contains("# Ruby"));
assert!(config.contains("# Node"));
assert!(config.contains("# Rust"));
assert!(config.contains("# Python"));
assert!(config.contains("# Go"));
assert!(config.contains("# Java"));
assert!(config.contains("# CocoaPods"));
assert!(config.contains("# --- MCP servers"));
}
#[test]
fn new_project_scaffolds_all_files() {
let dir = tempdir("scaffold-new");
run(dir.to_str().unwrap(), true).unwrap();
let dc = dir.join(".devcontainer");
assert!(dc.join("kap.toml").exists());
assert!(dc.join("docker-compose.yml").exists());
assert!(dc.join("devcontainer.json").exists());
assert!(dc.join(OVERLAY_FILENAME).exists());
let config = fs::read_to_string(dc.join("kap.toml")).unwrap();
assert!(config.contains("allow ="));
let compose = fs::read_to_string(dc.join("docker-compose.yml")).unwrap();
assert!(compose.contains("app:"));
assert!(!compose.contains("kap:"));
let overlay = fs::read_to_string(dc.join(OVERLAY_FILENAME)).unwrap();
assert!(overlay.contains("kap:"));
assert!(overlay.contains("kap_sandbox:"));
let dcjson: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dc.join("devcontainer.json")).unwrap())
.unwrap();
let compose_arr = dcjson["dockerComposeFile"].as_array().unwrap();
assert_eq!(compose_arr.len(), 2);
assert_eq!(compose_arr[0], "docker-compose.yml");
assert_eq!(compose_arr[1], OVERLAY_FILENAME);
let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gitignore.contains(OVERLAY_FILENAME));
assert!(dc.join(".env").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_project_creates_overlay_and_updates_json() {
let dir = tempdir("scaffold-existing");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{"service": "myapp", "dockerComposeFile": "compose.yaml"}"#,
)
.unwrap();
run(dir.to_str().unwrap(), true).unwrap();
assert!(dc.join("kap.toml").exists());
assert!(dc.join(OVERLAY_FILENAME).exists());
assert!(!dc.join("docker-compose.yml").exists());
let overlay = fs::read_to_string(dc.join(OVERLAY_FILENAME)).unwrap();
assert!(overlay.contains("myapp:"));
assert!(overlay.contains("kap:"));
assert!(overlay.contains("kap_sandbox:"));
let updated: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dc.join("devcontainer.json")).unwrap())
.unwrap();
let compose_arr = updated["dockerComposeFile"].as_array().unwrap();
assert_eq!(compose_arr.len(), 2);
assert_eq!(compose_arr[0], "compose.yaml");
assert_eq!(compose_arr[1], OVERLAY_FILENAME);
assert_eq!(updated["initializeCommand"], "kap sidecar-init");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_project_appends_to_compose_array() {
let dir = tempdir("scaffold-array");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{"service": "api", "dockerComposeFile": ["docker-compose.yml", "docker-compose.override.yml"]}"#,
)
.unwrap();
run(dir.to_str().unwrap(), true).unwrap();
let updated: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dc.join("devcontainer.json")).unwrap())
.unwrap();
let compose_arr = updated["dockerComposeFile"].as_array().unwrap();
assert_eq!(compose_arr.len(), 3);
assert_eq!(compose_arr[0], "docker-compose.yml");
assert_eq!(compose_arr[1], "docker-compose.override.yml");
assert_eq!(compose_arr[2], OVERLAY_FILENAME);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_project_fails_if_kap_toml_exists() {
let dir = tempdir("scaffold-exists");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(dc.join("devcontainer.json"), r#"{"service": "app"}"#).unwrap();
fs::write(dc.join("kap.toml"), "").unwrap();
assert!(run(dir.to_str().unwrap(), true).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_project_fails_if_no_devcontainer_json() {
let dir = tempdir("scaffold-no-json");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
assert!(run(dir.to_str().unwrap(), true).is_err());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn overlay_uses_default_service_name() {
let dir = tempdir("scaffold-default-svc");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(dc.join("devcontainer.json"), r#"{}"#).unwrap();
run(dir.to_str().unwrap(), true).unwrap();
let overlay = fs::read_to_string(dc.join(OVERLAY_FILENAME)).unwrap();
assert!(overlay.contains("app:"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_image_based_converts_to_compose() {
let dir = tempdir("scaffold-image-based");
let dc = dir.join(".devcontainer");
fs::create_dir_all(&dc).unwrap();
fs::write(
dc.join("devcontainer.json"),
r#"{"name": "test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", "postCreateCommand": "echo hi", "remoteUser": "vscode"}"#,
)
.unwrap();
run(dir.to_str().unwrap(), true).unwrap();
let compose = fs::read_to_string(dc.join("docker-compose.yml")).unwrap();
assert!(compose.contains("mcr.microsoft.com/devcontainers/base:ubuntu-24.04"));
assert!(compose.contains("app:"));
let updated: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dc.join("devcontainer.json")).unwrap())
.unwrap();
assert!(updated.get("image").is_none());
assert_eq!(updated["service"], "app");
assert_eq!(updated["workspaceFolder"], "/workspace");
let compose_arr = updated["dockerComposeFile"].as_array().unwrap();
assert_eq!(compose_arr.len(), 2);
assert_eq!(compose_arr[0], "docker-compose.yml");
assert_eq!(compose_arr[1], OVERLAY_FILENAME);
let raw = fs::read_to_string(dc.join("devcontainer.json")).unwrap();
let name_pos = raw.find("\"name\"").unwrap();
let service_pos = raw.find("\"service\"").unwrap();
assert!(name_pos < service_pos);
let post_create_pos = raw.find("\"postCreateCommand\"").unwrap();
let remote_user_pos = raw.find("\"remoteUser\"").unwrap();
assert!(
post_create_pos < remote_user_pos,
"postCreateCommand should stay before remoteUser (shift_remove preserves order)"
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn overlay_with_build_config() {
let compose = ComposeConfig {
image: None,
build: Some(crate::config::ComposeBuild {
context: "..".to_string(),
dockerfile: Some(".devcontainer/Dockerfile".to_string()),
target: Some("proxy".to_string()),
}),
};
let overlay = generate_overlay("app", &compose, &[], "172.28.0", "test-project");
assert!(overlay.contains("build:"));
assert!(overlay.contains("context: .."));
assert!(overlay.contains("dockerfile: .devcontainer/Dockerfile"));
assert!(overlay.contains("target: proxy"));
assert!(!overlay.contains("image:"));
}
#[test]
fn overlay_with_default_image() {
let compose = ComposeConfig::default();
let overlay = generate_overlay("app", &compose, &[], "172.28.0", "test-project");
assert!(overlay.contains("image: ghcr.io/6/kap:latest"));
assert!(!overlay.contains("build:"));
}
#[test]
fn overlay_includes_proxy_logs_volume() {
let compose = ComposeConfig::default();
let overlay = generate_overlay("app", &compose, &[], "172.28.0", "test-project");
assert!(overlay.contains("proxy-logs:/var/log/kap"));
assert!(overlay.contains("volumes:\n proxy-logs:"));
}
#[test]
fn gitignore_overlay_creates_file() {
let dir = tempdir("gitignore-create");
gitignore_overlay(&dir).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(content.contains(OVERLAY_FILENAME));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn gitignore_overlay_appends_to_existing() {
let dir = tempdir("gitignore-append");
fs::write(dir.join(".gitignore"), "target/\n").unwrap();
gitignore_overlay(&dir).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(content.starts_with("target/\n"));
assert!(content.contains(OVERLAY_FILENAME));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn gitignore_overlay_is_idempotent() {
let dir = tempdir("gitignore-idempotent");
gitignore_overlay(&dir).unwrap();
gitignore_overlay(&dir).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
let count = content.matches(OVERLAY_FILENAME).count();
assert_eq!(count, 1);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn derive_subnet_is_deterministic() {
let dir = tempdir("subnet-det");
let a = derive_subnet(&dir);
let b = derive_subnet(&dir);
assert_eq!(a, b);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn derive_subnet_differs_for_different_dirs() {
let dir1 = tempdir("subnet-a");
let dir2 = tempdir("subnet-b");
let s1 = derive_subnet(&dir1);
let s2 = derive_subnet(&dir2);
assert_ne!(s1, s2);
fs::remove_dir_all(&dir1).unwrap();
fs::remove_dir_all(&dir2).unwrap();
}
#[test]
fn derive_subnet_in_valid_range() {
let dir = tempdir("subnet-range");
let prefix = derive_subnet(&dir);
let parts: Vec<&str> = prefix.split('.').collect();
assert_eq!(parts[0], "172");
let second: u8 = parts[1].parse().unwrap();
assert!((18..=31).contains(&second));
let _third: u8 = parts[2].parse().unwrap(); fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn overlay_uses_custom_subnet() {
let compose = ComposeConfig::default();
let overlay = generate_overlay("app", &compose, &[], "172.25.42", "test-project");
assert!(overlay.contains("172.25.42.2")); assert!(overlay.contains("172.25.42.3")); assert!(overlay.contains("172.25.42.0/24")); assert!(!overlay.contains("172.28.0")); assert!(overlay.contains("hostname: test-project"));
}
#[test]
fn read_project_name_from_devcontainer_json() {
let dir = tempdir("project-name");
fs::write(
dir.join("devcontainer.json"),
r#"{"name": "my-cool-project"}"#,
)
.unwrap();
assert_eq!(read_project_name(&dir), "my-cool-project");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_project_name_falls_back_to_dir_name() {
let dir = tempdir("project-name-fallback");
assert_eq!(
read_project_name(&dir),
dir.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.to_string()
);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn read_project_name_no_name_field() {
let dir = tempdir("project-name-nofield");
fs::write(dir.join("devcontainer.json"), r#"{"service": "app"}"#).unwrap();
let name = read_project_name(&dir);
assert!(!name.is_empty());
assert_ne!(name, "dev"); fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn generate_env_file_with_detected_tools() {
let tool = DetectedTool {
name: "gh",
env: &["GH_TOKEN"],
allow: &["*"],
env_defaults: &[("GH_TOKEN", "$(gh auth token)")],
};
let content = generate_env_file(&[&tool]);
assert_eq!(content, "GH_TOKEN=$(gh auth token)");
}
#[test]
fn generate_env_file_empty_when_no_tools() {
let content = generate_env_file(&[]);
assert_eq!(content, "");
}
#[test]
fn env_var_default_known_var() {
assert_eq!(env_var_default("GH_TOKEN"), Some("$(gh auth token)"));
}
#[test]
fn env_var_default_unknown_var() {
assert_eq!(env_var_default("UNKNOWN_VAR"), None);
}
#[test]
fn overlay_contains_hostname() {
let compose = ComposeConfig::default();
let overlay = generate_overlay("app", &compose, &[], "172.28.0", "my-project");
assert!(overlay.contains("hostname: my-project"));
}
fn tempdir(suffix: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("kap-test-{}-{suffix}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
}