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