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}