devcontainer_json.rs

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