devcontainer_api.rs

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