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;
 15
 16use crate::{DevContainerFeature, DevContainerSettings, 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
 36#[derive(Debug, Deserialize)]
 37#[serde(rename_all = "camelCase")]
 38struct DevContainerUp {
 39    _outcome: String,
 40    container_id: String,
 41    remote_user: String,
 42    remote_workspace_folder: String,
 43}
 44
 45#[derive(Debug, Deserialize)]
 46#[serde(rename_all = "camelCase")]
 47pub(crate) struct DevContainerApply {
 48    pub(crate) files: Vec<String>,
 49}
 50
 51#[derive(Debug, Deserialize)]
 52#[serde(rename_all = "camelCase")]
 53pub(crate) struct DevContainerConfiguration {
 54    name: Option<String>,
 55}
 56
 57#[derive(Debug, Deserialize)]
 58pub(crate) struct DevContainerConfigurationOutput {
 59    configuration: DevContainerConfiguration,
 60}
 61
 62#[derive(Debug, Clone, PartialEq, Eq)]
 63pub enum DevContainerError {
 64    DockerNotAvailable,
 65    DevContainerCliNotAvailable,
 66    DevContainerTemplateApplyFailed(String),
 67    DevContainerUpFailed(String),
 68    DevContainerNotFound,
 69    DevContainerParseFailed,
 70    NodeRuntimeNotAvailable,
 71    NotInValidProject,
 72}
 73
 74impl Display for DevContainerError {
 75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 76        write!(
 77            f,
 78            "{}",
 79            match self {
 80                DevContainerError::DockerNotAvailable =>
 81                    "Docker CLI not found on $PATH".to_string(),
 82                DevContainerError::DevContainerCliNotAvailable =>
 83                    "Docker not found on path".to_string(),
 84                DevContainerError::DevContainerUpFailed(message) => {
 85                    format!("DevContainer creation failed with error: {}", message)
 86                }
 87                DevContainerError::DevContainerTemplateApplyFailed(message) => {
 88                    format!("DevContainer template apply failed with error: {}", message)
 89                }
 90                DevContainerError::DevContainerNotFound =>
 91                    "No valid dev container definition found in project".to_string(),
 92                DevContainerError::DevContainerParseFailed =>
 93                    "Failed to parse file .devcontainer/devcontainer.json".to_string(),
 94                DevContainerError::NodeRuntimeNotAvailable =>
 95                    "Cannot find a valid node runtime".to_string(),
 96                DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
 97            }
 98        )
 99    }
100}
101
102pub(crate) async fn read_devcontainer_configuration_for_project(
103    cx: &mut AsyncWindowContext,
104    node_runtime: &NodeRuntime,
105) -> Result<DevContainerConfigurationOutput, DevContainerError> {
106    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
107
108    let Some(directory) = project_directory(cx) else {
109        return Err(DevContainerError::NotInValidProject);
110    };
111
112    devcontainer_read_configuration(
113        &path_to_devcontainer_cli,
114        found_in_path,
115        node_runtime,
116        &directory,
117        None,
118        use_podman(cx),
119    )
120    .await
121}
122
123pub(crate) async fn apply_dev_container_template(
124    template: &DevContainerTemplate,
125    options_selected: &HashMap<String, String>,
126    features_selected: &HashSet<DevContainerFeature>,
127    cx: &mut AsyncWindowContext,
128    node_runtime: &NodeRuntime,
129) -> Result<DevContainerApply, DevContainerError> {
130    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
131
132    let Some(directory) = project_directory(cx) else {
133        return Err(DevContainerError::NotInValidProject);
134    };
135
136    devcontainer_template_apply(
137        template,
138        options_selected,
139        features_selected,
140        &path_to_devcontainer_cli,
141        found_in_path,
142        node_runtime,
143        &directory,
144        false, // devcontainer template apply does not use --docker-path option
145    )
146    .await
147}
148
149fn use_podman(cx: &mut AsyncWindowContext) -> bool {
150    cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
151        .unwrap_or(false)
152}
153
154/// Finds all available devcontainer configurations in the project.
155///
156/// This function scans for:
157/// 1. `.devcontainer/devcontainer.json` (the default location)
158/// 2. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
159///
160/// Returns a list of found configurations, or an empty list if none are found.
161pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContainerConfig> {
162    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
163        log::debug!("find_devcontainer_configs: No workspace found");
164        return Vec::new();
165    };
166
167    let Ok(configs) = workspace.update(cx, |workspace, _, cx| {
168        let project = workspace.project().read(cx);
169
170        let worktree = project
171            .visible_worktrees(cx)
172            .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
173
174        let Some(worktree) = worktree else {
175            log::debug!("find_devcontainer_configs: No worktree found");
176            return Vec::new();
177        };
178
179        let worktree = worktree.read(cx);
180        let mut configs = Vec::new();
181
182        let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
183
184        let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
185            log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
186            return Vec::new();
187        };
188
189        if !devcontainer_entry.is_dir() {
190            log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
191            return Vec::new();
192        }
193
194        log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
195        let devcontainer_json_path =
196            RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
197        for entry in worktree.child_entries(devcontainer_path) {
198            log::debug!(
199                "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
200                entry.path.as_unix_str(),
201                entry.is_file(),
202                entry.is_dir()
203            );
204
205            if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
206                log::debug!("find_devcontainer_configs: Found default devcontainer.json");
207                configs.push(DevContainerConfig::default_config());
208            } else if entry.is_dir() {
209                let subfolder_name = entry
210                    .path
211                    .file_name()
212                    .map(|n| n.to_string())
213                    .unwrap_or_default();
214
215                let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
216                if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
217                    if worktree.entry_for_path(rel_config_path).is_some() {
218                        log::debug!(
219                            "find_devcontainer_configs: Found config in subfolder: {}",
220                            subfolder_name
221                        );
222                        configs.push(DevContainerConfig {
223                            name: subfolder_name,
224                            config_path: PathBuf::from(&config_json_path),
225                        });
226                    } else {
227                        log::debug!(
228                            "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
229                            subfolder_name
230                        );
231                    }
232                }
233            }
234        }
235
236        log::info!(
237            "find_devcontainer_configs: Found {} configurations",
238            configs.len()
239        );
240
241        configs.sort_by(|a, b| {
242            if a.name == "default" {
243                std::cmp::Ordering::Less
244            } else if b.name == "default" {
245                std::cmp::Ordering::Greater
246            } else {
247                a.name.cmp(&b.name)
248            }
249        });
250
251        configs
252    }) else {
253        log::debug!("find_devcontainer_configs: Failed to update workspace");
254        return Vec::new();
255    };
256
257    configs
258}
259
260pub async fn start_dev_container(
261    cx: &mut AsyncWindowContext,
262    node_runtime: NodeRuntime,
263) -> Result<(DevContainerConnection, String), DevContainerError> {
264    start_dev_container_with_config(cx, node_runtime, None).await
265}
266
267pub async fn start_dev_container_with_config(
268    cx: &mut AsyncWindowContext,
269    node_runtime: NodeRuntime,
270    config: Option<DevContainerConfig>,
271) -> Result<(DevContainerConnection, String), DevContainerError> {
272    let use_podman = use_podman(cx);
273    check_for_docker(use_podman).await?;
274
275    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
276
277    let Some(directory) = project_directory(cx) else {
278        return Err(DevContainerError::NotInValidProject);
279    };
280
281    let config_path = config.map(|c| directory.join(&c.config_path));
282
283    match devcontainer_up(
284        &path_to_devcontainer_cli,
285        found_in_path,
286        &node_runtime,
287        directory.clone(),
288        config_path.clone(),
289        use_podman,
290    )
291    .await
292    {
293        Ok(DevContainerUp {
294            container_id,
295            remote_workspace_folder,
296            remote_user,
297            ..
298        }) => {
299            let project_name = match devcontainer_read_configuration(
300                &path_to_devcontainer_cli,
301                found_in_path,
302                &node_runtime,
303                &directory,
304                config_path.as_ref(),
305                use_podman,
306            )
307            .await
308            {
309                Ok(DevContainerConfigurationOutput {
310                    configuration:
311                        DevContainerConfiguration {
312                            name: Some(project_name),
313                        },
314                }) => project_name,
315                _ => get_backup_project_name(&remote_workspace_folder, &container_id),
316            };
317
318            let connection = DevContainerConnection {
319                name: project_name,
320                container_id: container_id,
321                use_podman,
322                remote_user,
323            };
324
325            Ok((connection, remote_workspace_folder))
326        }
327        Err(err) => {
328            let message = format!("Failed with nested error: {}", err);
329            Err(DevContainerError::DevContainerUpFailed(message))
330        }
331    }
332}
333
334#[cfg(not(target_os = "windows"))]
335fn dev_container_cli() -> String {
336    "devcontainer".to_string()
337}
338
339#[cfg(target_os = "windows")]
340fn dev_container_cli() -> String {
341    "devcontainer.cmd".to_string()
342}
343
344async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
345    let mut command = if use_podman {
346        util::command::new_smol_command("podman")
347    } else {
348        util::command::new_smol_command("docker")
349    };
350    command.arg("--version");
351
352    match command.output().await {
353        Ok(_) => Ok(()),
354        Err(e) => {
355            log::error!("Unable to find docker in $PATH: {:?}", e);
356            Err(DevContainerError::DockerNotAvailable)
357        }
358    }
359}
360
361async fn ensure_devcontainer_cli(
362    node_runtime: &NodeRuntime,
363) -> Result<(PathBuf, bool), DevContainerError> {
364    let mut command = util::command::new_smol_command(&dev_container_cli());
365    command.arg("--version");
366
367    if let Err(e) = command.output().await {
368        log::error!(
369            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
370            e
371        );
372
373        let Ok(node_runtime_path) = node_runtime.binary_path().await else {
374            return Err(DevContainerError::NodeRuntimeNotAvailable);
375        };
376
377        let datadir_cli_path = paths::devcontainer_dir()
378            .join("node_modules")
379            .join("@devcontainers")
380            .join("cli")
381            .join(format!("{}.js", &dev_container_cli()));
382
383        log::debug!(
384            "devcontainer not found in path, using local location: ${}",
385            datadir_cli_path.display()
386        );
387
388        let mut command =
389            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
390        command.arg(datadir_cli_path.display().to_string());
391        command.arg("--version");
392
393        match command.output().await {
394            Err(e) => log::error!(
395                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
396                e
397            ),
398            Ok(output) => {
399                if output.status.success() {
400                    log::info!("Found devcontainer CLI in Data dir");
401                    return Ok((datadir_cli_path.clone(), false));
402                } else {
403                    log::error!(
404                        "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
405                        output
406                    );
407                }
408            }
409        }
410
411        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
412            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
413            return Err(DevContainerError::DevContainerCliNotAvailable);
414        }
415
416        if let Err(e) = node_runtime
417            .npm_install_packages(
418                &paths::devcontainer_dir(),
419                &[("@devcontainers/cli", "latest")],
420            )
421            .await
422        {
423            log::error!(
424                "Unable to install devcontainer CLI to data directory. Error: {:?}",
425                e
426            );
427            return Err(DevContainerError::DevContainerCliNotAvailable);
428        };
429
430        let mut command =
431            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
432        command.arg(datadir_cli_path.display().to_string());
433        command.arg("--version");
434        if let Err(e) = command.output().await {
435            log::error!(
436                "Unable to find devcontainer cli after NPM install. Error: {:?}",
437                e
438            );
439            Err(DevContainerError::DevContainerCliNotAvailable)
440        } else {
441            Ok((datadir_cli_path, false))
442        }
443    } else {
444        log::info!("Found devcontainer cli on $PATH, using it");
445        Ok((PathBuf::from(&dev_container_cli()), true))
446    }
447}
448
449async fn devcontainer_up(
450    path_to_cli: &PathBuf,
451    found_in_path: bool,
452    node_runtime: &NodeRuntime,
453    path: Arc<Path>,
454    config_path: Option<PathBuf>,
455    use_podman: bool,
456) -> Result<DevContainerUp, DevContainerError> {
457    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
458        log::error!("Unable to find node runtime path");
459        return Err(DevContainerError::NodeRuntimeNotAvailable);
460    };
461
462    let mut command =
463        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
464    command.arg("up");
465    command.arg("--workspace-folder");
466    command.arg(path.display().to_string());
467
468    if let Some(config) = config_path {
469        command.arg("--config");
470        command.arg(config.display().to_string());
471    }
472
473    log::info!("Running full devcontainer up command: {:?}", command);
474
475    match command.output().await {
476        Ok(output) => {
477            if output.status.success() {
478                let raw = String::from_utf8_lossy(&output.stdout);
479                parse_json_from_cli(&raw)
480            } else {
481                let message = format!(
482                    "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
483                    String::from_utf8_lossy(&output.stdout),
484                    String::from_utf8_lossy(&output.stderr)
485                );
486
487                log::error!("{}", &message);
488                Err(DevContainerError::DevContainerUpFailed(message))
489            }
490        }
491        Err(e) => {
492            let message = format!("Error running devcontainer up: {:?}", e);
493            log::error!("{}", &message);
494            Err(DevContainerError::DevContainerUpFailed(message))
495        }
496    }
497}
498
499async fn devcontainer_read_configuration(
500    path_to_cli: &PathBuf,
501    found_in_path: bool,
502    node_runtime: &NodeRuntime,
503    path: &Arc<Path>,
504    config_path: Option<&PathBuf>,
505    use_podman: bool,
506) -> Result<DevContainerConfigurationOutput, DevContainerError> {
507    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
508        log::error!("Unable to find node runtime path");
509        return Err(DevContainerError::NodeRuntimeNotAvailable);
510    };
511
512    let mut command =
513        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
514    command.arg("read-configuration");
515    command.arg("--workspace-folder");
516    command.arg(path.display().to_string());
517
518    if let Some(config) = config_path {
519        command.arg("--config");
520        command.arg(config.display().to_string());
521    }
522
523    match command.output().await {
524        Ok(output) => {
525            if output.status.success() {
526                let raw = String::from_utf8_lossy(&output.stdout);
527                parse_json_from_cli(&raw)
528            } else {
529                let message = format!(
530                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
531                    String::from_utf8_lossy(&output.stdout),
532                    String::from_utf8_lossy(&output.stderr)
533                );
534                log::error!("{}", &message);
535                Err(DevContainerError::DevContainerNotFound)
536            }
537        }
538        Err(e) => {
539            let message = format!("Error running devcontainer read-configuration: {:?}", e);
540            log::error!("{}", &message);
541            Err(DevContainerError::DevContainerNotFound)
542        }
543    }
544}
545
546async fn devcontainer_template_apply(
547    template: &DevContainerTemplate,
548    template_options: &HashMap<String, String>,
549    features_selected: &HashSet<DevContainerFeature>,
550    path_to_cli: &PathBuf,
551    found_in_path: bool,
552    node_runtime: &NodeRuntime,
553    path: &Arc<Path>,
554    use_podman: bool,
555) -> Result<DevContainerApply, DevContainerError> {
556    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
557        log::error!("Unable to find node runtime path");
558        return Err(DevContainerError::NodeRuntimeNotAvailable);
559    };
560
561    let mut command =
562        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
563
564    let Ok(serialized_options) = serde_json::to_string(template_options) else {
565        log::error!("Unable to serialize options for {:?}", template_options);
566        return Err(DevContainerError::DevContainerParseFailed);
567    };
568
569    command.arg("templates");
570    command.arg("apply");
571    command.arg("--workspace-folder");
572    command.arg(path.display().to_string());
573    command.arg("--template-id");
574    command.arg(format!(
575        "{}/{}",
576        template
577            .source_repository
578            .as_ref()
579            .unwrap_or(&String::from("")),
580        template.id
581    ));
582    command.arg("--template-args");
583    command.arg(serialized_options);
584    command.arg("--features");
585    command.arg(template_features_to_json(features_selected));
586
587    log::debug!("Running full devcontainer apply command: {:?}", command);
588
589    match command.output().await {
590        Ok(output) => {
591            if output.status.success() {
592                let raw = String::from_utf8_lossy(&output.stdout);
593                parse_json_from_cli(&raw)
594            } else {
595                let message = format!(
596                    "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
597                    String::from_utf8_lossy(&output.stdout),
598                    String::from_utf8_lossy(&output.stderr)
599                );
600
601                log::error!("{}", &message);
602                Err(DevContainerError::DevContainerTemplateApplyFailed(message))
603            }
604        }
605        Err(e) => {
606            let message = format!("Error running devcontainer templates apply: {:?}", e);
607            log::error!("{}", &message);
608            Err(DevContainerError::DevContainerTemplateApplyFailed(message))
609        }
610    }
611}
612// Try to parse directly first (newer versions output pure JSON)
613// If that fails, look for JSON start (older versions have plaintext prefix)
614fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
615    serde_json::from_str::<T>(&raw)
616        .or_else(|e| {
617            log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
618            let json_start = raw
619                .find(|c| c == '{')
620                .ok_or_else(|| {
621                    log::error!("No JSON found in devcontainer up output");
622                    DevContainerError::DevContainerParseFailed
623                })?;
624
625            serde_json::from_str(&raw[json_start..]).map_err(|e| {
626                log::error!(
627                    "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
628                    json_start,
629                    e
630                );
631                DevContainerError::DevContainerParseFailed
632            })
633        })
634}
635
636fn devcontainer_cli_command(
637    path_to_cli: &PathBuf,
638    found_in_path: bool,
639    node_runtime_path: &PathBuf,
640    use_podman: bool,
641) -> Command {
642    let mut command = if found_in_path {
643        util::command::new_smol_command(path_to_cli.display().to_string())
644    } else {
645        let mut command =
646            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
647        command.arg(path_to_cli.display().to_string());
648        command
649    };
650
651    if use_podman {
652        command.arg("--docker-path");
653        command.arg("podman");
654    }
655    command
656}
657
658fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
659    Path::new(remote_workspace_folder)
660        .file_name()
661        .and_then(|name| name.to_str())
662        .map(|string| string.to_string())
663        .unwrap_or_else(|| container_id.to_string())
664}
665
666fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
667    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
668        return None;
669    };
670
671    match workspace.update(cx, |workspace, _, cx| {
672        workspace.project().read(cx).active_project_directory(cx)
673    }) {
674        Ok(dir) => dir,
675        Err(e) => {
676            log::error!("Error getting project directory from workspace: {:?}", e);
677            None
678        }
679    }
680}
681
682fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
683    let features_map = features_selected
684        .iter()
685        .map(|feature| {
686            let mut map = HashMap::new();
687            map.insert(
688                "id",
689                format!(
690                    "{}/{}:{}",
691                    feature
692                        .source_repository
693                        .as_ref()
694                        .unwrap_or(&String::from("")),
695                    feature.id,
696                    feature.major_version()
697                ),
698            );
699            map
700        })
701        .collect::<Vec<HashMap<&str, String>>>();
702    serde_json::to_string(&features_map).unwrap()
703}
704
705#[cfg(test)]
706mod tests {
707    use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
708
709    #[test]
710    fn should_parse_from_devcontainer_json() {
711        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
712        let up: DevContainerUp = parse_json_from_cli(json).unwrap();
713        assert_eq!(up._outcome, "success");
714        assert_eq!(
715            up.container_id,
716            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
717        );
718        assert_eq!(up.remote_user, "vscode");
719        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
720
721        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.
722            {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
723        let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
724        assert_eq!(up._outcome, "success");
725        assert_eq!(
726            up.container_id,
727            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
728        );
729        assert_eq!(up.remote_user, "vscode");
730        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
731    }
732}