Skip to main content

genai_rs/
environments.rs

1//! Environments resource (`/v1beta/environments`).
2//!
3//! An [`Environment`] is a server-side container of files (and network
4//! configuration) that agent interactions can execute against. Requests
5//! reference one via
6//! [`InteractionRequest::environment`](crate::request::InteractionRequest::environment)
7//! — either inline (the API creates one implicitly) or by ID. This module
8//! models the explicit CRUD surface: create an environment once, reference
9//! it from many interactions, list what exists, and delete what's stale.
10//!
11//! Wire format verified live 2026-08-08: the resource uses `created` /
12//! `updated` / `last_accessed` ISO-8601 timestamps, and `file_count` /
13//! `size_bytes` are int64s serialized as JSON *strings* (protobuf JSON
14//! convention); both are accepted here as numbers too.
15
16use crate::environment::{EnvironmentSource, NetworkConfig};
17use crate::serde_util::{
18    ForEnvironment, deserialize_lenient_timestamp, deserialize_string_i64, serialize_string_i64,
19};
20use chrono::{DateTime, Utc};
21use serde::de::Deserializer;
22use serde::{Deserialize, Serialize};
23use std::fmt;
24
25/// Status of an environment container.
26///
27/// This enum is marked `#[non_exhaustive]` for forward compatibility.
28///
29/// # Wire Format
30///
31/// Serializes as lowercase strings: `"active"`, `"expired"`.
32///
33/// # Evergreen Pattern
34///
35/// Unknown values from the API deserialize into the `Unknown` variant,
36/// preserving the original data for debugging and roundtrip serialization.
37#[derive(Clone, Debug, PartialEq)]
38#[non_exhaustive]
39pub enum EnvironmentStatus {
40    /// The environment is available for use.
41    Active,
42    /// The environment has expired and can no longer be used.
43    Expired,
44    /// Unknown variant for forward compatibility (Evergreen pattern)
45    Unknown {
46        /// The unrecognized status type from the API
47        status_type: String,
48        /// The raw JSON value, preserved for debugging and roundtrip
49        data: serde_json::Value,
50    },
51}
52
53impl EnvironmentStatus {
54    /// The wire string for this status — the single source both `Display`
55    /// and `Serialize` render, so the two can never disagree.
56    fn as_wire(&self) -> &str {
57        match self {
58            Self::Active => "active",
59            Self::Expired => "expired",
60            Self::Unknown { status_type, .. } => status_type,
61        }
62    }
63
64    /// Returns true if this is an unknown status.
65    #[must_use]
66    pub const fn is_unknown(&self) -> bool {
67        matches!(self, Self::Unknown { .. })
68    }
69
70    /// Returns the status type name if this is an unknown status.
71    #[must_use]
72    pub fn unknown_status_type(&self) -> Option<&str> {
73        match self {
74            Self::Unknown { status_type, .. } => Some(status_type),
75            _ => None,
76        }
77    }
78
79    /// Returns the preserved data if this is an unknown status.
80    #[must_use]
81    pub fn unknown_data(&self) -> Option<&serde_json::Value> {
82        match self {
83            Self::Unknown { data, .. } => Some(data),
84            _ => None,
85        }
86    }
87}
88
89impl fmt::Display for EnvironmentStatus {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        write!(f, "{}", self.as_wire())
92    }
93}
94
95impl Serialize for EnvironmentStatus {
96    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
97    where
98        S: serde::Serializer,
99    {
100        serializer.serialize_str(self.as_wire())
101    }
102}
103
104impl<'de> Deserialize<'de> for EnvironmentStatus {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: Deserializer<'de>,
108    {
109        let value = serde_json::Value::deserialize(deserializer)?;
110        match value.as_str() {
111            Some("active") => Ok(Self::Active),
112            Some("expired") => Ok(Self::Expired),
113            Some(other) => {
114                tracing::warn!(
115                    "Encountered unknown EnvironmentStatus '{other}' - using Unknown variant (Evergreen)"
116                );
117                Ok(Self::Unknown {
118                    status_type: other.to_string(),
119                    data: value.clone(),
120                })
121            }
122            None => {
123                tracing::warn!(
124                    "EnvironmentStatus received non-string value: {value}. Preserving in Unknown variant."
125                );
126                Ok(Self::Unknown {
127                    status_type: format!("<non-string: {value}>"),
128                    data: value,
129                })
130            }
131        }
132    }
133}
134
135/// An execution environment for an agent, as returned by the
136/// `/v1beta/environments` resource.
137#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
138#[serde(default)]
139pub struct Environment {
140    /// Output only. The ID of the environment.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub id: Option<String>,
143    /// The file sources materialized into the environment.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub sources: Option<Vec<EnvironmentSource>>,
146    /// Network configuration for the environment.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub network: Option<NetworkConfig>,
149    /// Output only. The status of the environment container.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub status: Option<EnvironmentStatus>,
152    /// Output only. When the environment was created.
153    #[serde(
154        default,
155        skip_serializing_if = "Option::is_none",
156        deserialize_with = "deserialize_lenient_timestamp::<_, ForEnvironment>"
157    )]
158    pub created: Option<DateTime<Utc>>,
159    /// Output only. When the environment was last updated.
160    #[serde(
161        default,
162        skip_serializing_if = "Option::is_none",
163        deserialize_with = "deserialize_lenient_timestamp::<_, ForEnvironment>"
164    )]
165    pub updated: Option<DateTime<Utc>>,
166    /// Output only. When the environment was last accessed.
167    #[serde(
168        default,
169        skip_serializing_if = "Option::is_none",
170        deserialize_with = "deserialize_lenient_timestamp::<_, ForEnvironment>"
171    )]
172    pub last_accessed: Option<DateTime<Utc>>,
173    /// Output only. The number of files in the environment.
174    #[serde(
175        default,
176        skip_serializing_if = "Option::is_none",
177        serialize_with = "serialize_string_i64",
178        deserialize_with = "deserialize_string_i64::<_, ForEnvironment>"
179    )]
180    pub file_count: Option<i64>,
181    /// Output only. The total size of the environment's files in bytes.
182    #[serde(
183        default,
184        skip_serializing_if = "Option::is_none",
185        serialize_with = "serialize_string_i64",
186        deserialize_with = "deserialize_string_i64::<_, ForEnvironment>"
187    )]
188    pub size_bytes: Option<i64>,
189}
190
191/// Request body for creating an environment explicitly.
192///
193/// For the *inline* per-request form (the `environment` field on an
194/// interaction), use [`RemoteEnvironment`](crate::RemoteEnvironment) /
195/// [`EnvironmentSpec`](crate::EnvironmentSpec) instead — same fields, but
196/// carrying the `remote` type discriminator the inline union requires.
197///
198/// # Example
199///
200/// ```
201/// use genai_rs::{CreateEnvironmentRequest, EnvironmentSource};
202///
203/// let request = CreateEnvironmentRequest::new()
204///     .add_source(EnvironmentSource::inline("/etc/motd", "hello"));
205/// ```
206#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
207pub struct CreateEnvironmentRequest {
208    /// The file sources to materialize into the environment.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub sources: Option<Vec<EnvironmentSource>>,
211    /// Network configuration for the environment.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub network: Option<NetworkConfig>,
214    /// Unrecognized fields, preserved for roundtrip (Evergreen) — lets an
215    /// unmodeled create-request field be set without a crate release.
216    ///
217    /// The inherent cost (same as
218    /// [`TriggerCreateParams::extra`](crate::TriggerCreateParams)): a
219    /// *typo'd optional* key in a deserialized config (`netwrok`, ...) is
220    /// silently absorbed here and forwarded to the server verbatim rather
221    /// than rejected.
222    ///
223    /// A key that collides with a modeled field **wins on serialize** via
224    /// `serde_json::to_value` — the form the request path uses — so the
225    /// escape hatch can also override a modeled field whose wire shape
226    /// turns out wrong. (`to_string` on a flattened struct emits both keys
227    /// rather than deduplicating; don't hand-serialize colliding params.)
228    #[serde(flatten)]
229    pub extra: serde_json::Map<String, serde_json::Value>,
230}
231
232impl CreateEnvironmentRequest {
233    /// Creates an empty environment request.
234    #[must_use]
235    pub fn new() -> Self {
236        Self::default()
237    }
238
239    /// Adds a file source, accumulating with any added earlier.
240    #[must_use]
241    pub fn add_source(mut self, source: EnvironmentSource) -> Self {
242        self.sources.get_or_insert_with(Vec::new).push(source);
243        self
244    }
245
246    /// Sets the network configuration.
247    #[must_use]
248    pub fn with_network(mut self, network: NetworkConfig) -> Self {
249        self.network = Some(network);
250        self
251    }
252}
253
254/// Response from listing environments.
255#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
256#[serde(default)]
257pub struct EnvironmentListResponse {
258    /// The environments in this page. A null or malformed list degrades to
259    /// empty; malformed elements drop individually.
260    #[serde(deserialize_with = "crate::serde_util::deserialize_lenient_vec")]
261    pub environments: Vec<Environment>,
262    /// Token for fetching the next page, absent on the last page.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub next_page_token: Option<String>,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn environment_deserializes_live_wire_shape() {
273        // Captured from a live GET /v1beta/environments on 2026-08-08:
274        // int64s arrive as strings, timestamps as ISO 8601 with offset.
275        let json = serde_json::json!({
276            "id": "38aac1ae7f30fe9bd67afe42382ea041",
277            "sources": [
278                {"type": "inline", "target": "/etc/motd", "content": "hello from genai-rs"}
279            ],
280            "created": "2026-08-08T13:24:10.64798+00:00",
281            "updated": "2026-08-08T13:24:10.64798+00:00",
282            "status": "active",
283            "file_count": "2",
284            "size_bytes": "19"
285        });
286        let env: Environment = serde_json::from_value(json).unwrap();
287        assert_eq!(env.id.as_deref(), Some("38aac1ae7f30fe9bd67afe42382ea041"));
288        assert_eq!(env.status, Some(EnvironmentStatus::Active));
289        assert_eq!(env.file_count, Some(2));
290        assert_eq!(env.size_bytes, Some(19));
291        assert!(env.created.is_some());
292
293        // Roundtrip preserves the protobuf-JSON string form the API sent.
294        let back = serde_json::to_value(&env).unwrap();
295        assert_eq!(back["file_count"], serde_json::json!("2"));
296        assert_eq!(back["size_bytes"], serde_json::json!("19"));
297        // Timestamps are deliberately NOT byte-faithful: chrono re-emits
298        // the same instant in its own RFC 3339 spelling (Z suffix, padded
299        // fraction) rather than the captured `+00:00` form — see the
300        // serde_util module doc for the scoped fidelity claim.
301        assert_eq!(
302            back["created"],
303            serde_json::json!("2026-08-08T13:24:10.647980Z")
304        );
305    }
306
307    #[test]
308    fn environment_timestamps_degrade_per_field() {
309        // Uniform with the trigger family: a timestamp arriving in an
310        // unexpected encoding drops that field to None instead of failing
311        // the whole list response, even though this resource's encoding is
312        // live-verified — the int64s next door got the same tolerance.
313        let json = serde_json::json!({
314            "id": "env-1",
315            "created": "2026-08-08T13:24:10.64798+00:00",
316            "updated": "not-a-time",
317            "last_accessed": 1754656200
318        });
319        let env: Environment = serde_json::from_value(json).unwrap();
320        assert_eq!(env.id.as_deref(), Some("env-1"));
321        assert!(env.created.is_some());
322        assert_eq!(env.updated, None);
323        assert_eq!(env.last_accessed, None);
324    }
325
326    #[test]
327    fn numeric_counts_also_accepted() {
328        let json = serde_json::json!({"id": "x", "file_count": 3, "size_bytes": 42});
329        let env: Environment = serde_json::from_value(json).unwrap();
330        assert_eq!(env.file_count, Some(3));
331        assert_eq!(env.size_bytes, Some(42));
332    }
333
334    #[test]
335    fn unknown_status_roundtrips() {
336        let json = serde_json::json!({"id": "x", "status": "hibernating"});
337        let env: Environment = serde_json::from_value(json).unwrap();
338        assert!(env.status.as_ref().unwrap().is_unknown());
339        assert_eq!(
340            env.status.as_ref().unwrap().unknown_status_type(),
341            Some("hibernating")
342        );
343    }
344
345    #[test]
346    fn display_agrees_with_wire_value() {
347        for (status, wire) in [
348            (EnvironmentStatus::Active, "active"),
349            (EnvironmentStatus::Expired, "expired"),
350        ] {
351            assert_eq!(serde_json::to_value(&status).unwrap(), wire);
352            assert_eq!(status.to_string(), wire);
353        }
354    }
355
356    #[test]
357    fn add_source_accumulates() {
358        let request = CreateEnvironmentRequest::new()
359            .add_source(EnvironmentSource::inline("/a", "one"))
360            .add_source(EnvironmentSource::inline("/b", "two"));
361        assert_eq!(request.sources.as_ref().map(Vec::len), Some(2));
362    }
363
364    #[test]
365    fn extra_passes_through_and_wins_on_collision() {
366        // Same contract as the trigger bodies: novel keys pass through,
367        // and a colliding key wins on serialize (the flattened map is
368        // emitted last) — pinned so the precedence reads as a decision.
369        let mut request =
370            CreateEnvironmentRequest::new().add_source(EnvironmentSource::inline("/a", "one"));
371        request
372            .extra
373            .insert("future_field".into(), serde_json::json!(true));
374        let json = serde_json::to_value(&request).unwrap();
375        assert_eq!(json["future_field"], true);
376
377        request
378            .extra
379            .insert("sources".into(), serde_json::json!([]));
380        let json = serde_json::to_value(&request).unwrap();
381        assert_eq!(json["sources"], serde_json::json!([]));
382
383        // The deserialize direction: an unmodeled key on the way in lands
384        // in `extra` — pinned because flatten reroutes every sibling
385        // through serde's buffering, where a regression would be silent.
386        let request: CreateEnvironmentRequest = serde_json::from_value(serde_json::json!({
387            "sources": [{"type": "inline", "target": "/a", "content": "one"}],
388            "future_field": true
389        }))
390        .unwrap();
391        assert_eq!(request.extra["future_field"], true);
392    }
393
394    #[test]
395    fn create_request_network_serializes_without_discriminator() {
396        use crate::environment::NetworkConfig;
397
398        let request = CreateEnvironmentRequest::new().with_network(NetworkConfig::Disabled);
399        let json = serde_json::to_value(&request).unwrap();
400        assert!(json.get("network").is_some(), "network key present");
401        assert!(
402            json.get("type").is_none(),
403            "the standalone create body must not carry the inline union's \
404             `remote` discriminator: {json}"
405        );
406    }
407
408    #[test]
409    fn empty_list_response_deserializes() {
410        // GET /v1beta/environments returns `{}` when nothing exists.
411        let list: EnvironmentListResponse = serde_json::from_value(serde_json::json!({})).unwrap();
412        assert!(list.environments.is_empty());
413        assert!(list.next_page_token.is_none());
414
415        // Present-but-degenerate list keys — the shapes the struct-level
416        // serde default does not reach — degrade rather than zeroing the
417        // page with an error: null and non-array values read as empty.
418        let list: EnvironmentListResponse =
419            serde_json::from_value(serde_json::json!({"environments": null})).unwrap();
420        assert!(list.environments.is_empty());
421        let list: EnvironmentListResponse =
422            serde_json::from_value(serde_json::json!({"environments": 7})).unwrap();
423        assert!(list.environments.is_empty());
424    }
425}