1use 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#[derive(Clone, Debug, PartialEq)]
38#[non_exhaustive]
39pub enum EnvironmentStatus {
40 Active,
42 Expired,
44 Unknown {
46 status_type: String,
48 data: serde_json::Value,
50 },
51}
52
53impl EnvironmentStatus {
54 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 #[must_use]
66 pub const fn is_unknown(&self) -> bool {
67 matches!(self, Self::Unknown { .. })
68 }
69
70 #[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 #[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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
138#[serde(default)]
139pub struct Environment {
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub id: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 pub sources: Option<Vec<EnvironmentSource>>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub network: Option<NetworkConfig>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub status: Option<EnvironmentStatus>,
152 #[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 #[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 #[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 #[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 #[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#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
207pub struct CreateEnvironmentRequest {
208 #[serde(skip_serializing_if = "Option::is_none")]
210 pub sources: Option<Vec<EnvironmentSource>>,
211 #[serde(skip_serializing_if = "Option::is_none")]
213 pub network: Option<NetworkConfig>,
214 #[serde(flatten)]
229 pub extra: serde_json::Map<String, serde_json::Value>,
230}
231
232impl CreateEnvironmentRequest {
233 #[must_use]
235 pub fn new() -> Self {
236 Self::default()
237 }
238
239 #[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 #[must_use]
248 pub fn with_network(mut self, network: NetworkConfig) -> Self {
249 self.network = Some(network);
250 self
251 }
252}
253
254#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
256#[serde(default)]
257pub struct EnvironmentListResponse {
258 #[serde(deserialize_with = "crate::serde_util::deserialize_lenient_vec")]
261 pub environments: Vec<Environment>,
262 #[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 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 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 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 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 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 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 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 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}