fabro/crates/arc-devcontainer/src/features.rs
Bryan Helmkamp eab81aee99 Simplify feature fetch functions: extract shared helpers
- Extract read_feature_metadata(), extract_tgz(), and create_feature_dir()
  helpers to eliminate copy-paste across fetch_feature_oci/local/https
- Move ensure_oras() out of per-feature calls into a once-per-resolve
  check via oras_checked flag
- Simplify dockerfile::generate() to take &HashMap instead of
  &Option<HashMap>, removing an unnecessary clone in the caller

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:59:41 -05:00

1127 lines
38 KiB
Rust

use std::collections::{HashMap, HashSet, VecDeque};
use std::path::Path;
use tracing::info;
use crate::types::{FeatureMetadata, LifecycleCommand};
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,
}
/// All resolved feature data: layers, environment, and lifecycle hooks.
#[derive(Debug, Clone, Default)]
pub struct ResolvedFeatures {
pub layers: Vec<FeatureLayer>,
pub container_env: HashMap<String, String>,
pub on_create_commands: Vec<LifecycleCommand>,
pub post_create_commands: Vec<LifecycleCommand>,
pub post_start_commands: Vec<LifecycleCommand>,
}
/// Extract the directory name from a feature ID.
/// Handles OCI refs ("ghcr.io/devcontainers/features/node:1" → "node"),
/// local paths ("./my-feature" → "my-feature"),
/// and HTTPS URLs ("https://example.com/feature.tgz" → "feature").
fn dir_name_from_id(feature_id: &str) -> String {
// Local path: strip leading ./ or ../
if feature_id.starts_with("./") || feature_id.starts_with("../") {
let stripped = feature_id
.trim_start_matches("../")
.trim_start_matches("./");
return stripped
.rsplit('/')
.next()
.unwrap_or(stripped)
.to_string();
}
// HTTPS URL: take filename, strip .tgz extension
if feature_id.starts_with("https://") {
let filename = feature_id.rsplit('/').next().unwrap_or(feature_id);
return filename
.strip_suffix(".tgz")
.or_else(|| filename.strip_suffix(".tar.gz"))
.unwrap_or(filename)
.to_string();
}
// OCI ref: strip tag, take last path segment
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(())
}
/// Extract a tgz archive in the given directory.
async fn extract_tgz(feature_dir: &Path, feature_id: &str) -> crate::Result<()> {
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}"
)));
}
Ok(())
}
/// Read and parse devcontainer-feature.json from a feature directory.
async fn read_feature_metadata(feature_dir: &Path) -> crate::Result<FeatureMetadata> {
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()
))
})?;
serde_json::from_str(&metadata_str).map_err(|e| {
DevcontainerError::Feature(format!(
"failed to parse {}: {e}",
metadata_path.display()
))
})
}
/// Create a feature output directory under the temp dir.
async fn create_feature_dir(output_dir: &Path, feature_id: &str) -> crate::Result<std::path::PathBuf> {
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()
))
})?;
Ok(feature_dir)
}
/// Fetch a single OCI feature using `oras pull` and extract its contents.
async fn fetch_feature_oci(
feature_id: &str,
output_dir: &Path,
) -> crate::Result<FeatureMetadata> {
let feature_dir = create_feature_dir(output_dir, feature_id).await?;
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}"
)));
}
if feature_dir.join("devcontainer-feature.tgz").exists() {
extract_tgz(&feature_dir, feature_id).await?;
}
read_feature_metadata(&feature_dir).await
}
/// Fetch a local feature by copying its directory.
async fn fetch_feature_local(
feature_id: &str,
output_dir: &Path,
devcontainer_dir: &Path,
) -> crate::Result<FeatureMetadata> {
let local_path = devcontainer_dir.join(feature_id);
if !local_path.is_dir() {
return Err(DevcontainerError::Feature(format!(
"local feature path not found: {}",
local_path.display()
)));
}
let feature_dir = create_feature_dir(output_dir, feature_id).await?;
copy_dir_recursive(&local_path, &feature_dir).await?;
read_feature_metadata(&feature_dir).await
}
/// Fetch a feature from an HTTPS URL (tgz archive).
async fn fetch_feature_https(
feature_id: &str,
output_dir: &Path,
) -> crate::Result<FeatureMetadata> {
let feature_dir = create_feature_dir(output_dir, feature_id).await?;
info!(feature_id, "downloading feature from HTTPS");
let response = reqwest::get(feature_id).await.map_err(|e| {
DevcontainerError::Feature(format!("failed to download {feature_id}: {e}"))
})?;
if !response.status().is_success() {
return Err(DevcontainerError::Feature(format!(
"HTTP {} downloading {feature_id}",
response.status()
)));
}
let bytes = response.bytes().await.map_err(|e| {
DevcontainerError::Feature(format!("failed to read response for {feature_id}: {e}"))
})?;
let tgz_path = feature_dir.join("devcontainer-feature.tgz");
tokio::fs::write(&tgz_path, &bytes).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to write {}: {e}",
tgz_path.display()
))
})?;
extract_tgz(&feature_dir, feature_id).await?;
read_feature_metadata(&feature_dir).await
}
/// Dispatch feature fetch based on the feature ID prefix.
async fn fetch_feature_dispatch(
feature_id: &str,
output_dir: &Path,
devcontainer_dir: &Path,
oras_checked: &mut bool,
) -> crate::Result<FeatureMetadata> {
if feature_id.starts_with("./") || feature_id.starts_with("../") {
fetch_feature_local(feature_id, output_dir, devcontainer_dir).await
} else if feature_id.starts_with("https://") {
fetch_feature_https(feature_id, output_dir).await
} else {
if !*oras_checked {
ensure_oras().await?;
*oras_checked = true;
}
fetch_feature_oci(feature_id, output_dir).await
}
}
/// Recursively copy a directory.
async fn copy_dir_recursive(src: &Path, dst: &Path) -> crate::Result<()> {
tokio::fs::create_dir_all(dst).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to create dir {}: {e}",
dst.display()
))
})?;
let mut entries = tokio::fs::read_dir(src).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to read dir {}: {e}",
src.display()
))
})?;
while let Some(entry) = entries.next_entry().await.map_err(|e| {
DevcontainerError::Feature(format!("failed to read dir entry: {e}"))
})? {
let entry_path = entry.path();
let dest_path = dst.join(entry.file_name());
if entry_path.is_dir() {
Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?;
} else {
tokio::fs::copy(&entry_path, &dest_path).await.map_err(|e| {
DevcontainerError::Feature(format!(
"failed to copy {} to {}: {e}",
entry_path.display(),
dest_path.display()
))
})?;
}
}
Ok(())
}
/// Topological sort of features based on `installsAfter` and `dependsOn` 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".
// Use a set of (from, to) pairs to deduplicate edges when the same dep
// appears in both installsAfter and dependsOn.
let mut in_degree: HashMap<&str, usize> = HashMap::new();
let mut edges: HashMap<&str, Vec<&str>> = HashMap::new();
let mut edge_set: HashSet<(&str, &str)> = HashSet::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) {
// Collect dependency refs from both installsAfter and dependsOn
let mut dep_refs: Vec<&str> = meta.installs_after.iter().map(|s| s.as_str()).collect();
for dep_id in meta.depends_on.keys() {
dep_refs.push(dep_id.as_str());
}
for dep in dep_refs {
let dep_dir = dir_name_from_id(dep);
for candidate in feature_ids {
if (candidate == dep || dir_name_from_id(candidate) == dep_dir)
&& id_set.contains(candidate.as_str())
{
// candidate -> id (candidate must come before id)
let edge = (candidate.as_str(), id.as_str());
if edge_set.insert(edge) {
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>,
devcontainer_dir: &Path,
) -> crate::Result<ResolvedFeatures> {
if features.is_empty() {
return Ok(ResolvedFeatures::default());
}
let unique_id = format!(
"devcontainer-features-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let tmp_dir = std::env::temp_dir().join(unique_id);
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 mut feature_ids: Vec<String> = features.keys().cloned().collect();
// Track options for each feature (including auto-injected ones)
let mut all_options: HashMap<String, serde_json::Value> = features.clone();
// Fetch all features and collect metadata
let mut oras_checked = false;
let mut metadata_map: HashMap<String, FeatureMetadata> = HashMap::new();
for feature_id in &feature_ids {
let metadata = fetch_feature_dispatch(feature_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?;
metadata_map.insert(feature_id.clone(), metadata);
}
// Auto-inject missing dependsOn targets
let mut injected = true;
while injected {
injected = false;
let current_ids: Vec<String> = feature_ids.clone();
for id in &current_ids {
if let Some(meta) = metadata_map.get(id).cloned() {
for (dep_id, dep_options) in &meta.depends_on {
// Check if dep is already present (by full ID or dir name)
let dep_dir = dir_name_from_id(dep_id);
let already_present = feature_ids.iter().any(|existing| {
existing == dep_id || dir_name_from_id(existing) == dep_dir
});
if !already_present {
info!(dep_id, "auto-injecting missing dependsOn target");
let dep_metadata = fetch_feature_dispatch(dep_id, &tmp_dir, devcontainer_dir, &mut oras_checked).await?;
metadata_map.insert(dep_id.clone(), dep_metadata);
feature_ids.push(dep_id.clone());
all_options.insert(dep_id.clone(), dep_options.clone());
injected = true;
}
}
}
}
}
// Topologically sort features
let sorted_ids = topo_sort(&feature_ids, &metadata_map);
// Generate layers and collect container_env
let mut resolved = ResolvedFeatures::default();
for id in &sorted_ids {
let dir_name = dir_name_from_id(id);
let options = all_options.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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
});
// Collect feature containerEnv (later features override earlier)
for (k, v) in &metadata.container_env {
resolved.container_env.insert(k.clone(), v.clone());
}
// Collect feature lifecycle hooks
if let Some(cmd) = &metadata.on_create_command {
resolved.on_create_commands.push(cmd.clone());
}
if let Some(cmd) = &metadata.post_create_command {
resolved.post_create_commands.push(cmd.clone());
}
if let Some(cmd) = &metadata.post_start_command {
resolved.post_start_commands.push(cmd.clone());
}
let dockerfile_snippet = generate_layer(id, &dir_name, &options, &metadata);
resolved.layers.push(FeatureLayer {
id: id.clone(),
dir_name,
dockerfile_snippet,
});
}
Ok(resolved)
}
#[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 dir_name_from_local_path() {
assert_eq!(dir_name_from_id("./my-feature"), "my-feature");
assert_eq!(dir_name_from_id("./sub/my-feature"), "my-feature");
assert_eq!(dir_name_from_id("../shared-feature"), "shared-feature");
}
#[test]
fn dir_name_from_https_url() {
assert_eq!(
dir_name_from_id("https://example.com/features/node.tgz"),
"node"
);
assert_eq!(
dir_name_from_id("https://example.com/features/python.tar.gz"),
"python"
);
assert_eq!(
dir_name_from_id("https://example.com/features/go"),
"go"
);
}
#[test]
fn fetch_feature_dispatch_routes() {
// Verify the routing logic by checking prefix detection
assert!("./local-feature".starts_with("./"));
assert!("../parent-feature".starts_with("../"));
assert!("https://example.com/feature.tgz".starts_with("https://"));
assert!(!"ghcr.io/foo/bar:1".starts_with("./"));
assert!(!"ghcr.io/foo/bar:1".starts_with("../"));
assert!(!"ghcr.io/foo/bar:1".starts_with("https://"));
}
#[tokio::test]
async fn fetch_feature_local_integration() {
let tmp_src = tempfile::tempdir().unwrap();
let feature_dir = tmp_src.path().join("my-feature");
std::fs::create_dir_all(&feature_dir).unwrap();
// Create a minimal devcontainer-feature.json
std::fs::write(
feature_dir.join("devcontainer-feature.json"),
r#"{"id": "my-feature", "version": "1.0.0"}"#,
)
.unwrap();
// Create a dummy install.sh
std::fs::write(feature_dir.join("install.sh"), "#!/bin/sh\necho hi").unwrap();
let tmp_out = tempfile::tempdir().unwrap();
let metadata = fetch_feature_local("./my-feature", tmp_out.path(), tmp_src.path())
.await
.unwrap();
assert_eq!(metadata.id.as_deref(), Some("my-feature"));
assert!(tmp_out.path().join("my-feature/install.sh").exists());
}
#[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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
)
})
.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()],
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["a".to_string()],
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
metadata.insert(
"c".to_string(),
FeatureMetadata {
id: Some("c".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["a".to_string()],
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
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()],
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
};
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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
};
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(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
};
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 "));
}
#[test]
fn topo_sort_depends_on_present() {
// A dependsOn B, both present → B before A
let ids = vec!["a".to_string(), "b".to_string()];
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
let mut depends = HashMap::new();
depends.insert("b".to_string(), serde_json::json!({}));
metadata.insert(
"a".to_string(),
FeatureMetadata {
id: Some("a".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: depends,
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
let sorted = topo_sort(&ids, &metadata);
assert_eq!(sorted, vec!["b", "a"]);
}
#[test]
fn topo_sort_depends_on_and_installs_after_deduped() {
// A has both dependsOn B and installsAfter B — should not double-count
let ids = vec!["a".to_string(), "b".to_string()];
let mut metadata: HashMap<String, FeatureMetadata> = HashMap::new();
let mut depends = HashMap::new();
depends.insert("b".to_string(), serde_json::json!({}));
metadata.insert(
"a".to_string(),
FeatureMetadata {
id: Some("a".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: vec!["b".to_string()],
depends_on: depends,
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
metadata.insert(
"b".to_string(),
FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: HashMap::new(),
container_env: HashMap::new(),
on_create_command: None,
post_create_command: None,
post_start_command: None,
},
);
let sorted = topo_sort(&ids, &metadata);
assert_eq!(sorted, vec!["b", "a"]);
}
#[tokio::test]
#[ignore = "requires oras"]
async fn fetch_feature_oci_integration() {
let tmp = tempfile::tempdir().unwrap();
let metadata =
fetch_feature_oci("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 resolved = resolve_features(&features, tmp.path()).await.unwrap();
assert_eq!(resolved.layers.len(), 1);
assert_eq!(resolved.layers[0].dir_name, "node");
assert!(resolved.layers[0].dockerfile_snippet.contains("export VERSION=\"20\""));
}
#[test]
fn feature_container_env_collected() {
// Simulate what resolve_features does: collect container_env from metadata in sort order
let mut resolved = ResolvedFeatures::default();
let meta_a = FeatureMetadata {
id: Some("a".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: HashMap::new(),
container_env: {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "from_a".to_string());
env.insert("BAR".to_string(), "from_a".to_string());
env
},
on_create_command: None,
post_create_command: None,
post_start_command: None,
};
let meta_b = FeatureMetadata {
id: Some("b".to_string()),
name: None,
version: None,
options: HashMap::new(),
installs_after: Vec::new(),
depends_on: HashMap::new(),
container_env: {
let mut env = HashMap::new();
env.insert("FOO".to_string(), "from_b".to_string());
env
},
on_create_command: None,
post_create_command: None,
post_start_command: None,
};
// A is sorted first, then B — B's FOO overrides A's
for meta in [&meta_a, &meta_b] {
for (k, v) in &meta.container_env {
resolved.container_env.insert(k.clone(), v.clone());
}
}
assert_eq!(resolved.container_env.get("FOO").map(String::as_str), Some("from_b"));
assert_eq!(resolved.container_env.get("BAR").map(String::as_str), Some("from_a"));
}
#[test]
fn feature_lifecycle_hooks_collected() {
let mut resolved = ResolvedFeatures::default();
let cmds = [
LifecycleCommand::String("setup-a".to_string()),
LifecycleCommand::Array(vec!["make".to_string(), "build".to_string()]),
];
// Simulate collecting from two features
resolved.on_create_commands.push(cmds[0].clone());
resolved.post_create_commands.push(cmds[1].clone());
resolved.post_start_commands.push(cmds[0].clone());
assert_eq!(resolved.on_create_commands.len(), 1);
assert!(matches!(&resolved.on_create_commands[0], LifecycleCommand::String(s) if s == "setup-a"));
assert_eq!(resolved.post_create_commands.len(), 1);
assert!(matches!(&resolved.post_create_commands[0], LifecycleCommand::Array(arr) if arr.len() == 2));
assert_eq!(resolved.post_start_commands.len(), 1);
}
}