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