docker.rs

   1use std::{collections::HashMap, path::PathBuf};
   2
   3use async_trait::async_trait;
   4use serde::{Deserialize, Deserializer, Serialize, de};
   5use util::command::Command;
   6
   7use crate::{
   8    command_json::evaluate_json_command, devcontainer_api::DevContainerError,
   9    devcontainer_json::MountDefinition,
  10};
  11
  12#[derive(Debug, Deserialize, Serialize, Eq, PartialEq)]
  13#[serde(rename_all = "PascalCase")]
  14pub(crate) struct DockerPs {
  15    #[serde(alias = "ID")]
  16    pub(crate) id: String,
  17}
  18
  19#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  20#[serde(rename_all = "PascalCase")]
  21pub(crate) struct DockerState {
  22    pub(crate) running: bool,
  23}
  24
  25#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  26#[serde(rename_all = "PascalCase")]
  27pub(crate) struct DockerInspect {
  28    pub(crate) id: String,
  29    pub(crate) config: DockerInspectConfig,
  30    pub(crate) mounts: Option<Vec<DockerInspectMount>>,
  31    pub(crate) state: Option<DockerState>,
  32}
  33
  34#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  35pub(crate) struct DockerConfigLabels {
  36    #[serde(
  37        default,
  38        rename = "devcontainer.metadata",
  39        deserialize_with = "deserialize_metadata"
  40    )]
  41    pub(crate) metadata: Option<Vec<HashMap<String, serde_json_lenient::Value>>>,
  42}
  43
  44#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  45#[serde(rename_all = "PascalCase")]
  46pub(crate) struct DockerInspectConfig {
  47    #[serde(default, deserialize_with = "deserialize_nullable_labels")]
  48    pub(crate) labels: DockerConfigLabels,
  49    #[serde(rename = "User")]
  50    pub(crate) image_user: Option<String>,
  51    #[serde(default)]
  52    pub(crate) env: Vec<String>,
  53}
  54
  55impl DockerInspectConfig {
  56    pub(crate) fn env_as_map(&self) -> Result<HashMap<String, String>, DevContainerError> {
  57        let mut map = HashMap::new();
  58        for env_var in &self.env {
  59            let Some((key, value)) = env_var.split_once('=') else {
  60                log::error!("Unable to parse {env_var} into an environment key-value");
  61                return Err(DevContainerError::DevContainerParseFailed);
  62            };
  63            map.insert(key.to_string(), value.to_string());
  64        }
  65        Ok(map)
  66    }
  67}
  68
  69#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq)]
  70#[serde(rename_all = "PascalCase")]
  71pub(crate) struct DockerInspectMount {
  72    pub(crate) source: String,
  73    pub(crate) destination: String,
  74}
  75
  76#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  77pub(crate) struct DockerComposeServiceBuild {
  78    #[serde(skip_serializing_if = "Option::is_none")]
  79    pub(crate) context: Option<String>,
  80    #[serde(skip_serializing_if = "Option::is_none")]
  81    pub(crate) dockerfile: Option<String>,
  82    #[serde(skip_serializing_if = "Option::is_none")]
  83    pub(crate) args: Option<HashMap<String, String>>,
  84    #[serde(skip_serializing_if = "Option::is_none")]
  85    pub(crate) additional_contexts: Option<HashMap<String, String>>,
  86}
  87
  88#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
  89pub(crate) struct DockerComposeServicePort {
  90    #[serde(deserialize_with = "deserialize_string_or_int")]
  91    pub(crate) target: String,
  92    #[serde(deserialize_with = "deserialize_string_or_int")]
  93    pub(crate) published: String,
  94    #[serde(skip_serializing_if = "Option::is_none")]
  95    pub(crate) mode: Option<String>,
  96    #[serde(skip_serializing_if = "Option::is_none")]
  97    pub(crate) protocol: Option<String>,
  98    #[serde(skip_serializing_if = "Option::is_none")]
  99    pub(crate) host_ip: Option<String>,
 100    #[serde(skip_serializing_if = "Option::is_none")]
 101    pub(crate) app_protocol: Option<String>,
 102    #[serde(skip_serializing_if = "Option::is_none")]
 103    pub(crate) name: Option<String>,
 104}
 105
 106fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<String, D::Error>
 107where
 108    D: serde::Deserializer<'de>,
 109{
 110    use serde::Deserialize;
 111
 112    #[derive(Deserialize)]
 113    #[serde(untagged)]
 114    enum StringOrInt {
 115        String(String),
 116        Int(u32),
 117    }
 118
 119    match StringOrInt::deserialize(deserializer)? {
 120        StringOrInt::String(s) => Ok(s),
 121        StringOrInt::Int(b) => Ok(b.to_string()),
 122    }
 123}
 124
 125#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 126pub(crate) struct DockerComposeService {
 127    pub(crate) image: Option<String>,
 128    #[serde(skip_serializing_if = "Option::is_none")]
 129    pub(crate) entrypoint: Option<Vec<String>>,
 130    #[serde(skip_serializing_if = "Option::is_none")]
 131    pub(crate) cap_add: Option<Vec<String>>,
 132    #[serde(skip_serializing_if = "Option::is_none")]
 133    pub(crate) security_opt: Option<Vec<String>>,
 134    #[serde(
 135        skip_serializing_if = "Option::is_none",
 136        default,
 137        deserialize_with = "deserialize_labels"
 138    )]
 139    pub(crate) labels: Option<HashMap<String, String>>,
 140    #[serde(skip_serializing_if = "Option::is_none")]
 141    pub(crate) build: Option<DockerComposeServiceBuild>,
 142    #[serde(skip_serializing_if = "Option::is_none")]
 143    pub(crate) privileged: Option<bool>,
 144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
 145    pub(crate) volumes: Vec<MountDefinition>,
 146    #[serde(skip_serializing_if = "Option::is_none")]
 147    pub(crate) env_file: Option<Vec<String>>,
 148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
 149    pub(crate) ports: Vec<DockerComposeServicePort>,
 150    #[serde(skip_serializing_if = "Option::is_none")]
 151    pub(crate) network_mode: Option<String>,
 152    #[serde(
 153        default,
 154        skip_serializing_if = "Vec::is_empty",
 155        deserialize_with = "deserialize_nullable_vec"
 156    )]
 157    pub(crate) command: Vec<String>,
 158}
 159
 160#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 161pub(crate) struct DockerComposeVolume {
 162    pub(crate) name: String,
 163}
 164
 165#[derive(Debug, Clone, Deserialize, Serialize, Eq, PartialEq, Default)]
 166pub(crate) struct DockerComposeConfig {
 167    #[serde(skip_serializing_if = "Option::is_none")]
 168    pub(crate) name: Option<String>,
 169    pub(crate) services: HashMap<String, DockerComposeService>,
 170    #[serde(default)]
 171    pub(crate) volumes: HashMap<String, DockerComposeVolume>,
 172}
 173
 174pub(crate) struct Docker {
 175    docker_cli: String,
 176}
 177
 178impl DockerInspect {
 179    pub(crate) fn is_running(&self) -> bool {
 180        self.state.as_ref().map_or(false, |s| s.running)
 181    }
 182}
 183
 184impl Docker {
 185    pub(crate) fn new(docker_cli: &str) -> Self {
 186        Self {
 187            docker_cli: docker_cli.to_string(),
 188        }
 189    }
 190
 191    fn is_podman(&self) -> bool {
 192        self.docker_cli == "podman"
 193    }
 194
 195    async fn pull_image(&self, image: &String) -> Result<(), DevContainerError> {
 196        let mut command = Command::new(&self.docker_cli);
 197        command.args(&["pull", image]);
 198
 199        let output = command.output().await.map_err(|e| {
 200            log::error!("Error pulling image: {e}");
 201            DevContainerError::ResourceFetchFailed
 202        })?;
 203
 204        if !output.status.success() {
 205            let stderr = String::from_utf8_lossy(&output.stderr);
 206            log::error!("Non-success result from docker pull: {stderr}");
 207            return Err(DevContainerError::ResourceFetchFailed);
 208        }
 209        Ok(())
 210    }
 211
 212    fn create_docker_query_containers(&self, filters: Vec<String>) -> Command {
 213        let mut command = Command::new(&self.docker_cli);
 214        command.args(&["ps", "-a"]);
 215
 216        for filter in filters {
 217            command.arg("--filter");
 218            command.arg(filter);
 219        }
 220        command.arg("--format={{ json . }}");
 221        command
 222    }
 223
 224    fn create_docker_inspect(&self, id: &str) -> Command {
 225        let mut command = Command::new(&self.docker_cli);
 226        command.args(&["inspect", "--format={{json . }}", id]);
 227        command
 228    }
 229
 230    fn create_docker_compose_config_command(&self, config_files: &Vec<PathBuf>) -> Command {
 231        let mut command = Command::new(&self.docker_cli);
 232        command.arg("compose");
 233        for file_path in config_files {
 234            command.args(&["-f", &file_path.display().to_string()]);
 235        }
 236        command.args(&["config", "--format", "json"]);
 237        command
 238    }
 239}
 240
 241#[async_trait]
 242impl DockerClient for Docker {
 243    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError> {
 244        // Try to pull the image, continue on failure; Image may be local only, id a reference to a running container
 245        self.pull_image(id).await.ok();
 246
 247        let command = self.create_docker_inspect(id);
 248
 249        let Some(docker_inspect): Option<DockerInspect> = evaluate_json_command(command).await?
 250        else {
 251            log::error!("Docker inspect produced no deserializable output");
 252            return Err(DevContainerError::CommandFailed(self.docker_cli.clone()));
 253        };
 254        Ok(docker_inspect)
 255    }
 256
 257    async fn get_docker_compose_config(
 258        &self,
 259        config_files: &Vec<PathBuf>,
 260    ) -> Result<Option<DockerComposeConfig>, DevContainerError> {
 261        let command = self.create_docker_compose_config_command(config_files);
 262        evaluate_json_command(command).await
 263    }
 264
 265    async fn docker_compose_build(
 266        &self,
 267        config_files: &Vec<PathBuf>,
 268        project_name: &str,
 269    ) -> Result<(), DevContainerError> {
 270        let mut command = Command::new(&self.docker_cli);
 271        if !self.is_podman() {
 272            command.env("DOCKER_BUILDKIT", "1");
 273        }
 274        command.args(&["compose", "--project-name", project_name]);
 275        for docker_compose_file in config_files {
 276            command.args(&["-f", &docker_compose_file.display().to_string()]);
 277        }
 278        command.arg("build");
 279
 280        let output = command.output().await.map_err(|e| {
 281            log::error!("Error running docker compose up: {e}");
 282            DevContainerError::CommandFailed(command.get_program().display().to_string())
 283        })?;
 284
 285        if !output.status.success() {
 286            let stderr = String::from_utf8_lossy(&output.stderr);
 287            log::error!("Non-success status from docker compose up: {}", stderr);
 288            return Err(DevContainerError::CommandFailed(
 289                command.get_program().display().to_string(),
 290            ));
 291        }
 292
 293        Ok(())
 294    }
 295    async fn run_docker_exec(
 296        &self,
 297        container_id: &str,
 298        remote_folder: &str,
 299        user: &str,
 300        env: &HashMap<String, String>,
 301        inner_command: Command,
 302    ) -> Result<(), DevContainerError> {
 303        let mut command = Command::new(&self.docker_cli);
 304
 305        command.args(&["exec", "-w", remote_folder, "-u", user]);
 306
 307        for (k, v) in env.iter() {
 308            command.arg("-e");
 309            let env_declaration = format!("{}={}", k, v);
 310            command.arg(&env_declaration);
 311        }
 312
 313        command.arg(container_id);
 314
 315        command.arg("sh");
 316
 317        let mut inner_program_script: Vec<String> =
 318            vec![inner_command.get_program().display().to_string()];
 319        let mut args: Vec<String> = inner_command
 320            .get_args()
 321            .map(|arg| arg.display().to_string())
 322            .collect();
 323        inner_program_script.append(&mut args);
 324        command.args(&["-c", &inner_program_script.join(" ")]);
 325
 326        let output = command.output().await.map_err(|e| {
 327            log::error!("Error running command {e} in container exec");
 328            DevContainerError::ContainerNotValid(container_id.to_string())
 329        })?;
 330        if !output.status.success() {
 331            let std_err = String::from_utf8_lossy(&output.stderr);
 332            log::error!("Command produced a non-successful output. StdErr: {std_err}");
 333        }
 334        let std_out = String::from_utf8_lossy(&output.stdout);
 335        log::debug!("Command output:\n {std_out}");
 336
 337        Ok(())
 338    }
 339    async fn start_container(&self, id: &str) -> Result<(), DevContainerError> {
 340        let mut command = Command::new(&self.docker_cli);
 341
 342        command.args(&["start", id]);
 343
 344        let output = command.output().await.map_err(|e| {
 345            log::error!("Error running docker start: {e}");
 346            DevContainerError::CommandFailed(command.get_program().display().to_string())
 347        })?;
 348
 349        if !output.status.success() {
 350            let stderr = String::from_utf8_lossy(&output.stderr);
 351            log::error!("Non-success status from docker start: {stderr}");
 352            return Err(DevContainerError::CommandFailed(
 353                command.get_program().display().to_string(),
 354            ));
 355        }
 356
 357        Ok(())
 358    }
 359
 360    async fn find_process_by_filters(
 361        &self,
 362        filters: Vec<String>,
 363    ) -> Result<Option<DockerPs>, DevContainerError> {
 364        let command = self.create_docker_query_containers(filters);
 365        evaluate_json_command(command).await
 366    }
 367
 368    fn docker_cli(&self) -> String {
 369        self.docker_cli.clone()
 370    }
 371
 372    fn supports_compose_buildkit(&self) -> bool {
 373        !self.is_podman()
 374    }
 375}
 376
 377#[async_trait]
 378pub(crate) trait DockerClient {
 379    async fn inspect(&self, id: &String) -> Result<DockerInspect, DevContainerError>;
 380    async fn get_docker_compose_config(
 381        &self,
 382        config_files: &Vec<PathBuf>,
 383    ) -> Result<Option<DockerComposeConfig>, DevContainerError>;
 384    async fn docker_compose_build(
 385        &self,
 386        config_files: &Vec<PathBuf>,
 387        project_name: &str,
 388    ) -> Result<(), DevContainerError>;
 389    async fn run_docker_exec(
 390        &self,
 391        container_id: &str,
 392        remote_folder: &str,
 393        user: &str,
 394        env: &HashMap<String, String>,
 395        inner_command: Command,
 396    ) -> Result<(), DevContainerError>;
 397    async fn start_container(&self, id: &str) -> Result<(), DevContainerError>;
 398    async fn find_process_by_filters(
 399        &self,
 400        filters: Vec<String>,
 401    ) -> Result<Option<DockerPs>, DevContainerError>;
 402    fn supports_compose_buildkit(&self) -> bool;
 403    /// This operates as an escape hatch for more custom uses of the docker API.
 404    /// See DevContainerManifest::create_docker_build as an example
 405    fn docker_cli(&self) -> String;
 406}
 407
 408fn deserialize_labels<'de, D>(deserializer: D) -> Result<Option<HashMap<String, String>>, D::Error>
 409where
 410    D: Deserializer<'de>,
 411{
 412    struct LabelsVisitor;
 413
 414    impl<'de> de::Visitor<'de> for LabelsVisitor {
 415        type Value = Option<HashMap<String, String>>;
 416
 417        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
 418            formatter.write_str("a sequence of strings or a map of string key-value pairs")
 419        }
 420
 421        fn visit_seq<A>(self, seq: A) -> Result<Self::Value, A::Error>
 422        where
 423            A: de::SeqAccess<'de>,
 424        {
 425            let values = Vec::<String>::deserialize(de::value::SeqAccessDeserializer::new(seq))?;
 426
 427            Ok(Some(
 428                values
 429                    .iter()
 430                    .filter_map(|v| {
 431                        let (key, value) = v.split_once('=')?;
 432                        Some((key.to_string(), value.to_string()))
 433                    })
 434                    .collect(),
 435            ))
 436        }
 437
 438        fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
 439        where
 440            M: de::MapAccess<'de>,
 441        {
 442            HashMap::<String, String>::deserialize(de::value::MapAccessDeserializer::new(map))
 443                .map(|v| Some(v))
 444        }
 445
 446        fn visit_none<E>(self) -> Result<Self::Value, E>
 447        where
 448            E: de::Error,
 449        {
 450            Ok(None)
 451        }
 452
 453        fn visit_unit<E>(self) -> Result<Self::Value, E>
 454        where
 455            E: de::Error,
 456        {
 457            Ok(None)
 458        }
 459    }
 460
 461    deserializer.deserialize_any(LabelsVisitor)
 462}
 463
 464fn deserialize_nullable_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
 465where
 466    D: Deserializer<'de>,
 467    T: Deserialize<'de>,
 468{
 469    Option::<Vec<T>>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 470}
 471
 472fn deserialize_nullable_labels<'de, D>(deserializer: D) -> Result<DockerConfigLabels, D::Error>
 473where
 474    D: Deserializer<'de>,
 475{
 476    Option::<DockerConfigLabels>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
 477}
 478
 479fn deserialize_metadata<'de, D>(
 480    deserializer: D,
 481) -> Result<Option<Vec<HashMap<String, serde_json_lenient::Value>>>, D::Error>
 482where
 483    D: Deserializer<'de>,
 484{
 485    let s: Option<String> = Option::deserialize(deserializer)?;
 486    match s {
 487        Some(json_string) => {
 488            let parsed: Vec<HashMap<String, serde_json_lenient::Value>> =
 489                serde_json_lenient::from_str(&json_string).map_err(|e| {
 490                    log::error!("Error deserializing metadata: {e}");
 491                    serde::de::Error::custom(e)
 492                })?;
 493            Ok(Some(parsed))
 494        }
 495        None => Ok(None),
 496    }
 497}
 498
 499pub(crate) fn get_remote_dir_from_config(
 500    config: &DockerInspect,
 501    local_dir: String,
 502) -> Result<String, DevContainerError> {
 503    let local_path = PathBuf::from(&local_dir);
 504
 505    let Some(mounts) = &config.mounts else {
 506        log::error!("No mounts defined for container");
 507        return Err(DevContainerError::ContainerNotValid(config.id.clone()));
 508    };
 509
 510    for mount in mounts {
 511        // Sometimes docker will mount the local filesystem on host_mnt for system isolation
 512        let mount_source = PathBuf::from(&mount.source.trim_start_matches("/host_mnt"));
 513        if let Ok(relative_path_to_project) = local_path.strip_prefix(&mount_source) {
 514            let remote_dir = format!(
 515                "{}/{}",
 516                &mount.destination,
 517                relative_path_to_project.display()
 518            );
 519            return Ok(remote_dir);
 520        }
 521        if mount.source == local_dir {
 522            return Ok(mount.destination.clone());
 523        }
 524    }
 525    log::error!("No mounts to local folder");
 526    Err(DevContainerError::ContainerNotValid(config.id.clone()))
 527}
 528
 529#[cfg(test)]
 530mod test {
 531    use std::{
 532        collections::HashMap,
 533        ffi::OsStr,
 534        process::{ExitStatus, Output},
 535    };
 536
 537    use crate::{
 538        command_json::deserialize_json_output,
 539        devcontainer_json::MountDefinition,
 540        docker::{
 541            Docker, DockerComposeConfig, DockerComposeService, DockerComposeServicePort,
 542            DockerComposeVolume, DockerInspect, DockerPs, get_remote_dir_from_config,
 543        },
 544    };
 545
 546    #[test]
 547    fn should_parse_simple_env_var() {
 548        let config = super::DockerInspectConfig {
 549            labels: super::DockerConfigLabels { metadata: None },
 550            image_user: None,
 551            env: vec!["KEY=value".to_string()],
 552        };
 553
 554        let map = config.env_as_map().unwrap();
 555        assert_eq!(map.get("KEY").unwrap(), "value");
 556    }
 557
 558    #[test]
 559    fn should_parse_env_var_with_equals_in_value() {
 560        let config = super::DockerInspectConfig {
 561            labels: super::DockerConfigLabels { metadata: None },
 562            image_user: None,
 563            env: vec!["COMPLEX=key=val other>=1.0".to_string()],
 564        };
 565
 566        let map = config.env_as_map().unwrap();
 567        assert_eq!(map.get("COMPLEX").unwrap(), "key=val other>=1.0");
 568    }
 569
 570    #[test]
 571    fn should_parse_simple_label() {
 572        let json = r#"{"volumes": [], "labels": ["com.example.key=value"]}"#;
 573        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 574        let labels = service.labels.unwrap();
 575        assert_eq!(labels.get("com.example.key").unwrap(), "value");
 576    }
 577
 578    #[test]
 579    fn should_parse_label_with_equals_in_value() {
 580        let json = r#"{"volumes": [], "labels": ["com.example.key=value=with=equals"]}"#;
 581        let service: DockerComposeService = serde_json_lenient::from_str(json).unwrap();
 582        let labels = service.labels.unwrap();
 583        assert_eq!(labels.get("com.example.key").unwrap(), "value=with=equals");
 584    }
 585
 586    #[test]
 587    fn should_create_docker_inspect_command() {
 588        let docker = Docker::new("docker");
 589        let given_id = "given_docker_id";
 590
 591        let command = docker.create_docker_inspect(given_id);
 592
 593        assert_eq!(
 594            command.get_args().collect::<Vec<&OsStr>>(),
 595            vec![
 596                OsStr::new("inspect"),
 597                OsStr::new("--format={{json . }}"),
 598                OsStr::new(given_id)
 599            ]
 600        )
 601    }
 602
 603    #[test]
 604    fn should_deserialize_docker_ps_with_filters() {
 605        // First, deserializes empty
 606        let empty_output = Output {
 607            status: ExitStatus::default(),
 608            stderr: vec![],
 609            stdout: String::from("").into_bytes(),
 610        };
 611
 612        let result: Option<DockerPs> = deserialize_json_output(empty_output).unwrap();
 613
 614        assert!(result.is_none());
 615
 616        let full_output = Output {
 617                status: ExitStatus::default(),
 618                stderr: vec![],
 619                stdout: String::from(r#"
 620    {
 621        "Command": "\"/bin/sh -c 'echo Co…\"",
 622        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 623        "ID": "abdb6ab59573",
 624        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 625        "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
 626        "LocalVolumes": "0",
 627        "Mounts": "/host_mnt/User…",
 628        "Names": "objective_haslett",
 629        "Networks": "bridge",
 630        "Platform": {
 631        "architecture": "arm64",
 632        "os": "linux"
 633        },
 634        "Ports": "",
 635        "RunningFor": "47 hours ago",
 636        "Size": "0B",
 637        "State": "running",
 638        "Status": "Up 47 hours"
 639    }
 640                    "#).into_bytes(),
 641            };
 642
 643        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 644
 645        assert!(result.is_some());
 646        let result = result.unwrap();
 647        assert_eq!(result.id, "abdb6ab59573".to_string());
 648
 649        // Podman variant (Id, not ID)
 650        let full_output = Output {
 651                status: ExitStatus::default(),
 652                stderr: vec![],
 653                stdout: String::from(r#"
 654    {
 655        "Command": "\"/bin/sh -c 'echo Co…\"",
 656        "CreatedAt": "2026-02-04 15:44:21 -0800 PST",
 657        "Id": "abdb6ab59573",
 658        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 659        "Labels": "desktop.docker.io/mounts/0/Source=/somepath/cli,desktop.docker.io/mounts/0/SourceKind=hostFile,desktop.docker.io/mounts/0/Target=/workspaces/cli,desktop.docker.io/ports.scheme=v2,dev.containers.features=common,dev.containers.id=base-ubuntu,dev.containers.release=v0.4.24,dev.containers.source=https://github.com/devcontainers/images,dev.containers.timestamp=Fri, 30 Jan 2026 16:52:34 GMT,dev.containers.variant=noble,devcontainer.config_file=/somepath/cli/.devcontainer/dev_container_2/devcontainer.json,devcontainer.local_folder=/somepath/cli,devcontainer.metadata=[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}],org.opencontainers.image.ref.name=ubuntu,org.opencontainers.image.version=24.04,version=2.1.6",
 660        "LocalVolumes": "0",
 661        "Mounts": "/host_mnt/User…",
 662        "Names": "objective_haslett",
 663        "Networks": "bridge",
 664        "Platform": {
 665        "architecture": "arm64",
 666        "os": "linux"
 667        },
 668        "Ports": "",
 669        "RunningFor": "47 hours ago",
 670        "Size": "0B",
 671        "State": "running",
 672        "Status": "Up 47 hours"
 673    }
 674                    "#).into_bytes(),
 675            };
 676
 677        let result: Option<DockerPs> = deserialize_json_output(full_output).unwrap();
 678
 679        assert!(result.is_some());
 680        let result = result.unwrap();
 681        assert_eq!(result.id, "abdb6ab59573".to_string());
 682    }
 683
 684    #[test]
 685    fn should_get_target_dir_from_docker_inspect() {
 686        let given_config = r#"
 687    {
 688      "Id": "abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85",
 689      "Created": "2026-02-04T23:44:21.802688084Z",
 690      "Path": "/bin/sh",
 691      "Args": [
 692        "-c",
 693        "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
 694        "-"
 695      ],
 696      "State": {
 697        "Status": "running",
 698        "Running": true,
 699        "Paused": false,
 700        "Restarting": false,
 701        "OOMKilled": false,
 702        "Dead": false,
 703        "Pid": 23087,
 704        "ExitCode": 0,
 705        "Error": "",
 706        "StartedAt": "2026-02-04T23:44:21.954875084Z",
 707        "FinishedAt": "0001-01-01T00:00:00Z"
 708      },
 709      "Image": "sha256:3dcb059253b2ebb44de3936620e1cff3dadcd2c1c982d579081ca8128c1eb319",
 710      "ResolvConfPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/resolv.conf",
 711      "HostnamePath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hostname",
 712      "HostsPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/hosts",
 713      "LogPath": "/var/lib/docker/containers/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85/abdb6ab59573659b11dac9f4973796741be35b642c9b48960709304ce46dbf85-json.log",
 714      "Name": "/objective_haslett",
 715      "RestartCount": 0,
 716      "Driver": "overlayfs",
 717      "Platform": "linux",
 718      "MountLabel": "",
 719      "ProcessLabel": "",
 720      "AppArmorProfile": "",
 721      "ExecIDs": [
 722        "008019d93df4107fcbba78bcc6e1ed7e121844f36c26aca1a56284655a6adb53"
 723      ],
 724      "HostConfig": {
 725        "Binds": null,
 726        "ContainerIDFile": "",
 727        "LogConfig": {
 728          "Type": "json-file",
 729          "Config": {}
 730        },
 731        "NetworkMode": "bridge",
 732        "PortBindings": {},
 733        "RestartPolicy": {
 734          "Name": "no",
 735          "MaximumRetryCount": 0
 736        },
 737        "AutoRemove": false,
 738        "VolumeDriver": "",
 739        "VolumesFrom": null,
 740        "ConsoleSize": [
 741          0,
 742          0
 743        ],
 744        "CapAdd": null,
 745        "CapDrop": null,
 746        "CgroupnsMode": "private",
 747        "Dns": [],
 748        "DnsOptions": [],
 749        "DnsSearch": [],
 750        "ExtraHosts": null,
 751        "GroupAdd": null,
 752        "IpcMode": "private",
 753        "Cgroup": "",
 754        "Links": null,
 755        "OomScoreAdj": 0,
 756        "PidMode": "",
 757        "Privileged": false,
 758        "PublishAllPorts": false,
 759        "ReadonlyRootfs": false,
 760        "SecurityOpt": null,
 761        "UTSMode": "",
 762        "UsernsMode": "",
 763        "ShmSize": 67108864,
 764        "Runtime": "runc",
 765        "Isolation": "",
 766        "CpuShares": 0,
 767        "Memory": 0,
 768        "NanoCpus": 0,
 769        "CgroupParent": "",
 770        "BlkioWeight": 0,
 771        "BlkioWeightDevice": [],
 772        "BlkioDeviceReadBps": [],
 773        "BlkioDeviceWriteBps": [],
 774        "BlkioDeviceReadIOps": [],
 775        "BlkioDeviceWriteIOps": [],
 776        "CpuPeriod": 0,
 777        "CpuQuota": 0,
 778        "CpuRealtimePeriod": 0,
 779        "CpuRealtimeRuntime": 0,
 780        "CpusetCpus": "",
 781        "CpusetMems": "",
 782        "Devices": [],
 783        "DeviceCgroupRules": null,
 784        "DeviceRequests": null,
 785        "MemoryReservation": 0,
 786        "MemorySwap": 0,
 787        "MemorySwappiness": null,
 788        "OomKillDisable": null,
 789        "PidsLimit": null,
 790        "Ulimits": [],
 791        "CpuCount": 0,
 792        "CpuPercent": 0,
 793        "IOMaximumIOps": 0,
 794        "IOMaximumBandwidth": 0,
 795        "Mounts": [
 796          {
 797            "Type": "bind",
 798            "Source": "/somepath/cli",
 799            "Target": "/workspaces/cli",
 800            "Consistency": "cached"
 801          }
 802        ],
 803        "MaskedPaths": [
 804          "/proc/asound",
 805          "/proc/acpi",
 806          "/proc/interrupts",
 807          "/proc/kcore",
 808          "/proc/keys",
 809          "/proc/latency_stats",
 810          "/proc/timer_list",
 811          "/proc/timer_stats",
 812          "/proc/sched_debug",
 813          "/proc/scsi",
 814          "/sys/firmware",
 815          "/sys/devices/virtual/powercap"
 816        ],
 817        "ReadonlyPaths": [
 818          "/proc/bus",
 819          "/proc/fs",
 820          "/proc/irq",
 821          "/proc/sys",
 822          "/proc/sysrq-trigger"
 823        ]
 824      },
 825      "GraphDriver": {
 826        "Data": null,
 827        "Name": "overlayfs"
 828      },
 829      "Mounts": [
 830        {
 831          "Type": "bind",
 832          "Source": "/somepath/cli",
 833          "Destination": "/workspaces/cli",
 834          "Mode": "",
 835          "RW": true,
 836          "Propagation": "rprivate"
 837        }
 838      ],
 839      "Config": {
 840        "Hostname": "abdb6ab59573",
 841        "Domainname": "",
 842        "User": "root",
 843        "AttachStdin": false,
 844        "AttachStdout": true,
 845        "AttachStderr": true,
 846        "Tty": false,
 847        "OpenStdin": false,
 848        "StdinOnce": false,
 849        "Env": [
 850          "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
 851        ],
 852        "Cmd": [
 853          "-c",
 854          "echo Container started\ntrap \"exit 0\" 15\n\nexec \"$@\"\nwhile sleep 1 & wait $!; do :; done",
 855          "-"
 856        ],
 857        "Image": "mcr.microsoft.com/devcontainers/base:ubuntu",
 858        "Volumes": null,
 859        "WorkingDir": "",
 860        "Entrypoint": [
 861          "/bin/sh"
 862        ],
 863        "OnBuild": null,
 864        "Labels": {
 865          "dev.containers.features": "common",
 866          "dev.containers.id": "base-ubuntu",
 867          "dev.containers.release": "v0.4.24",
 868          "dev.containers.source": "https://github.com/devcontainers/images",
 869          "dev.containers.timestamp": "Fri, 30 Jan 2026 16:52:34 GMT",
 870          "dev.containers.variant": "noble",
 871          "devcontainer.config_file": "/somepath/cli/.devcontainer/dev_container_2/devcontainer.json",
 872          "devcontainer.local_folder": "/somepath/cli",
 873          "devcontainer.metadata": "[{\"id\":\"ghcr.io/devcontainers/features/common-utils:2\"},{\"id\":\"ghcr.io/devcontainers/features/git:1\",\"customizations\":{\"vscode\":{\"settings\":{\"github.copilot.chat.codeGeneration.instructions\":[{\"text\":\"This dev container includes an up-to-date version of Git, built from source as needed, pre-installed and available on the `PATH`.\"}]}}}},{\"remoteUser\":\"vscode\"}]",
 874          "org.opencontainers.image.ref.name": "ubuntu",
 875          "org.opencontainers.image.version": "24.04",
 876          "version": "2.1.6"
 877        },
 878        "StopTimeout": 1
 879      },
 880      "NetworkSettings": {
 881        "Bridge": "",
 882        "SandboxID": "2a94990d542fe532deb75f1cc67f761df2d669e3b41161f914079e88516cc54b",
 883        "SandboxKey": "/var/run/docker/netns/2a94990d542f",
 884        "Ports": {},
 885        "HairpinMode": false,
 886        "LinkLocalIPv6Address": "",
 887        "LinkLocalIPv6PrefixLen": 0,
 888        "SecondaryIPAddresses": null,
 889        "SecondaryIPv6Addresses": null,
 890        "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
 891        "Gateway": "172.17.0.1",
 892        "GlobalIPv6Address": "",
 893        "GlobalIPv6PrefixLen": 0,
 894        "IPAddress": "172.17.0.3",
 895        "IPPrefixLen": 16,
 896        "IPv6Gateway": "",
 897        "MacAddress": "",
 898        "Networks": {
 899          "bridge": {
 900            "IPAMConfig": null,
 901            "Links": null,
 902            "Aliases": null,
 903            "MacAddress": "9a:ec:af:8a:ac:81",
 904            "DriverOpts": null,
 905            "GwPriority": 0,
 906            "NetworkID": "51bb8ccc4d1281db44f16d915963fc728619d4a68e2f90e5ea8f1cb94885063e",
 907            "EndpointID": "ef5b35a8fbb145565853e1a1d960e737fcc18c20920e96494e4c0cfc55683570",
 908            "Gateway": "172.17.0.1",
 909            "IPAddress": "172.17.0.3",
 910            "IPPrefixLen": 16,
 911            "IPv6Gateway": "",
 912            "GlobalIPv6Address": "",
 913            "GlobalIPv6PrefixLen": 0,
 914            "DNSNames": null
 915          }
 916        }
 917      },
 918      "ImageManifestDescriptor": {
 919        "mediaType": "application/vnd.oci.image.manifest.v1+json",
 920        "digest": "sha256:39c3436527190561948236894c55b59fa58aa08d68d8867e703c8d5ab72a3593",
 921        "size": 2195,
 922        "platform": {
 923          "architecture": "arm64",
 924          "os": "linux"
 925        }
 926      }
 927    }
 928                "#;
 929        let config = serde_json_lenient::from_str::<DockerInspect>(given_config).unwrap();
 930
 931        let target_dir = get_remote_dir_from_config(&config, "/somepath/cli".to_string());
 932
 933        assert!(target_dir.is_ok());
 934        assert_eq!(target_dir.unwrap(), "/workspaces/cli/".to_string());
 935    }
 936
 937    #[test]
 938    fn should_deserialize_docker_compose_config() {
 939        let given_config = r#"
 940    {
 941        "name": "devcontainer",
 942        "networks": {
 943        "default": {
 944            "name": "devcontainer_default",
 945            "ipam": {}
 946        }
 947        },
 948        "services": {
 949            "app": {
 950                "command": [
 951                "sleep",
 952                "infinity"
 953                ],
 954                "depends_on": {
 955                "db": {
 956                    "condition": "service_started",
 957                    "restart": true,
 958                    "required": true
 959                }
 960                },
 961                "entrypoint": null,
 962                "environment": {
 963                "POSTGRES_DB": "postgres",
 964                "POSTGRES_HOSTNAME": "localhost",
 965                "POSTGRES_PASSWORD": "postgres",
 966                "POSTGRES_PORT": "5432",
 967                "POSTGRES_USER": "postgres"
 968                },
 969                "ports": [
 970                    {
 971                        "target": "5443",
 972                        "published": "5442"
 973                    },
 974                    {
 975                        "name": "custom port",
 976                        "protocol": "udp",
 977                        "host_ip": "127.0.0.1",
 978                        "app_protocol": "http",
 979                        "mode": "host",
 980                        "target": "8081",
 981                        "published": "8083"
 982
 983                    }
 984                ],
 985                "image": "mcr.microsoft.com/devcontainers/rust:2-1-bookworm",
 986                "network_mode": "service:db",
 987                "volumes": [
 988                {
 989                    "type": "bind",
 990                    "source": "/path/to",
 991                    "target": "/workspaces",
 992                    "bind": {
 993                    "create_host_path": true
 994                    }
 995                }
 996                ]
 997            },
 998            "db": {
 999                "command": null,
1000                "entrypoint": null,
1001                "environment": {
1002                "POSTGRES_DB": "postgres",
1003                "POSTGRES_HOSTNAME": "localhost",
1004                "POSTGRES_PASSWORD": "postgres",
1005                "POSTGRES_PORT": "5432",
1006                "POSTGRES_USER": "postgres"
1007                },
1008                "image": "postgres:14.1",
1009                "networks": {
1010                "default": null
1011                },
1012                "restart": "unless-stopped",
1013                "volumes": [
1014                {
1015                    "type": "volume",
1016                    "source": "postgres-data",
1017                    "target": "/var/lib/postgresql/data",
1018                    "volume": {}
1019                }
1020                ]
1021            }
1022        },
1023        "volumes": {
1024        "postgres-data": {
1025            "name": "devcontainer_postgres-data"
1026        }
1027        }
1028    }
1029                "#;
1030
1031        let docker_compose_config: DockerComposeConfig =
1032            serde_json_lenient::from_str(given_config).unwrap();
1033
1034        let expected_config = DockerComposeConfig {
1035            name: Some("devcontainer".to_string()),
1036            services: HashMap::from([
1037                (
1038                    "app".to_string(),
1039                    DockerComposeService {
1040                        command: vec!["sleep".to_string(), "infinity".to_string()],
1041                        image: Some(
1042                            "mcr.microsoft.com/devcontainers/rust:2-1-bookworm".to_string(),
1043                        ),
1044                        volumes: vec![MountDefinition {
1045                            mount_type: Some("bind".to_string()),
1046                            source: Some("/path/to".to_string()),
1047                            target: "/workspaces".to_string(),
1048                        }],
1049                        network_mode: Some("service:db".to_string()),
1050
1051                        ports: vec![
1052                            DockerComposeServicePort {
1053                                target: "5443".to_string(),
1054                                published: "5442".to_string(),
1055                                ..Default::default()
1056                            },
1057                            DockerComposeServicePort {
1058                                target: "8081".to_string(),
1059                                published: "8083".to_string(),
1060                                mode: Some("host".to_string()),
1061                                protocol: Some("udp".to_string()),
1062                                host_ip: Some("127.0.0.1".to_string()),
1063                                app_protocol: Some("http".to_string()),
1064                                name: Some("custom port".to_string()),
1065                            },
1066                        ],
1067                        ..Default::default()
1068                    },
1069                ),
1070                (
1071                    "db".to_string(),
1072                    DockerComposeService {
1073                        image: Some("postgres:14.1".to_string()),
1074                        volumes: vec![MountDefinition {
1075                            mount_type: Some("volume".to_string()),
1076                            source: Some("postgres-data".to_string()),
1077                            target: "/var/lib/postgresql/data".to_string(),
1078                        }],
1079                        ..Default::default()
1080                    },
1081                ),
1082            ]),
1083            volumes: HashMap::from([(
1084                "postgres-data".to_string(),
1085                DockerComposeVolume {
1086                    name: "devcontainer_postgres-data".to_string(),
1087                },
1088            )]),
1089        };
1090
1091        assert_eq!(docker_compose_config, expected_config);
1092    }
1093
1094    #[test]
1095    fn should_deserialize_compose_labels_as_map() {
1096        let given_config = r#"
1097        {
1098            "name": "devcontainer",
1099            "services": {
1100                "app": {
1101                    "image": "node:22-alpine",
1102                    "volumes": [],
1103                    "labels": {
1104                        "com.example.test": "value",
1105                        "another.label": "another-value"
1106                    }
1107                }
1108            }
1109        }
1110        "#;
1111
1112        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1113        let service = config.services.get("app").unwrap();
1114        let labels = service.labels.clone().unwrap();
1115        assert_eq!(
1116            labels,
1117            HashMap::from([
1118                ("another.label".to_string(), "another-value".to_string()),
1119                ("com.example.test".to_string(), "value".to_string())
1120            ])
1121        );
1122    }
1123
1124    #[test]
1125    fn should_deserialize_compose_labels_as_array() {
1126        let given_config = r#"
1127        {
1128            "name": "devcontainer",
1129            "services": {
1130                "app": {
1131                    "image": "node:22-alpine",
1132                    "volumes": [],
1133                    "labels": ["com.example.test=value"]
1134                }
1135            }
1136        }
1137        "#;
1138
1139        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1140        let service = config.services.get("app").unwrap();
1141        assert_eq!(
1142            service.labels,
1143            Some(HashMap::from([(
1144                "com.example.test".to_string(),
1145                "value".to_string()
1146            )]))
1147        );
1148    }
1149
1150    #[test]
1151    fn should_deserialize_compose_without_volumes() {
1152        let given_config = r#"
1153        {
1154            "name": "devcontainer",
1155            "services": {
1156                "app": {
1157                    "image": "node:22-alpine",
1158                    "volumes": []
1159                }
1160            }
1161        }
1162        "#;
1163
1164        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1165        assert!(config.volumes.is_empty());
1166    }
1167
1168    #[test]
1169    fn should_deserialize_compose_with_missing_volumes_field() {
1170        let given_config = r#"
1171        {
1172            "name": "devcontainer",
1173            "services": {
1174                "sidecar": {
1175                    "image": "ubuntu:24.04"
1176                }
1177            }
1178        }
1179        "#;
1180
1181        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1182        let service = config.services.get("sidecar").unwrap();
1183        assert!(service.volumes.is_empty());
1184    }
1185
1186    #[test]
1187    fn should_deserialize_compose_volume_without_source() {
1188        let given_config = r#"
1189        {
1190            "name": "devcontainer",
1191            "services": {
1192                "app": {
1193                    "image": "ubuntu:24.04",
1194                    "volumes": [
1195                        {
1196                            "type": "tmpfs",
1197                            "target": "/tmp"
1198                        }
1199                    ]
1200                }
1201            }
1202        }
1203        "#;
1204
1205        let config: DockerComposeConfig = serde_json_lenient::from_str(given_config).unwrap();
1206        let service = config.services.get("app").unwrap();
1207        assert_eq!(service.volumes.len(), 1);
1208        assert_eq!(service.volumes[0].source, None);
1209        assert_eq!(service.volumes[0].target, "/tmp");
1210        assert_eq!(service.volumes[0].mount_type, Some("tmpfs".to_string()));
1211    }
1212
1213    #[test]
1214    fn should_deserialize_inspect_without_labels() {
1215        let given_config = r#"
1216        {
1217            "Id": "sha256:abc123",
1218            "Config": {
1219                "Env": ["PATH=/usr/bin"],
1220                "Cmd": ["node"],
1221                "WorkingDir": "/"
1222            }
1223        }
1224        "#;
1225
1226        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1227        assert!(inspect.config.labels.metadata.is_none());
1228        assert!(inspect.config.image_user.is_none());
1229    }
1230
1231    #[test]
1232    fn should_deserialize_inspect_with_null_labels() {
1233        let given_config = r#"
1234        {
1235            "Id": "sha256:abc123",
1236            "Config": {
1237                "Labels": null,
1238                "Env": ["PATH=/usr/bin"]
1239            }
1240        }
1241        "#;
1242
1243        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1244        assert!(inspect.config.labels.metadata.is_none());
1245    }
1246
1247    #[test]
1248    fn should_deserialize_inspect_with_labels_but_no_metadata() {
1249        let given_config = r#"
1250        {
1251            "Id": "sha256:abc123",
1252            "Config": {
1253                "Labels": {
1254                    "com.example.test": "value"
1255                },
1256                "Env": ["PATH=/usr/bin"]
1257            }
1258        }
1259        "#;
1260
1261        let inspect: DockerInspect = serde_json_lenient::from_str(given_config).unwrap();
1262        assert!(inspect.config.labels.metadata.is_none());
1263    }
1264}