Skip to main content

genai_rs/
environment.rs

1//! Environment types for the `environment` request field and the Agents
2//! resource's `base_environment`.
3//!
4//! An environment describes the sandbox an agent runs in: which sources are
5//! mounted (GCS buckets, inline files, repositories, skill registries) and
6//! what outbound network access is allowed.
7//!
8//! The wire union accepts either a string environment ID — an environment
9//! created explicitly via
10//! [`Client::create_environment()`](crate::Client::create_environment) (see
11//! [`environments`](crate::environments)) or by a previous interaction,
12//! echoed as
13//! [`InteractionResponse::environment_id`](crate::InteractionResponse) — or
14//! a typed remote environment object, modeled here as [`EnvironmentSpec`].
15
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17use std::collections::HashMap;
18
19/// The type of a source mounted into an environment.
20///
21/// This enum is marked `#[non_exhaustive]` for forward compatibility.
22///
23/// # Wire Format
24///
25/// Serializes as lowercase snake_case strings: `"gcs"`, `"inline"`,
26/// `"repository"`, `"skill_registry"`.
27///
28/// # Evergreen Pattern
29///
30/// Unknown values from the API deserialize into the `Unknown` variant,
31/// preserving the original data for debugging and roundtrip serialization.
32#[derive(Clone, Debug, PartialEq)]
33#[non_exhaustive]
34pub enum SourceType {
35    /// Google Cloud Storage path.
36    Gcs,
37    /// Inline content provided in the request.
38    Inline,
39    /// A source-code repository (e.g., GitHub path).
40    Repository,
41    /// A skill registry entry.
42    SkillRegistry,
43    /// Unknown variant for forward compatibility (Evergreen pattern)
44    Unknown {
45        /// The unrecognized source type from the API
46        source_type: String,
47        /// The raw JSON value, preserved for debugging and roundtrip
48        data: serde_json::Value,
49    },
50}
51
52impl SourceType {
53    /// Returns true if this is an unknown source type.
54    #[must_use]
55    pub const fn is_unknown(&self) -> bool {
56        matches!(self, Self::Unknown { .. })
57    }
58
59    /// Returns the source type name if this is an unknown source type.
60    #[must_use]
61    pub fn unknown_source_type(&self) -> Option<&str> {
62        match self {
63            Self::Unknown { source_type, .. } => Some(source_type),
64            _ => None,
65        }
66    }
67
68    /// Returns the preserved data if this is an unknown source type.
69    #[must_use]
70    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
71        match self {
72            Self::Unknown { data, .. } => Some(data),
73            _ => None,
74        }
75    }
76}
77
78impl Serialize for SourceType {
79    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
80    where
81        S: Serializer,
82    {
83        match self {
84            Self::Gcs => serializer.serialize_str("gcs"),
85            Self::Inline => serializer.serialize_str("inline"),
86            Self::Repository => serializer.serialize_str("repository"),
87            Self::SkillRegistry => serializer.serialize_str("skill_registry"),
88            Self::Unknown { source_type, .. } => serializer.serialize_str(source_type),
89        }
90    }
91}
92
93impl<'de> Deserialize<'de> for SourceType {
94    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95    where
96        D: Deserializer<'de>,
97    {
98        let value = serde_json::Value::deserialize(deserializer)?;
99        match value.as_str() {
100            Some("gcs") => Ok(Self::Gcs),
101            Some("inline") => Ok(Self::Inline),
102            Some("repository") => Ok(Self::Repository),
103            Some("skill_registry") => Ok(Self::SkillRegistry),
104            Some(other) => {
105                tracing::warn!(
106                    "Encountered unknown SourceType '{}' - using Unknown variant (Evergreen)",
107                    other
108                );
109                Ok(Self::Unknown {
110                    source_type: other.to_string(),
111                    data: value,
112                })
113            }
114            None => {
115                let source_type = format!("<non-string: {}>", value);
116                tracing::warn!(
117                    "SourceType received non-string value: {}. \
118                     Preserving in Unknown variant.",
119                    value
120                );
121                Ok(Self::Unknown {
122                    source_type,
123                    data: value,
124                })
125            }
126        }
127    }
128}
129
130/// A source to be mounted into an environment.
131///
132/// # Example
133///
134/// ```
135/// use genai_rs::EnvironmentSource;
136///
137/// // Mount a GCS bucket at /data
138/// let gcs = EnvironmentSource::gcs("gs://my-bucket/data", "/data");
139///
140/// // Provide an inline file
141/// let inline = EnvironmentSource::inline("/etc/config.toml", "verbose = true");
142/// ```
143#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
144#[serde(default)]
145pub struct EnvironmentSource {
146    /// The kind of source (wire: `type`).
147    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
148    pub source_type: Option<SourceType>,
149    /// The source location. For GCS this is the GCS path; for repositories
150    /// this is the repository path.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub source: Option<String>,
153    /// Where the source should appear in the environment.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub target: Option<String>,
156    /// The inline content when `source_type` is [`SourceType::Inline`].
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub content: Option<String>,
159    /// Optional encoding for inline content (e.g., `base64`).
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub encoding: Option<String>,
162    /// Additional fields not yet modeled (Evergreen forward compatibility)
163    #[serde(flatten)]
164    pub extra: serde_json::Map<String, serde_json::Value>,
165}
166
167impl EnvironmentSource {
168    /// Creates a GCS source mounted at `target`.
169    #[must_use]
170    pub fn gcs(source: impl Into<String>, target: impl Into<String>) -> Self {
171        Self {
172            source_type: Some(SourceType::Gcs),
173            source: Some(source.into()),
174            target: Some(target.into()),
175            ..Default::default()
176        }
177    }
178
179    /// Creates an inline source placed at `target` with the given content.
180    #[must_use]
181    pub fn inline(target: impl Into<String>, content: impl Into<String>) -> Self {
182        Self {
183            source_type: Some(SourceType::Inline),
184            target: Some(target.into()),
185            content: Some(content.into()),
186            ..Default::default()
187        }
188    }
189
190    /// Creates a repository source mounted at `target`.
191    #[must_use]
192    pub fn repository(source: impl Into<String>, target: impl Into<String>) -> Self {
193        Self {
194            source_type: Some(SourceType::Repository),
195            source: Some(source.into()),
196            target: Some(target.into()),
197            ..Default::default()
198        }
199    }
200
201    /// Creates a skill-registry source.
202    #[must_use]
203    pub fn skill_registry(source: impl Into<String>) -> Self {
204        Self {
205            source_type: Some(SourceType::SkillRegistry),
206            source: Some(source.into()),
207            ..Default::default()
208        }
209    }
210
211    /// Sets the encoding for inline content (e.g., `base64`).
212    #[must_use]
213    pub fn with_encoding(mut self, encoding: impl Into<String>) -> Self {
214        self.encoding = Some(encoding.into());
215        self
216    }
217}
218
219/// A single domain allowlist rule with optional header injection.
220///
221/// `domain` supports wildcards (e.g. `*.googleapis.com`); use `*` to allow
222/// all domains while still injecting headers on specific ones. Each
223/// `transform` entry is a flat `{header_name: header_value}` map injected on
224/// matching outbound requests by the egress proxy.
225#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
226#[serde(default)]
227pub struct AllowlistEntry {
228    /// Domain to allow outbound requests to.
229    pub domain: String,
230    /// Headers to inject on outbound requests matching this domain.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub transform: Option<Vec<HashMap<String, String>>>,
233    /// Additional fields not yet modeled (Evergreen forward compatibility)
234    #[serde(flatten)]
235    pub extra: serde_json::Map<String, serde_json::Value>,
236}
237
238impl AllowlistEntry {
239    /// Creates an allowlist rule for the given domain.
240    #[must_use]
241    pub fn new(domain: impl Into<String>) -> Self {
242        Self {
243            domain: domain.into(),
244            ..Default::default()
245        }
246    }
247
248    /// Sets header-injection transforms for this domain.
249    #[must_use]
250    pub fn with_transform(mut self, transform: Vec<HashMap<String, String>>) -> Self {
251        self.transform = Some(transform);
252        self
253    }
254}
255
256/// Outbound network configuration for an environment sandbox.
257///
258/// The wire union accepts the string `"disabled"` (all network off) or an
259/// object `{"allowlist": [...]}` restricting outbound domains. Omit the
260/// field entirely to allow all outbound traffic.
261///
262/// This enum is marked `#[non_exhaustive]` for forward compatibility.
263///
264/// # Evergreen Pattern
265///
266/// Unknown shapes from the API deserialize into the `Unknown` variant,
267/// preserving the original data for debugging and roundtrip serialization.
268#[derive(Clone, Debug, PartialEq)]
269#[non_exhaustive]
270pub enum NetworkConfig {
271    /// Turns all network access off (wire: `"disabled"`).
272    Disabled,
273    /// Restricts outbound traffic to the listed domains
274    /// (wire: `{"allowlist": [{domain, transform}]}`).
275    ///
276    /// Construct with [`NetworkConfig::allowlist`].
277    Allowlist {
278        /// The allowlist rules.
279        entries: Vec<AllowlistEntry>,
280        /// Sibling fields next to `allowlist` not yet modeled (Evergreen
281        /// forward compatibility), preserved on roundtrip.
282        extra: serde_json::Map<String, serde_json::Value>,
283    },
284    /// Unknown variant for forward compatibility (Evergreen pattern)
285    Unknown {
286        /// A short descriptor of the unrecognized shape
287        network_type: String,
288        /// The raw JSON value, preserved for debugging and roundtrip
289        data: serde_json::Value,
290    },
291}
292
293impl NetworkConfig {
294    /// Creates an allowlist config from the given rules (with no extra
295    /// sibling fields).
296    #[must_use]
297    pub fn allowlist(entries: Vec<AllowlistEntry>) -> Self {
298        Self::Allowlist {
299            entries,
300            extra: serde_json::Map::new(),
301        }
302    }
303
304    /// Returns true if this is an unknown network config.
305    #[must_use]
306    pub const fn is_unknown(&self) -> bool {
307        matches!(self, Self::Unknown { .. })
308    }
309
310    /// Returns the descriptor if this is an unknown network config.
311    #[must_use]
312    pub fn unknown_network_type(&self) -> Option<&str> {
313        match self {
314            Self::Unknown { network_type, .. } => Some(network_type),
315            _ => None,
316        }
317    }
318
319    /// Returns the preserved data if this is an unknown network config.
320    #[must_use]
321    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
322        match self {
323            Self::Unknown { data, .. } => Some(data),
324            _ => None,
325        }
326    }
327}
328
329impl Serialize for NetworkConfig {
330    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
331    where
332        S: Serializer,
333    {
334        use serde::ser::SerializeMap;
335        match self {
336            Self::Disabled => serializer.serialize_str("disabled"),
337            Self::Allowlist { entries, extra } => {
338                let mut map = serializer.serialize_map(Some(1 + extra.len()))?;
339                map.serialize_entry("allowlist", entries)?;
340                for (key, value) in extra {
341                    map.serialize_entry(key, value)?;
342                }
343                map.end()
344            }
345            Self::Unknown { data, .. } => data.serialize(serializer),
346        }
347    }
348}
349
350impl<'de> Deserialize<'de> for NetworkConfig {
351    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
352    where
353        D: Deserializer<'de>,
354    {
355        let value = serde_json::Value::deserialize(deserializer)?;
356        match &value {
357            serde_json::Value::String(s) if s == "disabled" => Ok(Self::Disabled),
358            serde_json::Value::Object(obj) if obj.contains_key("allowlist") => {
359                match serde_json::from_value::<Vec<AllowlistEntry>>(obj["allowlist"].clone()) {
360                    Ok(entries) => Ok(Self::Allowlist {
361                        entries,
362                        // Preserve sibling keys next to `allowlist` for
363                        // Evergreen roundtrip (mirrors RemoteEnvironment's
364                        // flattened `extra`).
365                        extra: obj
366                            .iter()
367                            .filter(|(key, _)| key.as_str() != "allowlist")
368                            .map(|(key, value)| (key.clone(), value.clone()))
369                            .collect(),
370                    }),
371                    Err(e) => {
372                        tracing::warn!(
373                            "Failed to parse network allowlist: {}. \
374                             Preserving in Unknown variant.",
375                            e
376                        );
377                        Ok(Self::Unknown {
378                            network_type: "allowlist".to_string(),
379                            data: value,
380                        })
381                    }
382                }
383            }
384            _ => {
385                tracing::warn!(
386                    "Encountered unknown NetworkConfig shape: {}. \
387                     Preserving in Unknown variant.",
388                    value
389                );
390                Ok(Self::Unknown {
391                    network_type: format!("<unrecognized: {}>", value),
392                    data: value,
393                })
394            }
395        }
396    }
397}
398
399/// A typed remote environment definition (wire: `{"type": "remote", ...}`).
400///
401/// # Example
402///
403/// ```
404/// use genai_rs::{AllowlistEntry, EnvironmentSource, NetworkConfig, RemoteEnvironment};
405///
406/// let env = RemoteEnvironment::new()
407///     .add_source(EnvironmentSource::gcs("gs://my-bucket/data", "/data"))
408///     .add_source(EnvironmentSource::inline("/etc/motd", "hello"))
409///     .with_network(NetworkConfig::allowlist(vec![
410///         AllowlistEntry::new("*.googleapis.com"),
411///     ]));
412/// ```
413#[derive(Clone, Debug, Default, PartialEq)]
414pub struct RemoteEnvironment {
415    /// Sources mounted into the environment.
416    pub sources: Vec<EnvironmentSource>,
417    /// Outbound network configuration. `None` allows all outbound traffic.
418    pub network: Option<NetworkConfig>,
419    /// Additional fields not yet modeled (Evergreen forward compatibility)
420    pub extra: serde_json::Map<String, serde_json::Value>,
421}
422
423impl RemoteEnvironment {
424    /// Creates an empty remote environment.
425    #[must_use]
426    pub fn new() -> Self {
427        Self::default()
428    }
429
430    /// Adds a source to mount into the environment.
431    #[must_use]
432    pub fn add_source(mut self, source: EnvironmentSource) -> Self {
433        self.sources.push(source);
434        self
435    }
436
437    /// Sets the outbound network configuration.
438    #[must_use]
439    pub fn with_network(mut self, network: NetworkConfig) -> Self {
440        self.network = Some(network);
441        self
442    }
443}
444
445impl Serialize for RemoteEnvironment {
446    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
447    where
448        S: Serializer,
449    {
450        use serde::ser::SerializeMap;
451        let mut map = serializer.serialize_map(None)?;
452        map.serialize_entry("type", "remote")?;
453        if !self.sources.is_empty() {
454            map.serialize_entry("sources", &self.sources)?;
455        }
456        if let Some(network) = &self.network {
457            map.serialize_entry("network", network)?;
458        }
459        for (key, value) in &self.extra {
460            map.serialize_entry(key, value)?;
461        }
462        map.end()
463    }
464}
465
466impl<'de> Deserialize<'de> for RemoteEnvironment {
467    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
468    where
469        D: Deserializer<'de>,
470    {
471        #[derive(Deserialize)]
472        struct Raw {
473            #[serde(default)]
474            sources: Vec<EnvironmentSource>,
475            #[serde(default)]
476            network: Option<NetworkConfig>,
477            /// Unknown fields, preserved for Evergreen roundtrip.
478            #[serde(flatten)]
479            extra: serde_json::Map<String, serde_json::Value>,
480        }
481        let mut raw = Raw::deserialize(deserializer)?;
482        // The `type` discriminant is re-emitted by `Serialize`; keeping it in
483        // `extra` would duplicate the key on roundtrip.
484        raw.extra.remove("type");
485        Ok(Self {
486            sources: raw.sources,
487            network: raw.network,
488            extra: raw.extra,
489        })
490    }
491}
492
493/// The `environment` union on an interaction request (and the Agents
494/// resource's `base_environment`).
495///
496/// Either a string environment ID referencing an existing environment, or a
497/// typed [`RemoteEnvironment`] object.
498///
499/// This enum is marked `#[non_exhaustive]` for forward compatibility.
500///
501/// # Example
502///
503/// ```
504/// use genai_rs::{EnvironmentSpec, EnvironmentSource, RemoteEnvironment};
505///
506/// // Reference an existing environment by ID
507/// let by_id: EnvironmentSpec = "38aac1ae7f30fe9bd67afe42382ea041".into();
508///
509/// // Or define one inline
510/// let inline: EnvironmentSpec = RemoteEnvironment::new()
511///     .add_source(EnvironmentSource::gcs("gs://bucket", "/data"))
512///     .into();
513/// ```
514#[derive(Clone, Debug, PartialEq)]
515#[non_exhaustive]
516pub enum EnvironmentSpec {
517    /// A string ID referencing an existing environment.
518    Id(String),
519    /// A typed remote environment definition.
520    Remote(RemoteEnvironment),
521    /// Unknown variant for forward compatibility (Evergreen pattern)
522    Unknown {
523        /// The unrecognized environment type from the API
524        environment_type: String,
525        /// The raw JSON value, preserved for debugging and roundtrip
526        data: serde_json::Value,
527    },
528}
529
530impl EnvironmentSpec {
531    /// Returns true if this is an unknown environment spec.
532    #[must_use]
533    pub const fn is_unknown(&self) -> bool {
534        matches!(self, Self::Unknown { .. })
535    }
536
537    /// Returns the environment type name if this is an unknown environment spec.
538    #[must_use]
539    pub fn unknown_environment_type(&self) -> Option<&str> {
540        match self {
541            Self::Unknown {
542                environment_type, ..
543            } => Some(environment_type),
544            _ => None,
545        }
546    }
547
548    /// Returns the preserved data if this is an unknown environment spec.
549    #[must_use]
550    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
551        match self {
552            Self::Unknown { data, .. } => Some(data),
553            _ => None,
554        }
555    }
556}
557
558impl From<String> for EnvironmentSpec {
559    fn from(id: String) -> Self {
560        Self::Id(id)
561    }
562}
563
564impl From<&str> for EnvironmentSpec {
565    fn from(id: &str) -> Self {
566        Self::Id(id.to_string())
567    }
568}
569
570impl From<RemoteEnvironment> for EnvironmentSpec {
571    fn from(env: RemoteEnvironment) -> Self {
572        Self::Remote(env)
573    }
574}
575
576impl Serialize for EnvironmentSpec {
577    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
578    where
579        S: Serializer,
580    {
581        match self {
582            Self::Id(id) => serializer.serialize_str(id),
583            Self::Remote(env) => env.serialize(serializer),
584            Self::Unknown { data, .. } => data.serialize(serializer),
585        }
586    }
587}
588
589impl<'de> Deserialize<'de> for EnvironmentSpec {
590    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
591    where
592        D: Deserializer<'de>,
593    {
594        let value = serde_json::Value::deserialize(deserializer)?;
595        match &value {
596            serde_json::Value::String(id) => Ok(Self::Id(id.clone())),
597            serde_json::Value::Object(obj) => {
598                let env_type = obj.get("type").and_then(|t| t.as_str()).unwrap_or("remote");
599                if env_type == "remote" {
600                    match serde_json::from_value::<RemoteEnvironment>(value.clone()) {
601                        Ok(env) => Ok(Self::Remote(env)),
602                        Err(e) => {
603                            tracing::warn!(
604                                "Failed to parse remote environment: {}. \
605                                 Preserving in Unknown variant.",
606                                e
607                            );
608                            Ok(Self::Unknown {
609                                environment_type: "remote".to_string(),
610                                data: value,
611                            })
612                        }
613                    }
614                } else {
615                    tracing::warn!(
616                        "Encountered unknown EnvironmentSpec type '{}' - using Unknown variant (Evergreen)",
617                        env_type
618                    );
619                    Ok(Self::Unknown {
620                        environment_type: env_type.to_string(),
621                        data: value,
622                    })
623                }
624            }
625            _ => {
626                tracing::warn!(
627                    "Encountered unknown EnvironmentSpec shape: {}. \
628                     Preserving in Unknown variant.",
629                    value
630                );
631                Ok(Self::Unknown {
632                    environment_type: format!("<unrecognized: {}>", value),
633                    data: value,
634                })
635            }
636        }
637    }
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use serde_json::json;
644
645    #[test]
646    fn test_source_type_wire_roundtrip() {
647        for (source_type, wire) in [
648            (SourceType::Gcs, "\"gcs\""),
649            (SourceType::Inline, "\"inline\""),
650            (SourceType::Repository, "\"repository\""),
651            (SourceType::SkillRegistry, "\"skill_registry\""),
652        ] {
653            assert_eq!(serde_json::to_string(&source_type).unwrap(), wire);
654            let parsed: SourceType = serde_json::from_str(wire).unwrap();
655            assert_eq!(parsed, source_type);
656        }
657    }
658
659    #[test]
660    fn test_source_type_unknown_roundtrip() {
661        let unknown: SourceType = serde_json::from_str("\"s3\"").unwrap();
662        assert!(unknown.is_unknown());
663        assert_eq!(unknown.unknown_source_type(), Some("s3"));
664        assert!(unknown.unknown_data().is_some());
665        assert_eq!(serde_json::to_string(&unknown).unwrap(), "\"s3\"");
666    }
667
668    #[test]
669    fn test_environment_source_constructors() {
670        let gcs = EnvironmentSource::gcs("gs://bucket/path", "/data");
671        let value = serde_json::to_value(&gcs).unwrap();
672        assert_eq!(
673            value,
674            json!({"type": "gcs", "source": "gs://bucket/path", "target": "/data"})
675        );
676
677        let inline = EnvironmentSource::inline("/etc/motd", "aGVsbG8=").with_encoding("base64");
678        let value = serde_json::to_value(&inline).unwrap();
679        assert_eq!(
680            value,
681            json!({
682                "type": "inline",
683                "target": "/etc/motd",
684                "content": "aGVsbG8=",
685                "encoding": "base64"
686            })
687        );
688
689        let repo = EnvironmentSource::repository("github.com/org/repo", "/workspace");
690        assert_eq!(repo.source_type, Some(SourceType::Repository));
691
692        let skill = EnvironmentSource::skill_registry("skills/my-skill");
693        assert_eq!(skill.source_type, Some(SourceType::SkillRegistry));
694        assert_eq!(skill.target, None);
695    }
696
697    #[test]
698    fn test_network_config_disabled_roundtrip() {
699        let network = NetworkConfig::Disabled;
700        let json = serde_json::to_string(&network).unwrap();
701        assert_eq!(json, "\"disabled\"");
702        let parsed: NetworkConfig = serde_json::from_str(&json).unwrap();
703        assert_eq!(parsed, network);
704    }
705
706    #[test]
707    fn test_network_config_allowlist_roundtrip() {
708        let network = NetworkConfig::allowlist(vec![
709            AllowlistEntry::new("*.googleapis.com"),
710            AllowlistEntry::new("api.example.com").with_transform(vec![HashMap::from([(
711                "Authorization".to_string(),
712                "Bearer token".to_string(),
713            )])]),
714        ]);
715        assert!(!network.is_unknown());
716
717        let value = serde_json::to_value(&network).unwrap();
718        assert_eq!(value["allowlist"][0]["domain"], "*.googleapis.com");
719        assert!(value["allowlist"][0].get("transform").is_none());
720        assert_eq!(
721            value["allowlist"][1]["transform"][0]["Authorization"],
722            "Bearer token"
723        );
724
725        let parsed: NetworkConfig = serde_json::from_value(value).unwrap();
726        assert_eq!(parsed, network);
727    }
728
729    #[test]
730    fn test_network_config_allowlist_preserves_sibling_keys() {
731        // Sibling keys next to `allowlist` (e.g. a future `default_policy`)
732        // must survive the roundtrip (Evergreen), mirroring
733        // RemoteEnvironment's flattened `extra`.
734        let raw = json!({
735            "allowlist": [{"domain": "*.googleapis.com"}],
736            "default_policy": "deny"
737        });
738        let parsed: NetworkConfig = serde_json::from_value(raw.clone()).unwrap();
739        let NetworkConfig::Allowlist { entries, extra } = &parsed else {
740            panic!("expected Allowlist, got {parsed:?}");
741        };
742        assert_eq!(entries.len(), 1);
743        assert_eq!(extra["default_policy"], "deny");
744        assert_eq!(serde_json::to_value(&parsed).unwrap(), raw);
745    }
746
747    #[test]
748    fn test_network_config_unknown_roundtrip() {
749        let raw = json!({"proxy": "socks5://localhost"});
750        let parsed: NetworkConfig = serde_json::from_value(raw.clone()).unwrap();
751        assert!(parsed.is_unknown());
752        assert!(parsed.unknown_network_type().is_some());
753        assert_eq!(parsed.unknown_data(), Some(&raw));
754        assert_eq!(serde_json::to_value(&parsed).unwrap(), raw);
755    }
756
757    #[test]
758    fn test_remote_environment_serialization() {
759        let env = RemoteEnvironment::new()
760            .add_source(EnvironmentSource::gcs("gs://bucket", "/data"))
761            .with_network(NetworkConfig::Disabled);
762
763        let value = serde_json::to_value(&env).unwrap();
764        assert_eq!(
765            value,
766            json!({
767                "type": "remote",
768                "sources": [{"type": "gcs", "source": "gs://bucket", "target": "/data"}],
769                "network": "disabled"
770            })
771        );
772    }
773
774    #[test]
775    fn test_remote_environment_empty_serializes_type_only() {
776        let env = RemoteEnvironment::new();
777        let value = serde_json::to_value(&env).unwrap();
778        assert_eq!(value, json!({"type": "remote"}));
779    }
780
781    #[test]
782    fn test_remote_environment_preserves_unknown_fields() {
783        // Unknown top-level fields must survive a deserialize/serialize
784        // roundtrip (Evergreen), without duplicating the `type` discriminant.
785        let raw = json!({
786            "type": "remote",
787            "sources": [{"type": "gcs", "source": "gs://bucket", "target": "/data"}],
788            "timeout_seconds": 300,
789            "region": "us-central1"
790        });
791        let env: RemoteEnvironment = serde_json::from_value(raw.clone()).unwrap();
792        assert_eq!(env.sources.len(), 1);
793        assert_eq!(env.extra["timeout_seconds"], json!(300));
794        assert_eq!(env.extra["region"], json!("us-central1"));
795        assert!(!env.extra.contains_key("type"));
796        assert_eq!(serde_json::to_value(&env).unwrap(), raw);
797    }
798
799    #[test]
800    fn test_environment_source_preserves_unknown_fields() {
801        let raw = json!({
802            "type": "repository",
803            "source": "owner/repo",
804            "target": "/workspace",
805            "branch": "main"
806        });
807        let source: EnvironmentSource = serde_json::from_value(raw.clone()).unwrap();
808        assert_eq!(source.extra["branch"], json!("main"));
809        assert_eq!(serde_json::to_value(&source).unwrap(), raw);
810    }
811
812    #[test]
813    fn test_allowlist_entry_preserves_unknown_fields() {
814        let raw = json!({
815            "domain": "*.googleapis.com",
816            "max_requests": 10
817        });
818        let entry: AllowlistEntry = serde_json::from_value(raw.clone()).unwrap();
819        assert_eq!(entry.domain, "*.googleapis.com");
820        assert_eq!(entry.extra["max_requests"], json!(10));
821        assert_eq!(serde_json::to_value(&entry).unwrap(), raw);
822    }
823
824    #[test]
825    fn test_environment_spec_id_roundtrip() {
826        let spec: EnvironmentSpec = "38aac1ae7f30fe9bd67afe42382ea041".into();
827        let json = serde_json::to_string(&spec).unwrap();
828        assert_eq!(json, "\"38aac1ae7f30fe9bd67afe42382ea041\"");
829        let parsed: EnvironmentSpec = serde_json::from_str(&json).unwrap();
830        assert_eq!(parsed, spec);
831    }
832
833    #[test]
834    fn test_environment_spec_remote_roundtrip() {
835        let spec: EnvironmentSpec = RemoteEnvironment::new()
836            .add_source(EnvironmentSource::inline("/f.txt", "content"))
837            .into();
838        let json = serde_json::to_string(&spec).unwrap();
839        let parsed: EnvironmentSpec = serde_json::from_str(&json).unwrap();
840        assert_eq!(parsed, spec);
841    }
842
843    #[test]
844    fn test_environment_spec_unknown_type_roundtrip() {
845        let raw = json!({"type": "local", "path": "/tmp"});
846        let parsed: EnvironmentSpec = serde_json::from_value(raw.clone()).unwrap();
847        assert!(parsed.is_unknown());
848        assert_eq!(parsed.unknown_environment_type(), Some("local"));
849        assert_eq!(parsed.unknown_data(), Some(&raw));
850        assert_eq!(serde_json::to_value(&parsed).unwrap(), raw);
851    }
852
853    #[test]
854    fn test_environment_spec_known_not_unknown() {
855        let spec: EnvironmentSpec = "env-1".into();
856        assert!(!spec.is_unknown());
857        assert_eq!(spec.unknown_environment_type(), None);
858        assert_eq!(spec.unknown_data(), None);
859    }
860}