Files
zed/crates/dev_container/src/devcontainer_api.rs
T
Toni AlataloandClaude Opus 4.7 685fea0b4a dev_container: Align compose project name with reference CLI (#54302)
> **Draft / open question for maintainers.** The failure mode this fixes
is narrow — a new-Zed-created container exists under the `name`-field
project while a CLI-derivation tool (`@devcontainers/cli`, VS Code)
operates on the same folder (the container persists in Docker, so the
originating Zed session doesn't need to still be open). See issue #54255
failure mode 3 and the fixture's step 6.
>
> I'd like to pose this as a question rather than a claim: is matching
`@devcontainers/cli`'s `getProjectName` precedence something the project
wants to take on, given the narrowness of the bug? I wrote this
implementation mostly as a way to explore what parity would actually
cost — happy to close it if you'd rather leave it as-is, or pare it down
(e.g. just rule 4) if a partial match is preferable.
>
> The broader value beyond this specific bug: devcontainer impls
agreeing on the same project name means containers created by Zed, the
devcontainer CLI, and VS Code are interchangeable for the same folder,
which feels worth it to me — but you know the project's priorities
better.
>
> Folds in #54068 (detection) — closing that PR unmerged; its
`MultipleMatchingContainers` error lands here.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #54255

## Summary

**Match `@devcontainers/cli`'s full `getProjectName` precedence.**
Replaces `safe_id_lower(devcontainer.json's name)` with the five-step
chain the reference CLI walks (see [`src/spec-node/dockerCompose.ts` in
devcontainers/cli](https://github.com/devcontainers/cli/blob/main/src/spec-node/dockerCompose.ts)):

1. `COMPOSE_PROJECT_NAME` from the local environment.
2. `COMPOSE_PROJECT_NAME=` in the workspace `.env` file.
3. Top-level `name:` on the merged compose config, when at least one
fragment declared it explicitly.
4. `${workspaceFolderBasename}_devcontainer` — only when the first
compose file's directory is `<workspace>/.devcontainer/`.
5. Otherwise, the plain basename of the first compose file's directory
(no suffix).

The old Zed implementation diverged at every one of those inputs: any
user setting `COMPOSE_PROJECT_NAME`, shipping a `.env` with one,
declaring a top-level compose `name:`, or pointing `dockerComposeFile`
outside `.devcontainer/` (e.g. `"../docker-compose.yml"`) got a
different project namespace than the CLI and VS Code, producing two
compose projects for the same folder.

Adds a small `sanitize_compose_project_name()` helper implementing the
CLI's rules (lowercase + strip `[^-_a-z0-9]`) — notably preserving
hyphens, which `safe_id_lower` would have replaced with underscores.

Adds two helpers used by the precedence walk:
- `parse_dotenv_compose_project_name` — line scan extracting
`COMPOSE_PROJECT_NAME=…` from the workspace `.env`, matching the subset
the CLI's regex dotenv reader recognizes.
- `compose_fragment_declares_name` — parses each compose fragment with
`yaml-rust2` (already a transitive workspace dep; slated to become a
direct dep via #53922) and checks for a `name` key on the root mapping
(block, quoted, or flow style all work), matching the CLI's own
`yaml.load`. `docker compose config` always injects `name: devcontainer`
into its merged output when no fragment declared one, so rule 3 needs to
distinguish the user-provided case from the injected default — this
helper supplies that signal. On YAML parse failure it returns "not
declared" (rule 4 applies), matching the CLI's fallback.

`project_name()` becomes async and fallible (`async fn
project_name(&self) -> Result<String, DevContainerError>`) so it can
load the `.env` file and each compose fragment via `self.fs.load`. Four
call sites now `.await?` the derivation. Real I/O errors on the `.env`
read propagate as `FilesystemError` (matching the CLI's narrow
`ENOENT`/`EISDIR` swallow); fragment-rescan read errors are logged and
skipped (matching the CLI's broader try/catch over its fragment read +
parse).

The `name` field is still used as the features image-tag prefix
(`generate_features_image_tag`); only the compose project namespace is
decoupled from it.

**Duplicate-container detection (from #54068).** When
`check_for_existing_container`'s label-based lookup returns more than
one match, propagate `MultipleMatchingContainers(ids)` with instructions
to clean up the stale one(s). This covers the mixed-version upgrade edge
case where a pre-fix Zed left a container under the legacy project name
alongside a CLI-style one — transparent to users in the common case (one
tool, one container), explicit error when two legacy siblings need
manual cleanup.

## Why

Full write-up with verified fixtures and captured output: #54255.

Three failure modes from the same root cause, all resolved by this
change:

1. **Interop** — opening a folder in both Zed and `devcontainer up` (or
Zed and VS Code) creates two compose projects with identical
`devcontainer.local_folder` + `devcontainer.config_file` labels,
breaking the spec's uniqueness invariant.
2. **Cross-worktree silent db/volume reuse** — if multiple git worktrees
share a `devcontainer.json` with the same `name`, Zed uses the same
compose project for all of them; Compose reuses stateful siblings (db,
cache, localstack) by config-hash, so worktree B silently inherits
worktree A's database. Fixture + captured output:
[antont/zed-devcontainer-db-share-repro](https://github.com/antont/zed-devcontainer-db-share-repro).
3. **Mixed-version Zed sessions** — the Rust impl landed in stable
v0.232.2 (2026-04-15, #52338). Older Zed (≤v0.231.x) shelled out to
`@devcontainers/cli` so it used the reference derivation. The collision
shows up when a new-Zed-created container exists under the name-field
project while a CLI-derivation tool (old Zed, `devcontainer up`, VS
Code) operates on the same folder.

## Migration / compatibility

Existing Zed-created containers (under the old `safe_id_lower(name)`
project) continue to be found via `check_for_existing_container`'s
label-based lookup — they're looked up by `devcontainer.local_folder` +
`devcontainer.config_file`, not by project name. A user with duplicate
legacy containers from a prior Zed session sees
`MultipleMatchingContainers` with cleanup instructions.

## Revision — 2026-04-22

Revised per @KyleBarton review on the prior version:
- Swapped the YAML parser from `serde_yaml_ng` to `yaml-rust2` (already
transitive via `tree-sitter-yaml`; net reduction of one direct workspace
dep; also what #53922 will pull in).
- Dropped the mixed-version tiebreak (`pick_canonical_container`) and
its `com.docker.compose.project` serde label. The edge case it covered
is transient enough to address via the explicit
`MultipleMatchingContainers` error rather than permanent tiebreaking
code.
- Folded #54068's detection commit into this PR; #54068 closed unmerged.
- Rebased onto `main`.

## Test plan

- [x] `cargo test -p dev_container --lib` — 89 passed, including:
  - `sanitize_compose_project_name_matches_cli_rules`
- `--project-name` assertion added to
`test_spawns_devcontainer_with_docker_compose`
  - `check_for_existing_container_errors_when_multiple_match`
  - `derive_project_name_env_wins_over_everything`
  - `derive_project_name_dotenv_wins_over_compose_and_fallback`
  - `derive_project_name_compose_name_wins_over_fallback`
- `derive_project_name_skips_compose_name_when_not_explicitly_declared`
-
`derive_project_name_omits_suffix_when_compose_file_outside_devcontainer_dir`
  - `derive_project_name_normalizes_compose_path_for_rule_4`
- `compose_fragment_declares_name_detects_top_level_name_key` (covers
block, quoted-key, and flow-style roots, plus parse failure →
not-declared)
  - `is_missing_file_error_only_accepts_notfound_and_isadirectory`
- [x] `cargo fmt --all` — clean
- [x] `./script/clippy -p dev_container` — clean
- [x] **End-to-end with fixture**
[antont/zed-devcontainer-compose-test](https://github.com/antont/zed-devcontainer-compose-test):
  - Build `zed` from this branch.
- Clean slate: `docker ps -a --filter
"label=devcontainer.local_folder=$PWD" -q | xargs -r docker rm -f`
- `zed --dev-container /path/to/devcontainer-compose-test` → Zed creates
container under project `devcontainer-compose-test_devcontainer` (was
`compose_duplicate_repro` before the fix).
- `devcontainer up --workspace-folder $PWD` → CLI reports the same
`containerId` Zed created; no second compose project is introduced.
- Captured: `devcontainer-compose-test_devcontainer-app-1`,
`composeProjectName: "devcontainer-compose-test_devcontainer"` reported
by both tools.

Release Notes:

- Fixed dev container Docker Compose project name now matches the full
`getProjectName` precedence from the reference devcontainer CLI
(`COMPOSE_PROJECT_NAME` in the environment, then in the workspace
`.env`, then an explicit top-level `name:` on the merged compose config,
then the basename of the first compose file's directory — with the
`_devcontainer` suffix only when that directory is
`<workspace>/.devcontainer`). This prevents duplicate containers when
the same folder is opened with both Zed and the devcontainer CLI / VS
Code.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 16:56:14 -07:00

755 lines
25 KiB
Rust

use std::{
collections::{HashMap, HashSet},
fmt::Display,
path::{Path, PathBuf},
sync::Arc,
};
use futures::TryFutureExt;
use gpui::{AsyncWindowContext, Entity};
use project::Worktree;
use serde::Deserialize;
use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text};
use util::rel_path::RelPath;
use walkdir::WalkDir;
use workspace::Workspace;
use worktree::Snapshot;
use crate::{
DevContainerContext, DevContainerFeature, DevContainerTemplate,
devcontainer_json::DevContainer,
devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container},
devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry,
oci::download_oci_tarball,
};
/// Represents a discovered devcontainer configuration
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DevContainerConfig {
/// Display name for the configuration (subfolder name or "default")
pub name: String,
/// Relative path to the devcontainer.json file from the project root
pub config_path: PathBuf,
}
impl DevContainerConfig {
pub fn default_config() -> Self {
Self {
name: "default".to_string(),
config_path: PathBuf::from(".devcontainer/devcontainer.json"),
}
}
pub fn root_config() -> Self {
Self {
name: "root".to_string(),
config_path: PathBuf::from(".devcontainer.json"),
}
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DevContainerUp {
pub(crate) container_id: String,
pub(crate) remote_user: String,
pub(crate) remote_workspace_folder: String,
#[serde(default)]
pub(crate) extension_ids: Vec<String>,
#[serde(default)]
pub(crate) remote_env: HashMap<String, String>,
}
#[derive(Debug)]
pub(crate) struct DevContainerApply {
pub(crate) project_files: Vec<Arc<RelPath>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DevContainerError {
CommandFailed(String),
DockerNotAvailable,
ContainerNotValid(String),
DevContainerTemplateApplyFailed(String),
DevContainerScriptsFailed,
DevContainerUpFailed(String),
DevContainerNotFound,
DevContainerParseFailed,
DevContainerValidationFailed(String),
FilesystemError,
ResourceFetchFailed,
NotInValidProject,
/// Multiple existing containers match this project's identifying labels
/// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec
/// expects those labels to be unique per project, so Zed can't choose
/// which one to connect to. The user must remove the duplicate(s).
MultipleMatchingContainers(Vec<String>),
}
impl Display for DevContainerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
DevContainerError::DockerNotAvailable =>
"docker CLI not found on $PATH".to_string(),
DevContainerError::ContainerNotValid(id) => format!(
"docker image {id} did not have expected configuration for a dev container"
),
DevContainerError::DevContainerScriptsFailed =>
"lifecycle scripts could not execute for dev container".to_string(),
DevContainerError::DevContainerUpFailed(_) => {
"DevContainer creation failed".to_string()
}
DevContainerError::DevContainerTemplateApplyFailed(_) => {
"DevContainer template apply failed".to_string()
}
DevContainerError::DevContainerNotFound =>
"No valid dev container definition found in project".to_string(),
DevContainerError::DevContainerParseFailed =>
"Failed to parse file .devcontainer/devcontainer.json".to_string(),
DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
DevContainerError::CommandFailed(program) =>
format!("Failure running external program {program}"),
DevContainerError::FilesystemError =>
"Error downloading resources locally".to_string(),
DevContainerError::ResourceFetchFailed =>
"Failed to fetch resources from template or feature repository".to_string(),
DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(),
DevContainerError::MultipleMatchingContainers(ids) => format!(
"Multiple containers match this project's dev container labels ({}). \
Zed can't decide which to connect to. Stop and remove the stale one(s) with \
`docker stop <id>` and `docker rm <id>`, then try again.",
ids.join(", ")
),
}
)
}
}
pub(crate) async fn read_default_devcontainer_configuration(
cx: &DevContainerContext,
environment: HashMap<String, String>,
) -> Result<DevContainer, DevContainerError> {
let default_config = DevContainerConfig::default_config();
read_devcontainer_configuration(default_config, cx, environment)
.await
.map_err(|e| {
log::error!("Default configuration not found: {:?}", e);
DevContainerError::DevContainerNotFound
})
}
/// Finds all available devcontainer configurations in the project.
///
/// See [`find_configs_in_snapshot`] for the locations that are scanned.
pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
let project = workspace.project().read(cx);
let worktree = project
.visible_worktrees(cx)
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
let Some(worktree) = worktree else {
log::debug!("find_devcontainer_configs: No worktree found");
return Vec::new();
};
let worktree = worktree.read(cx);
find_configs_in_snapshot(worktree)
}
/// Scans a worktree snapshot for devcontainer configurations.
///
/// Scans for configurations in these locations:
/// 1. `.devcontainer/devcontainer.json` (the default location)
/// 2. `.devcontainer.json` in the project root
/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
///
/// All found configurations are returned so the user can pick between them.
pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
let mut configs = Vec::new();
let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
if devcontainer_entry.is_dir() {
log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
let devcontainer_json_path =
RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
for entry in snapshot.child_entries(devcontainer_dir_path) {
log::debug!(
"find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
entry.path.as_unix_str(),
entry.is_file(),
entry.is_dir()
);
if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
configs.push(DevContainerConfig::default_config());
} else if entry.is_dir() {
let subfolder_name = entry
.path
.file_name()
.map(|n| n.to_string())
.unwrap_or_default();
let config_json_path =
format!("{}/devcontainer.json", entry.path.as_unix_str());
if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
if snapshot.entry_for_path(rel_config_path).is_some() {
log::debug!(
"find_configs_in_snapshot: Found config in subfolder: {}",
subfolder_name
);
configs.push(DevContainerConfig {
name: subfolder_name,
config_path: PathBuf::from(&config_json_path),
});
} else {
log::debug!(
"find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
subfolder_name
);
}
}
}
}
}
}
// Always include `.devcontainer.json` so the user can pick it from the UI
// even when `.devcontainer/devcontainer.json` also exists.
let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
if snapshot
.entry_for_path(root_config_path)
.is_some_and(|entry| entry.is_file())
{
log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
configs.push(DevContainerConfig::root_config());
}
log::info!(
"find_configs_in_snapshot: Found {} configurations",
configs.len()
);
configs.sort_by(|a, b| {
let a_is_primary = a.name == "default" || a.name == "root";
let b_is_primary = b.name == "default" || b.name == "root";
match (a_is_primary, b_is_primary) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
configs
}
pub async fn start_dev_container_with_config(
context: DevContainerContext,
config: Option<DevContainerConfig>,
environment: HashMap<String, String>,
) -> Result<(DevContainerConnection, String), DevContainerError> {
check_for_docker(context.use_podman).await?;
let Some(actual_config) = config.clone() else {
return Err(DevContainerError::NotInValidProject);
};
match spawn_dev_container(
&context,
environment.clone(),
actual_config.clone(),
context.project_directory.clone().as_ref(),
)
.await
{
Ok(DevContainerUp {
container_id,
remote_workspace_folder,
remote_user,
extension_ids,
remote_env,
..
}) => {
let project_name =
match read_devcontainer_configuration(actual_config, &context, environment).await {
Ok(DevContainer {
name: Some(name), ..
}) => name,
_ => get_backup_project_name(&remote_workspace_folder, &container_id),
};
let connection = DevContainerConnection {
name: project_name,
container_id,
use_podman: context.use_podman,
remote_user,
extension_ids,
remote_env: remote_env.into_iter().collect(),
};
Ok((connection, remote_workspace_folder))
}
Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err),
Err(err) => {
let message = format!("Failed with nested error: {:?}", err);
Err(DevContainerError::DevContainerUpFailed(message))
}
}
}
async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
let mut command = if use_podman {
util::command::new_command("podman")
} else {
util::command::new_command("docker")
};
command.arg("--version");
match command.output().await {
Ok(_) => Ok(()),
Err(e) => {
log::error!("Unable to find docker in $PATH: {:?}", e);
Err(DevContainerError::DockerNotAvailable)
}
}
}
pub(crate) async fn apply_devcontainer_template(
worktree: Entity<Worktree>,
template: &DevContainerTemplate,
template_options: &HashMap<String, String>,
features_selected: &HashSet<DevContainerFeature>,
context: &DevContainerContext,
cx: &mut AsyncWindowContext,
) -> Result<DevContainerApply, DevContainerError> {
let token = get_oci_token(
ghcr_registry(),
devcontainer_templates_repository(),
&context.http_client,
)
.map_err(|e| {
log::error!("Failed to get OCI auth token: {e}");
DevContainerError::ResourceFetchFailed
})
.await?;
let manifest = get_latest_oci_manifest(
&token.token,
ghcr_registry(),
devcontainer_templates_repository(),
&context.http_client,
Some(&template.id),
)
.map_err(|e| {
log::error!("Failed to fetch template from OCI repository: {e}");
DevContainerError::ResourceFetchFailed
})
.await?;
let layer = &manifest.layers.get(0).ok_or_else(|| {
log::error!("Given manifest has no layers to query for blob. Aborting");
DevContainerError::ResourceFetchFailed
})?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let extract_dir = std::env::temp_dir()
.join(&template.id)
.join(format!("extracted-{timestamp}"));
context.fs.create_dir(&extract_dir).await.map_err(|e| {
log::error!("Could not create temporary directory: {e}");
DevContainerError::FilesystemError
})?;
download_oci_tarball(
&token.token,
ghcr_registry(),
devcontainer_templates_repository(),
&layer.digest,
"application/vnd.oci.image.manifest.v1+json",
&extract_dir,
&context.http_client,
&context.fs,
Some(&template.id),
)
.map_err(|e| {
log::error!("Error downloading tarball: {:?}", e);
DevContainerError::ResourceFetchFailed
})
.await?;
let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/");
let mut project_files = Vec::new();
for entry in WalkDir::new(downloaded_devcontainer_folder) {
let Ok(entry) = entry else {
continue;
};
if !entry.file_type().is_file() {
continue;
}
let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| {
log::error!("Can't create relative path: {e}");
DevContainerError::FilesystemError
})?;
let rel_path = RelPath::unix(relative_path)
.map_err(|e| {
log::error!("Can't create relative path: {e}");
DevContainerError::FilesystemError
})?
.into_arc();
let content = context.fs.load(entry.path()).await.map_err(|e| {
log::error!("Unable to read file: {e}");
DevContainerError::FilesystemError
})?;
let mut content = expand_template_options(content, template_options);
if let Some("devcontainer.json") = &rel_path.file_name() {
content = insert_features_into_devcontainer_json(&content, features_selected)
}
worktree
.update(cx, |worktree, cx| {
worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx)
})
.await
.map_err(|e| {
log::error!("Unable to create entry in worktree: {e}");
DevContainerError::NotInValidProject
})?;
project_files.push(rel_path);
}
Ok(DevContainerApply { project_files })
}
fn insert_features_into_devcontainer_json(
content: &str,
features: &HashSet<DevContainerFeature>,
) -> String {
if features.is_empty() {
return content.to_string();
}
let features_value: serde_json::Value = features
.iter()
.map(|f| {
let key = format!(
"{}/{}:{}",
f.source_repository.as_deref().unwrap_or(""),
f.id,
f.major_version()
);
(key, serde_json::Value::Object(Default::default()))
})
.collect::<serde_json::Map<String, serde_json::Value>>()
.into();
let tab_size = infer_json_indent_size(content);
let (range, replacement) = replace_value_in_json_text(
content,
&["features"],
tab_size,
Some(&features_value),
None,
);
let mut result = content.to_string();
result.replace_range(range, &replacement);
result
}
fn expand_template_options(content: String, template_options: &HashMap<String, String>) -> String {
let mut replaced_content = content;
for (key, val) in template_options {
replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val)
}
replaced_content
}
fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
Path::new(remote_workspace_folder)
.file_name()
.and_then(|name| name.to_str())
.map(|string| string.to_string())
.unwrap_or_else(|| container_id.to_string())
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot};
use fs::FakeFs;
use gpui::TestAppContext;
use project::Project;
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
}
#[gpui::test]
async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}"
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0].name, "root");
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
}
#[gpui::test]
async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"devcontainer.json": "{}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0], DevContainerConfig::default_config());
}
#[gpui::test]
async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {
"devcontainer.json": "{}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0], DevContainerConfig::default_config());
assert_eq!(configs[1], DevContainerConfig::root_config());
}
#[gpui::test]
async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"rust": {
"devcontainer.json": "{}"
},
"python": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
assert!(names.contains(&"python"));
assert!(names.contains(&"rust"));
}
#[gpui::test]
async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer": {
"devcontainer.json": "{}",
"gpu": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0].name, "default");
assert_eq!(configs[1].name, "gpu");
}
#[gpui::test]
async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"src": {
"main.rs": "fn main() {}"
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert!(configs.is_empty());
}
#[gpui::test]
async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {
"rust": {
"devcontainer.json": "{}"
}
}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 2);
assert_eq!(configs[0].name, "root");
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
assert_eq!(configs[1].name, "rust");
assert_eq!(
configs[1].config_path,
PathBuf::from(".devcontainer/rust/devcontainer.json")
);
}
#[gpui::test]
async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
".devcontainer.json": "{}",
".devcontainer": {}
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
cx.run_until_parked();
let configs = project.read_with(cx, |project, cx| {
let worktree = project
.visible_worktrees(cx)
.next()
.expect("should have a worktree");
find_configs_in_snapshot(worktree.read(cx))
});
assert_eq!(configs.len(), 1);
assert_eq!(configs[0], DevContainerConfig::root_config());
}
}