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