1use std::collections::BTreeMap;
13use std::fmt;
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use super::types::{EpochId, GitOid};
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
33#[serde(try_from = "String", into = "String")]
34pub struct FileId(u128);
35
36impl FileId {
37 #[must_use]
39 pub const fn new(id: u128) -> Self {
40 Self(id)
41 }
42
43 #[must_use]
48 pub fn random() -> Self {
49 Self(rand::random::<u128>())
50 }
51
52 #[must_use]
54 pub const fn as_u128(self) -> u128 {
55 self.0
56 }
57
58 pub fn from_hex(s: &str) -> Result<Self, FileIdError> {
63 if s.len() != 32 {
64 return Err(FileIdError {
65 value: s.to_owned(),
66 reason: format!("expected 32 hex characters, got {}", s.len()),
67 });
68 }
69 if !s
70 .chars()
71 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
72 {
73 return Err(FileIdError {
74 value: s.to_owned(),
75 reason: "must contain only lowercase hex characters (0-9, a-f)".to_owned(),
76 });
77 }
78 let n = u128::from_str_radix(s, 16).map_err(|e| FileIdError {
79 value: s.to_owned(),
80 reason: e.to_string(),
81 })?;
82 Ok(Self(n))
83 }
84
85 #[must_use]
87 pub fn to_hex(self) -> String {
88 format!("{:032x}", self.0)
89 }
90}
91
92impl fmt::Display for FileId {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 write!(f, "{:032x}", self.0)
95 }
96}
97
98impl TryFrom<String> for FileId {
99 type Error = FileIdError;
100 fn try_from(s: String) -> Result<Self, Self::Error> {
101 Self::from_hex(&s)
102 }
103}
104
105impl From<FileId> for String {
106 fn from(id: FileId) -> Self {
107 id.to_hex()
108 }
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
113pub struct FileIdError {
114 pub value: String,
116 pub reason: String,
118}
119
120impl fmt::Display for FileIdError {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 write!(f, "invalid FileId: {:?} — {}", self.value, self.reason)
123 }
124}
125
126impl std::error::Error for FileIdError {}
127
128#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
141pub struct PatchSet {
142 pub base_epoch: EpochId,
144 pub patches: BTreeMap<PathBuf, PatchValue>,
146}
147
148impl PatchSet {
149 #[must_use]
151 pub const fn empty(base_epoch: EpochId) -> Self {
152 Self {
153 base_epoch,
154 patches: BTreeMap::new(),
155 }
156 }
157
158 #[must_use]
160 pub fn is_empty(&self) -> bool {
161 self.patches.is_empty()
162 }
163
164 #[must_use]
166 pub fn len(&self) -> usize {
167 self.patches.len()
168 }
169}
170
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(tag = "op", rename_all = "snake_case")]
181pub enum PatchValue {
182 Add {
184 blob: GitOid,
186 file_id: FileId,
188 },
189 Delete {
191 previous_blob: GitOid,
193 file_id: FileId,
195 },
196 Modify {
198 base_blob: GitOid,
200 new_blob: GitOid,
202 file_id: FileId,
204 },
205 Rename {
210 from: PathBuf,
212 file_id: FileId,
214 new_blob: Option<GitOid>,
217 },
218}
219
220#[cfg(test)]
225#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
226mod tests {
227 use super::*;
228
229 fn oid(c: char) -> String {
231 c.to_string().repeat(40)
232 }
233
234 fn epoch(c: char) -> EpochId {
236 EpochId::new(&oid(c)).unwrap()
237 }
238
239 fn git_oid(c: char) -> GitOid {
241 GitOid::new(&oid(c)).unwrap()
242 }
243
244 #[test]
249 fn file_id_round_trip_u128() {
250 let id = FileId::new(42);
251 assert_eq!(id.as_u128(), 42);
252 }
253
254 #[test]
255 fn file_id_display_is_32_hex_chars() {
256 let id = FileId::new(0);
257 let s = format!("{id}");
258 assert_eq!(s.len(), 32);
259 assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
260 }
261
262 #[test]
263 fn file_id_to_hex_round_trip() {
264 for n in [0_u128, 1, u128::from(u64::MAX), u128::MAX] {
265 let id = FileId::new(n);
266 let hex = id.to_hex();
267 let decoded = FileId::from_hex(&hex).unwrap();
268 assert_eq!(decoded, id);
269 }
270 }
271
272 #[test]
273 fn file_id_from_hex_rejects_short() {
274 assert!(FileId::from_hex("abc").is_err());
275 }
276
277 #[test]
278 fn file_id_from_hex_rejects_long() {
279 assert!(FileId::from_hex(&"a".repeat(33)).is_err());
280 }
281
282 #[test]
283 fn file_id_from_hex_rejects_uppercase() {
284 let hex = "A".repeat(32);
285 assert!(FileId::from_hex(&hex).is_err());
286 }
287
288 #[test]
289 fn file_id_from_hex_rejects_non_hex() {
290 let bad = "z".repeat(32);
291 assert!(FileId::from_hex(&bad).is_err());
292 }
293
294 #[test]
295 fn file_id_serde_round_trip() {
296 let id = FileId::new(0xdead_beef_cafe);
297 let json = serde_json::to_string(&id).unwrap();
298 assert!(json.starts_with('"'));
300 let decoded: FileId = serde_json::from_str(&json).unwrap();
301 assert_eq!(decoded, id);
302 }
303
304 #[test]
305 fn file_id_serde_rejects_invalid() {
306 let json = "\"not-a-valid-id\"";
307 assert!(serde_json::from_str::<FileId>(json).is_err());
308 }
309
310 #[test]
311 fn file_id_zero_display() {
312 assert_eq!(FileId::new(0).to_hex(), "0".repeat(32));
313 }
314
315 #[test]
316 fn file_id_max_display() {
317 assert_eq!(FileId::new(u128::MAX).to_hex(), "f".repeat(32));
318 }
319
320 #[test]
325 fn patch_set_empty() {
326 let ps = PatchSet::empty(epoch('1'));
327 assert!(ps.is_empty());
328 assert_eq!(ps.len(), 0);
329 }
330
331 #[test]
332 fn patch_set_len_and_is_empty() {
333 let mut ps = PatchSet::empty(epoch('2'));
334 ps.patches.insert(
335 PathBuf::from("src/main.rs"),
336 PatchValue::Add {
337 blob: git_oid('a'),
338 file_id: FileId::new(1),
339 },
340 );
341 assert!(!ps.is_empty());
342 assert_eq!(ps.len(), 1);
343 }
344
345 #[test]
346 fn patch_set_btreemap_is_sorted() {
347 let mut ps = PatchSet::empty(epoch('3'));
348 ps.patches.insert(
350 PathBuf::from("z.rs"),
351 PatchValue::Add {
352 blob: git_oid('a'),
353 file_id: FileId::new(10),
354 },
355 );
356 ps.patches.insert(
357 PathBuf::from("a.rs"),
358 PatchValue::Add {
359 blob: git_oid('b'),
360 file_id: FileId::new(11),
361 },
362 );
363
364 let keys: Vec<_> = ps.patches.keys().collect();
366 assert_eq!(keys[0], &PathBuf::from("a.rs"));
367 assert_eq!(keys[1], &PathBuf::from("z.rs"));
368 }
369
370 #[test]
371 fn patch_set_serde_round_trip_empty() {
372 let ps = PatchSet::empty(epoch('4'));
373 let json = serde_json::to_string(&ps).unwrap();
374 let decoded: PatchSet = serde_json::from_str(&json).unwrap();
375 assert_eq!(decoded, ps);
376 }
377
378 #[test]
379 fn patch_set_serde_round_trip_with_entries() {
380 let mut ps = PatchSet::empty(epoch('5'));
381 ps.patches.insert(
382 PathBuf::from("src/lib.rs"),
383 PatchValue::Modify {
384 base_blob: git_oid('b'),
385 new_blob: git_oid('c'),
386 file_id: FileId::new(99),
387 },
388 );
389 ps.patches.insert(
390 PathBuf::from("README.md"),
391 PatchValue::Delete {
392 previous_blob: git_oid('d'),
393 file_id: FileId::new(100),
394 },
395 );
396
397 let json = serde_json::to_string(&ps).unwrap();
398 let decoded: PatchSet = serde_json::from_str(&json).unwrap();
399 assert_eq!(decoded, ps);
400 }
401
402 #[test]
407 fn patch_value_add_round_trip() {
408 let pv = PatchValue::Add {
409 blob: git_oid('a'),
410 file_id: FileId::new(1),
411 };
412 let json = serde_json::to_string(&pv).unwrap();
413 assert!(json.contains("\"op\":\"add\""));
415 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
416 assert_eq!(decoded, pv);
417 }
418
419 #[test]
420 fn patch_value_delete_round_trip() {
421 let pv = PatchValue::Delete {
422 previous_blob: git_oid('b'),
423 file_id: FileId::new(2),
424 };
425 let json = serde_json::to_string(&pv).unwrap();
426 assert!(json.contains("\"op\":\"delete\""));
427 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
428 assert_eq!(decoded, pv);
429 }
430
431 #[test]
432 fn patch_value_modify_round_trip() {
433 let pv = PatchValue::Modify {
434 base_blob: git_oid('c'),
435 new_blob: git_oid('d'),
436 file_id: FileId::new(3),
437 };
438 let json = serde_json::to_string(&pv).unwrap();
439 assert!(json.contains("\"op\":\"modify\""));
440 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
441 assert_eq!(decoded, pv);
442 }
443
444 #[test]
445 fn patch_value_rename_no_content_change_round_trip() {
446 let pv = PatchValue::Rename {
447 from: PathBuf::from("old/path.rs"),
448 file_id: FileId::new(4),
449 new_blob: None,
450 };
451 let json = serde_json::to_string(&pv).unwrap();
452 assert!(json.contains("\"op\":\"rename\""));
453 assert!(json.contains("\"new_blob\":null"));
454 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
455 assert_eq!(decoded, pv);
456 }
457
458 #[test]
459 fn patch_value_rename_with_content_change_round_trip() {
460 let pv = PatchValue::Rename {
461 from: PathBuf::from("old/path.rs"),
462 file_id: FileId::new(5),
463 new_blob: Some(git_oid('e')),
464 };
465 let json = serde_json::to_string(&pv).unwrap();
466 assert!(json.contains("\"op\":\"rename\""));
467 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
468 assert_eq!(decoded, pv);
469 }
470
471 #[test]
472 fn patch_value_serde_tagged() {
473 let variants: &[PatchValue] = &[
475 PatchValue::Add {
476 blob: git_oid('a'),
477 file_id: FileId::new(10),
478 },
479 PatchValue::Delete {
480 previous_blob: git_oid('b'),
481 file_id: FileId::new(11),
482 },
483 PatchValue::Modify {
484 base_blob: git_oid('c'),
485 new_blob: git_oid('d'),
486 file_id: FileId::new(12),
487 },
488 PatchValue::Rename {
489 from: PathBuf::from("foo.rs"),
490 file_id: FileId::new(13),
491 new_blob: None,
492 },
493 ];
494
495 for pv in variants {
496 let json = serde_json::to_string(pv).unwrap();
497 assert!(json.contains("\"op\":"), "Missing 'op' tag in: {json}");
498 let decoded: PatchValue = serde_json::from_str(&json).unwrap();
499 assert_eq!(&decoded, pv);
500 }
501 }
502
503 #[test]
504 fn patch_set_json_is_deterministic() {
505 let make = || {
507 let mut ps = PatchSet::empty(epoch('6'));
508 ps.patches.insert(
509 PathBuf::from("b.rs"),
510 PatchValue::Add {
511 blob: git_oid('1'),
512 file_id: FileId::new(20),
513 },
514 );
515 ps.patches.insert(
516 PathBuf::from("a.rs"),
517 PatchValue::Add {
518 blob: git_oid('2'),
519 file_id: FileId::new(21),
520 },
521 );
522 ps
523 };
524 let json1 = serde_json::to_string(&make()).unwrap();
525 let json2 = serde_json::to_string(&make()).unwrap();
526 assert_eq!(json1, json2);
527 }
528}