1use std::fmt;
98
99use serde::de::{self, Visitor};
100use serde::{Deserialize, Deserializer, Serialize, Serializer};
101
102pub const SEPARATOR: char = '@';
105
106const HASH_HEX_LEN: usize = 64;
108
109pub const MAX_STORE_ID_LEN: usize = 64;
119
120pub const STORE_ID_ALPHABET: &str = "A-Za-z0-9_.-";
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
142pub struct StoreId(String);
143
144impl StoreId {
145 pub fn new(s: impl Into<String>) -> Result<Self, StoreIdError> {
150 let s = s.into();
151
152 if s.is_empty() {
153 return Err(StoreIdError::Empty);
154 }
155 if s.len() > MAX_STORE_ID_LEN {
160 return Err(StoreIdError::TooLong { len: s.len(), max: MAX_STORE_ID_LEN });
161 }
162
163 for (position, ch) in s.char_indices() {
164 if ch == SEPARATOR {
170 return Err(StoreIdError::ContainsSeparator { position });
171 }
172 if !is_store_id_char(ch) {
173 return Err(StoreIdError::InvalidChar { ch, position });
174 }
175 }
176
177 if s == "." || s == ".." {
183 return Err(StoreIdError::Reserved { id: s });
184 }
185
186 Ok(StoreId(s))
187 }
188
189 pub fn as_str(&self) -> &str {
191 &self.0
192 }
193
194 pub fn into_string(self) -> String {
196 self.0
197 }
198}
199
200fn is_store_id_char(ch: char) -> bool {
201 ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
202}
203
204impl fmt::Display for StoreId {
205 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
206 f.write_str(&self.0)
207 }
208}
209
210impl AsRef<str> for StoreId {
211 fn as_ref(&self) -> &str {
212 &self.0
213 }
214}
215
216impl std::str::FromStr for StoreId {
217 type Err = StoreIdError;
218 fn from_str(s: &str) -> Result<Self, Self::Err> {
219 StoreId::new(s)
220 }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
229pub enum Cause {
230 Local(String),
234 Qualified { store: StoreId, hash: String },
237}
238
239impl Cause {
240 pub fn local(hash: impl Into<String>) -> Result<Self, CauseParseError> {
242 let hash = hash.into();
243 validate_hash(&hash)?;
244 Ok(Cause::Local(hash))
245 }
246
247 pub fn qualified(store: StoreId, hash: impl Into<String>) -> Result<Self, CauseParseError> {
250 let hash = hash.into();
251 validate_hash(&hash)?;
252 Ok(Cause::Qualified { store, hash })
253 }
254
255 pub fn hash(&self) -> &str {
257 match self {
258 Cause::Local(h) => h,
259 Cause::Qualified { hash, .. } => hash,
260 }
261 }
262
263 pub fn store(&self) -> Option<&StoreId> {
265 match self {
266 Cause::Local(_) => None,
267 Cause::Qualified { store, .. } => Some(store),
268 }
269 }
270
271 pub fn is_qualified(&self) -> bool {
273 matches!(self, Cause::Qualified { .. })
274 }
275
276 pub fn render(&self) -> String {
278 render(self)
279 }
280}
281
282impl fmt::Display for Cause {
283 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284 match self {
285 Cause::Local(h) => f.write_str(h),
286 Cause::Qualified { store, hash } => write!(f, "{hash}{SEPARATOR}{store}"),
287 }
288 }
289}
290
291impl std::str::FromStr for Cause {
292 type Err = CauseParseError;
293 fn from_str(s: &str) -> Result<Self, Self::Err> {
294 parse(s)
295 }
296}
297
298impl TryFrom<String> for Cause {
299 type Error = CauseParseError;
300 fn try_from(s: String) -> Result<Self, Self::Error> {
301 parse(&s)
302 }
303}
304
305impl From<Cause> for String {
306 fn from(c: Cause) -> String {
307 render(&c)
308 }
309}
310
311pub fn parse(s: &str) -> Result<Cause, CauseParseError> {
325 if s.is_empty() {
326 return Err(CauseParseError::Empty);
327 }
328
329 let separators = s.matches(SEPARATOR).count();
330 match separators {
331 0 => {
332 validate_hash(s)?;
333 Ok(Cause::Local(s.to_string()))
334 }
335 1 => {
336 let (hash, store) = s.split_once(SEPARATOR).expect("one separator counted");
340 validate_hash(hash)?;
341 let store = StoreId::new(store).map_err(CauseParseError::BadStoreId)?;
342 Ok(Cause::Qualified { store, hash: hash.to_string() })
343 }
344 count => Err(CauseParseError::TooManySeparators { count, input: s.to_string() }),
349 }
350}
351
352pub fn render(c: &Cause) -> String {
359 match c {
360 Cause::Local(h) => h.clone(),
361 Cause::Qualified { store, hash } => {
362 let mut out = String::with_capacity(hash.len() + 1 + store.as_str().len());
363 out.push_str(hash);
364 out.push(SEPARATOR);
365 out.push_str(store.as_str());
366 out
367 }
368 }
369}
370
371pub fn target_store<'a>(c: &'a Cause, reading_store: &'a str) -> &'a str {
378 match c {
379 Cause::Local(_) => reading_store,
380 Cause::Qualified { store, .. } => store.as_str(),
381 }
382}
383
384fn validate_hash(h: &str) -> Result<(), CauseParseError> {
386 if h.len() != HASH_HEX_LEN {
387 return Err(CauseParseError::BadHashLength { got: h.len(), expected: HASH_HEX_LEN });
388 }
389 for (position, ch) in h.char_indices() {
390 if ch.is_ascii_digit() || ('a'..='f').contains(&ch) {
391 continue;
392 }
393 if ch.is_ascii_uppercase() && ch.is_ascii_hexdigit() {
398 return Err(CauseParseError::UppercaseHex { ch, position });
399 }
400 return Err(CauseParseError::NonHexChar { ch, position });
401 }
402 Ok(())
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
411pub enum StoreIdError {
412 Empty,
414 TooLong { len: usize, max: usize },
416 ContainsSeparator { position: usize },
418 InvalidChar { ch: char, position: usize },
420 Reserved { id: String },
423}
424
425impl fmt::Display for StoreIdError {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 match self {
428 StoreIdError::Empty => write!(f, "store id is empty"),
429 StoreIdError::TooLong { len, max } => {
430 write!(f, "store id is {len} bytes, maximum is {max}")
431 }
432 StoreIdError::ContainsSeparator { position } => write!(
433 f,
434 "store id contains the reserved separator {SEPARATOR:?} at byte {position}; \
435 a store id that can break the cause encoding is not a valid store id"
436 ),
437 StoreIdError::InvalidChar { ch, position } => write!(
438 f,
439 "store id contains invalid character {ch:?} at byte {position}; \
440 allowed characters are [{STORE_ID_ALPHABET}]"
441 ),
442 StoreIdError::Reserved { id } => {
443 write!(f, "store id {id:?} is reserved (it names a directory, not a store)")
444 }
445 }
446 }
447}
448
449impl std::error::Error for StoreIdError {}
450
451#[derive(Debug, Clone, PartialEq, Eq)]
458pub enum CauseParseError {
459 Empty,
461 BadHashLength { got: usize, expected: usize },
463 NonHexChar { ch: char, position: usize },
465 UppercaseHex { ch: char, position: usize },
468 BadStoreId(StoreIdError),
470 TooManySeparators { count: usize, input: String },
472}
473
474impl fmt::Display for CauseParseError {
475 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476 match self {
477 CauseParseError::Empty => {
478 write!(f, "empty cause reference: expected a 64-char hex hash, optionally followed by {SEPARATOR:?} and a store id")
479 }
480 CauseParseError::BadHashLength { got, expected } => {
481 write!(f, "cause hash is {got} characters, expected exactly {expected} hex characters")
482 }
483 CauseParseError::NonHexChar { ch, position } => {
484 write!(f, "cause hash contains non-hex character {ch:?} at position {position}; expected [0-9a-f]")
485 }
486 CauseParseError::UppercaseHex { ch, position } => write!(
487 f,
488 "cause hash contains uppercase hex character {ch:?} at position {position}; \
489 hashes must be lowercase (refused rather than normalised, so that one hash has one spelling)"
490 ),
491 CauseParseError::BadStoreId(e) => write!(f, "invalid store id in cause reference: {e}"),
492 CauseParseError::TooManySeparators { count, input } => write!(
493 f,
494 "cause reference {input:?} contains {count} {SEPARATOR:?} separators, expected at most 1"
495 ),
496 }
497 }
498}
499
500impl std::error::Error for CauseParseError {
501 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
502 match self {
503 CauseParseError::BadStoreId(e) => Some(e),
504 _ => None,
505 }
506 }
507}
508
509impl From<StoreIdError> for CauseParseError {
510 fn from(e: StoreIdError) -> Self {
511 CauseParseError::BadStoreId(e)
512 }
513}
514
515impl Serialize for Cause {
531 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
532 s.serialize_str(&render(self))
533 }
534}
535
536impl<'de> Deserialize<'de> for Cause {
537 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
538 struct CauseVisitor;
539
540 impl Visitor<'_> for CauseVisitor {
541 type Value = Cause;
542
543 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544 write!(f, "a cause reference string: 64 lowercase hex characters, optionally followed by {SEPARATOR:?} and a store id")
545 }
546
547 fn visit_str<E: de::Error>(self, v: &str) -> Result<Cause, E> {
548 parse(v).map_err(|e| E::custom(e.to_string()))
552 }
553 }
554
555 d.deserialize_str(CauseVisitor)
556 }
557}
558
559impl Serialize for StoreId {
560 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
561 s.serialize_str(&self.0)
562 }
563}
564
565impl<'de> Deserialize<'de> for StoreId {
566 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
567 let s = String::deserialize(d)?;
568 StoreId::new(s).map_err(|e| de::Error::custom(e.to_string()))
569 }
570}
571
572#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn hash_n(seed: u8) -> String {
583 let alphabet = b"0123456789abcdef";
584 (0..HASH_HEX_LEN)
585 .map(|i| alphabet[(i.wrapping_mul(7).wrapping_add(seed as usize)) % 16] as char)
586 .collect()
587 }
588
589 const H: &str = "3f9a7c1e0b2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e1b3d5f7a9c1e2d4f6a8c0e";
590
591 #[test]
594 fn legacy_bare_hash_parses_as_local() {
595 let c = parse(H).expect("64-hex must parse");
596 assert_eq!(c, Cause::Local(H.to_string()));
597 assert!(!c.is_qualified());
598 assert_eq!(c.hash(), H);
599 assert_eq!(c.store(), None);
600 }
601
602 #[test]
603 fn legacy_bare_hash_renders_byte_identically() {
604 for seed in 0..32u8 {
608 let h = hash_n(seed);
609 let round = render(&parse(&h).unwrap());
610 assert_eq!(round, h, "bare hash must survive parse/render unchanged");
611 }
612 assert_eq!(render(&parse(H).unwrap()), H);
613 }
614
615 #[test]
616 fn legacy_caused_by_vec_needs_no_migration() {
617 let legacy: Vec<String> = (0..8u8).map(hash_n).collect();
619 let parsed: Vec<Cause> = legacy.iter().map(|s| parse(s).unwrap()).collect();
620 assert!(parsed.iter().all(|c| !c.is_qualified()));
621 let rendered: Vec<String> = parsed.iter().map(render).collect();
622 assert_eq!(rendered, legacy);
623 }
624
625 #[test]
628 fn qualified_can_never_look_like_a_bare_hash() {
629 let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
633 let s = render(&q);
634 assert!(s.contains(SEPARATOR));
635 assert_eq!(s.matches(SEPARATOR).count(), 1);
636 assert!(!render(&Cause::local(H).unwrap()).contains(SEPARATOR));
637 assert!(!SEPARATOR.is_ascii_hexdigit());
639 assert!(!is_store_id_char(SEPARATOR));
641 }
642
643 #[test]
644 fn store_id_containing_separator_is_refused_at_construction() {
645 let e = StoreId::new("branch@evil").unwrap_err();
646 assert_eq!(e, StoreIdError::ContainsSeparator { position: 6 });
647 assert!(e.to_string().contains("separator"));
648
649 for bad in ["@main", "main@", "@", "a@b@c"] {
652 assert!(
653 matches!(StoreId::new(bad), Err(StoreIdError::ContainsSeparator { .. })),
654 "{bad:?} must be refused"
655 );
656 }
657
658 let rendered = format!("{H}{SEPARATOR}inner");
663 assert_eq!(
664 StoreId::new(&rendered).unwrap_err(),
665 StoreIdError::TooLong { len: rendered.len(), max: MAX_STORE_ID_LEN }
666 );
667 assert!(matches!(
670 StoreId::new("abcdef@inner"),
671 Err(StoreIdError::ContainsSeparator { position: 6 })
672 ));
673 }
674
675 #[test]
676 fn too_many_separators_is_refused_not_guessed() {
677 let s = format!("{H}@a@b");
678 match parse(&s).unwrap_err() {
679 CauseParseError::TooManySeparators { count, input } => {
680 assert_eq!(count, 2);
681 assert_eq!(input, s);
682 }
683 other => panic!("expected TooManySeparators, got {other:?}"),
684 }
685 }
686
687 #[test]
690 fn qualified_round_trips() {
691 let q = Cause::qualified(StoreId::new("branch-feature-x").unwrap(), H).unwrap();
692 let s = render(&q);
693 assert_eq!(s, format!("{H}@branch-feature-x"));
694 assert_eq!(parse(&s).unwrap(), q);
695 assert_eq!(render(&parse(&s).unwrap()), s);
696 }
697
698 #[test]
699 fn table_driven_round_trip() {
700 let store_ids = [
703 "a",
704 "Z",
705 "0",
706 "_",
707 "-",
708 ".", "main",
710 "MAIN",
711 "branch-feature-x",
712 "branch_feature_x",
713 "team.alpha",
714 "v1.2.3-rc.4_final",
715 "0123456789",
716 "A-Za-z0-9_.",
717 &"x".repeat(MAX_STORE_ID_LEN),
718 &"y".repeat(MAX_STORE_ID_LEN - 1),
719 ];
720
721 let mut cases: Vec<Cause> = Vec::new();
722
723 for seed in 0..8u8 {
725 cases.push(Cause::local(hash_n(seed)).unwrap());
726 }
727 cases.push(Cause::local("0".repeat(64)).unwrap());
728 cases.push(Cause::local("f".repeat(64)).unwrap());
729 cases.push(Cause::local(H).unwrap());
730
731 for (i, sid) in store_ids.iter().enumerate() {
733 let store = if *sid == "." {
734 StoreId::new("a.b").unwrap()
736 } else {
737 StoreId::new(*sid).unwrap_or_else(|e| panic!("{sid:?} should be valid: {e}"))
738 };
739 cases.push(Cause::qualified(store, hash_n(i as u8 * 3)).unwrap());
740 }
741
742 assert!(cases.len() >= 20, "want a decent sample, got {}", cases.len());
743
744 for c in &cases {
745 let s = render(c);
746 let back = parse(&s).unwrap_or_else(|e| panic!("{s:?} must re-parse: {e}"));
747 assert_eq!(&back, c, "parse(render(c)) must equal c");
748 assert_eq!(render(&back), s, "render must be stable across a round trip");
749 assert_eq!(c.to_string(), s);
751 let json = serde_json::to_string(c).unwrap();
753 assert!(json.starts_with('"') && json.ends_with('"'), "must be a JSON string");
754 let from_json: Cause = serde_json::from_str(&json).unwrap();
755 assert_eq!(&from_json, c);
756 }
757 }
758
759 #[test]
762 fn short_and_long_hashes_are_refused_distinguishably() {
763 let short = &H[..63];
764 let long = format!("{H}a");
765
766 let e_short = parse(short).unwrap_err();
767 let e_long = parse(&long).unwrap_err();
768
769 assert_eq!(e_short, CauseParseError::BadHashLength { got: 63, expected: 64 });
770 assert_eq!(e_long, CauseParseError::BadHashLength { got: 65, expected: 64 });
771 assert_ne!(e_short, e_long, "63 and 65 must be distinguishable");
772 assert!(e_short.to_string().contains("63"));
773 assert!(e_long.to_string().contains("65"));
774 }
775
776 #[test]
777 fn uppercase_hex_is_refused_not_normalised() {
778 let upper = H.to_uppercase();
779 match parse(&upper).unwrap_err() {
780 CauseParseError::UppercaseHex { ch, position } => {
781 assert_eq!(ch, 'F');
782 assert_eq!(position, 1); }
784 other => panic!("expected UppercaseHex, got {other:?}"),
785 }
786 let mixed = format!("{}A{}", &H[..10], &H[11..]);
788 assert!(matches!(parse(&mixed), Err(CauseParseError::UppercaseHex { ch: 'A', .. })));
789 assert!(parse(&upper).is_err());
790 }
791
792 #[test]
793 fn non_hex_character_is_refused_and_named() {
794 let bad = format!("{}z{}", &H[..5], &H[6..]);
795 match parse(&bad).unwrap_err() {
796 CauseParseError::NonHexChar { ch, position } => {
797 assert_eq!(ch, 'z');
798 assert_eq!(position, 5);
799 }
800 other => panic!("expected NonHexChar, got {other:?}"),
801 }
802 assert!(parse(&bad).unwrap_err().to_string().contains("'z'"));
803
804 let uni = format!("{}é{}", &H[..3], &H[5..]); assert!(matches!(parse(&uni), Err(CauseParseError::NonHexChar { ch: 'é', .. })));
807 }
808
809 #[test]
810 fn empty_input_is_its_own_error() {
811 assert_eq!(parse("").unwrap_err(), CauseParseError::Empty);
812 assert!(parse("").unwrap_err().to_string().contains("empty"));
813 }
814
815 #[test]
816 fn qualified_with_bad_hash_reports_the_hash_not_the_store() {
817 let e = parse("abc@main").unwrap_err();
818 assert_eq!(e, CauseParseError::BadHashLength { got: 3, expected: 64 });
819 }
820
821 #[test]
824 fn empty_store_id_is_refused() {
825 assert_eq!(StoreId::new("").unwrap_err(), StoreIdError::Empty);
826 assert_eq!(
828 parse(&format!("{H}@")).unwrap_err(),
829 CauseParseError::BadStoreId(StoreIdError::Empty)
830 );
831 }
832
833 #[test]
834 fn oversized_store_id_is_refused() {
835 let big = "a".repeat(MAX_STORE_ID_LEN + 1);
836 assert_eq!(
837 StoreId::new(&big).unwrap_err(),
838 StoreIdError::TooLong { len: MAX_STORE_ID_LEN + 1, max: MAX_STORE_ID_LEN }
839 );
840 assert!(StoreId::new("a".repeat(MAX_STORE_ID_LEN)).is_ok());
842 assert!(matches!(
843 parse(&format!("{H}@{big}")),
844 Err(CauseParseError::BadStoreId(StoreIdError::TooLong { .. }))
845 ));
846 }
847
848 #[test]
849 fn whitespace_in_store_id_is_refused() {
850 for (bad, pos) in [("main branch", 4), (" main", 0), ("main\t", 4), ("main\n", 4)] {
851 match StoreId::new(bad).unwrap_err() {
852 StoreIdError::InvalidChar { ch, position } => {
853 assert_eq!(position, pos, "for {bad:?}");
854 assert!(ch.is_whitespace(), "for {bad:?}");
855 }
856 other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
857 }
858 }
859 }
860
861 #[test]
862 fn path_unsafe_and_exotic_store_ids_are_refused_naming_the_character() {
863 for bad in ["a/b", "a\\b", "a:b", "a#b", "a?b", "a%b", "a*b", "a\0b", "brânch"] {
864 let e = StoreId::new(bad).unwrap_err();
865 match e {
866 StoreIdError::InvalidChar { ch, .. } => {
867 assert!(
868 e.to_string().contains(&format!("{ch:?}")),
869 "message must name the offending character for {bad:?}"
870 );
871 }
872 other => panic!("expected InvalidChar for {bad:?}, got {other:?}"),
873 }
874 }
875 }
876
877 #[test]
878 fn dot_store_ids_are_reserved_but_dots_inside_ids_are_fine() {
879 assert_eq!(StoreId::new(".").unwrap_err(), StoreIdError::Reserved { id: ".".into() });
880 assert_eq!(StoreId::new("..").unwrap_err(), StoreIdError::Reserved { id: "..".into() });
881 assert_eq!(StoreId::new("team.alpha").unwrap().as_str(), "team.alpha");
882 assert_eq!(StoreId::new("...").unwrap().as_str(), "...");
883 }
884
885 #[test]
888 fn every_error_variant_has_a_distinct_informative_message() {
889 let errs = vec![
890 CauseParseError::Empty,
891 CauseParseError::BadHashLength { got: 63, expected: 64 },
892 CauseParseError::NonHexChar { ch: 'z', position: 5 },
893 CauseParseError::UppercaseHex { ch: 'F', position: 1 },
894 CauseParseError::BadStoreId(StoreIdError::Empty),
895 CauseParseError::BadStoreId(StoreIdError::TooLong { len: 99, max: 64 }),
896 CauseParseError::BadStoreId(StoreIdError::ContainsSeparator { position: 2 }),
897 CauseParseError::BadStoreId(StoreIdError::InvalidChar { ch: '/', position: 1 }),
898 CauseParseError::BadStoreId(StoreIdError::Reserved { id: "..".into() }),
899 CauseParseError::TooManySeparators { count: 2, input: "a@b@c".into() },
900 ];
901 let msgs: Vec<String> = errs.iter().map(|e| e.to_string()).collect();
902 for (i, m) in msgs.iter().enumerate() {
903 assert!(!m.is_empty());
904 for (j, n) in msgs.iter().enumerate() {
905 if i != j {
906 assert_ne!(m, n, "error messages must be distinguishable");
907 }
908 }
909 }
910 assert!(format!("{:?}", errs[1]).contains("BadHashLength"));
912 use std::error::Error as _;
913 assert!(errs[4].source().is_some());
914 assert!(errs[0].source().is_none());
915 }
916
917 #[test]
920 fn vec_of_causes_is_a_json_array_of_plain_strings() {
921 let causes = vec![
922 Cause::local(H).unwrap(),
923 Cause::qualified(StoreId::new("branch-x").unwrap(), hash_n(1)).unwrap(),
924 Cause::local(hash_n(2)).unwrap(),
925 ];
926 let json = serde_json::to_string(&causes).unwrap();
927 assert_eq!(
928 json,
929 format!(
930 "[\"{}\",\"{}@branch-x\",\"{}\"]",
931 H,
932 hash_n(1),
933 hash_n(2)
934 )
935 );
936
937 let as_values: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
940 assert!(as_values.iter().all(|v| v.is_string()));
941
942 let back: Vec<Cause> = serde_json::from_str(&json).unwrap();
943 assert_eq!(back, causes);
944 }
945
946 #[test]
947 fn caused_by_is_wire_compatible_with_vec_string() {
948 let legacy: Vec<String> = (0..5u8).map(hash_n).collect();
951 let as_causes: Vec<Cause> = legacy.iter().map(|h| Cause::local(h).unwrap()).collect();
952 assert_eq!(
953 serde_json::to_string(&as_causes).unwrap(),
954 serde_json::to_string(&legacy).unwrap()
955 );
956 let from_legacy: Vec<Cause> =
958 serde_json::from_str(&serde_json::to_string(&legacy).unwrap()).unwrap();
959 assert_eq!(from_legacy, as_causes);
960 }
961
962 #[test]
963 fn deserialising_garbage_fails_with_the_parse_diagnosis() {
964 let err = serde_json::from_str::<Cause>("\"nope\"").unwrap_err().to_string();
965 assert!(err.contains("expected exactly 64"), "got: {err}");
966
967 let err = serde_json::from_str::<Vec<Cause>>(&format!("[\"{}\"]", H.to_uppercase()))
968 .unwrap_err()
969 .to_string();
970 assert!(err.contains("uppercase"), "got: {err}");
971
972 assert!(serde_json::from_str::<Cause>("{\"Local\":\"x\"}").is_err());
974 assert!(serde_json::from_str::<Cause>("42").is_err());
975 }
976
977 #[test]
978 fn store_id_serde_round_trips_and_validates() {
979 let s = StoreId::new("branch-x").unwrap();
980 let json = serde_json::to_string(&s).unwrap();
981 assert_eq!(json, "\"branch-x\"");
982 assert_eq!(serde_json::from_str::<StoreId>(&json).unwrap(), s);
983 assert!(serde_json::from_str::<StoreId>("\"bad id\"").is_err());
984 }
985
986 #[test]
989 fn target_store_resolves_local_to_reader_and_qualified_to_itself() {
990 let local = Cause::local(H).unwrap();
991 assert_eq!(target_store(&local, "main"), "main");
992 assert_eq!(target_store(&local, "some-other-store"), "some-other-store");
993
994 let q = Cause::qualified(StoreId::new("branch-x").unwrap(), H).unwrap();
995 assert_eq!(target_store(&q, "main"), "branch-x");
996 assert_eq!(target_store(&q, "branch-x"), "branch-x");
999 assert_eq!(target_store(&q, "anything-at-all"), "branch-x");
1000 }
1001
1002 #[test]
1005 fn constructors_validate_and_accessors_agree() {
1006 assert!(Cause::local("short").is_err());
1007 assert!(Cause::qualified(StoreId::new("s").unwrap(), "short").is_err());
1008
1009 let q = Cause::qualified(StoreId::new("s").unwrap(), H).unwrap();
1010 assert_eq!(q.hash(), H);
1011 assert_eq!(q.store().unwrap().as_str(), "s");
1012 assert!(q.is_qualified());
1013 assert_eq!(q.render(), render(&q));
1014
1015 use std::str::FromStr as _;
1017 assert_eq!(Cause::from_str(H).unwrap(), Cause::local(H).unwrap());
1018 assert_eq!(Cause::try_from(H.to_string()).unwrap(), Cause::local(H).unwrap());
1019 assert_eq!(String::from(q.clone()), render(&q));
1020 assert_eq!(StoreId::from_str("ok").unwrap().into_string(), "ok");
1021 assert_eq!(StoreId::new("ok").unwrap().as_ref() as &str, "ok");
1022 assert_eq!(StoreId::new("ok").unwrap().to_string(), "ok");
1023 }
1024}