devcontainer_json.rs

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