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