1use alloc::string::{String, ToString};
20use alloc::vec::Vec;
21use core::fmt;
22use core::str::FromStr;
23
24use serde::de::{self, Deserializer, Visitor};
25use serde::{Deserialize, Serialize, Serializer};
26
27pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct ContentHash(
37 #[cfg_attr(feature = "schema", schemars(with = "String"))]
39 pub u64,
40);
41
42impl fmt::Display for ContentHash {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "{:016x}", self.0)
46 }
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NotAHash(pub String);
52
53impl fmt::Display for NotAHash {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 write!(
56 f,
57 "\"{}\" is not a content hash (sixteen hex digits)",
58 self.0
59 )
60 }
61}
62
63impl FromStr for ContentHash {
64 type Err = NotAHash;
65
66 fn from_str(text: &str) -> Result<Self, NotAHash> {
68 let digits = text.len() == 16 && text.bytes().all(|b| b.is_ascii_hexdigit());
69 digits
70 .then(|| u64::from_str_radix(text, 16).ok())
71 .flatten()
72 .map(Self)
73 .ok_or_else(|| NotAHash(text.to_string()))
74 }
75}
76
77impl Serialize for ContentHash {
78 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
79 serializer.collect_str(self)
80 }
81}
82
83impl<'de> Deserialize<'de> for ContentHash {
84 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85 struct Text;
87
88 impl Visitor<'_> for Text {
89 type Value = ContentHash;
90
91 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 f.write_str("sixteen hex digits")
93 }
94
95 fn visit_str<E: de::Error>(self, text: &str) -> Result<ContentHash, E> {
96 text.parse().map_err(E::custom)
97 }
98 }
99
100 deserializer.deserialize_str(Text)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[serde(deny_unknown_fields)]
109#[non_exhaustive]
111pub struct Made {
112 pub engine: String,
114 pub game: String,
117 pub content: ContentHash,
119}
120
121impl Made {
122 pub fn new(game: impl Into<String>, content: ContentHash) -> Self {
125 Self {
126 engine: ENGINE_VERSION.to_string(),
127 game: game.into(),
128 content,
129 }
130 }
131
132 pub fn check(&self, now: &Made) -> Result<(), Mismatch> {
135 let parts: Vec<Differs> = [
136 (Part::Engine, self.engine.clone(), now.engine.clone()),
137 (Part::Game, self.game.clone(), now.game.clone()),
138 (
139 Part::Content,
140 self.content.to_string(),
141 now.content.to_string(),
142 ),
143 ]
144 .into_iter()
145 .filter(|(_, made, now)| made != now)
146 .map(|(part, made, now)| Differs { part, made, now })
147 .collect();
148 if parts.is_empty() {
149 Ok(())
150 } else {
151 Err(Mismatch { parts })
152 }
153 }
154}
155
156impl fmt::Display for Made {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(
160 f,
161 "engine {}, game \"{}\", content {}",
162 self.engine, self.game, self.content
163 )
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Part {
170 Engine,
172 Game,
174 Content,
176}
177
178impl Part {
179 pub fn name(self) -> &'static str {
181 match self {
182 Part::Engine => "engine",
183 Part::Game => "game",
184 Part::Content => "content",
185 }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub struct Differs {
193 pub part: Part,
195 pub made: String,
197 pub now: String,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub struct Mismatch {
208 pub parts: Vec<Differs>,
210}
211
212impl Mismatch {
213 fn side(&self, f: &mut fmt::Formatter<'_>, value: fn(&Differs) -> &str) -> fmt::Result {
215 for (i, differs) in self.parts.iter().enumerate() {
216 let comma = if i == 0 { "" } else { ", " };
217 let value = value(differs);
218 match differs.part {
219 Part::Game => write!(f, "{comma}game \"{value}\"")?,
220 part => write!(f, "{comma}{} {value}", part.name())?,
221 }
222 }
223 Ok(())
224 }
225}
226
227impl fmt::Display for Mismatch {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 f.write_str("made with ")?;
230 self.side(f, |d| &d.made)?;
231 f.write_str("; this build: ")?;
232 self.side(f, |d| &d.now)
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use alloc::format;
239
240 use super::*;
241
242 fn made(engine: &str, game: &str, content: u64) -> Made {
243 Made {
244 engine: engine.into(),
245 game: game.into(),
246 content: ContentHash(content),
247 }
248 }
249
250 #[test]
251 fn a_new_stamp_names_this_engine() {
252 let stamp = Made::new("demo 1.0", ContentHash(7));
253 assert_eq!(stamp, made(ENGINE_VERSION, "demo 1.0", 7));
254 assert_eq!(ENGINE_VERSION, env!("CARGO_PKG_VERSION"));
255 assert_eq!(stamp.check(&stamp.clone()), Ok(()));
256 }
257
258 #[test]
259 fn a_check_names_each_part_that_differs_with_both_values() {
260 let now = made("0.2.0", "demo 1.0", 0x3c4d_5e6f_7a8b_9c0d);
261 let engine = made("0.1.0", "demo 1.0", 0x3c4d_5e6f_7a8b_9c0d)
262 .check(&now)
263 .unwrap_err();
264 assert_eq!(
265 engine.parts,
266 [Differs {
267 part: Part::Engine,
268 made: "0.1.0".into(),
269 now: "0.2.0".into()
270 }]
271 );
272 assert_eq!(
273 engine.to_string(),
274 "made with engine 0.1.0; this build: engine 0.2.0"
275 );
276 let game = made("0.2.0", "demo 0.9", 0x3c4d_5e6f_7a8b_9c0d)
277 .check(&now)
278 .unwrap_err();
279 assert_eq!(
280 game.to_string(),
281 "made with game \"demo 0.9\"; this build: game \"demo 1.0\""
282 );
283 let content = made("0.2.0", "demo 1.0", 1).check(&now).unwrap_err();
284 assert_eq!(content.parts[0].part, Part::Content);
285 assert_eq!(
286 content.to_string(),
287 "made with content 0000000000000001; this build: content 3c4d5e6f7a8b9c0d"
288 );
289 let two = made("0.1.0", "demo 1.0", 0x1a2b_3c4d_5e6f_7a8b)
290 .check(&now)
291 .unwrap_err();
292 assert_eq!(
293 two.to_string(),
294 "made with engine 0.1.0, content 1a2b3c4d5e6f7a8b; this build: engine 0.2.0, \
295 content 3c4d5e6f7a8b9c0d"
296 );
297 let all = made("0.1.0", "x", 1).check(&now).unwrap_err();
298 let parts: Vec<&str> = all.parts.iter().map(|d| d.part.name()).collect();
299 assert_eq!(parts, ["engine", "game", "content"]);
300 }
301
302 #[test]
303 fn a_content_hash_reads_back_from_its_sixteen_hex_digits() {
304 let hash = ContentHash(0x00ab_cdef_0123_4567);
305 assert_eq!(hash.to_string(), "00abcdef01234567");
306 assert_eq!("00abcdef01234567".parse(), Ok(hash));
307 assert_eq!("00ABCDEF01234567".parse(), Ok(hash));
308 assert_eq!(format!("{}", ContentHash(u64::MAX)), "ffffffffffffffff");
309 for bad in [
310 "",
311 "abc",
312 "00abcdef012345678",
313 "+0abcdef01234567",
314 "00abcdef0123456g",
315 ] {
316 let error = bad.parse::<ContentHash>().unwrap_err();
317 assert_eq!(error, NotAHash(bad.into()));
318 }
319 assert_eq!(
320 NotAHash("x".into()).to_string(),
321 "\"x\" is not a content hash (sixteen hex digits)"
322 );
323 }
324
325 #[test]
326 fn a_stamp_is_written_with_its_hash_as_text_and_read_back() {
327 let stamp = made("0.1.0", "demo 1.0", 0x1a2b_3c4d_5e6f_7a8b);
328 let text = ron::to_string(&stamp).unwrap();
329 assert_eq!(
330 text,
331 "(engine:\"0.1.0\",game:\"demo 1.0\",content:\"1a2b3c4d5e6f7a8b\")"
332 );
333 assert_eq!(ron::from_str::<Made>(&text).unwrap(), stamp);
334 assert_eq!(
335 stamp.to_string(),
336 "engine 0.1.0, game \"demo 1.0\", content 1a2b3c4d5e6f7a8b"
337 );
338 let bad = ron::from_str::<ContentHash>("\"1a2b\"").unwrap_err();
339 assert!(bad.to_string().contains("not a content hash"), "{bad}");
340 assert!(ron::from_str::<ContentHash>("12").is_err());
341 let number: Result<ContentHash, serde::de::value::Error> =
343 ContentHash::deserialize(serde::de::IntoDeserializer::into_deserializer(12_u64));
344 let error = number.unwrap_err().to_string();
345 assert!(error.ends_with("expected sixteen hex digits"), "{error}");
346 }
347}