Skip to main content

shiplog_ids/
lib.rs

1#![warn(missing_docs)]
2//! Stable identifier types used across the shiplog pipeline.
3//!
4//! Includes deterministic SHA-256 constructors for event/workstream IDs and a
5//! timestamp-based run ID helper.
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::fmt;
10
11/// Stable identifiers used across the shiplog pipeline.
12///
13/// The rule is simple:
14/// - IDs are deterministic when derived from source data.
15/// - IDs are printable and safe to paste into docs.
16///
17/// This makes downstream redaction and diffing tractable.
18///
19/// # Examples
20///
21/// ```
22/// use shiplog_ids::EventId;
23///
24/// let id = EventId::from_parts(["github", "pr", "owner/repo", "42"]);
25/// assert_eq!(id.0.len(), 64); // SHA-256 hex string
26///
27/// // Same inputs always produce the same ID:
28/// let id2 = EventId::from_parts(["github", "pr", "owner/repo", "42"]);
29/// assert_eq!(id, id2);
30/// ```
31#[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/// A deterministic workstream identifier.
42///
43/// # Examples
44///
45/// ```
46/// use shiplog_ids::WorkstreamId;
47///
48/// let id = WorkstreamId::from_parts(["repo", "acme/widgets"]);
49/// assert_eq!(id.0.len(), 64);
50/// ```
51#[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/// A timestamp-based run identifier.
62///
63/// # Examples
64///
65/// ```
66/// use shiplog_ids::RunId;
67///
68/// let id = RunId::now("shiplog");
69/// assert!(id.0.starts_with("shiplog_"));
70/// ```
71#[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    /// Deterministic event id from a small set of stable parts.
83    ///
84    /// You want this to survive:
85    /// - re-runs
86    /// - different machines
87    /// - different render profiles
88    ///
89    /// # Examples
90    ///
91    /// ```
92    /// use shiplog_ids::EventId;
93    ///
94    /// let id = EventId::from_parts(["github", "pr", "owner/repo", "42"]);
95    /// // Different parts produce different IDs:
96    /// let other = EventId::from_parts(["github", "pr", "owner/repo", "99"]);
97    /// assert_ne!(id, other);
98    /// ```
99    pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
100        Self(hash_hex(parts))
101    }
102}
103
104impl WorkstreamId {
105    /// Deterministic workstream id from stable parts.
106    ///
107    /// # Examples
108    ///
109    /// ```
110    /// use shiplog_ids::WorkstreamId;
111    ///
112    /// let a = WorkstreamId::from_parts(["repo", "acme/widgets"]);
113    /// let b = WorkstreamId::from_parts(["repo", "acme/widgets"]);
114    /// assert_eq!(a, b);
115    /// ```
116    pub fn from_parts(parts: impl IntoIterator<Item = impl AsRef<str>>) -> Self {
117        Self(hash_hex(parts))
118    }
119}
120
121impl RunId {
122    /// Non-deterministic enough to avoid collisions without dragging in UUID/rand.
123    ///
124    /// # Examples
125    ///
126    /// ```
127    /// use shiplog_ids::RunId;
128    ///
129    /// let run = RunId::now("shiplog");
130    /// // Each call generates a unique ID:
131    /// let run2 = RunId::now("shiplog");
132    /// assert_ne!(run, run2);
133    /// ```
134    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        // SHA-256("abc") — no leading newline
212        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        // SHA-256("a\nb") — newline between parts, not before first
222        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        // Verify it does NOT equal SHA-256("\na\nb") which the mutant would produce
231        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}