1use std::collections::HashMap;
16
17use crate::types::IdentityDID;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct KelEventRef<'a> {
26 pub prefix: &'a str,
28 pub seq: u64,
30 pub said: &'a str,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36#[serde(tag = "type")]
37pub enum DuplicityReport {
38 Clean,
40 Diverging {
42 shared_kel_prefix: IdentityDID,
44 seq: u64,
46 event_saids: Vec<String>,
48 },
49}
50
51impl DuplicityReport {
52 pub fn is_diverging(&self) -> bool {
54 matches!(self, DuplicityReport::Diverging { .. })
55 }
56}
57
58pub fn detect_duplicity(events: &[KelEventRef<'_>]) -> DuplicityReport {
83 let mut seen: HashMap<(String, u64), Vec<String>> = HashMap::new();
87 let mut first_conflict: Option<(String, u64)> = None;
88
89 for ev in events {
90 let key = (ev.prefix.to_string(), ev.seq);
91 let saids = seen.entry(key.clone()).or_default();
92 if !saids.iter().any(|s| s == ev.said) {
93 saids.push(ev.said.to_string());
94 if saids.len() >= 2 && first_conflict.is_none() {
95 first_conflict = Some(key);
96 }
97 }
98 }
99
100 if let Some((prefix, seq)) = first_conflict {
101 let saids = seen.remove(&(prefix.clone(), seq)).unwrap_or_default();
102 let shared_kel_prefix = {
103 #[allow(clippy::disallowed_methods)]
104 IdentityDID::new_unchecked(prefix)
105 };
106 return DuplicityReport::Diverging {
107 shared_kel_prefix,
108 seq,
109 event_saids: saids,
110 };
111 }
112
113 DuplicityReport::Clean
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn ev<'a>(prefix: &'a str, seq: u64, said: &'a str) -> KelEventRef<'a> {
121 KelEventRef { prefix, seq, said }
122 }
123
124 #[test]
125 fn empty_is_clean() {
126 assert_eq!(detect_duplicity(&[]), DuplicityReport::Clean);
127 }
128
129 #[test]
130 fn single_event_is_clean() {
131 let events = vec![ev("did:keri:E1", 0, "EaaaA")];
132 assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
133 }
134
135 #[test]
136 fn sequential_rots_are_clean() {
137 let events = vec![
139 ev("did:keri:E1", 0, "EincpA"),
140 ev("did:keri:E1", 1, "ErotA"),
141 ev("did:keri:E1", 2, "ErotB"),
142 ];
143 assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
144 }
145
146 #[test]
147 fn identical_said_at_same_seq_is_clean() {
148 let events = vec![ev("did:keri:E1", 2, "Erot"), ev("did:keri:E1", 2, "Erot")];
151 assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
152 }
153
154 #[test]
155 fn two_different_saids_at_same_seq_is_diverging() {
156 let events = vec![
157 ev("did:keri:E1", 0, "Eincp"),
158 ev("did:keri:E1", 2, "ErotA"),
159 ev("did:keri:E1", 2, "ErotB"),
160 ];
161 match detect_duplicity(&events) {
162 DuplicityReport::Diverging {
163 shared_kel_prefix,
164 seq,
165 event_saids,
166 } => {
167 assert_eq!(shared_kel_prefix.as_str(), "did:keri:E1");
168 assert_eq!(seq, 2);
169 assert_eq!(event_saids, vec!["ErotA".to_string(), "ErotB".to_string()]);
170 }
171 DuplicityReport::Clean => panic!("expected Diverging"),
172 }
173 }
174
175 #[test]
176 fn three_way_fork_reports_all_saids() {
177 let events = vec![
178 ev("did:keri:E1", 2, "ErotA"),
179 ev("did:keri:E1", 2, "ErotB"),
180 ev("did:keri:E1", 2, "ErotC"),
181 ];
182 match detect_duplicity(&events) {
183 DuplicityReport::Diverging { event_saids, .. } => {
184 assert_eq!(event_saids.len(), 3);
185 }
186 _ => panic!("expected three-way divergence"),
187 }
188 }
189
190 #[test]
191 fn icp_only_stream_is_clean() {
192 let events = vec![ev("did:keri:E1", 0, "Eincp")];
193 assert_eq!(detect_duplicity(&events), DuplicityReport::Clean);
194 }
195
196 #[test]
197 fn diverging_report_is_diverging() {
198 let report = DuplicityReport::Diverging {
199 #[allow(clippy::disallowed_methods)]
200 shared_kel_prefix: IdentityDID::new_unchecked("did:keri:E1".to_string()),
201 seq: 2,
202 event_saids: vec!["Ea".into(), "Eb".into()],
203 };
204 assert!(report.is_diverging());
205 assert!(!DuplicityReport::Clean.is_diverging());
206 }
207}