Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:31:34.600Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

devcontainer_json.rs

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