devcontainer_api.rs

  1use std::{
  2    collections::{HashMap, HashSet},
  3    fmt::Display,
  4    path::{Path, PathBuf},
  5};
  6
  7use node_runtime::NodeRuntime;
  8use serde::Deserialize;
  9use settings::DevContainerConnection;
 10use smol::fs;
 11use util::command::Command;
 12use util::rel_path::RelPath;
 13use workspace::Workspace;
 14use worktree::Snapshot;
 15
 16use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate};
 17
 18/// Represents a discovered devcontainer configuration
 19#[derive(Debug, Clone, PartialEq, Eq)]
 20pub struct DevContainerConfig {
 21    /// Display name for the configuration (subfolder name or "default")
 22    pub name: String,
 23    /// Relative path to the devcontainer.json file from the project root
 24    pub config_path: PathBuf,
 25}
 26
 27impl DevContainerConfig {
 28    pub fn default_config() -> Self {
 29        Self {
 30            name: "default".to_string(),
 31            config_path: PathBuf::from(".devcontainer/devcontainer.json"),
 32        }
 33    }
 34
 35    pub fn root_config() -> Self {
 36        Self {
 37            name: "root".to_string(),
 38            config_path: PathBuf::from(".devcontainer.json"),
 39        }
 40    }
 41}
 42
 43#[derive(Debug, Deserialize)]
 44#[serde(rename_all = "camelCase")]
 45struct DevContainerUp {
 46    _outcome: String,
 47    container_id: String,
 48    remote_user: String,
 49    remote_workspace_folder: String,
 50}
 51
 52#[derive(Debug, Deserialize)]
 53#[serde(rename_all = "camelCase")]
 54pub(crate) struct DevContainerApply {
 55    pub(crate) files: Vec<String>,
 56}
 57
 58#[derive(Debug, Deserialize)]
 59#[serde(rename_all = "camelCase")]
 60pub(crate) struct DevContainerConfiguration {
 61    name: Option<String>,
 62}
 63
 64#[derive(Debug, Deserialize)]
 65pub(crate) struct DevContainerConfigurationOutput {
 66    configuration: DevContainerConfiguration,
 67}
 68
 69pub(crate) struct DevContainerCli {
 70    pub path: PathBuf,
 71    node_runtime_path: Option<PathBuf>,
 72}
 73
 74impl DevContainerCli {
 75    fn command(&self, use_podman: bool) -> Command {
 76        let mut command = if let Some(node_runtime_path) = &self.node_runtime_path {
 77            let mut command =
 78                util::command::new_command(node_runtime_path.as_os_str().display().to_string());
 79            command.arg(self.path.display().to_string());
 80            command
 81        } else {
 82            util::command::new_command(self.path.display().to_string())
 83        };
 84
 85        if use_podman {
 86            command.arg("--docker-path");
 87            command.arg("podman");
 88        }
 89        command
 90    }
 91}
 92
 93#[derive(Debug, Clone, PartialEq, Eq)]
 94pub enum DevContainerError {
 95    DockerNotAvailable,
 96    DevContainerCliNotAvailable,
 97    DevContainerTemplateApplyFailed(String),
 98    DevContainerUpFailed(String),
 99    DevContainerNotFound,
100    DevContainerParseFailed,
101    NodeRuntimeNotAvailable,
102    NotInValidProject,
103}
104
105impl Display for DevContainerError {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        write!(
108            f,
109            "{}",
110            match self {
111                DevContainerError::DockerNotAvailable =>
112                    "docker CLI not found on $PATH".to_string(),
113                DevContainerError::DevContainerCliNotAvailable =>
114                    "devcontainer CLI not found on path".to_string(),
115                DevContainerError::DevContainerUpFailed(_) => {
116                    "DevContainer creation failed".to_string()
117                }
118                DevContainerError::DevContainerTemplateApplyFailed(_) => {
119                    "DevContainer template apply failed".to_string()
120                }
121                DevContainerError::DevContainerNotFound =>
122                    "No valid dev container definition found in project".to_string(),
123                DevContainerError::DevContainerParseFailed =>
124                    "Failed to parse file .devcontainer/devcontainer.json".to_string(),
125                DevContainerError::NodeRuntimeNotAvailable =>
126                    "Cannot find a valid node runtime".to_string(),
127                DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
128            }
129        )
130    }
131}
132
133/// Finds all available devcontainer configurations in the project.
134///
135/// See [`find_configs_in_snapshot`] for the locations that are scanned.
136pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
137    let project = workspace.project().read(cx);
138
139    let worktree = project
140        .visible_worktrees(cx)
141        .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
142
143    let Some(worktree) = worktree else {
144        log::debug!("find_devcontainer_configs: No worktree found");
145        return Vec::new();
146    };
147
148    let worktree = worktree.read(cx);
149    find_configs_in_snapshot(worktree)
150}
151
152/// Scans a worktree snapshot for devcontainer configurations.
153///
154/// Scans for configurations in these locations:
155/// 1. `.devcontainer/devcontainer.json` (the default location)
156/// 2. `.devcontainer.json` in the project root
157/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
158///
159/// All found configurations are returned so the user can pick between them.
160pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
161    let mut configs = Vec::new();
162
163    let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
164
165    if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
166        if devcontainer_entry.is_dir() {
167            log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
168            let devcontainer_json_path =
169                RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
170            for entry in snapshot.child_entries(devcontainer_dir_path) {
171                log::debug!(
172                    "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
173                    entry.path.as_unix_str(),
174                    entry.is_file(),
175                    entry.is_dir()
176                );
177
178                if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
179                    log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
180                    configs.push(DevContainerConfig::default_config());
181                } else if entry.is_dir() {
182                    let subfolder_name = entry
183                        .path
184                        .file_name()
185                        .map(|n| n.to_string())
186                        .unwrap_or_default();
187
188                    let config_json_path =
189                        format!("{}/devcontainer.json", entry.path.as_unix_str());
190                    if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
191                        if snapshot.entry_for_path(rel_config_path).is_some() {
192                            log::debug!(
193                                "find_configs_in_snapshot: Found config in subfolder: {}",
194                                subfolder_name
195                            );
196                            configs.push(DevContainerConfig {
197                                name: subfolder_name,
198                                config_path: PathBuf::from(&config_json_path),
199                            });
200                        } else {
201                            log::debug!(
202                                "find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
203                                subfolder_name
204                            );
205                        }
206                    }
207                }
208            }
209        }
210    }
211
212    // Always include `.devcontainer.json` so the user can pick it from the UI
213    // even when `.devcontainer/devcontainer.json` also exists.
214    let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
215    if snapshot
216        .entry_for_path(root_config_path)
217        .is_some_and(|entry| entry.is_file())
218    {
219        log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
220        configs.push(DevContainerConfig::root_config());
221    }
222
223    log::info!(
224        "find_configs_in_snapshot: Found {} configurations",
225        configs.len()
226    );
227
228    configs.sort_by(|a, b| {
229        let a_is_primary = a.name == "default" || a.name == "root";
230        let b_is_primary = b.name == "default" || b.name == "root";
231        match (a_is_primary, b_is_primary) {
232            (true, false) => std::cmp::Ordering::Less,
233            (false, true) => std::cmp::Ordering::Greater,
234            _ => a.name.cmp(&b.name),
235        }
236    });
237
238    configs
239}
240
241pub async fn start_dev_container_with_config(
242    context: DevContainerContext,
243    config: Option<DevContainerConfig>,
244) -> Result<(DevContainerConnection, String), DevContainerError> {
245    check_for_docker(context.use_podman).await?;
246    let cli = ensure_devcontainer_cli(&context.node_runtime).await?;
247    let config_path = config.map(|c| context.project_directory.join(&c.config_path));
248
249    match devcontainer_up(&context, &cli, config_path.as_deref()).await {
250        Ok(DevContainerUp {
251            container_id,
252            remote_workspace_folder,
253            remote_user,
254            ..
255        }) => {
256            let project_name =
257                match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await
258                {
259                    Ok(DevContainerConfigurationOutput {
260                        configuration:
261                            DevContainerConfiguration {
262                                name: Some(project_name),
263                            },
264                    }) => project_name,
265                    _ => get_backup_project_name(&remote_workspace_folder, &container_id),
266                };
267
268            let connection = DevContainerConnection {
269                name: project_name,
270                container_id,
271                use_podman: context.use_podman,
272                remote_user,
273            };
274
275            Ok((connection, remote_workspace_folder))
276        }
277        Err(err) => {
278            let message = format!("Failed with nested error: {}", err);
279            Err(DevContainerError::DevContainerUpFailed(message))
280        }
281    }
282}
283
284#[cfg(not(target_os = "windows"))]
285fn dev_container_cli() -> String {
286    "devcontainer".to_string()
287}
288
289#[cfg(target_os = "windows")]
290fn dev_container_cli() -> String {
291    "devcontainer.cmd".to_string()
292}
293
294fn dev_container_script() -> String {
295    "devcontainer.js".to_string()
296}
297
298async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
299    let mut command = if use_podman {
300        util::command::new_command("podman")
301    } else {
302        util::command::new_command("docker")
303    };
304    command.arg("--version");
305
306    match command.output().await {
307        Ok(_) => Ok(()),
308        Err(e) => {
309            log::error!("Unable to find docker in $PATH: {:?}", e);
310            Err(DevContainerError::DockerNotAvailable)
311        }
312    }
313}
314
315pub(crate) async fn ensure_devcontainer_cli(
316    node_runtime: &NodeRuntime,
317) -> Result<DevContainerCli, DevContainerError> {
318    let mut command = util::command::new_command(&dev_container_cli());
319    command.arg("--version");
320
321    if let Err(e) = command.output().await {
322        log::error!(
323            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
324            e
325        );
326
327        let Ok(node_runtime_path) = node_runtime.binary_path().await else {
328            return Err(DevContainerError::NodeRuntimeNotAvailable);
329        };
330
331        let datadir_cli_path = paths::devcontainer_dir()
332            .join("node_modules")
333            .join("@devcontainers")
334            .join("cli")
335            .join(&dev_container_script());
336
337        log::debug!(
338            "devcontainer not found in path, using local location: ${}",
339            datadir_cli_path.display()
340        );
341
342        let mut command =
343            util::command::new_command(node_runtime_path.as_os_str().display().to_string());
344        command.arg(datadir_cli_path.display().to_string());
345        command.arg("--version");
346
347        match command.output().await {
348            Err(e) => log::error!(
349                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
350                e
351            ),
352            Ok(output) => {
353                if output.status.success() {
354                    log::info!("Found devcontainer CLI in Data dir");
355                    return Ok(DevContainerCli {
356                        path: datadir_cli_path.clone(),
357                        node_runtime_path: Some(node_runtime_path.clone()),
358                    });
359                } else {
360                    log::error!(
361                        "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
362                        output
363                    );
364                }
365            }
366        }
367
368        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
369            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
370            return Err(DevContainerError::DevContainerCliNotAvailable);
371        }
372
373        if let Err(e) = node_runtime
374            .npm_install_packages(
375                &paths::devcontainer_dir(),
376                &[("@devcontainers/cli", "latest")],
377            )
378            .await
379        {
380            log::error!(
381                "Unable to install devcontainer CLI to data directory. Error: {:?}",
382                e
383            );
384            return Err(DevContainerError::DevContainerCliNotAvailable);
385        };
386
387        let mut command =
388            util::command::new_command(node_runtime_path.as_os_str().display().to_string());
389        command.arg(datadir_cli_path.display().to_string());
390        command.arg("--version");
391        if let Err(e) = command.output().await {
392            log::error!(
393                "Unable to find devcontainer cli after NPM install. Error: {:?}",
394                e
395            );
396            Err(DevContainerError::DevContainerCliNotAvailable)
397        } else {
398            Ok(DevContainerCli {
399                path: datadir_cli_path,
400                node_runtime_path: Some(node_runtime_path),
401            })
402        }
403    } else {
404        log::info!("Found devcontainer cli on $PATH, using it");
405        Ok(DevContainerCli {
406            path: PathBuf::from(&dev_container_cli()),
407            node_runtime_path: None,
408        })
409    }
410}
411
412async fn devcontainer_up(
413    context: &DevContainerContext,
414    cli: &DevContainerCli,
415    config_path: Option<&Path>,
416) -> Result<DevContainerUp, DevContainerError> {
417    let mut command = cli.command(context.use_podman);
418    command.arg("up");
419    command.arg("--workspace-folder");
420    command.arg(context.project_directory.display().to_string());
421
422    if let Some(config) = config_path {
423        command.arg("--config");
424        command.arg(config.display().to_string());
425    }
426
427    log::info!("Running full devcontainer up command: {:?}", command);
428
429    match command.output().await {
430        Ok(output) => {
431            if output.status.success() {
432                let raw = String::from_utf8_lossy(&output.stdout);
433                parse_json_from_cli(&raw)
434            } else {
435                let message = format!(
436                    "Non-success status running devcontainer up for workspace: out: {}, err: {}",
437                    String::from_utf8_lossy(&output.stdout),
438                    String::from_utf8_lossy(&output.stderr)
439                );
440
441                log::error!("{}", &message);
442                Err(DevContainerError::DevContainerUpFailed(message))
443            }
444        }
445        Err(e) => {
446            let message = format!("Error running devcontainer up: {:?}", e);
447            log::error!("{}", &message);
448            Err(DevContainerError::DevContainerUpFailed(message))
449        }
450    }
451}
452
453pub(crate) async fn read_devcontainer_configuration(
454    context: &DevContainerContext,
455    cli: &DevContainerCli,
456    config_path: Option<&Path>,
457) -> Result<DevContainerConfigurationOutput, DevContainerError> {
458    let mut command = cli.command(context.use_podman);
459    command.arg("read-configuration");
460    command.arg("--workspace-folder");
461    command.arg(context.project_directory.display().to_string());
462
463    if let Some(config) = config_path {
464        command.arg("--config");
465        command.arg(config.display().to_string());
466    }
467
468    match command.output().await {
469        Ok(output) => {
470            if output.status.success() {
471                let raw = String::from_utf8_lossy(&output.stdout);
472                parse_json_from_cli(&raw)
473            } else {
474                let message = format!(
475                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
476                    String::from_utf8_lossy(&output.stdout),
477                    String::from_utf8_lossy(&output.stderr)
478                );
479                log::error!("{}", &message);
480                Err(DevContainerError::DevContainerNotFound)
481            }
482        }
483        Err(e) => {
484            let message = format!("Error running devcontainer read-configuration: {:?}", e);
485            log::error!("{}", &message);
486            Err(DevContainerError::DevContainerNotFound)
487        }
488    }
489}
490
491pub(crate) async fn apply_dev_container_template(
492    template: &DevContainerTemplate,
493    template_options: &HashMap<String, String>,
494    features_selected: &HashSet<DevContainerFeature>,
495    context: &DevContainerContext,
496    cli: &DevContainerCli,
497) -> Result<DevContainerApply, DevContainerError> {
498    let mut command = cli.command(context.use_podman);
499
500    let Ok(serialized_options) = serde_json::to_string(template_options) else {
501        log::error!("Unable to serialize options for {:?}", template_options);
502        return Err(DevContainerError::DevContainerParseFailed);
503    };
504
505    command.arg("templates");
506    command.arg("apply");
507    command.arg("--workspace-folder");
508    command.arg(context.project_directory.display().to_string());
509    command.arg("--template-id");
510    command.arg(format!(
511        "{}/{}",
512        template
513            .source_repository
514            .as_ref()
515            .unwrap_or(&String::from("")),
516        template.id
517    ));
518    command.arg("--template-args");
519    command.arg(serialized_options);
520    command.arg("--features");
521    command.arg(template_features_to_json(features_selected));
522
523    log::debug!("Running full devcontainer apply command: {:?}", command);
524
525    match command.output().await {
526        Ok(output) => {
527            if output.status.success() {
528                let raw = String::from_utf8_lossy(&output.stdout);
529                parse_json_from_cli(&raw)
530            } else {
531                let message = format!(
532                    "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
533                    String::from_utf8_lossy(&output.stdout),
534                    String::from_utf8_lossy(&output.stderr)
535                );
536
537                log::error!("{}", &message);
538                Err(DevContainerError::DevContainerTemplateApplyFailed(message))
539            }
540        }
541        Err(e) => {
542            let message = format!("Error running devcontainer templates apply: {:?}", e);
543            log::error!("{}", &message);
544            Err(DevContainerError::DevContainerTemplateApplyFailed(message))
545        }
546    }
547}
548// Try to parse directly first (newer versions output pure JSON)
549// If that fails, look for JSON start (older versions have plaintext prefix)
550fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
551    serde_json::from_str::<T>(&raw)
552        .or_else(|e| {
553            log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
554            let json_start = raw
555                .find(|c| c == '{')
556                .ok_or_else(|| {
557                    log::error!("No JSON found in devcontainer up output");
558                    DevContainerError::DevContainerParseFailed
559                })?;
560
561            serde_json::from_str(&raw[json_start..]).map_err(|e| {
562                log::error!(
563                    "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
564                    json_start,
565                    e
566                );
567                DevContainerError::DevContainerParseFailed
568            })
569        })
570}
571
572fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
573    Path::new(remote_workspace_folder)
574        .file_name()
575        .and_then(|name| name.to_str())
576        .map(|string| string.to_string())
577        .unwrap_or_else(|| container_id.to_string())
578}
579
580fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
581    let features_map = features_selected
582        .iter()
583        .map(|feature| {
584            let mut map = HashMap::new();
585            map.insert(
586                "id",
587                format!(
588                    "{}/{}:{}",
589                    feature
590                        .source_repository
591                        .as_ref()
592                        .unwrap_or(&String::from("")),
593                    feature.id,
594                    feature.major_version()
595                ),
596            );
597            map
598        })
599        .collect::<Vec<HashMap<&str, String>>>();
600    serde_json::to_string(&features_map).unwrap()
601}
602
603#[cfg(test)]
604mod tests {
605    use std::path::PathBuf;
606
607    use crate::devcontainer_api::{
608        DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli,
609    };
610    use fs::FakeFs;
611    use gpui::TestAppContext;
612    use project::Project;
613    use serde_json::json;
614    use settings::SettingsStore;
615    use util::path;
616
617    fn init_test(cx: &mut TestAppContext) {
618        cx.update(|cx| {
619            let settings_store = SettingsStore::test(cx);
620            cx.set_global(settings_store);
621        });
622    }
623
624    #[test]
625    fn should_parse_from_devcontainer_json() {
626        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
627        let up: DevContainerUp = parse_json_from_cli(json).unwrap();
628        assert_eq!(up._outcome, "success");
629        assert_eq!(
630            up.container_id,
631            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
632        );
633        assert_eq!(up.remote_user, "vscode");
634        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
635
636        let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64.
637            {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
638        let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
639        assert_eq!(up._outcome, "success");
640        assert_eq!(
641            up.container_id,
642            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
643        );
644        assert_eq!(up.remote_user, "vscode");
645        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
646    }
647
648    #[gpui::test]
649    async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
650        init_test(cx);
651        let fs = FakeFs::new(cx.executor());
652        fs.insert_tree(
653            path!("/project"),
654            json!({
655                ".devcontainer.json": "{}"
656            }),
657        )
658        .await;
659
660        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
661        cx.run_until_parked();
662
663        let configs = project.read_with(cx, |project, cx| {
664            let worktree = project
665                .visible_worktrees(cx)
666                .next()
667                .expect("should have a worktree");
668            find_configs_in_snapshot(worktree.read(cx))
669        });
670
671        assert_eq!(configs.len(), 1);
672        assert_eq!(configs[0].name, "root");
673        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
674    }
675
676    #[gpui::test]
677    async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
678        init_test(cx);
679        let fs = FakeFs::new(cx.executor());
680        fs.insert_tree(
681            path!("/project"),
682            json!({
683                ".devcontainer": {
684                    "devcontainer.json": "{}"
685                }
686            }),
687        )
688        .await;
689
690        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
691        cx.run_until_parked();
692
693        let configs = project.read_with(cx, |project, cx| {
694            let worktree = project
695                .visible_worktrees(cx)
696                .next()
697                .expect("should have a worktree");
698            find_configs_in_snapshot(worktree.read(cx))
699        });
700
701        assert_eq!(configs.len(), 1);
702        assert_eq!(configs[0], DevContainerConfig::default_config());
703    }
704
705    #[gpui::test]
706    async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
707        init_test(cx);
708        let fs = FakeFs::new(cx.executor());
709        fs.insert_tree(
710            path!("/project"),
711            json!({
712                ".devcontainer.json": "{}",
713                ".devcontainer": {
714                    "devcontainer.json": "{}"
715                }
716            }),
717        )
718        .await;
719
720        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
721        cx.run_until_parked();
722
723        let configs = project.read_with(cx, |project, cx| {
724            let worktree = project
725                .visible_worktrees(cx)
726                .next()
727                .expect("should have a worktree");
728            find_configs_in_snapshot(worktree.read(cx))
729        });
730
731        assert_eq!(configs.len(), 2);
732        assert_eq!(configs[0], DevContainerConfig::default_config());
733        assert_eq!(configs[1], DevContainerConfig::root_config());
734    }
735
736    #[gpui::test]
737    async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
738        init_test(cx);
739        let fs = FakeFs::new(cx.executor());
740        fs.insert_tree(
741            path!("/project"),
742            json!({
743                ".devcontainer": {
744                    "rust": {
745                        "devcontainer.json": "{}"
746                    },
747                    "python": {
748                        "devcontainer.json": "{}"
749                    }
750                }
751            }),
752        )
753        .await;
754
755        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
756        cx.run_until_parked();
757
758        let configs = project.read_with(cx, |project, cx| {
759            let worktree = project
760                .visible_worktrees(cx)
761                .next()
762                .expect("should have a worktree");
763            find_configs_in_snapshot(worktree.read(cx))
764        });
765
766        assert_eq!(configs.len(), 2);
767        let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
768        assert!(names.contains(&"python"));
769        assert!(names.contains(&"rust"));
770    }
771
772    #[gpui::test]
773    async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
774        init_test(cx);
775        let fs = FakeFs::new(cx.executor());
776        fs.insert_tree(
777            path!("/project"),
778            json!({
779                ".devcontainer": {
780                    "devcontainer.json": "{}",
781                    "gpu": {
782                        "devcontainer.json": "{}"
783                    }
784                }
785            }),
786        )
787        .await;
788
789        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
790        cx.run_until_parked();
791
792        let configs = project.read_with(cx, |project, cx| {
793            let worktree = project
794                .visible_worktrees(cx)
795                .next()
796                .expect("should have a worktree");
797            find_configs_in_snapshot(worktree.read(cx))
798        });
799
800        assert_eq!(configs.len(), 2);
801        assert_eq!(configs[0].name, "default");
802        assert_eq!(configs[1].name, "gpu");
803    }
804
805    #[gpui::test]
806    async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
807        init_test(cx);
808        let fs = FakeFs::new(cx.executor());
809        fs.insert_tree(
810            path!("/project"),
811            json!({
812                "src": {
813                    "main.rs": "fn main() {}"
814                }
815            }),
816        )
817        .await;
818
819        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
820        cx.run_until_parked();
821
822        let configs = project.read_with(cx, |project, cx| {
823            let worktree = project
824                .visible_worktrees(cx)
825                .next()
826                .expect("should have a worktree");
827            find_configs_in_snapshot(worktree.read(cx))
828        });
829
830        assert!(configs.is_empty());
831    }
832
833    #[gpui::test]
834    async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
835        init_test(cx);
836        let fs = FakeFs::new(cx.executor());
837        fs.insert_tree(
838            path!("/project"),
839            json!({
840                ".devcontainer.json": "{}",
841                ".devcontainer": {
842                    "rust": {
843                        "devcontainer.json": "{}"
844                    }
845                }
846            }),
847        )
848        .await;
849
850        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
851        cx.run_until_parked();
852
853        let configs = project.read_with(cx, |project, cx| {
854            let worktree = project
855                .visible_worktrees(cx)
856                .next()
857                .expect("should have a worktree");
858            find_configs_in_snapshot(worktree.read(cx))
859        });
860
861        assert_eq!(configs.len(), 2);
862        assert_eq!(configs[0].name, "root");
863        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
864        assert_eq!(configs[1].name, "rust");
865        assert_eq!(
866            configs[1].config_path,
867            PathBuf::from(".devcontainer/rust/devcontainer.json")
868        );
869    }
870
871    #[gpui::test]
872    async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
873        init_test(cx);
874        let fs = FakeFs::new(cx.executor());
875        fs.insert_tree(
876            path!("/project"),
877            json!({
878                ".devcontainer.json": "{}",
879                ".devcontainer": {}
880            }),
881        )
882        .await;
883
884        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
885        cx.run_until_parked();
886
887        let configs = project.read_with(cx, |project, cx| {
888            let worktree = project
889                .visible_worktrees(cx)
890                .next()
891                .expect("should have a worktree");
892            find_configs_in_snapshot(worktree.read(cx))
893        });
894
895        assert_eq!(configs.len(), 1);
896        assert_eq!(configs[0], DevContainerConfig::root_config());
897    }
898}