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    #[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}