1use serde::{Deserialize, Serialize};
17
18use crate::hostname::{ephemeral_id_from_spec, HostnameError, EPHEMERAL_ID_HASH_LEN};
19
20#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
22pub enum EnvIdError {
23 #[error("ephemeral env id must be {EPHEMERAL_ID_HASH_LEN} chars, got {0}")]
24 WrongLength(usize),
25 #[error("ephemeral env id must be lowercase hex [0-9a-f], got {0:?}")]
26 NotLowercaseHex(String),
27}
28
29#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
33#[serde(transparent)]
34pub struct EphemeralEnvId(String);
35
36impl EphemeralEnvId {
37 pub fn from_spec<T: Serialize>(spec: &T) -> Result<Self, HostnameError> {
42 Ok(Self(ephemeral_id_from_spec(spec)?))
43 }
44
45 pub fn parse(s: &str) -> Result<Self, EnvIdError> {
48 if s.len() != EPHEMERAL_ID_HASH_LEN {
49 return Err(EnvIdError::WrongLength(s.len()));
50 }
51 if !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
52 return Err(EnvIdError::NotLowercaseHex(s.to_string()));
53 }
54 Ok(Self(s.to_string()))
55 }
56
57 #[must_use]
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61}
62
63impl std::fmt::Display for EphemeralEnvId {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.write_str(&self.0)
66 }
67}
68
69impl<'de> Deserialize<'de> for EphemeralEnvId {
70 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71 let s = String::deserialize(d)?;
72 Self::parse(&s).map_err(serde::de::Error::custom)
73 }
74}
75
76impl schemars::JsonSchema for EphemeralEnvId {
77 fn schema_name() -> String {
78 "EphemeralEnvId".into()
79 }
80 fn json_schema(g: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
81 let mut s = <String as schemars::JsonSchema>::json_schema(g);
82 if let schemars::schema::Schema::Object(ref mut o) = s {
83 o.string = Some(Box::new(schemars::schema::StringValidation {
84 pattern: Some(format!("^[0-9a-f]{{{EPHEMERAL_ID_HASH_LEN}}}$")),
85 min_length: Some(EPHEMERAL_ID_HASH_LEN as u32),
86 max_length: Some(EPHEMERAL_ID_HASH_LEN as u32),
87 ..Default::default()
88 }));
89 }
90 s
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::{EnvIdError, EphemeralEnvId};
97 use crate::hostname::{ephemeral_id_from_spec, EPHEMERAL_ID_HASH_LEN};
98 use serde::Serialize;
99
100 #[derive(Serialize)]
101 struct DummySpec {
102 name: String,
103 n: u32,
104 }
105
106 #[test]
107 fn from_spec_is_deterministic_and_matches_the_raw_fn() {
108 let spec = DummySpec { name: "gateway".into(), n: 7 };
109 let a = EphemeralEnvId::from_spec(&spec).unwrap();
110 let b = EphemeralEnvId::from_spec(&spec).unwrap();
111 assert_eq!(a, b, "same spec ⇒ same id (idempotent)");
112 assert_eq!(a.as_str(), ephemeral_id_from_spec(&spec).unwrap());
114 assert_eq!(a.as_str().len(), EPHEMERAL_ID_HASH_LEN);
115 }
116
117 #[test]
118 fn different_specs_yield_different_ids() {
119 let a = EphemeralEnvId::from_spec(&DummySpec { name: "a".into(), n: 1 }).unwrap();
120 let b = EphemeralEnvId::from_spec(&DummySpec { name: "a".into(), n: 2 }).unwrap();
121 assert_ne!(a, b);
122 }
123
124 #[test]
125 fn parse_accepts_valid_and_rejects_invalid() {
126 let valid = "0a1b2c3d"; assert_eq!(EphemeralEnvId::parse(valid).unwrap().as_str(), valid);
128 assert!(matches!(EphemeralEnvId::parse("0a1b").unwrap_err(), EnvIdError::WrongLength(4)));
129 assert!(matches!(EphemeralEnvId::parse("0a1b2c3d4e").unwrap_err(), EnvIdError::WrongLength(10)));
130 assert!(matches!(EphemeralEnvId::parse("0A1B2C3D").unwrap_err(), EnvIdError::NotLowercaseHex(_)));
132 assert!(matches!(EphemeralEnvId::parse("0a1b2c3z").unwrap_err(), EnvIdError::NotLowercaseHex(_)));
133 }
134
135 #[test]
136 fn serde_round_trips_and_deserialize_rejects_malformed() {
137 let id = EphemeralEnvId::parse("deadbeef").unwrap();
138 let json = serde_json::to_string(&id).unwrap();
139 assert_eq!(json, "\"deadbeef\"");
140 let back: EphemeralEnvId = serde_json::from_str(&json).unwrap();
141 assert_eq!(back, id);
142 assert!(serde_json::from_str::<EphemeralEnvId>("\"NOTHEX!!\"").is_err());
144 assert!(serde_json::from_str::<EphemeralEnvId>("\"short\"").is_err());
145 }
146}