1use super::{AppId, CanonicalNetworkId};
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
9use std::{fmt, str::FromStr};
10use thiserror::Error as ThisError;
11
12const FLEET_NAME_MAX_BYTES: usize = 40;
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
21pub struct FleetId([u8; 32]);
22
23impl FleetId {
24 #[must_use]
29 pub const fn from_generated_bytes(bytes: [u8; 32]) -> Self {
30 Self(bytes)
31 }
32
33 #[must_use]
34 pub const fn as_bytes(&self) -> &[u8; 32] {
35 &self.0
36 }
37}
38
39impl fmt::Display for FleetId {
40 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
41 for byte in self.0 {
42 write!(formatter, "{byte:02x}")?;
43 }
44 Ok(())
45 }
46}
47
48impl FromStr for FleetId {
49 type Err = FleetIdParseError;
50
51 fn from_str(value: &str) -> Result<Self, Self::Err> {
52 if value.len() != 64 {
53 return Err(FleetIdParseError::Length(value.len()));
54 }
55 if !value
56 .bytes()
57 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
58 {
59 return Err(FleetIdParseError::CanonicalHex);
60 }
61
62 let mut bytes = [0; 32];
63 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
64 bytes[index] = (decode_nibble(pair[0]) << 4) | decode_nibble(pair[1]);
65 }
66 Ok(Self(bytes))
67 }
68}
69
70impl Serialize for FleetId {
71 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
72 where
73 S: Serializer,
74 {
75 serializer.collect_str(self)
76 }
77}
78
79impl<'de> Deserialize<'de> for FleetId {
80 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
81 where
82 D: Deserializer<'de>,
83 {
84 let value = String::deserialize(deserializer)?;
85 value.parse().map_err(de::Error::custom)
86 }
87}
88
89#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
96#[serde(transparent)]
97pub struct FleetName(String);
98
99impl FleetName {
100 #[must_use]
101 pub const fn as_str(&self) -> &str {
102 self.0.as_str()
103 }
104}
105
106impl fmt::Display for FleetName {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter.write_str(self.as_str())
109 }
110}
111
112impl AsRef<str> for FleetName {
113 fn as_ref(&self) -> &str {
114 self.as_str()
115 }
116}
117
118impl FromStr for FleetName {
119 type Err = FleetNameParseError;
120
121 fn from_str(value: &str) -> Result<Self, Self::Err> {
122 validate_fleet_name(value)?;
123 Ok(Self(value.to_string()))
124 }
125}
126
127impl TryFrom<String> for FleetName {
128 type Error = FleetNameParseError;
129
130 fn try_from(value: String) -> Result<Self, Self::Error> {
131 validate_fleet_name(&value)?;
132 Ok(Self(value))
133 }
134}
135
136impl<'de> Deserialize<'de> for FleetName {
137 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
138 where
139 D: Deserializer<'de>,
140 {
141 let value = String::deserialize(deserializer)?;
142 Self::try_from(value).map_err(de::Error::custom)
143 }
144}
145
146#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
153#[serde(deny_unknown_fields)]
154pub struct FleetKey {
155 pub network: CanonicalNetworkId,
156 pub fleet_id: FleetId,
157}
158
159#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
166#[serde(deny_unknown_fields)]
167pub struct FleetBinding {
168 pub fleet: FleetKey,
169 pub app: AppId,
170}
171
172#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
179pub enum FleetIdParseError {
180 #[error("Fleet ID must contain exactly 64 characters, got {0}")]
181 Length(usize),
182
183 #[error("Fleet ID must contain only lowercase hexadecimal characters")]
184 CanonicalHex,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq, ThisError)]
194pub enum FleetNameParseError {
195 #[error("Fleet name must not be empty")]
196 Empty,
197
198 #[error("Fleet name must not exceed {max_bytes} bytes, got {actual_bytes}")]
199 TooLong {
200 max_bytes: usize,
201 actual_bytes: usize,
202 },
203
204 #[error("Fleet name must use only ASCII letters, numbers, '-' or '_'")]
205 InvalidCharacters,
206}
207
208fn validate_fleet_name(value: &str) -> Result<(), FleetNameParseError> {
209 if value.is_empty() {
210 return Err(FleetNameParseError::Empty);
211 }
212 if value.len() > FLEET_NAME_MAX_BYTES {
213 return Err(FleetNameParseError::TooLong {
214 max_bytes: FLEET_NAME_MAX_BYTES,
215 actual_bytes: value.len(),
216 });
217 }
218 if !value
219 .bytes()
220 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
221 {
222 return Err(FleetNameParseError::InvalidCharacters);
223 }
224 Ok(())
225}
226
227fn decode_nibble(byte: u8) -> u8 {
228 match byte {
229 b'0'..=b'9' => byte - b'0',
230 b'a'..=b'f' => byte - b'a' + 10,
231 _ => unreachable!("canonical hex was validated before decoding"),
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn fleet_id_uses_exact_canonical_text() {
241 let fleet_id = FleetId::from_generated_bytes([0xab; 32]);
242 let text = "ab".repeat(32);
243
244 assert_eq!(fleet_id.to_string(), text);
245 assert_eq!(text.parse::<FleetId>(), Ok(fleet_id));
246 }
247
248 #[test]
249 fn fleet_id_rejects_noncanonical_text() {
250 std::assert_matches!("ab".parse::<FleetId>(), Err(FleetIdParseError::Length(2)));
251 std::assert_matches!(
252 "A000000000000000000000000000000000000000000000000000000000000000".parse::<FleetId>(),
253 Err(FleetIdParseError::CanonicalHex)
254 );
255 }
256
257 #[test]
258 fn fleet_name_accepts_only_the_existing_canonical_name_shape() {
259 let name = "toko-production"
260 .parse::<FleetName>()
261 .expect("canonical Fleet name");
262
263 assert_eq!(name.as_str(), "toko-production");
264 std::assert_matches!("".parse::<FleetName>(), Err(FleetNameParseError::Empty));
265 std::assert_matches!(
266 "bad/name".parse::<FleetName>(),
267 Err(FleetNameParseError::InvalidCharacters)
268 );
269 std::assert_matches!(
270 "a".repeat(FLEET_NAME_MAX_BYTES + 1).parse::<FleetName>(),
271 Err(FleetNameParseError::TooLong { .. })
272 );
273 }
274
275 #[test]
276 fn fleet_binding_keeps_app_and_network_separate_from_the_label() {
277 let network = CanonicalNetworkId::public_ic();
278 let fleet_id = FleetId::from_generated_bytes([7; 32]);
279 let binding = FleetBinding {
280 fleet: FleetKey { network, fleet_id },
281 app: AppId::from("toko"),
282 };
283
284 assert_eq!(binding.fleet.network, network);
285 assert_eq!(binding.fleet.fleet_id, fleet_id);
286 assert_eq!(binding.app.as_str(), "toko");
287 }
288}