1#![warn(missing_docs)]
2use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::fmt;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(transparent)]
33pub struct EventId(pub String);
34
35impl fmt::Display for EventId {
36 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 self.0.fmt(f)
38 }
39}
40
41#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(transparent)]
53pub struct WorkstreamId(pub String);
54
55impl fmt::Display for WorkstreamId {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 self.0.fmt(f)
58 }
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
72#[serde(transparent)]
73pub struct RunId(pub String);
74
75impl fmt::Display for RunId {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 self.0.fmt(f)
78 }
79}
80
81impl EventId {
82 pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
100 Self(hash_hex(parts))
101 }
102}
103
104impl WorkstreamId {
105 pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
117 Self(hash_hex(parts))
118 }
119}
120
121impl RunId {
122 pub fn now(prefix: &str) -> Self {
135 let nanos = std::time::SystemTime::now()
136 .duration_since(std::time::UNIX_EPOCH)
137 .unwrap_or_default()
138 .as_nanos();
139 RunId(format!("{prefix}_{nanos}"))
140 }
141}
142
143fn hash_hex(parts: impl IntoIterator<Item = impl AsRef<str>>) -> String {
144 let mut hasher = Sha256::new();
145 for (i, p) in parts.into_iter().enumerate() {
146 if i > 0 {
147 hasher.update(b"\n");
148 }
149 hasher.update(p.as_ref().as_bytes());
150 }
151 let out = hasher.finalize();
152 hex::encode(out)
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn event_id_deterministic() {
161 let a = EventId::from_parts(["github", "pr", "o/r", "42"]);
162 let b = EventId::from_parts(["github", "pr", "o/r", "42"]);
163 assert_eq!(a, b);
164 }
165
166 #[test]
167 fn event_id_varies_with_parts() {
168 let a = EventId::from_parts(["github", "pr", "o/r", "1"]);
169 let b = EventId::from_parts(["github", "pr", "o/r", "2"]);
170 assert_ne!(a, b);
171 }
172
173 #[test]
174 fn event_id_is_valid_sha256_hex() {
175 let id = EventId::from_parts(["x"]);
176 assert_eq!(id.0.len(), 64);
177 assert!(id.0.chars().all(|c| c.is_ascii_hexdigit()));
178 }
179
180 #[test]
181 fn workstream_id_deterministic() {
182 let a = WorkstreamId::from_parts(["repo", "acme/foo"]);
183 let b = WorkstreamId::from_parts(["repo", "acme/foo"]);
184 assert_eq!(a, b);
185 }
186
187 #[test]
188 fn part_boundary_matters() {
189 let a = EventId::from_parts(["a", "bc"]);
190 let b = EventId::from_parts(["ab", "c"]);
191 assert_ne!(
192 a, b,
193 "newline separator should prevent part-boundary collisions"
194 );
195 }
196
197 #[test]
198 fn run_id_starts_with_prefix() {
199 let id = RunId::now("shiplog");
200 assert!(id.0.starts_with("shiplog_"));
201 }
202
203 #[test]
204 fn display_matches_inner() {
205 let id = EventId::from_parts(["display", "test"]);
206 assert_eq!(format!("{id}"), id.0);
207 }
208
209 #[test]
210 fn single_part_matches_known_sha256() {
211 let id = EventId::from_parts(["abc"]);
213 assert_eq!(
214 id.0,
215 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
216 );
217 }
218
219 #[test]
220 fn multi_part_uses_newline_separator_not_prefix() {
221 let id = EventId::from_parts(["a", "b"]);
223 let expected = {
224 let mut h = sha2::Sha256::new();
225 h.update(b"a\nb");
226 hex::encode(h.finalize())
227 };
228 assert_eq!(id.0, expected);
229
230 let wrong = {
232 let mut h = sha2::Sha256::new();
233 h.update(b"\na\nb");
234 hex::encode(h.finalize())
235 };
236 assert_ne!(
237 id.0, wrong,
238 "hash_hex must not prepend a newline before the first part"
239 );
240 }
241}