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