devcontainer_json.rs

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