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}