1use std::{collections::HashMap, fmt::Display, path::Path, sync::Arc};
2
3use crate::{command_json::CommandRunner, devcontainer_api::DevContainerError};
4use serde::{Deserialize, Deserializer, Serialize};
5use serde_json_lenient::Value;
6use util::command::Command;
7
8#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
9#[serde(untagged)]
10pub(crate) enum ForwardPort {
11 Number(u16),
12 String(String),
13}
14
15#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
16#[serde(rename_all = "camelCase")]
17pub(crate) enum PortAttributeProtocol {
18 Https,
19 Http,
20}
21
22#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
23#[serde(rename_all = "camelCase")]
24pub(crate) enum OnAutoForward {
25 Notify,
26 OpenBrowser,
27 OpenBrowserOnce,
28 OpenPreview,
29 Silent,
30 Ignore,
31}
32
33#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
34#[serde(rename_all = "camelCase")]
35pub(crate) struct PortAttributes {
36 label: String,
37 on_auto_forward: OnAutoForward,
38 elevate_if_needed: bool,
39 require_local_port: bool,
40 protocol: PortAttributeProtocol,
41}
42
43#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
44#[serde(rename_all = "camelCase")]
45pub(crate) enum UserEnvProbe {
46 None,
47 InteractiveShell,
48 LoginShell,
49 LoginInteractiveShell,
50}
51
52#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
53#[serde(rename_all = "camelCase")]
54pub(crate) enum ShutdownAction {
55 None,
56 StopContainer,
57 StopCompose,
58}
59
60#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
61#[serde(rename_all = "camelCase")]
62pub(crate) struct MountDefinition {
63 #[serde(default)]
64 pub(crate) source: Option<String>,
65 pub(crate) target: String,
66 #[serde(rename = "type")]
67 pub(crate) mount_type: Option<String>,
68}
69
70impl Display for MountDefinition {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 let mount_type = self.mount_type.clone().unwrap_or_else(|| {
73 if let Some(source) = &self.source {
74 if source.starts_with('/')
75 || source.starts_with("\\\\")
76 || source.get(1..3) == Some(":\\")
77 || source.get(1..3) == Some(":/")
78 {
79 return "bind".to_string();
80 }
81 }
82 "volume".to_string()
83 });
84 write!(f, "type={}", mount_type)?;
85 if let Some(source) = &self.source {
86 write!(f, ",source={}", source)?;
87 }
88 write!(f, ",target={},consistency=cached", self.target)
89 }
90}
91
92/// Represents the value associated with a feature ID in the `features` map of devcontainer.json.
93///
94/// Per the spec, the value can be:
95/// - A boolean (`true` to enable with defaults)
96/// - A string (shorthand for `{"version": "<value>"}`)
97/// - An object mapping option names to string or boolean values
98///
99/// See: https://containers.dev/implementors/features/#devcontainerjson-properties
100#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
101#[serde(untagged)]
102pub(crate) enum FeatureOptions {
103 Bool(bool),
104 String(String),
105 Options(HashMap<String, FeatureOptionValue>),
106}
107
108#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, Clone)]
109#[serde(untagged)]
110pub(crate) enum FeatureOptionValue {
111 Bool(bool),
112 String(String),
113}
114impl std::fmt::Display for FeatureOptionValue {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 match self {
117 FeatureOptionValue::Bool(b) => write!(f, "{}", b),
118 FeatureOptionValue::String(s) => write!(f, "{}", s),
119 }
120 }
121}
122
123#[derive(Clone, Debug, Serialize, Eq, PartialEq, Default)]
124pub(crate) struct ZedCustomizationsWrapper {
125 pub(crate) zed: ZedCustomization,
126}
127
128#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]
129pub(crate) struct ZedCustomization {
130 #[serde(default)]
131 pub(crate) extensions: Vec<String>,
132}
133
134#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
135#[serde(rename_all = "camelCase")]
136pub(crate) struct ContainerBuild {
137 pub(crate) dockerfile: String,
138 context: Option<String>,
139 pub(crate) args: Option<HashMap<String, String>>,
140 options: Option<Vec<String>>,
141 pub(crate) target: Option<String>,
142 #[serde(default, deserialize_with = "deserialize_string_or_array")]
143 cache_from: Option<Vec<String>>,
144}
145
146#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
147struct LifecycleScriptInternal {
148 command: Option<String>,
149 args: Vec<String>,
150}
151
152impl LifecycleScriptInternal {
153 fn from_args(args: Vec<String>) -> Self {
154 let command = args.get(0).map(|a| a.to_string());
155 let remaining = args.iter().skip(1).map(|a| a.to_string()).collect();
156 Self {
157 command,
158 args: remaining,
159 }
160 }
161}
162
163#[derive(Clone, Debug, Serialize, Eq, PartialEq)]
164pub struct LifecycleScript {
165 scripts: HashMap<String, LifecycleScriptInternal>,
166}
167
168#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
169#[serde(rename_all = "camelCase")]
170pub(crate) struct HostRequirements {
171 cpus: Option<u16>,
172 memory: Option<String>,
173 storage: Option<String>,
174}
175
176#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
177#[serde(rename_all = "camelCase")]
178pub(crate) enum LifecycleCommand {
179 InitializeCommand,
180 OnCreateCommand,
181 UpdateContentCommand,
182 PostCreateCommand,
183 PostStartCommand,
184}
185
186#[derive(Debug, PartialEq, Eq)]
187pub(crate) enum DevContainerBuildType {
188 Image(String),
189 Dockerfile(ContainerBuild),
190 DockerCompose,
191 None,
192}
193#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Default)]
194#[serde(rename_all = "camelCase")]
195pub(crate) struct DevContainer {
196 pub(crate) image: Option<String>,
197 pub(crate) name: Option<String>,
198 pub(crate) remote_user: Option<String>,
199 pub(crate) forward_ports: Option<Vec<ForwardPort>>,
200 pub(crate) ports_attributes: Option<HashMap<String, PortAttributes>>,
201 pub(crate) other_ports_attributes: Option<PortAttributes>,
202 pub(crate) container_env: Option<HashMap<String, String>>,
203 pub(crate) remote_env: Option<HashMap<String, String>>,
204 pub(crate) container_user: Option<String>,
205 #[serde(rename = "updateRemoteUserUID")]
206 pub(crate) update_remote_user_uid: Option<bool>,
207 user_env_probe: Option<UserEnvProbe>,
208 override_command: Option<bool>,
209 shutdown_action: Option<ShutdownAction>,
210 init: Option<bool>,
211 pub(crate) privileged: Option<bool>,
212 cap_add: Option<Vec<String>>,
213 security_opt: Option<Vec<String>>,
214 #[serde(default, deserialize_with = "deserialize_mount_definitions")]
215 pub(crate) mounts: Option<Vec<MountDefinition>>,
216 pub(crate) features: Option<HashMap<String, FeatureOptions>>,
217 pub(crate) override_feature_install_order: Option<Vec<String>>,
218 pub(crate) customizations: Option<ZedCustomizationsWrapper>,
219 pub(crate) build: Option<ContainerBuild>,
220 #[serde(default, deserialize_with = "deserialize_app_port")]
221 pub(crate) app_port: Vec<String>,
222 #[serde(default, deserialize_with = "deserialize_mount_definition")]
223 pub(crate) workspace_mount: Option<MountDefinition>,
224 pub(crate) workspace_folder: Option<String>,
225 pub(crate) run_args: Option<Vec<String>>,
226 #[serde(default, deserialize_with = "deserialize_string_or_array")]
227 pub(crate) docker_compose_file: Option<Vec<String>>,
228 pub(crate) service: Option<String>,
229 run_services: Option<Vec<String>>,
230 pub(crate) initialize_command: Option<LifecycleScript>,
231 pub(crate) on_create_command: Option<LifecycleScript>,
232 pub(crate) update_content_command: Option<LifecycleScript>,
233 pub(crate) post_create_command: Option<LifecycleScript>,
234 pub(crate) post_start_command: Option<LifecycleScript>,
235 pub(crate) post_attach_command: Option<LifecycleScript>,
236 wait_for: Option<LifecycleCommand>,
237 host_requirements: Option<HostRequirements>,
238}
239
240pub(crate) fn deserialize_devcontainer_json(json: &str) -> Result<DevContainer, DevContainerError> {
241 match serde_json_lenient::from_str(json) {
242 Ok(devcontainer) => Ok(devcontainer),
243 Err(e) => {
244 log::error!("Unable to deserialize devcontainer from json: {e}");
245 Err(DevContainerError::DevContainerParseFailed)
246 }
247 }
248}
249
250impl DevContainer {
251 pub(crate) fn build_type(&self) -> DevContainerBuildType {
252 if let Some(image) = &self.image {
253 DevContainerBuildType::Image(image.clone())
254 } else if self.docker_compose_file.is_some() {
255 DevContainerBuildType::DockerCompose
256 } else if let Some(build) = &self.build {
257 DevContainerBuildType::Dockerfile(build.clone())
258 } else {
259 DevContainerBuildType::None
260 }
261 }
262
263 pub(crate) fn validate_devcontainer_contents(&self) -> Result<(), DevContainerError> {
264 match self.build_type() {
265 DevContainerBuildType::Image(_) => Ok(()),
266 DevContainerBuildType::Dockerfile(_) => {
267 if (self.workspace_folder.is_some() && self.workspace_mount.is_none())
268 || (self.workspace_folder.is_none() && self.workspace_mount.is_some())
269 {
270 return Err(DevContainerError::DevContainerValidationFailed(
271 "workspaceMount and workspaceFolder must both be defined, or neither defined"
272 .to_string(),
273 ));
274 }
275 Ok(())
276 }
277 DevContainerBuildType::DockerCompose => {
278 if self.service.is_none() {
279 return Err(DevContainerError::DevContainerValidationFailed(
280 "must specify a connecting service for docker-compose".to_string(),
281 ));
282 }
283 Ok(())
284 }
285 DevContainerBuildType::None => Ok(()),
286 }
287 }
288}
289
290// Custom deserializer that parses the entire customizations object as a
291// serde_json_lenient::Value first, then extracts the "zed" portion.
292// This avoids a bug in serde_json_lenient's `ignore_value` codepath which
293// does not handle trailing commas in skipped values.
294impl<'de> Deserialize<'de> for ZedCustomizationsWrapper {
295 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
296 where
297 D: Deserializer<'de>,
298 {
299 let value = Value::deserialize(deserializer)?;
300 let zed = value
301 .get("zed")
302 .map(|zed_value| serde_json_lenient::from_value::<ZedCustomization>(zed_value.clone()))
303 .transpose()
304 .map_err(serde::de::Error::custom)?
305 .unwrap_or_default();
306 Ok(ZedCustomizationsWrapper { zed })
307 }
308}
309
310impl LifecycleScript {
311 fn from_map(args: HashMap<String, Vec<String>>) -> Self {
312 Self {
313 scripts: args
314 .into_iter()
315 .map(|(k, v)| (k, LifecycleScriptInternal::from_args(v)))
316 .collect(),
317 }
318 }
319 fn from_str(args: &str) -> Self {
320 let script: Vec<String> = args.split(" ").map(|a| a.to_string()).collect();
321
322 Self::from_args(script)
323 }
324 fn from_args(args: Vec<String>) -> Self {
325 Self::from_map(HashMap::from([("default".to_string(), args)]))
326 }
327 pub fn script_commands(&self) -> HashMap<String, Command> {
328 self.scripts
329 .iter()
330 .filter_map(|(k, v)| {
331 if let Some(inner_command) = &v.command {
332 let mut command = Command::new(inner_command);
333 command.args(&v.args);
334 Some((k.clone(), command))
335 } else {
336 log::warn!(
337 "Lifecycle script command {k}, value {:?} has no program to run. Skipping",
338 v
339 );
340 None
341 }
342 })
343 .collect()
344 }
345
346 pub async fn run(
347 &self,
348 command_runnder: &Arc<dyn CommandRunner>,
349 working_directory: &Path,
350 ) -> Result<(), DevContainerError> {
351 for (command_name, mut command) in self.script_commands() {
352 log::debug!("Running script {command_name}");
353
354 command.current_dir(working_directory);
355
356 let output = command_runnder
357 .run_command(&mut command)
358 .await
359 .map_err(|e| {
360 log::error!("Error running command {command_name}: {e}");
361 DevContainerError::CommandFailed(command_name.clone())
362 })?;
363 if !output.status.success() {
364 let std_err = String::from_utf8_lossy(&output.stderr);
365 log::error!(
366 "Command {command_name} produced a non-successful output. StdErr: {std_err}"
367 );
368 }
369 let std_out = String::from_utf8_lossy(&output.stdout);
370 log::debug!("Command {command_name} output:\n {std_out}");
371 }
372 Ok(())
373 }
374}
375
376impl<'de> Deserialize<'de> for LifecycleScript {
377 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
378 where
379 D: Deserializer<'de>,
380 {
381 use serde::de::{self, Visitor};
382 use std::fmt;
383
384 struct LifecycleScriptVisitor;
385
386 impl<'de> Visitor<'de> for LifecycleScriptVisitor {
387 type Value = LifecycleScript;
388
389 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
390 formatter.write_str("a string, an array of strings, or a map of arrays")
391 }
392
393 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
394 where
395 E: de::Error,
396 {
397 Ok(LifecycleScript::from_str(value))
398 }
399
400 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
401 where
402 A: de::SeqAccess<'de>,
403 {
404 let mut array = Vec::new();
405 while let Some(elem) = seq.next_element()? {
406 array.push(elem);
407 }
408 Ok(LifecycleScript::from_args(array))
409 }
410
411 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
412 where
413 A: de::MapAccess<'de>,
414 {
415 let mut result = HashMap::new();
416 while let Some(key) = map.next_key::<String>()? {
417 let value: Value = map.next_value()?;
418 let script_args = match value {
419 Value::String(s) => {
420 s.split(" ").map(|s| s.to_string()).collect::<Vec<String>>()
421 }
422 Value::Array(arr) => {
423 let strings: Vec<String> = arr
424 .into_iter()
425 .filter_map(|v| v.as_str().map(|s| s.to_string()))
426 .collect();
427 strings
428 }
429 _ => continue,
430 };
431 result.insert(key, script_args);
432 }
433 Ok(LifecycleScript::from_map(result))
434 }
435 }
436
437 deserializer.deserialize_any(LifecycleScriptVisitor)
438 }
439}
440
441fn deserialize_mount_definition<'de, D>(
442 deserializer: D,
443) -> Result<Option<MountDefinition>, D::Error>
444where
445 D: serde::Deserializer<'de>,
446{
447 use serde::Deserialize;
448 use serde::de::Error;
449
450 #[derive(Deserialize)]
451 #[serde(untagged)]
452 enum MountItem {
453 Object(MountDefinition),
454 String(String),
455 }
456
457 let item = MountItem::deserialize(deserializer)?;
458
459 let mount = match item {
460 MountItem::Object(mount) => mount,
461 MountItem::String(s) => {
462 let mut source = None;
463 let mut target = None;
464 let mut mount_type = None;
465
466 for part in s.split(',') {
467 let part = part.trim();
468 if let Some((key, value)) = part.split_once('=') {
469 match key.trim() {
470 "source" => source = Some(value.trim().to_string()),
471 "target" => target = Some(value.trim().to_string()),
472 "type" => mount_type = Some(value.trim().to_string()),
473 _ => {} // Ignore unknown keys
474 }
475 }
476 }
477
478 let target = target
479 .ok_or_else(|| D::Error::custom(format!("mount string missing 'target': {}", s)))?;
480
481 MountDefinition {
482 source,
483 target,
484 mount_type,
485 }
486 }
487 };
488
489 Ok(Some(mount))
490}
491
492fn deserialize_mount_definitions<'de, D>(
493 deserializer: D,
494) -> Result<Option<Vec<MountDefinition>>, D::Error>
495where
496 D: serde::Deserializer<'de>,
497{
498 use serde::Deserialize;
499 use serde::de::Error;
500
501 #[derive(Deserialize)]
502 #[serde(untagged)]
503 enum MountItem {
504 Object(MountDefinition),
505 String(String),
506 }
507
508 let items = Vec::<MountItem>::deserialize(deserializer)?;
509 let mut mounts = Vec::new();
510
511 for item in items {
512 match item {
513 MountItem::Object(mount) => mounts.push(mount),
514 MountItem::String(s) => {
515 let mut source = None;
516 let mut target = None;
517 let mut mount_type = None;
518
519 for part in s.split(',') {
520 let part = part.trim();
521 if let Some((key, value)) = part.split_once('=') {
522 match key.trim() {
523 "source" => source = Some(value.trim().to_string()),
524 "target" => target = Some(value.trim().to_string()),
525 "type" => mount_type = Some(value.trim().to_string()),
526 _ => {} // Ignore unknown keys
527 }
528 }
529 }
530
531 let target = target.ok_or_else(|| {
532 D::Error::custom(format!("mount string missing 'target': {}", s))
533 })?;
534
535 mounts.push(MountDefinition {
536 source,
537 target,
538 mount_type,
539 });
540 }
541 }
542 }
543
544 Ok(Some(mounts))
545}
546
547fn deserialize_app_port<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
548where
549 D: serde::Deserializer<'de>,
550{
551 use serde::Deserialize;
552
553 #[derive(Deserialize)]
554 #[serde(untagged)]
555 enum StringOrInt {
556 String(String),
557 Int(u32),
558 }
559
560 #[derive(Deserialize)]
561 #[serde(untagged)]
562 enum AppPort {
563 Array(Vec<StringOrInt>),
564 Single(StringOrInt),
565 }
566
567 fn normalize_port(value: StringOrInt) -> String {
568 match value {
569 StringOrInt::String(s) => {
570 if s.contains(':') {
571 s
572 } else {
573 format!("{s}:{s}")
574 }
575 }
576 StringOrInt::Int(n) => format!("{n}:{n}"),
577 }
578 }
579
580 match AppPort::deserialize(deserializer)? {
581 AppPort::Single(value) => Ok(vec![normalize_port(value)]),
582 AppPort::Array(values) => Ok(values.into_iter().map(normalize_port).collect()),
583 }
584}
585
586fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
587where
588 D: serde::Deserializer<'de>,
589{
590 use serde::Deserialize;
591
592 #[derive(Deserialize)]
593 #[serde(untagged)]
594 enum StringOrArray {
595 String(String),
596 Array(Vec<String>),
597 }
598
599 match StringOrArray::deserialize(deserializer)? {
600 StringOrArray::String(s) => Ok(Some(vec![s])),
601 StringOrArray::Array(b) => Ok(Some(b)),
602 }
603}
604
605#[cfg(test)]
606mod test {
607 use std::collections::HashMap;
608
609 use crate::{
610 devcontainer_api::DevContainerError,
611 devcontainer_json::{
612 ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort,
613 HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward,
614 PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization,
615 ZedCustomizationsWrapper, deserialize_devcontainer_json,
616 },
617 };
618
619 #[test]
620 fn should_deserialize_customizations_with_unknown_keys() {
621 let json_with_other_customizations = r#"
622 {
623 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
624 "customizations": {
625 "vscode": {
626 "extensions": [
627 "dbaeumer.vscode-eslint",
628 "GitHub.vscode-pull-request-github",
629 ],
630 },
631 "zed": {
632 "extensions": ["vue", "ruby"],
633 },
634 "codespaces": {
635 "repositories": {
636 "devcontainers/features": {
637 "permissions": {
638 "contents": "write",
639 "workflows": "write",
640 },
641 },
642 },
643 },
644 },
645 }
646 "#;
647
648 let result = deserialize_devcontainer_json(json_with_other_customizations);
649
650 assert!(
651 result.is_ok(),
652 "Should ignore unknown customization keys, but got: {:?}",
653 result.err()
654 );
655 let devcontainer = result.expect("ok");
656 assert_eq!(
657 devcontainer.customizations,
658 Some(ZedCustomizationsWrapper {
659 zed: ZedCustomization {
660 extensions: vec!["vue".to_string(), "ruby".to_string()]
661 }
662 })
663 );
664 }
665
666 #[test]
667 fn should_deserialize_customizations_without_zed_key() {
668 let json_without_zed = r#"
669 {
670 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
671 "customizations": {
672 "vscode": {
673 "extensions": ["dbaeumer.vscode-eslint"]
674 }
675 }
676 }
677 "#;
678
679 let result = deserialize_devcontainer_json(json_without_zed);
680
681 assert!(
682 result.is_ok(),
683 "Should handle missing zed key in customizations, but got: {:?}",
684 result.err()
685 );
686 let devcontainer = result.expect("ok");
687 assert_eq!(
688 devcontainer.customizations,
689 Some(ZedCustomizationsWrapper {
690 zed: ZedCustomization { extensions: vec![] }
691 })
692 );
693 }
694
695 #[test]
696 fn should_deserialize_simple_devcontainer_json() {
697 let given_bad_json = "{ \"image\": 123 }";
698
699 let result = deserialize_devcontainer_json(given_bad_json);
700
701 assert!(result.is_err());
702 assert_eq!(
703 result.expect_err("err"),
704 DevContainerError::DevContainerParseFailed
705 );
706
707 let given_image_container_json = r#"
708 // These are some external comments. serde_lenient should handle them
709 {
710 // These are some internal comments
711 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
712 "name": "myDevContainer",
713 "remoteUser": "root",
714 "forwardPorts": [
715 "db:5432",
716 3000
717 ],
718 "portsAttributes": {
719 "3000": {
720 "label": "This Port",
721 "onAutoForward": "notify",
722 "elevateIfNeeded": false,
723 "requireLocalPort": true,
724 "protocol": "https"
725 },
726 "db:5432": {
727 "label": "This Port too",
728 "onAutoForward": "silent",
729 "elevateIfNeeded": true,
730 "requireLocalPort": false,
731 "protocol": "http"
732 }
733 },
734 "otherPortsAttributes": {
735 "label": "Other Ports",
736 "onAutoForward": "openBrowser",
737 "elevateIfNeeded": true,
738 "requireLocalPort": true,
739 "protocol": "https"
740 },
741 "updateRemoteUserUID": true,
742 "remoteEnv": {
743 "MYVAR1": "myvarvalue",
744 "MYVAR2": "myvarothervalue"
745 },
746 "initializeCommand": ["echo", "initialize_command"],
747 "onCreateCommand": "echo on_create_command",
748 "updateContentCommand": {
749 "first": "echo update_content_command",
750 "second": ["echo", "update_content_command"]
751 },
752 "postCreateCommand": ["echo", "post_create_command"],
753 "postStartCommand": "echo post_start_command",
754 "postAttachCommand": {
755 "something": "echo post_attach_command",
756 "something1": "echo something else",
757 },
758 "waitFor": "postStartCommand",
759 "userEnvProbe": "loginShell",
760 "features": {
761 "ghcr.io/devcontainers/features/aws-cli:1": {},
762 "ghcr.io/devcontainers/features/anaconda:1": {}
763 },
764 "overrideFeatureInstallOrder": [
765 "ghcr.io/devcontainers/features/anaconda:1",
766 "ghcr.io/devcontainers/features/aws-cli:1"
767 ],
768 "hostRequirements": {
769 "cpus": 2,
770 "memory": "8gb",
771 "storage": "32gb",
772 // Note that we're not parsing this currently
773 "gpu": true,
774 },
775 "appPort": 8081,
776 "containerEnv": {
777 "MYVAR3": "myvar3",
778 "MYVAR4": "myvar4"
779 },
780 "containerUser": "myUser",
781 "mounts": [
782 {
783 "source": "/localfolder/app",
784 "target": "/workspaces/app",
785 "type": "volume"
786 }
787 ],
788 "runArgs": [
789 "-c",
790 "some_command"
791 ],
792 "shutdownAction": "stopContainer",
793 "overrideCommand": true,
794 "workspaceFolder": "/workspaces",
795 "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
796 "customizations": {
797 "vscode": {
798 // Just confirm that this can be included and ignored
799 },
800 "zed": {
801 "extensions": [
802 "html"
803 ]
804 }
805 }
806 }
807 "#;
808
809 let result = deserialize_devcontainer_json(given_image_container_json);
810
811 assert!(result.is_ok());
812 let devcontainer = result.expect("ok");
813 assert_eq!(
814 devcontainer,
815 DevContainer {
816 image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")),
817 name: Some(String::from("myDevContainer")),
818 remote_user: Some(String::from("root")),
819 forward_ports: Some(vec![
820 ForwardPort::String("db:5432".to_string()),
821 ForwardPort::Number(3000),
822 ]),
823 ports_attributes: Some(HashMap::from([
824 (
825 "3000".to_string(),
826 PortAttributes {
827 label: "This Port".to_string(),
828 on_auto_forward: OnAutoForward::Notify,
829 elevate_if_needed: false,
830 require_local_port: true,
831 protocol: PortAttributeProtocol::Https
832 }
833 ),
834 (
835 "db:5432".to_string(),
836 PortAttributes {
837 label: "This Port too".to_string(),
838 on_auto_forward: OnAutoForward::Silent,
839 elevate_if_needed: true,
840 require_local_port: false,
841 protocol: PortAttributeProtocol::Http
842 }
843 )
844 ])),
845 other_ports_attributes: Some(PortAttributes {
846 label: "Other Ports".to_string(),
847 on_auto_forward: OnAutoForward::OpenBrowser,
848 elevate_if_needed: true,
849 require_local_port: true,
850 protocol: PortAttributeProtocol::Https
851 }),
852 update_remote_user_uid: Some(true),
853 remote_env: Some(HashMap::from([
854 ("MYVAR1".to_string(), "myvarvalue".to_string()),
855 ("MYVAR2".to_string(), "myvarothervalue".to_string())
856 ])),
857 initialize_command: Some(LifecycleScript::from_args(vec![
858 "echo".to_string(),
859 "initialize_command".to_string()
860 ])),
861 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
862 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
863 (
864 "first".to_string(),
865 vec!["echo".to_string(), "update_content_command".to_string()]
866 ),
867 (
868 "second".to_string(),
869 vec!["echo".to_string(), "update_content_command".to_string()]
870 )
871 ]))),
872 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
873 post_start_command: Some(LifecycleScript::from_args(vec![
874 "echo".to_string(),
875 "post_start_command".to_string()
876 ])),
877 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
878 (
879 "something".to_string(),
880 vec!["echo".to_string(), "post_attach_command".to_string()]
881 ),
882 (
883 "something1".to_string(),
884 vec![
885 "echo".to_string(),
886 "something".to_string(),
887 "else".to_string()
888 ]
889 )
890 ]))),
891 wait_for: Some(LifecycleCommand::PostStartCommand),
892 user_env_probe: Some(UserEnvProbe::LoginShell),
893 features: Some(HashMap::from([
894 (
895 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
896 FeatureOptions::Options(HashMap::new())
897 ),
898 (
899 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
900 FeatureOptions::Options(HashMap::new())
901 )
902 ])),
903 override_feature_install_order: Some(vec![
904 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
905 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
906 ]),
907 host_requirements: Some(HostRequirements {
908 cpus: Some(2),
909 memory: Some("8gb".to_string()),
910 storage: Some("32gb".to_string()),
911 }),
912 app_port: vec!["8081:8081".to_string()],
913 container_env: Some(HashMap::from([
914 ("MYVAR3".to_string(), "myvar3".to_string()),
915 ("MYVAR4".to_string(), "myvar4".to_string())
916 ])),
917 container_user: Some("myUser".to_string()),
918 mounts: Some(vec![MountDefinition {
919 source: Some("/localfolder/app".to_string()),
920 target: "/workspaces/app".to_string(),
921 mount_type: Some("volume".to_string()),
922 }]),
923 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
924 shutdown_action: Some(ShutdownAction::StopContainer),
925 override_command: Some(true),
926 workspace_folder: Some("/workspaces".to_string()),
927 workspace_mount: Some(MountDefinition {
928 source: Some("/app".to_string()),
929 target: "/workspaces/app".to_string(),
930 mount_type: Some("bind".to_string())
931 }),
932 customizations: Some(ZedCustomizationsWrapper {
933 zed: ZedCustomization {
934 extensions: vec!["html".to_string()]
935 }
936 }),
937 ..Default::default()
938 }
939 );
940
941 assert_eq!(
942 devcontainer.build_type(),
943 DevContainerBuildType::Image(String::from(
944 "mcr.microsoft.com/devcontainers/base:ubuntu"
945 ))
946 );
947 }
948
949 #[test]
950 fn should_deserialize_docker_compose_devcontainer_json() {
951 let given_docker_compose_json = r#"
952 // These are some external comments. serde_lenient should handle them
953 {
954 // These are some internal comments
955 "name": "myDevContainer",
956 "remoteUser": "root",
957 "forwardPorts": [
958 "db:5432",
959 3000
960 ],
961 "portsAttributes": {
962 "3000": {
963 "label": "This Port",
964 "onAutoForward": "notify",
965 "elevateIfNeeded": false,
966 "requireLocalPort": true,
967 "protocol": "https"
968 },
969 "db:5432": {
970 "label": "This Port too",
971 "onAutoForward": "silent",
972 "elevateIfNeeded": true,
973 "requireLocalPort": false,
974 "protocol": "http"
975 }
976 },
977 "otherPortsAttributes": {
978 "label": "Other Ports",
979 "onAutoForward": "openBrowser",
980 "elevateIfNeeded": true,
981 "requireLocalPort": true,
982 "protocol": "https"
983 },
984 "updateRemoteUserUID": true,
985 "remoteEnv": {
986 "MYVAR1": "myvarvalue",
987 "MYVAR2": "myvarothervalue"
988 },
989 "initializeCommand": ["echo", "initialize_command"],
990 "onCreateCommand": "echo on_create_command",
991 "updateContentCommand": {
992 "first": "echo update_content_command",
993 "second": ["echo", "update_content_command"]
994 },
995 "postCreateCommand": ["echo", "post_create_command"],
996 "postStartCommand": "echo post_start_command",
997 "postAttachCommand": {
998 "something": "echo post_attach_command",
999 "something1": "echo something else",
1000 },
1001 "waitFor": "postStartCommand",
1002 "userEnvProbe": "loginShell",
1003 "features": {
1004 "ghcr.io/devcontainers/features/aws-cli:1": {},
1005 "ghcr.io/devcontainers/features/anaconda:1": {}
1006 },
1007 "overrideFeatureInstallOrder": [
1008 "ghcr.io/devcontainers/features/anaconda:1",
1009 "ghcr.io/devcontainers/features/aws-cli:1"
1010 ],
1011 "hostRequirements": {
1012 "cpus": 2,
1013 "memory": "8gb",
1014 "storage": "32gb",
1015 // Note that we're not parsing this currently
1016 "gpu": true,
1017 },
1018 "dockerComposeFile": "docker-compose.yml",
1019 "service": "myService",
1020 "runServices": [
1021 "myService",
1022 "mySupportingService"
1023 ],
1024 "workspaceFolder": "/workspaces/thing",
1025 "shutdownAction": "stopCompose",
1026 "overrideCommand": true
1027 }
1028 "#;
1029 let result = deserialize_devcontainer_json(given_docker_compose_json);
1030
1031 assert!(result.is_ok());
1032 let devcontainer = result.expect("ok");
1033 assert_eq!(
1034 devcontainer,
1035 DevContainer {
1036 name: Some(String::from("myDevContainer")),
1037 remote_user: Some(String::from("root")),
1038 forward_ports: Some(vec![
1039 ForwardPort::String("db:5432".to_string()),
1040 ForwardPort::Number(3000),
1041 ]),
1042 ports_attributes: Some(HashMap::from([
1043 (
1044 "3000".to_string(),
1045 PortAttributes {
1046 label: "This Port".to_string(),
1047 on_auto_forward: OnAutoForward::Notify,
1048 elevate_if_needed: false,
1049 require_local_port: true,
1050 protocol: PortAttributeProtocol::Https
1051 }
1052 ),
1053 (
1054 "db:5432".to_string(),
1055 PortAttributes {
1056 label: "This Port too".to_string(),
1057 on_auto_forward: OnAutoForward::Silent,
1058 elevate_if_needed: true,
1059 require_local_port: false,
1060 protocol: PortAttributeProtocol::Http
1061 }
1062 )
1063 ])),
1064 other_ports_attributes: Some(PortAttributes {
1065 label: "Other Ports".to_string(),
1066 on_auto_forward: OnAutoForward::OpenBrowser,
1067 elevate_if_needed: true,
1068 require_local_port: true,
1069 protocol: PortAttributeProtocol::Https
1070 }),
1071 update_remote_user_uid: Some(true),
1072 remote_env: Some(HashMap::from([
1073 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1074 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1075 ])),
1076 initialize_command: Some(LifecycleScript::from_args(vec![
1077 "echo".to_string(),
1078 "initialize_command".to_string()
1079 ])),
1080 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1081 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1082 (
1083 "first".to_string(),
1084 vec!["echo".to_string(), "update_content_command".to_string()]
1085 ),
1086 (
1087 "second".to_string(),
1088 vec!["echo".to_string(), "update_content_command".to_string()]
1089 )
1090 ]))),
1091 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1092 post_start_command: Some(LifecycleScript::from_args(vec![
1093 "echo".to_string(),
1094 "post_start_command".to_string()
1095 ])),
1096 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1097 (
1098 "something".to_string(),
1099 vec!["echo".to_string(), "post_attach_command".to_string()]
1100 ),
1101 (
1102 "something1".to_string(),
1103 vec![
1104 "echo".to_string(),
1105 "something".to_string(),
1106 "else".to_string()
1107 ]
1108 )
1109 ]))),
1110 wait_for: Some(LifecycleCommand::PostStartCommand),
1111 user_env_probe: Some(UserEnvProbe::LoginShell),
1112 features: Some(HashMap::from([
1113 (
1114 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1115 FeatureOptions::Options(HashMap::new())
1116 ),
1117 (
1118 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1119 FeatureOptions::Options(HashMap::new())
1120 )
1121 ])),
1122 override_feature_install_order: Some(vec![
1123 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1124 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1125 ]),
1126 host_requirements: Some(HostRequirements {
1127 cpus: Some(2),
1128 memory: Some("8gb".to_string()),
1129 storage: Some("32gb".to_string()),
1130 }),
1131 docker_compose_file: Some(vec!["docker-compose.yml".to_string()]),
1132 service: Some("myService".to_string()),
1133 run_services: Some(vec![
1134 "myService".to_string(),
1135 "mySupportingService".to_string(),
1136 ]),
1137 workspace_folder: Some("/workspaces/thing".to_string()),
1138 shutdown_action: Some(ShutdownAction::StopCompose),
1139 override_command: Some(true),
1140 ..Default::default()
1141 }
1142 );
1143
1144 assert_eq!(
1145 devcontainer.build_type(),
1146 DevContainerBuildType::DockerCompose
1147 );
1148 }
1149
1150 #[test]
1151 fn should_deserialize_dockerfile_devcontainer_json() {
1152 let given_dockerfile_container_json = r#"
1153 // These are some external comments. serde_lenient should handle them
1154 {
1155 // These are some internal comments
1156 "name": "myDevContainer",
1157 "remoteUser": "root",
1158 "forwardPorts": [
1159 "db:5432",
1160 3000
1161 ],
1162 "portsAttributes": {
1163 "3000": {
1164 "label": "This Port",
1165 "onAutoForward": "notify",
1166 "elevateIfNeeded": false,
1167 "requireLocalPort": true,
1168 "protocol": "https"
1169 },
1170 "db:5432": {
1171 "label": "This Port too",
1172 "onAutoForward": "silent",
1173 "elevateIfNeeded": true,
1174 "requireLocalPort": false,
1175 "protocol": "http"
1176 }
1177 },
1178 "otherPortsAttributes": {
1179 "label": "Other Ports",
1180 "onAutoForward": "openBrowser",
1181 "elevateIfNeeded": true,
1182 "requireLocalPort": true,
1183 "protocol": "https"
1184 },
1185 "updateRemoteUserUID": true,
1186 "remoteEnv": {
1187 "MYVAR1": "myvarvalue",
1188 "MYVAR2": "myvarothervalue"
1189 },
1190 "initializeCommand": ["echo", "initialize_command"],
1191 "onCreateCommand": "echo on_create_command",
1192 "updateContentCommand": {
1193 "first": "echo update_content_command",
1194 "second": ["echo", "update_content_command"]
1195 },
1196 "postCreateCommand": ["echo", "post_create_command"],
1197 "postStartCommand": "echo post_start_command",
1198 "postAttachCommand": {
1199 "something": "echo post_attach_command",
1200 "something1": "echo something else",
1201 },
1202 "waitFor": "postStartCommand",
1203 "userEnvProbe": "loginShell",
1204 "features": {
1205 "ghcr.io/devcontainers/features/aws-cli:1": {},
1206 "ghcr.io/devcontainers/features/anaconda:1": {}
1207 },
1208 "overrideFeatureInstallOrder": [
1209 "ghcr.io/devcontainers/features/anaconda:1",
1210 "ghcr.io/devcontainers/features/aws-cli:1"
1211 ],
1212 "hostRequirements": {
1213 "cpus": 2,
1214 "memory": "8gb",
1215 "storage": "32gb",
1216 // Note that we're not parsing this currently
1217 "gpu": true,
1218 },
1219 "appPort": 8081,
1220 "containerEnv": {
1221 "MYVAR3": "myvar3",
1222 "MYVAR4": "myvar4"
1223 },
1224 "containerUser": "myUser",
1225 "mounts": [
1226 {
1227 "source": "/localfolder/app",
1228 "target": "/workspaces/app",
1229 "type": "volume"
1230 },
1231 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
1232 ],
1233 "runArgs": [
1234 "-c",
1235 "some_command"
1236 ],
1237 "shutdownAction": "stopContainer",
1238 "overrideCommand": true,
1239 "workspaceFolder": "/workspaces",
1240 "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached",
1241 "build": {
1242 "dockerfile": "DockerFile",
1243 "context": "..",
1244 "args": {
1245 "MYARG": "MYVALUE"
1246 },
1247 "options": [
1248 "--some-option",
1249 "--mount"
1250 ],
1251 "target": "development",
1252 "cacheFrom": "some_image"
1253 }
1254 }
1255 "#;
1256
1257 let result = deserialize_devcontainer_json(given_dockerfile_container_json);
1258
1259 assert!(result.is_ok());
1260 let devcontainer = result.expect("ok");
1261 assert_eq!(
1262 devcontainer,
1263 DevContainer {
1264 name: Some(String::from("myDevContainer")),
1265 remote_user: Some(String::from("root")),
1266 forward_ports: Some(vec![
1267 ForwardPort::String("db:5432".to_string()),
1268 ForwardPort::Number(3000),
1269 ]),
1270 ports_attributes: Some(HashMap::from([
1271 (
1272 "3000".to_string(),
1273 PortAttributes {
1274 label: "This Port".to_string(),
1275 on_auto_forward: OnAutoForward::Notify,
1276 elevate_if_needed: false,
1277 require_local_port: true,
1278 protocol: PortAttributeProtocol::Https
1279 }
1280 ),
1281 (
1282 "db:5432".to_string(),
1283 PortAttributes {
1284 label: "This Port too".to_string(),
1285 on_auto_forward: OnAutoForward::Silent,
1286 elevate_if_needed: true,
1287 require_local_port: false,
1288 protocol: PortAttributeProtocol::Http
1289 }
1290 )
1291 ])),
1292 other_ports_attributes: Some(PortAttributes {
1293 label: "Other Ports".to_string(),
1294 on_auto_forward: OnAutoForward::OpenBrowser,
1295 elevate_if_needed: true,
1296 require_local_port: true,
1297 protocol: PortAttributeProtocol::Https
1298 }),
1299 update_remote_user_uid: Some(true),
1300 remote_env: Some(HashMap::from([
1301 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1302 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1303 ])),
1304 initialize_command: Some(LifecycleScript::from_args(vec![
1305 "echo".to_string(),
1306 "initialize_command".to_string()
1307 ])),
1308 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1309 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1310 (
1311 "first".to_string(),
1312 vec!["echo".to_string(), "update_content_command".to_string()]
1313 ),
1314 (
1315 "second".to_string(),
1316 vec!["echo".to_string(), "update_content_command".to_string()]
1317 )
1318 ]))),
1319 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1320 post_start_command: Some(LifecycleScript::from_args(vec![
1321 "echo".to_string(),
1322 "post_start_command".to_string()
1323 ])),
1324 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1325 (
1326 "something".to_string(),
1327 vec!["echo".to_string(), "post_attach_command".to_string()]
1328 ),
1329 (
1330 "something1".to_string(),
1331 vec![
1332 "echo".to_string(),
1333 "something".to_string(),
1334 "else".to_string()
1335 ]
1336 )
1337 ]))),
1338 wait_for: Some(LifecycleCommand::PostStartCommand),
1339 user_env_probe: Some(UserEnvProbe::LoginShell),
1340 features: Some(HashMap::from([
1341 (
1342 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1343 FeatureOptions::Options(HashMap::new())
1344 ),
1345 (
1346 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1347 FeatureOptions::Options(HashMap::new())
1348 )
1349 ])),
1350 override_feature_install_order: Some(vec![
1351 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1352 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1353 ]),
1354 host_requirements: Some(HostRequirements {
1355 cpus: Some(2),
1356 memory: Some("8gb".to_string()),
1357 storage: Some("32gb".to_string()),
1358 }),
1359 app_port: vec!["8081:8081".to_string()],
1360 container_env: Some(HashMap::from([
1361 ("MYVAR3".to_string(), "myvar3".to_string()),
1362 ("MYVAR4".to_string(), "myvar4".to_string())
1363 ])),
1364 container_user: Some("myUser".to_string()),
1365 mounts: Some(vec![
1366 MountDefinition {
1367 source: Some("/localfolder/app".to_string()),
1368 target: "/workspaces/app".to_string(),
1369 mount_type: Some("volume".to_string()),
1370 },
1371 MountDefinition {
1372 source: Some("dev-containers-cli-bashhistory".to_string()),
1373 target: "/home/node/commandhistory".to_string(),
1374 mount_type: None,
1375 }
1376 ]),
1377 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
1378 shutdown_action: Some(ShutdownAction::StopContainer),
1379 override_command: Some(true),
1380 workspace_folder: Some("/workspaces".to_string()),
1381 workspace_mount: Some(MountDefinition {
1382 source: Some("/folder".to_string()),
1383 target: "/workspace".to_string(),
1384 mount_type: Some("bind".to_string())
1385 }),
1386 build: Some(ContainerBuild {
1387 dockerfile: "DockerFile".to_string(),
1388 context: Some("..".to_string()),
1389 args: Some(HashMap::from([(
1390 "MYARG".to_string(),
1391 "MYVALUE".to_string()
1392 )])),
1393 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1394 target: Some("development".to_string()),
1395 cache_from: Some(vec!["some_image".to_string()]),
1396 }),
1397 ..Default::default()
1398 }
1399 );
1400
1401 assert_eq!(
1402 devcontainer.build_type(),
1403 DevContainerBuildType::Dockerfile(ContainerBuild {
1404 dockerfile: "DockerFile".to_string(),
1405 context: Some("..".to_string()),
1406 args: Some(HashMap::from([(
1407 "MYARG".to_string(),
1408 "MYVALUE".to_string()
1409 )])),
1410 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1411 target: Some("development".to_string()),
1412 cache_from: Some(vec!["some_image".to_string()]),
1413 })
1414 );
1415 }
1416
1417 #[test]
1418 fn should_deserialize_app_port_array() {
1419 let given_json = r#"
1420 // These are some external comments. serde_lenient should handle them
1421 {
1422 // These are some internal comments
1423 "name": "myDevContainer",
1424 "remoteUser": "root",
1425 "appPort": [
1426 "8081:8083",
1427 "9001",
1428 ],
1429 "build": {
1430 "dockerfile": "DockerFile",
1431 }
1432 }
1433 "#;
1434
1435 let result = deserialize_devcontainer_json(given_json);
1436
1437 assert!(result.is_ok());
1438 let devcontainer = result.expect("ok");
1439
1440 assert_eq!(
1441 devcontainer.app_port,
1442 vec!["8081:8083".to_string(), "9001:9001".to_string()]
1443 )
1444 }
1445
1446 #[test]
1447 fn mount_definition_should_use_bind_type_for_unix_absolute_paths() {
1448 let mount = MountDefinition {
1449 source: Some("/home/user/project".to_string()),
1450 target: "/workspaces/project".to_string(),
1451 mount_type: None,
1452 };
1453
1454 let rendered = mount.to_string();
1455
1456 assert!(
1457 rendered.starts_with("type=bind,"),
1458 "Expected mount type 'bind' for Unix absolute path, but got: {rendered}"
1459 );
1460 }
1461
1462 #[test]
1463 fn mount_definition_should_use_bind_type_for_windows_unc_paths() {
1464 let mount = MountDefinition {
1465 source: Some("\\\\server\\share\\project".to_string()),
1466 target: "/workspaces/project".to_string(),
1467 mount_type: None,
1468 };
1469
1470 let rendered = mount.to_string();
1471
1472 assert!(
1473 rendered.starts_with("type=bind,"),
1474 "Expected mount type 'bind' for Windows UNC path, but got: {rendered}"
1475 );
1476 }
1477
1478 #[test]
1479 fn mount_definition_should_use_bind_type_for_windows_absolute_paths() {
1480 let mount = MountDefinition {
1481 source: Some("C:\\Users\\mrg\\cli".to_string()),
1482 target: "/workspaces/cli".to_string(),
1483 mount_type: None,
1484 };
1485
1486 let rendered = mount.to_string();
1487
1488 assert!(
1489 rendered.starts_with("type=bind,"),
1490 "Expected mount type 'bind' for Windows absolute path, but got: {rendered}"
1491 );
1492 }
1493
1494 #[test]
1495 fn mount_definition_should_omit_source_when_none() {
1496 let mount = MountDefinition {
1497 source: None,
1498 target: "/tmp".to_string(),
1499 mount_type: Some("tmpfs".to_string()),
1500 };
1501
1502 let rendered = mount.to_string();
1503
1504 assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached");
1505 }
1506
1507 #[test]
1508 fn should_fail_validation_with_workspace_mount_only() {
1509 let given_image_container_json = r#"
1510 // These are some external comments. serde_lenient should handle them
1511 {
1512 // These are some internal comments
1513 "build": {
1514 "dockerfile": "Dockerfile",
1515 },
1516 "name": "myDevContainer",
1517 "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
1518 "customizations": {
1519 "vscode": {
1520 // Just confirm that this can be included and ignored
1521 },
1522 "zed": {
1523 "extensions": [
1524 "html"
1525 ]
1526 }
1527 }
1528 }
1529 "#;
1530
1531 let result = deserialize_devcontainer_json(given_image_container_json);
1532
1533 assert!(result.is_ok());
1534 let devcontainer = result.expect("ok");
1535
1536 assert_eq!(
1537 devcontainer.validate_devcontainer_contents(),
1538 Err(DevContainerError::DevContainerValidationFailed(
1539 "workspaceMount and workspaceFolder must both be defined, or neither defined"
1540 .to_string()
1541 ))
1542 );
1543 }
1544
1545 #[test]
1546 fn should_fail_validation_with_workspace_folder_only() {
1547 let given_image_container_json = r#"
1548 // These are some external comments. serde_lenient should handle them
1549 {
1550 // These are some internal comments
1551 "build": {
1552 "dockerfile": "Dockerfile",
1553 },
1554 "name": "myDevContainer",
1555 "workspaceFolder": "/workspaces",
1556 "customizations": {
1557 "vscode": {
1558 // Just confirm that this can be included and ignored
1559 },
1560 "zed": {
1561 "extensions": [
1562 "html"
1563 ]
1564 }
1565 }
1566 }
1567 "#;
1568
1569 let result = deserialize_devcontainer_json(given_image_container_json);
1570
1571 assert!(result.is_ok());
1572 let devcontainer = result.expect("ok");
1573
1574 assert_eq!(
1575 devcontainer.validate_devcontainer_contents(),
1576 Err(DevContainerError::DevContainerValidationFailed(
1577 "workspaceMount and workspaceFolder must both be defined, or neither defined"
1578 .to_string()
1579 ))
1580 );
1581 }
1582 #[test]
1583 fn should_pass_validation_with_workspace_folder_for_docker_compose() {
1584 let given_image_container_json = r#"
1585 // These are some external comments. serde_lenient should handle them
1586 {
1587 // These are some internal comments
1588 "dockerComposeFile": "docker-compose-plain.yml",
1589 "service": "app",
1590 "name": "myDevContainer",
1591 "workspaceFolder": "/workspaces",
1592 "customizations": {
1593 "vscode": {
1594 // Just confirm that this can be included and ignored
1595 },
1596 "zed": {
1597 "extensions": [
1598 "html"
1599 ]
1600 }
1601 }
1602 }
1603 "#;
1604
1605 let result = deserialize_devcontainer_json(given_image_container_json);
1606
1607 assert!(result.is_ok());
1608 let devcontainer = result.expect("ok");
1609
1610 assert!(devcontainer.validate_devcontainer_contents().is_ok());
1611 }
1612}