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