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 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
264// Custom deserializer that parses the entire customizations object as a
265// serde_json_lenient::Value first, then extracts the "zed" portion.
266// This avoids a bug in serde_json_lenient's `ignore_value` codepath which
267// does not handle trailing commas in skipped values.
268impl<'de> Deserialize<'de> for ZedCustomizationsWrapper {
269 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
270 where
271 D: Deserializer<'de>,
272 {
273 let value = Value::deserialize(deserializer)?;
274 let zed = value
275 .get("zed")
276 .map(|zed_value| serde_json_lenient::from_value::<ZedCustomization>(zed_value.clone()))
277 .transpose()
278 .map_err(serde::de::Error::custom)?
279 .unwrap_or_default();
280 Ok(ZedCustomizationsWrapper { zed })
281 }
282}
283
284impl LifecycleScript {
285 fn from_map(args: HashMap<String, Vec<String>>) -> Self {
286 Self {
287 scripts: args
288 .into_iter()
289 .map(|(k, v)| (k, LifecycleScriptInternal::from_args(v)))
290 .collect(),
291 }
292 }
293 fn from_str(args: &str) -> Self {
294 let script: Vec<String> = args.split(" ").map(|a| a.to_string()).collect();
295
296 Self::from_args(script)
297 }
298 fn from_args(args: Vec<String>) -> Self {
299 Self::from_map(HashMap::from([("default".to_string(), args)]))
300 }
301 pub fn script_commands(&self) -> HashMap<String, Command> {
302 self.scripts
303 .iter()
304 .filter_map(|(k, v)| {
305 if let Some(inner_command) = &v.command {
306 let mut command = Command::new(inner_command);
307 command.args(&v.args);
308 Some((k.clone(), command))
309 } else {
310 log::warn!(
311 "Lifecycle script command {k}, value {:?} has no program to run. Skipping",
312 v
313 );
314 None
315 }
316 })
317 .collect()
318 }
319
320 pub async fn run(
321 &self,
322 command_runnder: &Arc<dyn CommandRunner>,
323 working_directory: &Path,
324 ) -> Result<(), DevContainerError> {
325 for (command_name, mut command) in self.script_commands() {
326 log::debug!("Running script {command_name}");
327
328 command.current_dir(working_directory);
329
330 let output = command_runnder
331 .run_command(&mut command)
332 .await
333 .map_err(|e| {
334 log::error!("Error running command {command_name}: {e}");
335 DevContainerError::CommandFailed(command_name.clone())
336 })?;
337 if !output.status.success() {
338 let std_err = String::from_utf8_lossy(&output.stderr);
339 log::error!(
340 "Command {command_name} produced a non-successful output. StdErr: {std_err}"
341 );
342 }
343 let std_out = String::from_utf8_lossy(&output.stdout);
344 log::debug!("Command {command_name} output:\n {std_out}");
345 }
346 Ok(())
347 }
348}
349
350impl<'de> Deserialize<'de> for LifecycleScript {
351 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352 where
353 D: Deserializer<'de>,
354 {
355 use serde::de::{self, Visitor};
356 use std::fmt;
357
358 struct LifecycleScriptVisitor;
359
360 impl<'de> Visitor<'de> for LifecycleScriptVisitor {
361 type Value = LifecycleScript;
362
363 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
364 formatter.write_str("a string, an array of strings, or a map of arrays")
365 }
366
367 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
368 where
369 E: de::Error,
370 {
371 Ok(LifecycleScript::from_str(value))
372 }
373
374 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
375 where
376 A: de::SeqAccess<'de>,
377 {
378 let mut array = Vec::new();
379 while let Some(elem) = seq.next_element()? {
380 array.push(elem);
381 }
382 Ok(LifecycleScript::from_args(array))
383 }
384
385 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
386 where
387 A: de::MapAccess<'de>,
388 {
389 let mut result = HashMap::new();
390 while let Some(key) = map.next_key::<String>()? {
391 let value: Value = map.next_value()?;
392 let script_args = match value {
393 Value::String(s) => {
394 s.split(" ").map(|s| s.to_string()).collect::<Vec<String>>()
395 }
396 Value::Array(arr) => {
397 let strings: Vec<String> = arr
398 .into_iter()
399 .filter_map(|v| v.as_str().map(|s| s.to_string()))
400 .collect();
401 strings
402 }
403 _ => continue,
404 };
405 result.insert(key, script_args);
406 }
407 Ok(LifecycleScript::from_map(result))
408 }
409 }
410
411 deserializer.deserialize_any(LifecycleScriptVisitor)
412 }
413}
414
415fn deserialize_mount_definition<'de, D>(
416 deserializer: D,
417) -> Result<Option<MountDefinition>, D::Error>
418where
419 D: serde::Deserializer<'de>,
420{
421 use serde::Deserialize;
422 use serde::de::Error;
423
424 #[derive(Deserialize)]
425 #[serde(untagged)]
426 enum MountItem {
427 Object(MountDefinition),
428 String(String),
429 }
430
431 let item = MountItem::deserialize(deserializer)?;
432
433 let mount = match item {
434 MountItem::Object(mount) => mount,
435 MountItem::String(s) => {
436 let mut source = None;
437 let mut target = None;
438 let mut mount_type = None;
439
440 for part in s.split(',') {
441 let part = part.trim();
442 if let Some((key, value)) = part.split_once('=') {
443 match key.trim() {
444 "source" => source = Some(value.trim().to_string()),
445 "target" => target = Some(value.trim().to_string()),
446 "type" => mount_type = Some(value.trim().to_string()),
447 _ => {} // Ignore unknown keys
448 }
449 }
450 }
451
452 let target = target
453 .ok_or_else(|| D::Error::custom(format!("mount string missing 'target': {}", s)))?;
454
455 MountDefinition {
456 source,
457 target,
458 mount_type,
459 }
460 }
461 };
462
463 Ok(Some(mount))
464}
465
466fn deserialize_mount_definitions<'de, D>(
467 deserializer: D,
468) -> Result<Option<Vec<MountDefinition>>, D::Error>
469where
470 D: serde::Deserializer<'de>,
471{
472 use serde::Deserialize;
473 use serde::de::Error;
474
475 #[derive(Deserialize)]
476 #[serde(untagged)]
477 enum MountItem {
478 Object(MountDefinition),
479 String(String),
480 }
481
482 let items = Vec::<MountItem>::deserialize(deserializer)?;
483 let mut mounts = Vec::new();
484
485 for item in items {
486 match item {
487 MountItem::Object(mount) => mounts.push(mount),
488 MountItem::String(s) => {
489 let mut source = None;
490 let mut target = None;
491 let mut mount_type = None;
492
493 for part in s.split(',') {
494 let part = part.trim();
495 if let Some((key, value)) = part.split_once('=') {
496 match key.trim() {
497 "source" => source = Some(value.trim().to_string()),
498 "target" => target = Some(value.trim().to_string()),
499 "type" => mount_type = Some(value.trim().to_string()),
500 _ => {} // Ignore unknown keys
501 }
502 }
503 }
504
505 let target = target.ok_or_else(|| {
506 D::Error::custom(format!("mount string missing 'target': {}", s))
507 })?;
508
509 mounts.push(MountDefinition {
510 source,
511 target,
512 mount_type,
513 });
514 }
515 }
516 }
517
518 Ok(Some(mounts))
519}
520
521fn deserialize_app_port<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
522where
523 D: serde::Deserializer<'de>,
524{
525 use serde::Deserialize;
526
527 #[derive(Deserialize)]
528 #[serde(untagged)]
529 enum StringOrInt {
530 String(String),
531 Int(u32),
532 }
533
534 #[derive(Deserialize)]
535 #[serde(untagged)]
536 enum AppPort {
537 Array(Vec<StringOrInt>),
538 Single(StringOrInt),
539 }
540
541 fn normalize_port(value: StringOrInt) -> String {
542 match value {
543 StringOrInt::String(s) => {
544 if s.contains(':') {
545 s
546 } else {
547 format!("{s}:{s}")
548 }
549 }
550 StringOrInt::Int(n) => format!("{n}:{n}"),
551 }
552 }
553
554 match AppPort::deserialize(deserializer)? {
555 AppPort::Single(value) => Ok(vec![normalize_port(value)]),
556 AppPort::Array(values) => Ok(values.into_iter().map(normalize_port).collect()),
557 }
558}
559
560fn deserialize_string_or_array<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
561where
562 D: serde::Deserializer<'de>,
563{
564 use serde::Deserialize;
565
566 #[derive(Deserialize)]
567 #[serde(untagged)]
568 enum StringOrArray {
569 String(String),
570 Array(Vec<String>),
571 }
572
573 match StringOrArray::deserialize(deserializer)? {
574 StringOrArray::String(s) => Ok(Some(vec![s])),
575 StringOrArray::Array(b) => Ok(Some(b)),
576 }
577}
578
579#[cfg(test)]
580mod test {
581 use std::collections::HashMap;
582
583 use crate::{
584 devcontainer_api::DevContainerError,
585 devcontainer_json::{
586 ContainerBuild, DevContainer, DevContainerBuildType, FeatureOptions, ForwardPort,
587 HostRequirements, LifecycleCommand, LifecycleScript, MountDefinition, OnAutoForward,
588 PortAttributeProtocol, PortAttributes, ShutdownAction, UserEnvProbe, ZedCustomization,
589 ZedCustomizationsWrapper, deserialize_devcontainer_json,
590 },
591 };
592
593 #[test]
594 fn should_deserialize_customizations_with_unknown_keys() {
595 let json_with_other_customizations = r#"
596 {
597 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
598 "customizations": {
599 "vscode": {
600 "extensions": [
601 "dbaeumer.vscode-eslint",
602 "GitHub.vscode-pull-request-github",
603 ],
604 },
605 "zed": {
606 "extensions": ["vue", "ruby"],
607 },
608 "codespaces": {
609 "repositories": {
610 "devcontainers/features": {
611 "permissions": {
612 "contents": "write",
613 "workflows": "write",
614 },
615 },
616 },
617 },
618 },
619 }
620 "#;
621
622 let result = deserialize_devcontainer_json(json_with_other_customizations);
623
624 assert!(
625 result.is_ok(),
626 "Should ignore unknown customization keys, but got: {:?}",
627 result.err()
628 );
629 let devcontainer = result.expect("ok");
630 assert_eq!(
631 devcontainer.customizations,
632 Some(ZedCustomizationsWrapper {
633 zed: ZedCustomization {
634 extensions: vec!["vue".to_string(), "ruby".to_string()]
635 }
636 })
637 );
638 }
639
640 #[test]
641 fn should_deserialize_customizations_without_zed_key() {
642 let json_without_zed = r#"
643 {
644 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
645 "customizations": {
646 "vscode": {
647 "extensions": ["dbaeumer.vscode-eslint"]
648 }
649 }
650 }
651 "#;
652
653 let result = deserialize_devcontainer_json(json_without_zed);
654
655 assert!(
656 result.is_ok(),
657 "Should handle missing zed key in customizations, but got: {:?}",
658 result.err()
659 );
660 let devcontainer = result.expect("ok");
661 assert_eq!(
662 devcontainer.customizations,
663 Some(ZedCustomizationsWrapper {
664 zed: ZedCustomization { extensions: vec![] }
665 })
666 );
667 }
668
669 #[test]
670 fn should_deserialize_simple_devcontainer_json() {
671 let given_bad_json = "{ \"image\": 123 }";
672
673 let result = deserialize_devcontainer_json(given_bad_json);
674
675 assert!(result.is_err());
676 assert_eq!(
677 result.expect_err("err"),
678 DevContainerError::DevContainerParseFailed
679 );
680
681 let given_image_container_json = r#"
682 // These are some external comments. serde_lenient should handle them
683 {
684 // These are some internal comments
685 "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
686 "name": "myDevContainer",
687 "remoteUser": "root",
688 "forwardPorts": [
689 "db:5432",
690 3000
691 ],
692 "portsAttributes": {
693 "3000": {
694 "label": "This Port",
695 "onAutoForward": "notify",
696 "elevateIfNeeded": false,
697 "requireLocalPort": true,
698 "protocol": "https"
699 },
700 "db:5432": {
701 "label": "This Port too",
702 "onAutoForward": "silent",
703 "elevateIfNeeded": true,
704 "requireLocalPort": false,
705 "protocol": "http"
706 }
707 },
708 "otherPortsAttributes": {
709 "label": "Other Ports",
710 "onAutoForward": "openBrowser",
711 "elevateIfNeeded": true,
712 "requireLocalPort": true,
713 "protocol": "https"
714 },
715 "updateRemoteUserUID": true,
716 "remoteEnv": {
717 "MYVAR1": "myvarvalue",
718 "MYVAR2": "myvarothervalue"
719 },
720 "initializeCommand": ["echo", "initialize_command"],
721 "onCreateCommand": "echo on_create_command",
722 "updateContentCommand": {
723 "first": "echo update_content_command",
724 "second": ["echo", "update_content_command"]
725 },
726 "postCreateCommand": ["echo", "post_create_command"],
727 "postStartCommand": "echo post_start_command",
728 "postAttachCommand": {
729 "something": "echo post_attach_command",
730 "something1": "echo something else",
731 },
732 "waitFor": "postStartCommand",
733 "userEnvProbe": "loginShell",
734 "features": {
735 "ghcr.io/devcontainers/features/aws-cli:1": {},
736 "ghcr.io/devcontainers/features/anaconda:1": {}
737 },
738 "overrideFeatureInstallOrder": [
739 "ghcr.io/devcontainers/features/anaconda:1",
740 "ghcr.io/devcontainers/features/aws-cli:1"
741 ],
742 "hostRequirements": {
743 "cpus": 2,
744 "memory": "8gb",
745 "storage": "32gb",
746 // Note that we're not parsing this currently
747 "gpu": true,
748 },
749 "appPort": 8081,
750 "containerEnv": {
751 "MYVAR3": "myvar3",
752 "MYVAR4": "myvar4"
753 },
754 "containerUser": "myUser",
755 "mounts": [
756 {
757 "source": "/localfolder/app",
758 "target": "/workspaces/app",
759 "type": "volume"
760 }
761 ],
762 "runArgs": [
763 "-c",
764 "some_command"
765 ],
766 "shutdownAction": "stopContainer",
767 "overrideCommand": true,
768 "workspaceFolder": "/workspaces",
769 "workspaceMount": "source=/app,target=/workspaces/app,type=bind,consistency=cached",
770 "customizations": {
771 "vscode": {
772 // Just confirm that this can be included and ignored
773 },
774 "zed": {
775 "extensions": [
776 "html"
777 ]
778 }
779 }
780 }
781 "#;
782
783 let result = deserialize_devcontainer_json(given_image_container_json);
784
785 assert!(result.is_ok());
786 let devcontainer = result.expect("ok");
787 assert_eq!(
788 devcontainer,
789 DevContainer {
790 image: Some(String::from("mcr.microsoft.com/devcontainers/base:ubuntu")),
791 name: Some(String::from("myDevContainer")),
792 remote_user: Some(String::from("root")),
793 forward_ports: Some(vec![
794 ForwardPort::String("db:5432".to_string()),
795 ForwardPort::Number(3000),
796 ]),
797 ports_attributes: Some(HashMap::from([
798 (
799 "3000".to_string(),
800 PortAttributes {
801 label: "This Port".to_string(),
802 on_auto_forward: OnAutoForward::Notify,
803 elevate_if_needed: false,
804 require_local_port: true,
805 protocol: PortAttributeProtocol::Https
806 }
807 ),
808 (
809 "db:5432".to_string(),
810 PortAttributes {
811 label: "This Port too".to_string(),
812 on_auto_forward: OnAutoForward::Silent,
813 elevate_if_needed: true,
814 require_local_port: false,
815 protocol: PortAttributeProtocol::Http
816 }
817 )
818 ])),
819 other_ports_attributes: Some(PortAttributes {
820 label: "Other Ports".to_string(),
821 on_auto_forward: OnAutoForward::OpenBrowser,
822 elevate_if_needed: true,
823 require_local_port: true,
824 protocol: PortAttributeProtocol::Https
825 }),
826 update_remote_user_uid: Some(true),
827 remote_env: Some(HashMap::from([
828 ("MYVAR1".to_string(), "myvarvalue".to_string()),
829 ("MYVAR2".to_string(), "myvarothervalue".to_string())
830 ])),
831 initialize_command: Some(LifecycleScript::from_args(vec![
832 "echo".to_string(),
833 "initialize_command".to_string()
834 ])),
835 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
836 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
837 (
838 "first".to_string(),
839 vec!["echo".to_string(), "update_content_command".to_string()]
840 ),
841 (
842 "second".to_string(),
843 vec!["echo".to_string(), "update_content_command".to_string()]
844 )
845 ]))),
846 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
847 post_start_command: Some(LifecycleScript::from_args(vec![
848 "echo".to_string(),
849 "post_start_command".to_string()
850 ])),
851 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
852 (
853 "something".to_string(),
854 vec!["echo".to_string(), "post_attach_command".to_string()]
855 ),
856 (
857 "something1".to_string(),
858 vec![
859 "echo".to_string(),
860 "something".to_string(),
861 "else".to_string()
862 ]
863 )
864 ]))),
865 wait_for: Some(LifecycleCommand::PostStartCommand),
866 user_env_probe: Some(UserEnvProbe::LoginShell),
867 features: Some(HashMap::from([
868 (
869 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
870 FeatureOptions::Options(HashMap::new())
871 ),
872 (
873 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
874 FeatureOptions::Options(HashMap::new())
875 )
876 ])),
877 override_feature_install_order: Some(vec![
878 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
879 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
880 ]),
881 host_requirements: Some(HostRequirements {
882 cpus: Some(2),
883 memory: Some("8gb".to_string()),
884 storage: Some("32gb".to_string()),
885 }),
886 app_port: vec!["8081:8081".to_string()],
887 container_env: Some(HashMap::from([
888 ("MYVAR3".to_string(), "myvar3".to_string()),
889 ("MYVAR4".to_string(), "myvar4".to_string())
890 ])),
891 container_user: Some("myUser".to_string()),
892 mounts: Some(vec![MountDefinition {
893 source: Some("/localfolder/app".to_string()),
894 target: "/workspaces/app".to_string(),
895 mount_type: Some("volume".to_string()),
896 }]),
897 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
898 shutdown_action: Some(ShutdownAction::StopContainer),
899 override_command: Some(true),
900 workspace_folder: Some("/workspaces".to_string()),
901 workspace_mount: Some(MountDefinition {
902 source: Some("/app".to_string()),
903 target: "/workspaces/app".to_string(),
904 mount_type: Some("bind".to_string())
905 }),
906 customizations: Some(ZedCustomizationsWrapper {
907 zed: ZedCustomization {
908 extensions: vec!["html".to_string()]
909 }
910 }),
911 ..Default::default()
912 }
913 );
914
915 assert_eq!(
916 devcontainer.build_type(),
917 DevContainerBuildType::Image(String::from(
918 "mcr.microsoft.com/devcontainers/base:ubuntu"
919 ))
920 );
921 }
922
923 #[test]
924 fn should_deserialize_docker_compose_devcontainer_json() {
925 let given_docker_compose_json = r#"
926 // These are some external comments. serde_lenient should handle them
927 {
928 // These are some internal comments
929 "name": "myDevContainer",
930 "remoteUser": "root",
931 "forwardPorts": [
932 "db:5432",
933 3000
934 ],
935 "portsAttributes": {
936 "3000": {
937 "label": "This Port",
938 "onAutoForward": "notify",
939 "elevateIfNeeded": false,
940 "requireLocalPort": true,
941 "protocol": "https"
942 },
943 "db:5432": {
944 "label": "This Port too",
945 "onAutoForward": "silent",
946 "elevateIfNeeded": true,
947 "requireLocalPort": false,
948 "protocol": "http"
949 }
950 },
951 "otherPortsAttributes": {
952 "label": "Other Ports",
953 "onAutoForward": "openBrowser",
954 "elevateIfNeeded": true,
955 "requireLocalPort": true,
956 "protocol": "https"
957 },
958 "updateRemoteUserUID": true,
959 "remoteEnv": {
960 "MYVAR1": "myvarvalue",
961 "MYVAR2": "myvarothervalue"
962 },
963 "initializeCommand": ["echo", "initialize_command"],
964 "onCreateCommand": "echo on_create_command",
965 "updateContentCommand": {
966 "first": "echo update_content_command",
967 "second": ["echo", "update_content_command"]
968 },
969 "postCreateCommand": ["echo", "post_create_command"],
970 "postStartCommand": "echo post_start_command",
971 "postAttachCommand": {
972 "something": "echo post_attach_command",
973 "something1": "echo something else",
974 },
975 "waitFor": "postStartCommand",
976 "userEnvProbe": "loginShell",
977 "features": {
978 "ghcr.io/devcontainers/features/aws-cli:1": {},
979 "ghcr.io/devcontainers/features/anaconda:1": {}
980 },
981 "overrideFeatureInstallOrder": [
982 "ghcr.io/devcontainers/features/anaconda:1",
983 "ghcr.io/devcontainers/features/aws-cli:1"
984 ],
985 "hostRequirements": {
986 "cpus": 2,
987 "memory": "8gb",
988 "storage": "32gb",
989 // Note that we're not parsing this currently
990 "gpu": true,
991 },
992 "dockerComposeFile": "docker-compose.yml",
993 "service": "myService",
994 "runServices": [
995 "myService",
996 "mySupportingService"
997 ],
998 "workspaceFolder": "/workspaces/thing",
999 "shutdownAction": "stopCompose",
1000 "overrideCommand": true
1001 }
1002 "#;
1003 let result = deserialize_devcontainer_json(given_docker_compose_json);
1004
1005 assert!(result.is_ok());
1006 let devcontainer = result.expect("ok");
1007 assert_eq!(
1008 devcontainer,
1009 DevContainer {
1010 name: Some(String::from("myDevContainer")),
1011 remote_user: Some(String::from("root")),
1012 forward_ports: Some(vec![
1013 ForwardPort::String("db:5432".to_string()),
1014 ForwardPort::Number(3000),
1015 ]),
1016 ports_attributes: Some(HashMap::from([
1017 (
1018 "3000".to_string(),
1019 PortAttributes {
1020 label: "This Port".to_string(),
1021 on_auto_forward: OnAutoForward::Notify,
1022 elevate_if_needed: false,
1023 require_local_port: true,
1024 protocol: PortAttributeProtocol::Https
1025 }
1026 ),
1027 (
1028 "db:5432".to_string(),
1029 PortAttributes {
1030 label: "This Port too".to_string(),
1031 on_auto_forward: OnAutoForward::Silent,
1032 elevate_if_needed: true,
1033 require_local_port: false,
1034 protocol: PortAttributeProtocol::Http
1035 }
1036 )
1037 ])),
1038 other_ports_attributes: Some(PortAttributes {
1039 label: "Other Ports".to_string(),
1040 on_auto_forward: OnAutoForward::OpenBrowser,
1041 elevate_if_needed: true,
1042 require_local_port: true,
1043 protocol: PortAttributeProtocol::Https
1044 }),
1045 update_remote_user_uid: Some(true),
1046 remote_env: Some(HashMap::from([
1047 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1048 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1049 ])),
1050 initialize_command: Some(LifecycleScript::from_args(vec![
1051 "echo".to_string(),
1052 "initialize_command".to_string()
1053 ])),
1054 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1055 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1056 (
1057 "first".to_string(),
1058 vec!["echo".to_string(), "update_content_command".to_string()]
1059 ),
1060 (
1061 "second".to_string(),
1062 vec!["echo".to_string(), "update_content_command".to_string()]
1063 )
1064 ]))),
1065 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1066 post_start_command: Some(LifecycleScript::from_args(vec![
1067 "echo".to_string(),
1068 "post_start_command".to_string()
1069 ])),
1070 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1071 (
1072 "something".to_string(),
1073 vec!["echo".to_string(), "post_attach_command".to_string()]
1074 ),
1075 (
1076 "something1".to_string(),
1077 vec![
1078 "echo".to_string(),
1079 "something".to_string(),
1080 "else".to_string()
1081 ]
1082 )
1083 ]))),
1084 wait_for: Some(LifecycleCommand::PostStartCommand),
1085 user_env_probe: Some(UserEnvProbe::LoginShell),
1086 features: Some(HashMap::from([
1087 (
1088 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1089 FeatureOptions::Options(HashMap::new())
1090 ),
1091 (
1092 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1093 FeatureOptions::Options(HashMap::new())
1094 )
1095 ])),
1096 override_feature_install_order: Some(vec![
1097 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1098 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1099 ]),
1100 host_requirements: Some(HostRequirements {
1101 cpus: Some(2),
1102 memory: Some("8gb".to_string()),
1103 storage: Some("32gb".to_string()),
1104 }),
1105 docker_compose_file: Some(vec!["docker-compose.yml".to_string()]),
1106 service: Some("myService".to_string()),
1107 run_services: Some(vec![
1108 "myService".to_string(),
1109 "mySupportingService".to_string(),
1110 ]),
1111 workspace_folder: Some("/workspaces/thing".to_string()),
1112 shutdown_action: Some(ShutdownAction::StopCompose),
1113 override_command: Some(true),
1114 ..Default::default()
1115 }
1116 );
1117
1118 assert_eq!(
1119 devcontainer.build_type(),
1120 DevContainerBuildType::DockerCompose
1121 );
1122 }
1123
1124 #[test]
1125 fn should_deserialize_dockerfile_devcontainer_json() {
1126 let given_dockerfile_container_json = r#"
1127 // These are some external comments. serde_lenient should handle them
1128 {
1129 // These are some internal comments
1130 "name": "myDevContainer",
1131 "remoteUser": "root",
1132 "forwardPorts": [
1133 "db:5432",
1134 3000
1135 ],
1136 "portsAttributes": {
1137 "3000": {
1138 "label": "This Port",
1139 "onAutoForward": "notify",
1140 "elevateIfNeeded": false,
1141 "requireLocalPort": true,
1142 "protocol": "https"
1143 },
1144 "db:5432": {
1145 "label": "This Port too",
1146 "onAutoForward": "silent",
1147 "elevateIfNeeded": true,
1148 "requireLocalPort": false,
1149 "protocol": "http"
1150 }
1151 },
1152 "otherPortsAttributes": {
1153 "label": "Other Ports",
1154 "onAutoForward": "openBrowser",
1155 "elevateIfNeeded": true,
1156 "requireLocalPort": true,
1157 "protocol": "https"
1158 },
1159 "updateRemoteUserUID": true,
1160 "remoteEnv": {
1161 "MYVAR1": "myvarvalue",
1162 "MYVAR2": "myvarothervalue"
1163 },
1164 "initializeCommand": ["echo", "initialize_command"],
1165 "onCreateCommand": "echo on_create_command",
1166 "updateContentCommand": {
1167 "first": "echo update_content_command",
1168 "second": ["echo", "update_content_command"]
1169 },
1170 "postCreateCommand": ["echo", "post_create_command"],
1171 "postStartCommand": "echo post_start_command",
1172 "postAttachCommand": {
1173 "something": "echo post_attach_command",
1174 "something1": "echo something else",
1175 },
1176 "waitFor": "postStartCommand",
1177 "userEnvProbe": "loginShell",
1178 "features": {
1179 "ghcr.io/devcontainers/features/aws-cli:1": {},
1180 "ghcr.io/devcontainers/features/anaconda:1": {}
1181 },
1182 "overrideFeatureInstallOrder": [
1183 "ghcr.io/devcontainers/features/anaconda:1",
1184 "ghcr.io/devcontainers/features/aws-cli:1"
1185 ],
1186 "hostRequirements": {
1187 "cpus": 2,
1188 "memory": "8gb",
1189 "storage": "32gb",
1190 // Note that we're not parsing this currently
1191 "gpu": true,
1192 },
1193 "appPort": 8081,
1194 "containerEnv": {
1195 "MYVAR3": "myvar3",
1196 "MYVAR4": "myvar4"
1197 },
1198 "containerUser": "myUser",
1199 "mounts": [
1200 {
1201 "source": "/localfolder/app",
1202 "target": "/workspaces/app",
1203 "type": "volume"
1204 },
1205 "source=dev-containers-cli-bashhistory,target=/home/node/commandhistory",
1206 ],
1207 "runArgs": [
1208 "-c",
1209 "some_command"
1210 ],
1211 "shutdownAction": "stopContainer",
1212 "overrideCommand": true,
1213 "workspaceFolder": "/workspaces",
1214 "workspaceMount": "source=/folder,target=/workspace,type=bind,consistency=cached",
1215 "build": {
1216 "dockerfile": "DockerFile",
1217 "context": "..",
1218 "args": {
1219 "MYARG": "MYVALUE"
1220 },
1221 "options": [
1222 "--some-option",
1223 "--mount"
1224 ],
1225 "target": "development",
1226 "cacheFrom": "some_image"
1227 }
1228 }
1229 "#;
1230
1231 let result = deserialize_devcontainer_json(given_dockerfile_container_json);
1232
1233 assert!(result.is_ok());
1234 let devcontainer = result.expect("ok");
1235 assert_eq!(
1236 devcontainer,
1237 DevContainer {
1238 name: Some(String::from("myDevContainer")),
1239 remote_user: Some(String::from("root")),
1240 forward_ports: Some(vec![
1241 ForwardPort::String("db:5432".to_string()),
1242 ForwardPort::Number(3000),
1243 ]),
1244 ports_attributes: Some(HashMap::from([
1245 (
1246 "3000".to_string(),
1247 PortAttributes {
1248 label: "This Port".to_string(),
1249 on_auto_forward: OnAutoForward::Notify,
1250 elevate_if_needed: false,
1251 require_local_port: true,
1252 protocol: PortAttributeProtocol::Https
1253 }
1254 ),
1255 (
1256 "db:5432".to_string(),
1257 PortAttributes {
1258 label: "This Port too".to_string(),
1259 on_auto_forward: OnAutoForward::Silent,
1260 elevate_if_needed: true,
1261 require_local_port: false,
1262 protocol: PortAttributeProtocol::Http
1263 }
1264 )
1265 ])),
1266 other_ports_attributes: Some(PortAttributes {
1267 label: "Other Ports".to_string(),
1268 on_auto_forward: OnAutoForward::OpenBrowser,
1269 elevate_if_needed: true,
1270 require_local_port: true,
1271 protocol: PortAttributeProtocol::Https
1272 }),
1273 update_remote_user_uid: Some(true),
1274 remote_env: Some(HashMap::from([
1275 ("MYVAR1".to_string(), "myvarvalue".to_string()),
1276 ("MYVAR2".to_string(), "myvarothervalue".to_string())
1277 ])),
1278 initialize_command: Some(LifecycleScript::from_args(vec![
1279 "echo".to_string(),
1280 "initialize_command".to_string()
1281 ])),
1282 on_create_command: Some(LifecycleScript::from_str("echo on_create_command")),
1283 update_content_command: Some(LifecycleScript::from_map(HashMap::from([
1284 (
1285 "first".to_string(),
1286 vec!["echo".to_string(), "update_content_command".to_string()]
1287 ),
1288 (
1289 "second".to_string(),
1290 vec!["echo".to_string(), "update_content_command".to_string()]
1291 )
1292 ]))),
1293 post_create_command: Some(LifecycleScript::from_str("echo post_create_command")),
1294 post_start_command: Some(LifecycleScript::from_args(vec![
1295 "echo".to_string(),
1296 "post_start_command".to_string()
1297 ])),
1298 post_attach_command: Some(LifecycleScript::from_map(HashMap::from([
1299 (
1300 "something".to_string(),
1301 vec!["echo".to_string(), "post_attach_command".to_string()]
1302 ),
1303 (
1304 "something1".to_string(),
1305 vec![
1306 "echo".to_string(),
1307 "something".to_string(),
1308 "else".to_string()
1309 ]
1310 )
1311 ]))),
1312 wait_for: Some(LifecycleCommand::PostStartCommand),
1313 user_env_probe: Some(UserEnvProbe::LoginShell),
1314 features: Some(HashMap::from([
1315 (
1316 "ghcr.io/devcontainers/features/aws-cli:1".to_string(),
1317 FeatureOptions::Options(HashMap::new())
1318 ),
1319 (
1320 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1321 FeatureOptions::Options(HashMap::new())
1322 )
1323 ])),
1324 override_feature_install_order: Some(vec![
1325 "ghcr.io/devcontainers/features/anaconda:1".to_string(),
1326 "ghcr.io/devcontainers/features/aws-cli:1".to_string()
1327 ]),
1328 host_requirements: Some(HostRequirements {
1329 cpus: Some(2),
1330 memory: Some("8gb".to_string()),
1331 storage: Some("32gb".to_string()),
1332 }),
1333 app_port: vec!["8081:8081".to_string()],
1334 container_env: Some(HashMap::from([
1335 ("MYVAR3".to_string(), "myvar3".to_string()),
1336 ("MYVAR4".to_string(), "myvar4".to_string())
1337 ])),
1338 container_user: Some("myUser".to_string()),
1339 mounts: Some(vec![
1340 MountDefinition {
1341 source: Some("/localfolder/app".to_string()),
1342 target: "/workspaces/app".to_string(),
1343 mount_type: Some("volume".to_string()),
1344 },
1345 MountDefinition {
1346 source: Some("dev-containers-cli-bashhistory".to_string()),
1347 target: "/home/node/commandhistory".to_string(),
1348 mount_type: None,
1349 }
1350 ]),
1351 run_args: Some(vec!["-c".to_string(), "some_command".to_string()]),
1352 shutdown_action: Some(ShutdownAction::StopContainer),
1353 override_command: Some(true),
1354 workspace_folder: Some("/workspaces".to_string()),
1355 workspace_mount: Some(MountDefinition {
1356 source: Some("/folder".to_string()),
1357 target: "/workspace".to_string(),
1358 mount_type: Some("bind".to_string())
1359 }),
1360 build: Some(ContainerBuild {
1361 dockerfile: "DockerFile".to_string(),
1362 context: Some("..".to_string()),
1363 args: Some(HashMap::from([(
1364 "MYARG".to_string(),
1365 "MYVALUE".to_string()
1366 )])),
1367 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1368 target: Some("development".to_string()),
1369 cache_from: Some(vec!["some_image".to_string()]),
1370 }),
1371 ..Default::default()
1372 }
1373 );
1374
1375 assert_eq!(
1376 devcontainer.build_type(),
1377 DevContainerBuildType::Dockerfile(ContainerBuild {
1378 dockerfile: "DockerFile".to_string(),
1379 context: Some("..".to_string()),
1380 args: Some(HashMap::from([(
1381 "MYARG".to_string(),
1382 "MYVALUE".to_string()
1383 )])),
1384 options: Some(vec!["--some-option".to_string(), "--mount".to_string()]),
1385 target: Some("development".to_string()),
1386 cache_from: Some(vec!["some_image".to_string()]),
1387 })
1388 );
1389 }
1390
1391 #[test]
1392 fn should_deserialize_app_port_array() {
1393 let given_json = r#"
1394 // These are some external comments. serde_lenient should handle them
1395 {
1396 // These are some internal comments
1397 "name": "myDevContainer",
1398 "remoteUser": "root",
1399 "appPort": [
1400 "8081:8083",
1401 "9001",
1402 ],
1403 "build": {
1404 "dockerfile": "DockerFile",
1405 }
1406 }
1407 "#;
1408
1409 let result = deserialize_devcontainer_json(given_json);
1410
1411 assert!(result.is_ok());
1412 let devcontainer = result.expect("ok");
1413
1414 assert_eq!(
1415 devcontainer.app_port,
1416 vec!["8081:8083".to_string(), "9001:9001".to_string()]
1417 )
1418 }
1419
1420 #[test]
1421 fn mount_definition_should_use_bind_type_for_unix_absolute_paths() {
1422 let mount = MountDefinition {
1423 source: Some("/home/user/project".to_string()),
1424 target: "/workspaces/project".to_string(),
1425 mount_type: None,
1426 };
1427
1428 let rendered = mount.to_string();
1429
1430 assert!(
1431 rendered.starts_with("type=bind,"),
1432 "Expected mount type 'bind' for Unix absolute path, but got: {rendered}"
1433 );
1434 }
1435
1436 #[test]
1437 fn mount_definition_should_use_bind_type_for_windows_unc_paths() {
1438 let mount = MountDefinition {
1439 source: Some("\\\\server\\share\\project".to_string()),
1440 target: "/workspaces/project".to_string(),
1441 mount_type: None,
1442 };
1443
1444 let rendered = mount.to_string();
1445
1446 assert!(
1447 rendered.starts_with("type=bind,"),
1448 "Expected mount type 'bind' for Windows UNC path, but got: {rendered}"
1449 );
1450 }
1451
1452 #[test]
1453 fn mount_definition_should_use_bind_type_for_windows_absolute_paths() {
1454 let mount = MountDefinition {
1455 source: Some("C:\\Users\\mrg\\cli".to_string()),
1456 target: "/workspaces/cli".to_string(),
1457 mount_type: None,
1458 };
1459
1460 let rendered = mount.to_string();
1461
1462 assert!(
1463 rendered.starts_with("type=bind,"),
1464 "Expected mount type 'bind' for Windows absolute path, but got: {rendered}"
1465 );
1466 }
1467
1468 #[test]
1469 fn mount_definition_should_omit_source_when_none() {
1470 let mount = MountDefinition {
1471 source: None,
1472 target: "/tmp".to_string(),
1473 mount_type: Some("tmpfs".to_string()),
1474 };
1475
1476 let rendered = mount.to_string();
1477
1478 assert_eq!(rendered, "type=tmpfs,target=/tmp,consistency=cached");
1479 }
1480}