From 62b4e9df747171aa09ed748cd7f103f1b87a3bc6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 3 Mar 2026 00:42:34 -0500 Subject: [PATCH] Add subdirectory file discovery to arc-devcontainer When neither .devcontainer/devcontainer.json nor .devcontainer.json exists, scan .devcontainer/ for subdirectories containing a devcontainer.json. Subdirectories are sorted alphabetically and the first match is used. Standard locations still take priority. Co-Authored-By: Claude Opus 4.6 --- crates/arc-devcontainer/src/lib.rs | 42 ++++++++++++++++++- .../.devcontainer/python/devcontainer.json | 4 ++ .../.devcontainer/alpha/devcontainer.json | 4 ++ .../.devcontainer/beta/devcontainer.json | 4 ++ .../.devcontainer/devcontainer.json | 4 ++ .../.devcontainer/python/devcontainer.json | 4 ++ crates/arc-devcontainer/tests/integration.rs | 33 +++++++++++++++ 7 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 crates/arc-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json create mode 100644 crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json diff --git a/crates/arc-devcontainer/src/lib.rs b/crates/arc-devcontainer/src/lib.rs index 09a6e0091..75b790f0e 100644 --- a/crates/arc-devcontainer/src/lib.rs +++ b/crates/arc-devcontainer/src/lib.rs @@ -357,17 +357,57 @@ impl DevcontainerResolver { return Ok((path.to_path_buf(), parsed)); } + // Subdirectory format: scan .devcontainer/ for subdirs containing devcontainer.json + let devcontainer_dir = path.join(".devcontainer"); + if devcontainer_dir.is_dir() { + let mut subdirs: Vec = std::fs::read_dir(&devcontainer_dir) + .map_err(|source| DevcontainerError::ReadFile { + path: devcontainer_dir.clone(), + source, + })? + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.path()) + .filter(|dir| dir.join("devcontainer.json").exists()) + .collect(); + + // Sort alphabetically to get deterministic first pick + subdirs.sort(); + + if let Some(subdir) = subdirs.first() { + let candidate = subdir.join("devcontainer.json"); + 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, 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 two levels up // 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; } + } else if let Some(grandparent) = parent.parent() { + if grandparent + .file_name() + .is_some_and(|n| n == ".devcontainer") + { + if let Some(repo_root) = grandparent.parent() { + let _ = repo_root; + } + } } } original_path diff --git a/crates/arc-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json new file mode 100644 index 000000000..726cfad36 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/subdirectory-mode/.devcontainer/python/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "remoteUser": "vscode" +} diff --git a/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json new file mode 100644 index 000000000..a52df2dae --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/alpha/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "alpha-user" +} diff --git a/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json new file mode 100644 index 000000000..b0085ad51 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/subdirectory-multiple/.devcontainer/beta/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "node:20", + "remoteUser": "beta-user" +} diff --git a/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json new file mode 100644 index 000000000..e58b2dce4 --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "remoteUser": "standard-user" +} diff --git a/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json b/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json new file mode 100644 index 000000000..e419cd97d --- /dev/null +++ b/crates/arc-devcontainer/tests/fixtures/subdirectory-with-standard/.devcontainer/python/devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "mcr.microsoft.com/devcontainers/python:3.12", + "remoteUser": "python-user" +} diff --git a/crates/arc-devcontainer/tests/integration.rs b/crates/arc-devcontainer/tests/integration.rs index 003e5f3e6..8ee707cd0 100644 --- a/crates/arc-devcontainer/tests/integration.rs +++ b/crates/arc-devcontainer/tests/integration.rs @@ -126,6 +126,39 @@ async fn resolve_not_found() { assert!(err.to_string().contains("no devcontainer.json found")); } +#[tokio::test] +async fn resolve_subdirectory_mode() { + let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-mode")) + .await + .unwrap(); + + assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/python:3.12")); + assert_eq!(config.remote_user.as_deref(), Some("vscode")); + assert_eq!(config.workspace_folder, "/workspaces/subdirectory-mode"); +} + +#[tokio::test] +async fn resolve_subdirectory_multiple_picks_alphabetical_first() { + let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-multiple")) + .await + .unwrap(); + + // "alpha" sorts before "beta", so alpha's config is used + assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert_eq!(config.remote_user.as_deref(), Some("alpha-user")); +} + +#[tokio::test] +async fn resolve_subdirectory_standard_wins_over_subdirs() { + let config = DevcontainerResolver::resolve(&fixture_path("subdirectory-with-standard")) + .await + .unwrap(); + + // Standard .devcontainer/devcontainer.json takes priority over subdirectory format + assert!(config.dockerfile.contains("FROM mcr.microsoft.com/devcontainers/base:ubuntu")); + assert_eq!(config.remote_user.as_deref(), Some("standard-user")); +} + #[tokio::test] async fn generated_dockerfile_is_well_formed() { let config = DevcontainerResolver::resolve(&fixture_path("image-only"))