Add arc-devcontainer crate for parsing and resolving devcontainer.json

Standalone crate that reads devcontainer.json (with JSONC support), fetches
OCI Features via oras, and produces a resolved config containing a generated
Dockerfile, lifecycle hooks, environment variables, and forwarded ports.

Supports image, Dockerfile, and Docker Compose modes with devcontainer
variable substitution. No coupling to arc-workflows or DaytonaSandbox.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 22:38:12 -05:00
parent ffac356c7b
commit 5e337b4cb3
19 changed files with 2680 additions and 0 deletions

13
Cargo.lock generated
View file

@ -196,6 +196,19 @@ dependencies = [
"tracing",
]
[[package]]
name = "arc-devcontainer"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"serde_yaml",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "arc-git-storage"
version = "0.1.0"

View file

@ -0,0 +1,21 @@
[package]
name = "arc-devcontainer"
edition.workspace = true
version.workspace = true
license.workspace = true
description = "Parse and resolve devcontainer.json into Dockerfiles and lifecycle hooks"
[lib]
doctest = false
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
thiserror = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
tempfile = "3"

View file

@ -0,0 +1,114 @@
# Devcontainer Spec Compatibility Matrix
Compatibility of `arc-devcontainer` with the [devcontainer.json reference](https://containers.dev/implementors/json_reference/).
**Legend**: Yes = fully supported, Partial = parsed but incomplete, No = not supported, Planned = intended for future
## General
| Property | Status | Notes |
|---|---|---|
| `name` | No | Parsed by serde (ignored via `#[serde(default)]`); not exposed in `DevcontainerConfig` |
| `forwardPorts` | Yes | Numeric ports extracted into `DevcontainerConfig::forwarded_ports`; string formats ignored |
| `portsAttributes` | No | Not parsed |
| `otherPortsAttributes` | No | Not parsed |
| `updateRemoteUserUID` | No | Not parsed |
| `containerEnv` | Partial | Parsed in `DevcontainerJson::container_env` but not merged into `DevcontainerConfig::environment` |
| `remoteEnv` | Yes | Merged into `DevcontainerConfig::environment` with variable substitution |
| `containerUser` | Partial | Parsed in `DevcontainerJson::container_user` but not exposed in `DevcontainerConfig` |
| `remoteUser` | Yes | Exposed as `DevcontainerConfig::remote_user` |
| `userEnvProbe` | No | Not parsed |
| `overrideCommand` | Partial | Parsed in `DevcontainerJson::override_command` but not acted on |
| `shutdownAction` | No | Not parsed |
## Image
| Property | Status | Notes |
|---|---|---|
| `image` | Yes | Used as `FROM` line when no Dockerfile is specified; defaults to `mcr.microsoft.com/devcontainers/base:ubuntu` |
## Build (Dockerfile)
| Property | Status | Notes |
|---|---|---|
| `build.dockerfile` | Yes | Resolved relative to devcontainer.json; content read and used as base Dockerfile |
| `build.context` | Yes | Resolved with variable substitution; passed as `DevcontainerConfig::build_context` |
| `build.args` | Partial | Parsed in `BuildConfig::args` but not injected into generated Dockerfile |
| `build.target` | No | Not parsed |
| `build.cacheFrom` | No | Not parsed |
| `build.options` | No | Not parsed |
## Compose
| Property | Status | Notes |
|---|---|---|
| `dockerComposeFile` | Partial | Single file path supported; array of paths not supported |
| `service` | Yes | Required when `dockerComposeFile` is set; used to extract service config |
| `runServices` | No | Not parsed; all services assumed |
| `shutdownAction` | No | Not parsed |
| `overrideCommand` | Partial | Parsed but not acted on in compose mode |
| `workspaceFolder` | Yes | Defaults to `/workspaces/{repo-name}` |
| `workspaceMount` | Partial | Parsed in `DevcontainerJson::workspace_mount` but not used |
## Features
| Property | Status | Notes |
|---|---|---|
| `features` | Yes | Fetched via `oras` CLI, topologically sorted by `installsAfter`, Dockerfile layers generated with options as env vars |
| `overrideFeatureInstallOrder` | No | Not parsed |
## Lifecycle
| Property | Status | Notes |
|---|---|---|
| `initializeCommand` | Yes | All three forms supported: string, array, object (parallel). Exposed as `DevcontainerConfig::initialize_commands` |
| `onCreateCommand` | No | Not parsed |
| `updateContentCommand` | No | Not parsed |
| `postCreateCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_create_commands` |
| `postStartCommand` | Yes | All three forms supported. Exposed as `DevcontainerConfig::post_start_commands` |
| `postAttachCommand` | No | Not parsed |
| `waitFor` | No | Not parsed |
## Host
| Property | Status | Notes |
|---|---|---|
| `hostRequirements` | No | Not parsed |
| `init` | No | Not parsed |
| `privileged` | No | Not parsed |
| `capAdd` | No | Not parsed |
| `securityOpt` | No | Not parsed |
| `mounts` | No | Not parsed |
| `gpuRequest` | No | Not parsed |
## Customizations
| Property | Status | Notes |
|---|---|---|
| `customizations` | No | Unknown fields are silently ignored by serde, so `customizations` is accepted but not processed |
## Variables
| Variable | Status | Notes |
|---|---|---|
| `${localWorkspaceFolder}` | Yes | Substituted via `VariableContext` |
| `${localWorkspaceFolderBasename}` | Yes | Substituted via `VariableContext` |
| `${containerWorkspaceFolder}` | Yes | Substituted via `VariableContext` |
| `${containerWorkspaceFolderBasename}` | Yes | Derived from `containerWorkspaceFolder` by splitting on `/` |
| `${localEnv:VAR}` | Yes | Reads from host environment; supports `:default` syntax |
| `${containerEnv:VAR}` | No | Not implemented (requires running container) |
| `${devcontainerId}` | No | Not implemented |
## JSONC Support
The parser supports JSONC (JSON with Comments):
- Line comments (`//`)
- Block comments (`/* */`)
- Trailing commas before `}` and `]`
## File Discovery
Searched in order:
1. `<path>/.devcontainer/devcontainer.json`
2. `<path>/.devcontainer.json`
3. Direct path if it ends in `devcontainer.json`

View file

@ -0,0 +1,186 @@
# Integrating DevcontainerConfig with DaytonaSandbox
How to wire the parsed `DevcontainerConfig` into sandbox creation.
## Overview
`DevcontainerResolver::resolve(repo_path)` reads a repository's devcontainer.json and produces a `DevcontainerConfig` containing everything needed to build and configure a sandbox:
```rust
pub struct DevcontainerConfig {
pub dockerfile: String, // Generated Dockerfile content
pub build_context: PathBuf, // Directory for docker build
pub initialize_commands: Vec<Command>, // Host-side pre-build commands
pub post_create_commands: Vec<Command>, // Container post-creation setup
pub post_start_commands: Vec<Command>, // Container on-each-start commands
pub environment: HashMap<String, String>, // remoteEnv merged
pub remote_user: Option<String>, // Non-root user
pub workspace_folder: String, // Working directory inside container
pub forwarded_ports: Vec<u16>, // Ports to expose
pub compose_file: Option<PathBuf>, // Set when in compose mode
pub compose_service: Option<String>,
}
```
## Mapping Devcontainer to Daytona
### Dockerfile and Image Build
`config.dockerfile` contains the full Dockerfile content (not a path). For image-only configs, this is a single `FROM` line. For Dockerfile configs, it is the file content with feature layers appended.
- Build a Docker image from `config.dockerfile` using `config.build_context` as the build context directory.
- Use this image as the Daytona sandbox snapshot/base image.
### Environment Variables
`config.environment` contains the merged `remoteEnv` values (with variables already substituted).
- Pass these as environment variables when creating the sandbox.
- `containerEnv` values (if supported in the future) would be baked into the Dockerfile via `ENV` directives.
### Workspace Folder
`config.workspace_folder` defaults to `/workspaces/{repo-name}`.
- Set this as the sandbox working directory.
- Mount or clone the repository into this path.
### Remote User
`config.remote_user` specifies the non-root user for running dev tools.
- Use this as the sandbox exec user when running lifecycle commands and user sessions.
- Falls back to root if not set.
### Forwarded Ports
`config.forwarded_ports` lists ports to expose (first port = default preview).
- Use the first port as the default preview URL for the sandbox.
- Forward all listed ports from the sandbox to the user.
## Docker Compose DinD Flow
When `config.compose_file` is `Some(path)`, the devcontainer uses Docker Compose mode.
### Strategy
Run Docker-in-Docker (DinD) inside the Daytona sandbox:
1. Create a sandbox using the extracted Dockerfile from the compose service.
2. Install Docker daemon inside the sandbox (or use a DinD-capable base image).
3. Copy the compose file and related context into the sandbox.
4. Run `docker compose up` inside the sandbox to start all services.
5. The compose service ports become available on localhost inside the sandbox.
6. Forward those ports from the sandbox to the user.
### Port Forwarding
Ports come from the compose service's `ports` configuration (parsed by `compose::parse_compose`). The compose parser extracts container-side ports from formats like `"8080:80"`, `"3000"`, and `5432`.
## Lifecycle Hook Execution Order
The devcontainer spec defines this execution order:
| Hook | Where | When | `DevcontainerConfig` field |
|---|---|---|---|
| `initializeCommand` | Host | Before build | `initialize_commands` |
| `onCreateCommand` | Container | After first creation | Not captured (not parsed) |
| `updateContentCommand` | Container | After create/content update | Not captured (not parsed) |
| `postCreateCommand` | Container | After create/content update | `post_create_commands` |
| `postStartCommand` | Container | On each start | `post_start_commands` |
| `postAttachCommand` | Container | On each attach | Not captured (not parsed) |
### Command Types
Each command is represented as a `Command` enum:
```rust
pub enum Command {
Shell(String), // "npm install"
Args(Vec<String>), // ["npm", "install"]
Parallel(HashMap<String, String>), // {"install": "npm install", "build": "npm run build"}
}
```
- `Shell` -- execute via `sh -c "<command>"`
- `Args` -- execute directly as argv
- `Parallel` -- execute all values concurrently, wait for all to complete
### Execution in Sandbox
```
1. Run initialize_commands on HOST (before sandbox creation)
2. Build image from config.dockerfile
3. Create sandbox from image
4. Run post_create_commands in sandbox (as remote_user if set)
5. Run post_start_commands in sandbox (as remote_user if set)
```
## Example Integration Code
```rust
use arc_devcontainer::{DevcontainerResolver, DevcontainerConfig, Command};
async fn create_sandbox_from_devcontainer(repo_path: &Path) -> Result<Sandbox> {
let config = DevcontainerResolver::resolve(repo_path).await?;
// 1. Run host-side init commands
for cmd in &config.initialize_commands {
run_host_command(cmd).await?;
}
// 2. Build image and create sandbox
let sandbox = if config.compose_file.is_some() {
// Compose mode: build from extracted service Dockerfile, then run compose inside
let sandbox = daytona.create_from_dockerfile(
&config.dockerfile,
&config.build_context,
).await?;
setup_dind(&sandbox).await?;
sandbox.exec("docker compose up -d").await?;
sandbox
} else {
// Image/Dockerfile mode: build directly
daytona.create_from_dockerfile(
&config.dockerfile,
&config.build_context,
).await?
};
// 3. Configure environment
for (key, value) in &config.environment {
sandbox.set_env(key, value).await?;
}
// 4. Set working directory
sandbox.set_workdir(&config.workspace_folder).await?;
// 5. Run lifecycle hooks
let user = config.remote_user.as_deref();
for cmd in &config.post_create_commands {
sandbox.exec_command(cmd, user).await?;
}
for cmd in &config.post_start_commands {
sandbox.exec_command(cmd, user).await?;
}
// 6. Set up port forwarding
if let Some(port) = config.forwarded_ports.first() {
sandbox.set_preview_port(*port).await?;
}
Ok(sandbox)
}
```
## Edge Cases and Limitations
- **Features require `oras`**: Feature resolution shells out to `oras` CLI for OCI registry pulls. The resolver attempts auto-install if `oras` is not on PATH.
- **`build.args` not injected**: Build arguments are parsed but not passed to `docker build` via `--build-arg` or `ARG` directives.
- **`containerEnv` not merged**: Only `remoteEnv` is included in `DevcontainerConfig::environment`. `containerEnv` is parsed but not forwarded.
- **Single compose file only**: `dockerComposeFile` is treated as a single string path. The spec allows an array of paths for compose file merging.
- **No `onCreateCommand` or `updateContentCommand`**: These lifecycle hooks are not parsed. For first-run setup, `postCreateCommand` serves as the primary hook.
- **No `postAttachCommand`**: Not parsed. Attach-time hooks would need to run on each user session connection.
- **`${containerEnv:VAR}` not supported**: Variable substitution only covers host-side variables. Container-side env vars require a running container.
- **Port forwarding is numeric only**: String port formats (e.g., `"label:3000"`) in `forwardPorts` are filtered out; only numeric values are extracted.

View file

@ -0,0 +1,304 @@
use std::collections::HashMap;
use std::path::Path;
/// Extracted configuration from a Docker Compose service.
#[derive(Debug, Clone, Default)]
pub struct ComposeServiceConfig {
pub image: Option<String>,
pub build: Option<ComposeBuild>,
pub ports: Vec<u16>,
pub environment: HashMap<String, String>,
pub user: Option<String>,
}
/// Build configuration from a Docker Compose service.
#[derive(Debug, Clone)]
pub struct ComposeBuild {
pub context: String,
pub dockerfile: Option<String>,
}
/// Parse a Docker Compose file and extract config for the named service.
pub fn parse_compose(
compose_path: &Path,
service_name: &str,
) -> Result<ComposeServiceConfig, String> {
let contents = std::fs::read_to_string(compose_path)
.map_err(|e| format!("failed to read compose file: {e}"))?;
let doc: serde_yaml::Value =
serde_yaml::from_str(&contents).map_err(|e| format!("failed to parse YAML: {e}"))?;
let service = doc
.get("services")
.and_then(|s| s.get(service_name))
.ok_or_else(|| format!("service '{service_name}' not found in compose file"))?;
let image = service
.get("image")
.and_then(|v| v.as_str())
.map(String::from);
let build = parse_build(service);
let ports = parse_ports(service);
let environment = parse_environment(service);
let user = service
.get("user")
.and_then(|v| v.as_str())
.map(String::from);
Ok(ComposeServiceConfig {
image,
build,
ports,
environment,
user,
})
}
fn parse_build(service: &serde_yaml::Value) -> Option<ComposeBuild> {
let build_val = service.get("build")?;
if let Some(context) = build_val.as_str() {
return Some(ComposeBuild {
context: context.to_string(),
dockerfile: None,
});
}
if build_val.is_mapping() {
let context = build_val
.get("context")
.and_then(|v| v.as_str())
.unwrap_or(".")
.to_string();
let dockerfile = build_val
.get("dockerfile")
.and_then(|v| v.as_str())
.map(String::from);
return Some(ComposeBuild {
context,
dockerfile,
});
}
None
}
fn parse_ports(service: &serde_yaml::Value) -> Vec<u16> {
let Some(ports_val) = service.get("ports") else {
return Vec::new();
};
let Some(ports_seq) = ports_val.as_sequence() else {
return Vec::new();
};
ports_seq
.iter()
.filter_map(|entry| {
if let Some(n) = entry.as_u64() {
return u16::try_from(n).ok();
}
if let Some(s) = entry.as_str() {
// Formats: "8080:80", "3000", "8080:80/tcp"
let s = s.split('/').next().unwrap_or(s); // strip protocol
return if let Some((_host, container)) = s.split_once(':') {
container.parse::<u16>().ok()
} else {
s.parse::<u16>().ok()
};
}
None
})
.collect()
}
fn parse_environment(service: &serde_yaml::Value) -> HashMap<String, String> {
let Some(env_val) = service.get("environment") else {
return HashMap::new();
};
// Array form: ["KEY=VALUE", ...]
if let Some(seq) = env_val.as_sequence() {
return seq
.iter()
.filter_map(|v| {
let s = v.as_str()?;
let (key, value) = s.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect();
}
// Object form: { KEY: VALUE, ... }
if let Some(mapping) = env_val.as_mapping() {
return mapping
.iter()
.filter_map(|(k, v)| {
let key = k.as_str()?.to_string();
let value = match v {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Null => String::new(),
_ => return None,
};
Some((key, value))
})
.collect();
}
HashMap::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_compose(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
#[test]
fn service_with_image_only() {
let f = write_compose(
r#"
services:
web:
image: nginx:latest
"#,
);
let cfg = parse_compose(f.path(), "web").unwrap();
assert_eq!(cfg.image.as_deref(), Some("nginx:latest"));
assert!(cfg.build.is_none());
assert!(cfg.ports.is_empty());
assert!(cfg.environment.is_empty());
assert!(cfg.user.is_none());
}
#[test]
fn service_with_build_string() {
let f = write_compose(
r#"
services:
app:
build: ./src
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
let build = cfg.build.unwrap();
assert_eq!(build.context, "./src");
assert!(build.dockerfile.is_none());
}
#[test]
fn service_with_build_object() {
let f = write_compose(
r#"
services:
app:
build:
context: ./app
dockerfile: Dockerfile.dev
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
let build = cfg.build.unwrap();
assert_eq!(build.context, "./app");
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile.dev"));
}
#[test]
fn ports_various_formats() {
let f = write_compose(
r#"
services:
web:
image: nginx
ports:
- "8080:80"
- "3000"
- 5432
- "9090:9090/tcp"
"#,
);
let cfg = parse_compose(f.path(), "web").unwrap();
assert_eq!(cfg.ports, vec![80, 3000, 5432, 9090]);
}
#[test]
fn environment_as_array() {
let f = write_compose(
r#"
services:
app:
image: myapp
environment:
- "DATABASE_URL=postgres://localhost/db"
- "DEBUG=true"
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.environment.len(), 2);
assert_eq!(
cfg.environment.get("DATABASE_URL").unwrap(),
"postgres://localhost/db"
);
assert_eq!(cfg.environment.get("DEBUG").unwrap(), "true");
}
#[test]
fn environment_as_object() {
let f = write_compose(
r#"
services:
app:
image: myapp
environment:
RAILS_ENV: production
PORT: 3000
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.environment.len(), 2);
assert_eq!(cfg.environment.get("RAILS_ENV").unwrap(), "production");
assert_eq!(cfg.environment.get("PORT").unwrap(), "3000");
}
#[test]
fn service_not_found() {
let f = write_compose(
r#"
services:
web:
image: nginx
"#,
);
let err = parse_compose(f.path(), "missing").unwrap_err();
assert!(err.contains("service 'missing' not found"));
}
#[test]
fn file_not_found() {
let err = parse_compose(Path::new("/nonexistent/docker-compose.yml"), "web").unwrap_err();
assert!(err.contains("failed to read compose file"));
}
#[test]
fn service_with_user() {
let f = write_compose(
r#"
services:
app:
image: myapp
user: "1000:1000"
"#,
);
let cfg = parse_compose(f.path(), "app").unwrap();
assert_eq!(cfg.user.as_deref(), Some("1000:1000"));
}
}

View file

@ -0,0 +1,171 @@
use std::collections::HashMap;
use crate::features::FeatureLayer;
/// Generate a combined Dockerfile from base + features + env + user.
pub fn generate(
base_dockerfile: &str,
feature_layers: &[FeatureLayer],
remote_env: &Option<HashMap<String, String>>,
remote_user: Option<&str>,
) -> String {
let mut sections: Vec<String> = Vec::new();
sections.push("# Generated by arc-devcontainer".to_string());
sections.push(base_dockerfile.to_string());
for layer in feature_layers {
sections.push(layer.dockerfile_snippet.clone());
}
if let Some(env) = remote_env {
if !env.is_empty() {
let mut keys: Vec<&String> = env.keys().collect();
keys.sort();
let env_lines: Vec<String> = keys
.iter()
.map(|k| format!("ENV {}={}", k, env[*k]))
.collect();
sections.push(env_lines.join("\n"));
}
}
if let Some(user) = remote_user {
sections.push(format!("USER {}", user));
}
let mut result = sections.join("\n\n");
result.push('\n');
result
}
#[cfg(test)]
mod tests {
use super::*;
fn make_layer(id: &str, dir_name: &str, snippet: &str) -> FeatureLayer {
FeatureLayer {
id: id.to_string(),
dir_name: dir_name.to_string(),
dockerfile_snippet: snippet.to_string(),
}
}
#[test]
fn base_image_only() {
let result = generate("FROM ubuntu:22.04", &[], &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\n"
);
}
#[test]
fn base_dockerfile_preserved_as_is() {
let base = "FROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl";
let result = generate(base, &[], &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM ubuntu:22.04\nRUN apt-get update\nRUN apt-get install -y curl\n"
);
}
#[test]
fn with_feature_layers() {
let layers = vec![
make_layer("node", "node-1", "RUN install-node.sh"),
make_layer("python", "python-1", "RUN install-python.sh"),
];
let result = generate("FROM ubuntu:22.04", &layers, &None, None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM ubuntu:22.04\n\n\
RUN install-node.sh\n\n\
RUN install-python.sh\n"
);
}
#[test]
fn with_env_sorted() {
let mut env = HashMap::new();
env.insert("ZEBRA".to_string(), "stripes".to_string());
env.insert("APPLE".to_string(), "red".to_string());
env.insert("MANGO".to_string(), "yellow".to_string());
let result = generate("FROM alpine", &[], &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM alpine\n\n\
ENV APPLE=red\n\
ENV MANGO=yellow\n\
ENV ZEBRA=stripes\n"
);
}
#[test]
fn with_remote_user() {
let result = generate("FROM alpine", &[], &None, Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM alpine\n\n\
USER vscode\n"
);
}
#[test]
fn all_combined() {
let layers = vec![make_layer("node", "node-1", "RUN install-node.sh")];
let mut env = HashMap::new();
env.insert("PATH".to_string(), "/usr/local/bin".to_string());
env.insert("HOME".to_string(), "/home/vscode".to_string());
let result = generate("FROM ubuntu:22.04", &layers, &Some(env), Some("vscode"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM ubuntu:22.04\n\n\
RUN install-node.sh\n\n\
ENV HOME=/home/vscode\n\
ENV PATH=/usr/local/bin\n\n\
USER vscode\n"
);
}
#[test]
fn empty_feature_layers_no_extra_blank_lines() {
let result = generate("FROM alpine", &[], &None, Some("dev"));
assert_eq!(
result,
"# Generated by arc-devcontainer\n\n\
FROM alpine\n\n\
USER dev\n"
);
}
#[test]
fn empty_env_map_treated_as_none() {
let env = HashMap::new();
let result = generate("FROM alpine", &[], &Some(env), None);
assert_eq!(
result,
"# Generated by arc-devcontainer\n\nFROM alpine\n"
);
}
#[test]
fn multiline_base_dockerfile() {
let base = "FROM ubuntu:22.04 AS builder\n\
RUN apt-get update && apt-get install -y build-essential\n\
COPY . /app\n\
RUN make\n\
\n\
FROM ubuntu:22.04\n\
COPY --from=builder /app/bin /usr/local/bin";
let result = generate(base, &[], &None, None);
assert!(result.starts_with("# Generated by arc-devcontainer\n\n"));
assert!(result.contains("FROM ubuntu:22.04 AS builder"));
assert!(result.contains("COPY --from=builder /app/bin /usr/local/bin"));
assert!(result.ends_with('\n'));
}
}

View file

@ -0,0 +1,641 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use tracing::info;
use crate::types::FeatureMetadata;
use crate::DevcontainerError;
/// A resolved feature layer ready to be inserted into a Dockerfile.
#[derive(Debug, Clone)]
pub struct FeatureLayer {
/// Feature identifier (e.g. "ghcr.io/devcontainers/features/node:1")
pub id: String,
/// Directory name for COPY
pub dir_name: String,
/// Dockerfile snippet for this feature
pub dockerfile_snippet: String,
}
/// Extract the directory name from a feature ID.
/// e.g. "ghcr.io/devcontainers/features/node:1" -> "node"
fn dir_name_from_id(feature_id: &str) -> String {
let without_tag = feature_id.split(':').next().unwrap_or(feature_id);
without_tag
.rsplit('/')
.next()
.unwrap_or(without_tag)
.to_string()
}
/// Ensure `oras` CLI is available, installing it if necessary.
async fn ensure_oras() -> crate::Result<()> {
let check = tokio::process::Command::new("which")
.arg("oras")
.output()
.await
.map_err(|e| DevcontainerError::OrasInstall(format!("failed to check for oras: {e}")))?;
if check.status.success() {
return Ok(());
}
info!("oras not found, attempting to install");
if cfg!(target_os = "macos") {
let status = tokio::process::Command::new("brew")
.args(["install", "oras"])
.status()
.await
.map_err(|e| {
DevcontainerError::OrasInstall(format!("failed to run brew install oras: {e}"))
})?;
if !status.success() {
return Err(DevcontainerError::OrasInstall(
"brew install oras failed".to_string(),
));
}
} else {
// Linux: download from GitHub releases to ~/.local/bin/
let home = std::env::var("HOME")
.map_err(|_| DevcontainerError::OrasInstall("HOME not set".to_string()))?;
let bin_dir = format!("{home}/.local/bin");
tokio::fs::create_dir_all(&bin_dir).await.map_err(|e| {
DevcontainerError::OrasInstall(format!("failed to create {bin_dir}: {e}"))
})?;
let version = "1.2.0";
let arch = if cfg!(target_arch = "aarch64") {
"arm64"
} else {
"amd64"
};
let url = format!(
"https://github.com/oras-project/oras/releases/download/v{version}/oras_{version}_linux_{arch}.tar.gz"
);
let status = tokio::process::Command::new("sh")
.args([
"-c",
&format!("curl -fsSL '{url}' | tar xzf - -C '{bin_dir}' oras"),
])
.status()
.await
.map_err(|e| {
DevcontainerError::OrasInstall(format!("failed to download oras: {e}"))
})?;
if !status.success() {
return Err(DevcontainerError::OrasInstall(
"downloading oras from GitHub releases failed".to_string(),
));
}
}
Ok(())
}
/// Fetch a single feature using `oras pull` and extract its contents.
/// Returns the parsed feature metadata.
async fn fetch_feature(
feature_id: &str,
output_dir: &Path,
) -> crate::Result<FeatureMetadata> {
let dir_name = dir_name_from_id(feature_id);
let feature_dir = output_dir.join(&dir_name);
tokio::fs::create_dir_all(&feature_dir)
.await
.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to create dir {}: {e}",
feature_dir.display()
))
})?;
info!(feature_id, "pulling feature with oras");
let output = tokio::process::Command::new("oras")
.args(["pull", feature_id, "-o"])
.arg(&feature_dir)
.output()
.await
.map_err(|e| DevcontainerError::OrasCommand(format!("failed to run oras pull: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(DevcontainerError::OrasCommand(format!(
"oras pull {feature_id} failed: {stderr}"
)));
}
// Extract any tgz files
let tgz_path = feature_dir.join("devcontainer-feature.tgz");
if tgz_path.exists() {
let status = tokio::process::Command::new("tar")
.args(["xzf", "devcontainer-feature.tgz"])
.current_dir(&feature_dir)
.status()
.await
.map_err(|e| {
DevcontainerError::Feature(format!("failed to extract tgz: {e}"))
})?;
if !status.success() {
return Err(DevcontainerError::Feature(format!(
"tar extraction failed for {feature_id}"
)));
}
}
// Read metadata
let metadata_path = feature_dir.join("devcontainer-feature.json");
let metadata_str = tokio::fs::read_to_string(&metadata_path)
.await
.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to read {}: {e}",
metadata_path.display()
))
})?;
let metadata: FeatureMetadata = serde_json::from_str(&metadata_str).map_err(|e| {
DevcontainerError::Feature(format!(
"failed to parse {}: {e}",
metadata_path.display()
))
})?;
Ok(metadata)
}
/// Topological sort of features based on `installsAfter` dependencies.
/// Uses Kahn's algorithm. Features without ordering constraints maintain input order.
fn topo_sort(
feature_ids: &[String],
metadata_map: &HashMap<String, FeatureMetadata>,
) -> Vec<String> {
if feature_ids.is_empty() {
return Vec::new();
}
let id_set: HashSet<&str> = feature_ids.iter().map(|s| s.as_str()).collect();
// Build adjacency list and in-degree count.
// An edge from A -> B means "A must be installed before B".
// If B has installsAfter containing a reference matching A, then A -> B.
let mut in_degree: HashMap<&str, usize> = HashMap::new();
let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
for id in feature_ids {
in_degree.entry(id.as_str()).or_insert(0);
edges.entry(id.as_str()).or_default();
}
for id in feature_ids {
if let Some(meta) = metadata_map.get(id) {
for dep in &meta.installs_after {
// Find matching feature in our set
// installsAfter may use short IDs or full IDs, match by dir name or full ID
let dep_dir = dir_name_from_id(dep);
for candidate in feature_ids {
if candidate == dep
|| dir_name_from_id(candidate) == dep_dir
{
if id_set.contains(candidate.as_str()) {
// candidate -> id (candidate must come before id)
edges.entry(candidate.as_str()).or_default().push(id.as_str());
*in_degree.entry(id.as_str()).or_insert(0) += 1;
}
}
}
}
}
}
// Kahn's algorithm preserving input order for ties
let mut queue: VecDeque<&str> = VecDeque::new();
for id in feature_ids {
if in_degree.get(id.as_str()).copied().unwrap_or(0) == 0 {
queue.push_back(id.as_str());
}
}
let mut sorted: Vec<String> = Vec::new();
while let Some(node) = queue.pop_front() {
sorted.push(node.to_string());
if let Some(neighbors) = edges.get(node) {
for neighbor in neighbors {
if let Some(deg) = in_degree.get_mut(neighbor) {
*deg -= 1;
if *deg == 0 {
queue.push_back(neighbor);
}
}
}
}
}
// If there are cycles, append remaining features in input order
if sorted.len() < feature_ids.len() {
for id in feature_ids {
if !sorted.contains(id) {
sorted.push(id.clone());
}
}
}
sorted
}
/// Generate a Dockerfile snippet for a single feature layer.
fn generate_layer(
feature_id: &str,
dir_name: &str,
options: &serde_json::Value,
metadata: &FeatureMetadata,
) -> String {
let mut env_lines = Vec::new();
// Collect all option names from metadata to set defaults
let user_options: HashMap<String, String> = match options.as_object() {
Some(obj) => obj
.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), val)
})
.collect(),
None => HashMap::new(),
};
// Merge metadata defaults with user-provided options
let mut merged_options: Vec<(String, String)> = Vec::new();
for (opt_name, opt_def) in &metadata.options {
let value = if let Some(user_val) = user_options.get(opt_name) {
user_val.clone()
} else if let Some(default_val) = &opt_def.default {
match default_val {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
other => other.to_string(),
}
} else {
continue;
};
merged_options.push((opt_name.clone(), value));
}
// Also add any user options not in metadata
for (key, val) in &user_options {
if !metadata.options.contains_key(key) {
merged_options.push((key.clone(), val.clone()));
}
}
// Sort for deterministic output
merged_options.sort_by(|a, b| a.0.cmp(&b.0));
for (name, value) in &merged_options {
let env_name = name.to_uppercase();
env_lines.push(format!(" export {env_name}=\"{value}\" && \\"));
}
let mut snippet = format!("# Feature: {feature_id}\n");
snippet.push_str(&format!(
"COPY {dir_name}/ /tmp/devcontainer-features/{dir_name}/\n"
));
snippet.push_str(&format!(
"RUN cd /tmp/devcontainer-features/{dir_name} && \\\n"
));
for line in &env_lines {
snippet.push_str(line);
snippet.push('\n');
}
snippet.push_str(" chmod +x install.sh && \\\n");
snippet.push_str(" ./install.sh");
snippet
}
/// Fetch, order, and resolve features into Dockerfile layers.
pub async fn resolve_features(
features: &HashMap<String, serde_json::Value>,
_build_context: &Path,
) -> crate::Result<Vec<FeatureLayer>> {
if features.is_empty() {
return Ok(Vec::new());
}
ensure_oras().await?;
let tmp_dir = std::env::temp_dir().join("devcontainer-features");
tokio::fs::create_dir_all(&tmp_dir).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to create temp dir: {e}"))
})?;
// Collect feature IDs in a stable order
let feature_ids: Vec<String> = features.keys().cloned().collect();
// Fetch all features and collect metadata
let mut metadata_map: HashMap<String, FeatureMetadata> = HashMap::new();
for feature_id in &feature_ids {
let metadata = fetch_feature(feature_id, &tmp_dir).await?;
metadata_map.insert(feature_id.clone(), metadata);
}
// Topologically sort features
let sorted_ids = topo_sort(&feature_ids, &metadata_map);
// Generate layers
let mut layers = Vec::new();
for id in &sorted_ids {
let dir_name = dir_name_from_id(id);
let options = features.get(id).cloned().unwrap_or(serde_json::Value::Object(
serde_json::Map::new(),
));
let metadata = metadata_map
.get(id)
.cloned()
.unwrap_or_else(|| FeatureMetadata {
id: None,
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
});
let dockerfile_snippet = generate_layer(id, &dir_name, &options, &metadata);
layers.push(FeatureLayer {
id: id.clone(),
dir_name,
dockerfile_snippet,
});
}
Ok(layers)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::FeatureOption;
#[test]
fn dir_name_from_full_id() {
assert_eq!(
dir_name_from_id("ghcr.io/devcontainers/features/node:1"),
"node"
);
}
#[test]
fn dir_name_from_id_no_tag() {
assert_eq!(
dir_name_from_id("ghcr.io/devcontainers/features/python"),
"python"
);
}
#[test]
fn dir_name_from_id_simple() {
assert_eq!(dir_name_from_id("node"), "node");
}
#[test]
fn topo_sort_no_dependencies() {
let ids = vec!["a".to_string(), "b".to_string(), "c".to_string()];
let metadata: HashMap<String, FeatureMetadata> = ids
.iter()
.map(|id| {
(
id.clone(),
FeatureMetadata {
id: Some(id.clone()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
},
)
})
.collect();
let sorted = topo_sort(&ids, &metadata);
assert_eq!(sorted, vec!["a", "b", "c"]);
}
#[test]
fn topo_sort_simple_chain() {
// A depends on B (A installs after B), so B should come first
let ids = vec!["a".to_string(), "b".to_string()];
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
metadata.insert(
"a".to_string(),
FeatureMetadata {
id: Some("a".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["b".to_string()],
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
},
);
let sorted = topo_sort(&ids, &metadata);
assert_eq!(sorted, vec!["b", "a"]);
}
#[test]
fn topo_sort_diamond() {
// D depends on B and C; B and C depend on A
// Expected: A, B, C, D (or A, C, B, D — both valid, but we preserve input order for ties)
let ids = vec![
"d".to_string(),
"b".to_string(),
"c".to_string(),
"a".to_string(),
];
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
metadata.insert(
"a".to_string(),
FeatureMetadata {
id: Some("a".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["a".to_string()],
},
);
metadata.insert(
"c".to_string(),
FeatureMetadata {
id: Some("c".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["a".to_string()],
},
);
metadata.insert(
"d".to_string(),
FeatureMetadata {
id: Some("d".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["b".to_string(), "c".to_string()],
},
);
let sorted = topo_sort(&ids, &metadata);
// A must come before B and C; B and C must come before D
let pos_a = sorted.iter().position(|x| x == "a").unwrap();
let pos_b = sorted.iter().position(|x| x == "b").unwrap();
let pos_c = sorted.iter().position(|x| x == "c").unwrap();
let pos_d = sorted.iter().position(|x| x == "d").unwrap();
assert!(pos_a < pos_b);
assert!(pos_a < pos_c);
assert!(pos_b < pos_d);
assert!(pos_c < pos_d);
}
#[test]
fn generate_layer_with_options() {
let options = serde_json::json!({"version": "20"});
let mut meta_options = HashMap::new();
meta_options.insert(
"version".to_string(),
FeatureOption {
option_type: Some("string".to_string()),
default: Some(serde_json::Value::String("lts".to_string())),
description: Some("Node.js version".to_string()),
},
);
let metadata = FeatureMetadata {
id: Some("node".to_string()),
name: Some("Node.js".to_string()),
version: Some("1.0.0".to_string()),
options: meta_options,
installs_after: Vec::new(),
};
let snippet = generate_layer(
"ghcr.io/devcontainers/features/node:1",
"node",
&options,
&metadata,
);
assert!(snippet.contains("# Feature: ghcr.io/devcontainers/features/node:1"));
assert!(snippet.contains("COPY node/ /tmp/devcontainer-features/node/"));
assert!(snippet.contains("export VERSION=\"20\""));
assert!(snippet.contains("chmod +x install.sh"));
assert!(snippet.contains("./install.sh"));
}
#[test]
fn generate_layer_with_defaults() {
let options = serde_json::json!({});
let mut meta_options = HashMap::new();
meta_options.insert(
"version".to_string(),
FeatureOption {
option_type: Some("string".to_string()),
default: Some(serde_json::Value::String("lts".to_string())),
description: Some("Node.js version".to_string()),
},
);
let metadata = FeatureMetadata {
id: Some("node".to_string()),
name: None,
version: None,
options: meta_options,
installs_after: Vec::new(),
};
let snippet = generate_layer(
"ghcr.io/devcontainers/features/node:1",
"node",
&options,
&metadata,
);
// Default value "lts" should be used
assert!(snippet.contains("export VERSION=\"lts\""));
}
#[test]
fn generate_layer_no_options() {
let options = serde_json::json!({});
let metadata = FeatureMetadata {
id: Some("common-utils".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
};
let snippet = generate_layer(
"ghcr.io/devcontainers/features/common-utils:1",
"common-utils",
&options,
&metadata,
);
assert!(snippet.contains("# Feature: ghcr.io/devcontainers/features/common-utils:1"));
assert!(snippet.contains("COPY common-utils/ /tmp/devcontainer-features/common-utils/"));
assert!(snippet.contains("chmod +x install.sh"));
assert!(!snippet.contains("export "));
}
#[tokio::test]
#[ignore = "requires oras"]
async fn fetch_feature_integration() {
let tmp = tempfile::tempdir().unwrap();
let metadata =
fetch_feature("ghcr.io/devcontainers/features/node:1", tmp.path())
.await
.unwrap();
assert!(metadata.id.is_some());
assert!(tmp.path().join("node/install.sh").exists());
}
#[tokio::test]
#[ignore = "requires oras"]
async fn resolve_features_integration() {
let tmp = tempfile::tempdir().unwrap();
let mut features = HashMap::new();
features.insert(
"ghcr.io/devcontainers/features/node:1".to_string(),
serde_json::json!({"version": "20"}),
);
let layers = resolve_features(&features, tmp.path()).await.unwrap();
assert_eq!(layers.len(), 1);
assert_eq!(layers[0].dir_name, "node");
assert!(layers[0].dockerfile_snippet.contains("export VERSION=\"20\""));
}
}

View file

@ -0,0 +1,280 @@
/// Strip JSONC comments and trailing commas, producing valid JSON.
pub fn strip_jsonc(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
match bytes[i] {
// String literal — copy verbatim (including any comment-like content)
b'"' => {
out.push('"');
i += 1;
while i < len {
match bytes[i] {
b'\\' => {
// Escaped character — copy both backslash and next char
out.push('\\');
i += 1;
if i < len {
out.push(bytes[i] as char);
i += 1;
}
}
b'"' => {
out.push('"');
i += 1;
break;
}
_ => {
out.push(bytes[i] as char);
i += 1;
}
}
}
}
// Potential comment start
b'/' if i + 1 < len => {
match bytes[i + 1] {
// Line comment — skip until end of line
b'/' => {
i += 2;
while i < len && bytes[i] != b'\n' {
i += 1;
}
}
// Block comment — skip until */
b'*' => {
i += 2;
while i + 1 < len {
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
i += 2;
break;
}
i += 1;
}
// Handle unterminated block comment at end of input
if i >= len {
break;
}
}
_ => {
out.push('/');
i += 1;
}
}
}
// Comma — check if it's a trailing comma before } or ]
b',' => {
// Look ahead past whitespace for } or ]
let mut j = i + 1;
while j < len && bytes[j].is_ascii_whitespace() {
j += 1;
}
// Also skip comments after the comma
while j < len {
if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'/' {
j += 2;
while j < len && bytes[j] != b'\n' {
j += 1;
}
while j < len && bytes[j].is_ascii_whitespace() {
j += 1;
}
} else if j + 1 < len && bytes[j] == b'/' && bytes[j + 1] == b'*' {
j += 2;
while j + 1 < len {
if bytes[j] == b'*' && bytes[j + 1] == b'/' {
j += 2;
break;
}
j += 1;
}
while j < len && bytes[j].is_ascii_whitespace() {
j += 1;
}
} else {
break;
}
}
if j < len && (bytes[j] == b'}' || bytes[j] == b']') {
// Trailing comma — skip it
i += 1;
} else {
out.push(',');
i += 1;
}
}
_ => {
out.push(bytes[i] as char);
i += 1;
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn passthrough_valid_json() {
let json = r#"{"key": "value"}"#;
assert_eq!(strip_jsonc(json), json);
}
#[test]
fn strip_line_comments() {
let input = r#"{
// this is a comment
"key": "value"
}"#;
// Leading whitespace on the comment line remains but that's valid JSON
let expected = "{\n \n \"key\": \"value\"\n}";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn strip_line_comment_at_end_of_line() {
let input = r#"{"key": "value" // inline comment
}"#;
// Space before the comment remains
let expected = "{\"key\": \"value\" \n}";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn strip_block_comments() {
let input = r#"{"key": /* comment */ "value"}"#;
let expected = r#"{"key": "value"}"#;
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn strip_multiline_block_comment() {
let input = r#"{
/* this is
a multi-line
comment */
"key": "value"
}"#;
// Leading whitespace before block comment remains
let expected = "{\n \n \"key\": \"value\"\n}";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn strip_trailing_comma_before_brace() {
let input = r#"{"a": 1, "b": 2,}"#;
let expected = r#"{"a": 1, "b": 2}"#;
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn strip_trailing_comma_before_bracket() {
let input = r#"[1, 2, 3,]"#;
let expected = r#"[1, 2, 3]"#;
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn trailing_comma_with_whitespace() {
let input = r#"{
"a": 1,
"b": 2,
}"#;
let expected = r#"{
"a": 1,
"b": 2
}"#;
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn comments_inside_strings_preserved() {
let input = r#"{"key": "value // not a comment"}"#;
assert_eq!(strip_jsonc(input), input);
}
#[test]
fn block_comment_inside_string_preserved() {
let input = r#"{"key": "value /* not a comment */ still here"}"#;
assert_eq!(strip_jsonc(input), input);
}
#[test]
fn mixed_comments_and_trailing_commas() {
let input = r#"{
// first comment
"name": "test", /* inline */
"items": [
1,
2, // trailing
],
}"#;
// Whitespace around stripped comments remains; trailing commas removed
let expected = "{\n \n \"name\": \"test\", \n \"items\": [\n 1,\n 2 \n ]\n}";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn empty_input() {
assert_eq!(strip_jsonc(""), "");
}
#[test]
fn escaped_quote_in_string() {
let input = r#"{"key": "val\"ue // not a comment"}"#;
assert_eq!(strip_jsonc(input), input);
}
#[test]
fn trailing_comma_with_comment_before_close() {
let input = r#"{"a": 1, // comment
}"#;
// Trailing comma removed; space before comment remains
let expected = "{\"a\": 1 \n}";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn trailing_comma_with_block_comment_before_close() {
let input = r#"{"a": 1, /* comment */ }"#;
// Trailing comma removed; spaces around stripped comment remain
let expected = "{\"a\": 1 }";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn only_comments() {
let input = "// just a comment\n/* block */";
let expected = "\n";
assert_eq!(strip_jsonc(input), expected);
}
#[test]
fn produces_valid_json() {
let input = r#"{
// devcontainer settings
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/rust:1": {},
},
/* forwarded ports */
"forwardPorts": [3000, 8080,],
"remoteEnv": {
"EDITOR": "code", // default editor
},
}"#;
let result = strip_jsonc(input);
let parsed: serde_json::Result<serde_json::Value> = serde_json::from_str(&result);
assert!(parsed.is_ok(), "should produce valid JSON, got: {result}");
}
}

View file

@ -0,0 +1,337 @@
mod compose;
mod dockerfile;
mod features;
mod jsonc;
mod types;
mod variables;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub use types::DevcontainerJson;
/// Lifecycle command — string, array, or object (parallel) form.
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
Shell(String),
Args(Vec<String>),
Parallel(HashMap<String, String>),
}
/// Parsed and resolved devcontainer configuration — everything needed to create a sandbox.
#[derive(Debug, Clone)]
pub struct DevcontainerConfig {
/// Generated Dockerfile content
pub dockerfile: String,
/// Directory for docker build context
pub build_context: PathBuf,
/// Run on host before build
pub initialize_commands: Vec<Command>,
/// Run in container after creation
pub post_create_commands: Vec<Command>,
/// Run in container on each start
pub post_start_commands: Vec<Command>,
/// remoteEnv merged
pub environment: HashMap<String, String>,
pub remote_user: Option<String>,
/// default: /workspaces/{repo-name}
pub workspace_folder: String,
/// first = default preview port
pub forwarded_ports: Vec<u16>,
/// if dockerComposeFile mode
pub compose_file: Option<PathBuf>,
pub compose_service: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum DevcontainerError {
#[error("no devcontainer.json found in {0}")]
NotFound(PathBuf),
#[error("parsing devcontainer.json: {0}")]
Parse(#[from] serde_json::Error),
#[error("reading file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("compose file error: {0}")]
Compose(String),
#[error("feature error: {0}")]
Feature(String),
#[error("oras not found and auto-install failed: {0}")]
OrasInstall(String),
#[error("oras command failed: {0}")]
OrasCommand(String),
#[error("variable substitution error: {0}")]
Variable(String),
}
pub type Result<T> = std::result::Result<T, DevcontainerError>;
/// Parse and resolve a devcontainer config from a repo directory.
pub struct DevcontainerResolver;
impl DevcontainerResolver {
/// path: repo root (or explicit .devcontainer/ path)
pub async fn resolve(path: &Path) -> Result<DevcontainerConfig> {
let (json_path, devcontainer) = Self::find_and_parse(path)?;
let repo_root = Self::repo_root_from_json_path(&json_path, path);
let base_dir = json_path.parent().unwrap_or(path);
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("workspace")
.to_string();
// Variable substitution — two-pass: first resolve workspace_folder itself,
// then create final context with the resolved value.
let raw_workspace_folder = devcontainer
.workspace_folder
.clone()
.unwrap_or_else(|| format!("/workspaces/{repo_name}"));
let preliminary_vars = variables::VariableContext {
local_workspace_folder: repo_root.to_string_lossy().to_string(),
local_workspace_folder_basename: repo_name.clone(),
container_workspace_folder: raw_workspace_folder.clone(),
};
let workspace_folder = variables::substitute(&raw_workspace_folder, &preliminary_vars);
let vars = variables::VariableContext {
local_workspace_folder: repo_root.to_string_lossy().to_string(),
local_workspace_folder_basename: repo_name.clone(),
container_workspace_folder: workspace_folder.clone(),
};
// Handle compose mode
if let Some(compose_ref) = &devcontainer.docker_compose_file {
let compose_path = base_dir.join(variables::substitute(compose_ref, &vars));
let service_name = devcontainer
.service
.as_ref()
.ok_or_else(|| {
DevcontainerError::Compose(
"dockerComposeFile requires 'service' field".to_string(),
)
})?
.clone();
let compose_config =
compose::parse_compose(&compose_path, &service_name).map_err(|e| {
DevcontainerError::Compose(format!("{}: {e}", compose_path.display()))
})?;
let mut environment = HashMap::new();
for (k, v) in compose_config.environment {
environment.insert(k, variables::substitute(&v, &vars));
}
if let Some(env) = &devcontainer.remote_env {
for (k, v) in env {
environment.insert(k.clone(), variables::substitute(v, &vars));
}
}
let dockerfile = if let Some(build) = &compose_config.build {
let df_path = compose_path
.parent()
.unwrap_or(base_dir)
.join(&build.context)
.join(build.dockerfile.as_deref().unwrap_or("Dockerfile"));
std::fs::read_to_string(&df_path).map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?
} else {
format!("FROM {}", compose_config.image.as_deref().unwrap_or("ubuntu"))
};
return Ok(DevcontainerConfig {
dockerfile,
build_context: compose_path
.parent()
.unwrap_or(base_dir)
.to_path_buf(),
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
post_create_commands: Self::collect_commands(
&devcontainer.post_create_command,
&vars,
),
post_start_commands: Self::collect_commands(
&devcontainer.post_start_command,
&vars,
),
environment,
remote_user: devcontainer
.remote_user
.clone()
.or(compose_config.user),
workspace_folder,
forwarded_ports: compose_config.ports,
compose_file: Some(compose_path),
compose_service: Some(service_name),
});
}
// Image or Dockerfile mode
let (base_dockerfile, build_context) = if let Some(build) = &devcontainer.build {
let context_dir = build
.context
.as_ref()
.map(|c| base_dir.join(variables::substitute(c, &vars)))
.unwrap_or_else(|| base_dir.to_path_buf());
let df_path = base_dir.join(variables::substitute(
build.dockerfile.as_deref().unwrap_or("Dockerfile"),
&vars,
));
let content =
std::fs::read_to_string(&df_path).map_err(|source| {
DevcontainerError::ReadFile {
path: df_path,
source,
}
})?;
(content, context_dir)
} else {
let image = devcontainer
.image
.as_deref()
.unwrap_or("mcr.microsoft.com/devcontainers/base:ubuntu");
(format!("FROM {image}"), base_dir.to_path_buf())
};
// Features
let feature_layers = if !devcontainer.features.is_empty() {
features::resolve_features(&devcontainer.features, &build_context).await?
} else {
Vec::new()
};
// Generate final Dockerfile
let dockerfile_content = dockerfile::generate(
&base_dockerfile,
&feature_layers,
&devcontainer.remote_env,
devcontainer.remote_user.as_deref(),
);
let mut environment = HashMap::new();
if let Some(env) = &devcontainer.remote_env {
for (k, v) in env {
environment.insert(k.clone(), variables::substitute(v, &vars));
}
}
let forwarded_ports = devcontainer
.forward_ports
.iter()
.filter_map(|p| match p {
serde_json::Value::Number(n) => n.as_u64().map(|n| n as u16),
_ => None,
})
.collect();
Ok(DevcontainerConfig {
dockerfile: dockerfile_content,
build_context,
initialize_commands: Self::collect_commands(&devcontainer.initialize_command, &vars),
post_create_commands: Self::collect_commands(&devcontainer.post_create_command, &vars),
post_start_commands: Self::collect_commands(&devcontainer.post_start_command, &vars),
environment,
remote_user: devcontainer.remote_user.clone(),
workspace_folder,
forwarded_ports,
compose_file: None,
compose_service: None,
})
}
fn find_and_parse(path: &Path) -> Result<(PathBuf, DevcontainerJson)> {
// Check standard locations
let candidates = [
path.join(".devcontainer/devcontainer.json"),
path.join(".devcontainer.json"),
];
for candidate in &candidates {
if candidate.exists() {
let raw = std::fs::read_to_string(candidate).map_err(|source| {
DevcontainerError::ReadFile {
path: candidate.clone(),
source,
}
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((candidate.clone(), parsed));
}
}
// Check if path itself is a devcontainer.json
if path.is_file()
&& path
.file_name()
.is_some_and(|n| n == "devcontainer.json")
{
let raw = std::fs::read_to_string(path).map_err(|source| {
DevcontainerError::ReadFile {
path: path.to_path_buf(),
source,
}
})?;
let stripped = jsonc::strip_jsonc(&raw);
let parsed: DevcontainerJson = serde_json::from_str(&stripped)?;
return Ok((path.to_path_buf(), parsed));
}
Err(DevcontainerError::NotFound(path.to_path_buf()))
}
fn repo_root_from_json_path<'a>(json_path: &Path, original_path: &'a Path) -> &'a Path {
// If json_path is inside .devcontainer/, the repo root is one level up
if let Some(parent) = json_path.parent() {
if parent.file_name().is_some_and(|n| n == ".devcontainer") {
if let Some(repo_root) = parent.parent() {
// Only return repo_root if it matches the original path structure
let _ = repo_root;
}
}
}
original_path
}
fn collect_commands(
cmd: &Option<types::LifecycleCommand>,
vars: &variables::VariableContext,
) -> Vec<Command> {
match cmd {
None => Vec::new(),
Some(types::LifecycleCommand::String(s)) => {
vec![Command::Shell(variables::substitute(s, vars))]
}
Some(types::LifecycleCommand::Array(arr)) => {
vec![Command::Args(
arr.iter()
.map(|s| variables::substitute(s, vars))
.collect(),
)]
}
Some(types::LifecycleCommand::Object(map)) => {
vec![Command::Parallel(
map.iter()
.map(|(k, v)| (k.clone(), variables::substitute(v, vars)))
.collect(),
)]
}
}
}
}

View file

@ -0,0 +1,225 @@
use serde::Deserialize;
use std::collections::HashMap;
/// Top-level devcontainer.json schema (subset of the spec we support).
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DevcontainerJson {
/// Base image (image mode)
pub image: Option<String>,
/// Dockerfile build config
pub build: Option<BuildConfig>,
/// Docker Compose file path (compose mode)
pub docker_compose_file: Option<String>,
/// Service name for compose mode
pub service: Option<String>,
/// Features to install: feature ID → options object
#[serde(default)]
pub features: HashMap<String, serde_json::Value>,
/// Ports to forward
#[serde(default, alias = "forwardPorts")]
pub forward_ports: Vec<serde_json::Value>,
/// Environment variables set in the container
#[serde(default)]
pub remote_env: Option<HashMap<String, String>>,
/// Environment variables set in the container (containerEnv)
#[serde(default)]
pub container_env: Option<HashMap<String, String>>,
/// Non-root user to run as
pub remote_user: Option<String>,
/// Container user
pub container_user: Option<String>,
/// Workspace folder path inside container
pub workspace_folder: Option<String>,
/// Workspace mount string
pub workspace_mount: Option<String>,
/// Run on host before anything else
pub initialize_command: Option<LifecycleCommand>,
/// Run in container after first creation
pub post_create_command: Option<LifecycleCommand>,
/// Run in container on every start
pub post_start_command: Option<LifecycleCommand>,
/// Override the default command
pub override_command: Option<bool>,
}
/// Build configuration for Dockerfile mode.
#[derive(Debug, Clone, Deserialize)]
pub struct BuildConfig {
/// Path to Dockerfile (relative to devcontainer.json)
pub dockerfile: Option<String>,
/// Build context directory (relative to devcontainer.json)
pub context: Option<String>,
/// Build arguments
#[serde(default)]
pub args: HashMap<String, String>,
}
/// A lifecycle command can be a string, array of strings, or object of named commands.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum LifecycleCommand {
String(String),
Array(Vec<String>),
Object(HashMap<String, String>),
}
/// Metadata from a devcontainer-feature.json file.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FeatureMetadata {
pub id: Option<String>,
pub name: Option<String>,
pub version: Option<String>,
#[serde(default)]
pub options: HashMap<String, FeatureOption>,
/// Feature IDs that this feature should be installed after
#[serde(default)]
pub installs_after: Vec<String>,
}
/// A single option for a devcontainer feature.
#[derive(Debug, Clone, Deserialize)]
pub struct FeatureOption {
#[serde(rename = "type")]
pub option_type: Option<String>,
pub default: Option<serde_json::Value>,
pub description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_image_only() {
let json = r#"{"image": "mcr.microsoft.com/devcontainers/base:ubuntu"}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(
config.image.as_deref(),
Some("mcr.microsoft.com/devcontainers/base:ubuntu")
);
}
#[test]
fn parse_with_features() {
let json = r#"{
"image": "ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {"version": "20"},
"ghcr.io/devcontainers/features/python:1": {}
}
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(config.features.len(), 2);
}
#[test]
fn parse_lifecycle_string() {
let json = r#"{"postCreateCommand": "npm install"}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert!(matches!(
config.post_create_command,
Some(LifecycleCommand::String(ref s)) if s == "npm install"
));
}
#[test]
fn parse_lifecycle_array() {
let json = r#"{"postCreateCommand": ["npm", "install"]}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert!(matches!(
config.post_create_command,
Some(LifecycleCommand::Array(ref arr)) if arr == &["npm", "install"]
));
}
#[test]
fn parse_lifecycle_object() {
let json = r#"{"postCreateCommand": {"install": "npm install", "build": "npm run build"}}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert!(matches!(
config.post_create_command,
Some(LifecycleCommand::Object(ref map)) if map.len() == 2
));
}
#[test]
fn parse_build_config() {
let json = r#"{
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {"VARIANT": "3.9"}
}
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
let build = config.build.unwrap();
assert_eq!(build.dockerfile.as_deref(), Some("Dockerfile"));
assert_eq!(build.context.as_deref(), Some(".."));
assert_eq!(build.args.get("VARIANT").map(String::as_str), Some("3.9"));
}
#[test]
fn parse_compose_mode() {
let json = r#"{
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace"
}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(
config.docker_compose_file.as_deref(),
Some("docker-compose.yml")
);
assert_eq!(config.service.as_deref(), Some("app"));
assert_eq!(config.workspace_folder.as_deref(), Some("/workspace"));
}
#[test]
fn unknown_fields_ignored() {
let json = r#"{"image": "ubuntu", "unknownField": true, "customizations": {}}"#;
let config: DevcontainerJson = serde_json::from_str(json).unwrap();
assert_eq!(config.image.as_deref(), Some("ubuntu"));
}
#[test]
fn parse_feature_metadata() {
let json = r#"{
"id": "node",
"name": "Node.js",
"version": "1.0.0",
"options": {
"version": {
"type": "string",
"default": "lts",
"description": "Node.js version"
}
},
"installsAfter": ["ghcr.io/devcontainers/features/common-utils"]
}"#;
let meta: FeatureMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.id.as_deref(), Some("node"));
assert_eq!(meta.options.len(), 1);
assert_eq!(meta.installs_after.len(), 1);
}
}

View file

@ -0,0 +1,223 @@
/// Context for variable substitution.
pub struct VariableContext {
pub local_workspace_folder: String,
pub local_workspace_folder_basename: String,
pub container_workspace_folder: String,
}
/// Replace devcontainer variables in a string value.
pub fn substitute(input: &str, ctx: &VariableContext) -> String {
let mut result = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
result.push_str(&rest[..start]);
let after_open = &rest[start + 2..];
if let Some(close) = after_open.find('}') {
let expr = &after_open[..close];
let replacement = resolve_variable(expr, ctx);
match replacement {
Some(val) => result.push_str(&val),
None => {
// Unknown variable — leave as-is
result.push_str(&rest[start..start + 2 + close + 1]);
}
}
rest = &after_open[close + 1..];
} else {
// No closing brace — copy literally
result.push_str(&rest[start..]);
rest = "";
}
}
result.push_str(rest);
result
}
fn resolve_variable(expr: &str, ctx: &VariableContext) -> Option<String> {
match expr {
"localWorkspaceFolder" => Some(ctx.local_workspace_folder.clone()),
"localWorkspaceFolderBasename" => Some(ctx.local_workspace_folder_basename.clone()),
"containerWorkspaceFolder" => Some(ctx.container_workspace_folder.clone()),
"containerWorkspaceFolderBasename" => {
let basename = ctx
.container_workspace_folder
.rsplit('/')
.next()
.unwrap_or(&ctx.container_workspace_folder);
Some(basename.to_string())
}
_ if expr.starts_with("localEnv:") => {
let var_part = &expr["localEnv:".len()..];
// Split on first colon for default value
if let Some(colon_pos) = var_part.find(':') {
let var_name = &var_part[..colon_pos];
let default = &var_part[colon_pos + 1..];
Some(std::env::var(var_name).unwrap_or_else(|_| default.to_string()))
} else {
Some(std::env::var(var_part).unwrap_or_default())
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ctx() -> VariableContext {
VariableContext {
local_workspace_folder: "/home/user/project".to_string(),
local_workspace_folder_basename: "project".to_string(),
container_workspace_folder: "/workspaces/project".to_string(),
}
}
#[test]
fn no_variables() {
let ctx = test_ctx();
assert_eq!(substitute("hello", &ctx), "hello");
}
#[test]
fn local_workspace_folder() {
let ctx = test_ctx();
assert_eq!(
substitute("${localWorkspaceFolder}/src", &ctx),
"/home/user/project/src"
);
}
#[test]
fn local_workspace_folder_basename() {
let ctx = test_ctx();
assert_eq!(
substitute("name: ${localWorkspaceFolderBasename}", &ctx),
"name: project"
);
}
#[test]
fn container_workspace_folder() {
let ctx = test_ctx();
assert_eq!(
substitute("${containerWorkspaceFolder}/app", &ctx),
"/workspaces/project/app"
);
}
#[test]
fn container_workspace_folder_basename() {
let ctx = test_ctx();
assert_eq!(
substitute("${containerWorkspaceFolderBasename}", &ctx),
"project"
);
}
#[test]
fn container_workspace_folder_basename_nested() {
let ctx = VariableContext {
local_workspace_folder: "/home/user/repos/my-app".to_string(),
local_workspace_folder_basename: "my-app".to_string(),
container_workspace_folder: "/workspaces/repos/my-app".to_string(),
};
assert_eq!(
substitute("${containerWorkspaceFolderBasename}", &ctx),
"my-app"
);
}
#[test]
fn multiple_variables() {
let ctx = test_ctx();
assert_eq!(
substitute(
"${localWorkspaceFolder} and ${containerWorkspaceFolder}",
&ctx
),
"/home/user/project and /workspaces/project"
);
}
#[test]
fn unknown_variable_left_as_is() {
let ctx = test_ctx();
assert_eq!(
substitute("${unknownVariable}", &ctx),
"${unknownVariable}"
);
}
#[test]
fn local_env_with_set_variable() {
let ctx = test_ctx();
std::env::set_var("ARC_TEST_VAR_SET", "hello");
assert_eq!(
substitute("${localEnv:ARC_TEST_VAR_SET}", &ctx),
"hello"
);
std::env::remove_var("ARC_TEST_VAR_SET");
}
#[test]
fn local_env_unset_returns_empty() {
let ctx = test_ctx();
std::env::remove_var("ARC_TEST_VAR_UNSET_123");
assert_eq!(
substitute("${localEnv:ARC_TEST_VAR_UNSET_123}", &ctx),
""
);
}
#[test]
fn local_env_with_default_when_unset() {
let ctx = test_ctx();
std::env::remove_var("ARC_TEST_VAR_DEFAULT_456");
assert_eq!(
substitute("${localEnv:ARC_TEST_VAR_DEFAULT_456:fallback}", &ctx),
"fallback"
);
}
#[test]
fn local_env_with_default_when_set() {
let ctx = test_ctx();
std::env::set_var("ARC_TEST_VAR_DEFAULT_SET", "actual");
assert_eq!(
substitute("${localEnv:ARC_TEST_VAR_DEFAULT_SET:fallback}", &ctx),
"actual"
);
std::env::remove_var("ARC_TEST_VAR_DEFAULT_SET");
}
#[test]
fn no_closing_brace() {
let ctx = test_ctx();
assert_eq!(substitute("${localWorkspaceFolder", &ctx), "${localWorkspaceFolder");
}
#[test]
fn empty_input() {
let ctx = test_ctx();
assert_eq!(substitute("", &ctx), "");
}
#[test]
fn dollar_without_brace() {
let ctx = test_ctx();
assert_eq!(substitute("$notavar", &ctx), "$notavar");
}
#[test]
fn adjacent_variables() {
let ctx = test_ctx();
assert_eq!(
substitute("${localWorkspaceFolderBasename}${containerWorkspaceFolderBasename}", &ctx),
"projectproject"
);
}
}

View file

@ -0,0 +1,10 @@
{
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"remoteUser": "node",
"postCreateCommand": "npm install",
"remoteEnv": {
"NODE_ENV": "development"
}
}

View file

@ -0,0 +1,13 @@
services:
app:
image: node:20
ports:
- "3000:3000"
- "9229:9229"
environment:
- "NODE_ENV=development"
- "DEBUG=true"
db:
image: postgres:15
ports:
- "5432:5432"

View file

@ -0,0 +1,3 @@
FROM node:20
RUN apt-get update && apt-get install -y git
WORKDIR /workspace

View file

@ -0,0 +1,10 @@
{
// This is a JSONC file with comments
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"remoteUser": "developer",
"postCreateCommand": "npm install",
"forwardPorts": [4000],
}

View file

@ -0,0 +1,9 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"forwardPorts": [3000, 8080],
"remoteUser": "vscode",
"remoteEnv": {
"EDITOR": "code"
},
"postCreateCommand": "echo hello"
}

View file

@ -0,0 +1,9 @@
{
"image": "ubuntu:22.04",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"remoteEnv": {
"PROJECT_ROOT": "${containerWorkspaceFolder}",
"PROJECT_NAME": "${containerWorkspaceFolderBasename}"
},
"postCreateCommand": "echo ${containerWorkspaceFolder}"
}

View file

@ -0,0 +1,9 @@
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/python:1": {}
}
}

View file

@ -0,0 +1,102 @@
use arc_devcontainer::{Command, DevcontainerResolver};
use std::path::PathBuf;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
#[tokio::test]
async fn resolve_image_only() {
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu"));
assert_eq!(config.remote_user.as_deref(), Some("vscode"));
assert_eq!(config.forwarded_ports, vec![3000, 8080]);
assert_eq!(config.environment.get("EDITOR").map(String::as_str), Some("code"));
assert_eq!(config.workspace_folder, "/workspaces/image-only");
assert!(config.compose_file.is_none());
assert!(config.compose_service.is_none());
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "echo hello"));
}
#[tokio::test]
async fn resolve_dockerfile_mode() {
let config = DevcontainerResolver::resolve(&fixture_path("dockerfile-mode"))
.await
.unwrap();
// Should read the actual Dockerfile content
assert!(config.dockerfile.contains("FROM node:20"));
assert!(config.dockerfile.contains("apt-get update"));
assert_eq!(config.remote_user.as_deref(), Some("developer"));
assert_eq!(config.forwarded_ports, vec![4000]);
assert_eq!(config.post_create_commands.len(), 1);
assert!(matches!(&config.post_create_commands[0], Command::Shell(s) if s == "npm install"));
}
#[tokio::test]
async fn resolve_compose_mode() {
let config = DevcontainerResolver::resolve(&fixture_path("compose-mode"))
.await
.unwrap();
// In compose mode, the dockerfile is derived from the compose service's image
assert!(config.dockerfile.contains("FROM node:20"));
assert_eq!(config.workspace_folder, "/workspace");
assert_eq!(config.remote_user.as_deref(), Some("node"));
assert!(config.compose_file.is_some());
assert_eq!(config.compose_service.as_deref(), Some("app"));
// Ports come from compose + remoteEnv merged
assert_eq!(config.forwarded_ports, vec![3000, 9229]);
// Environment merged from compose + remoteEnv
assert_eq!(config.environment.get("NODE_ENV").map(String::as_str), Some("development"));
assert_eq!(config.environment.get("DEBUG").map(String::as_str), Some("true"));
}
#[tokio::test]
async fn resolve_variables() {
let config = DevcontainerResolver::resolve(&fixture_path("variables"))
.await
.unwrap();
assert_eq!(config.workspace_folder, "/workspaces/variables");
assert_eq!(
config.environment.get("PROJECT_ROOT").map(String::as_str),
Some("/workspaces/variables")
);
assert_eq!(
config.environment.get("PROJECT_NAME").map(String::as_str),
Some("variables")
);
}
#[tokio::test]
async fn resolve_not_found() {
let result = DevcontainerResolver::resolve(&fixture_path("nonexistent")).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("no devcontainer.json found"));
}
#[tokio::test]
async fn generated_dockerfile_is_well_formed() {
let config = DevcontainerResolver::resolve(&fixture_path("image-only"))
.await
.unwrap();
// Should start with the generated header
assert!(config.dockerfile.contains("# Generated by arc-devcontainer"));
// Should have the base image
assert!(config.dockerfile.contains("FROM"));
// Should end with a newline
assert!(config.dockerfile.ends_with('\n'));
}