Skip to main content

eventuary_core/io/
cursor.rs

1use std::fmt;
2use std::sync::Arc;
3
4use crate::error::Result;
5
6#[derive(Debug, Clone, Eq, PartialEq, Hash)]
7pub struct CursorId(Arc<str>);
8
9impl CursorId {
10    pub fn new(value: impl Into<Arc<str>>) -> Result<Self> {
11        let value: Arc<str> = value.into();
12        if value.is_empty() || value.len() > 128 {
13            return Err(crate::error::Error::Config(format!(
14                "invalid cursor id: {:?}",
15                value.as_ref()
16            )));
17        }
18        if !value
19            .chars()
20            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' || c == ':')
21        {
22            return Err(crate::error::Error::Config(format!(
23                "invalid cursor id: {:?}",
24                value.as_ref()
25            )));
26        }
27        Ok(Self(value))
28    }
29
30    pub fn global() -> Self {
31        Self(Arc::from("global"))
32    }
33
34    pub fn partition(count: u16, id: u16) -> Self {
35        Self(Arc::from(format!("partition:{count}:{id}")))
36    }
37
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41}
42
43impl fmt::Display for CursorId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(&self.0)
46    }
47}
48
49impl serde::Serialize for CursorId {
50    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
51        s.serialize_str(&self.0)
52    }
53}
54
55impl<'de> serde::Deserialize<'de> for CursorId {
56    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
57        let value = String::deserialize(d)?;
58        CursorId::new(Arc::from(value)).map_err(serde::de::Error::custom)
59    }
60}
61
62#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
63pub struct NoCursor;
64
65pub trait Cursor {
66    fn id(&self) -> CursorId {
67        CursorId::global()
68    }
69}
70
71impl Cursor for NoCursor {}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn global_returns_static_global_id() {
79        assert_eq!(CursorId::global().as_str(), "global");
80        assert_eq!(CursorId::global(), CursorId::global());
81    }
82
83    #[test]
84    fn partition_constructs_stable_format() {
85        let id = CursorId::partition(100, 17);
86        assert_eq!(id.as_str(), "partition:100:17");
87    }
88
89    #[test]
90    fn new_validates_and_stores_value() {
91        let id = CursorId::new("custom.stream").unwrap();
92        assert_eq!(id.as_str(), "custom.stream");
93    }
94
95    #[test]
96    fn new_rejects_empty() {
97        assert!(CursorId::new("").is_err());
98    }
99
100    #[test]
101    fn new_rejects_too_long() {
102        let long = "a".repeat(129);
103        assert!(CursorId::new(long).is_err());
104    }
105
106    #[test]
107    fn new_rejects_invalid_chars() {
108        assert!(CursorId::new("bad space").is_err());
109        assert!(CursorId::new("bad/char").is_err());
110    }
111
112    #[test]
113    fn new_accepts_valid_chars() {
114        assert!(CursorId::new("valid-name_01.v2:tag").is_ok());
115    }
116
117    #[test]
118    fn equality_by_value() {
119        let a = CursorId::new("test").unwrap();
120        let b = CursorId::new("test").unwrap();
121        assert_eq!(a, b);
122    }
123
124    #[test]
125    fn distinct_values_differ() {
126        let a = CursorId::new("a").unwrap();
127        let b = CursorId::new("b").unwrap();
128        assert_ne!(a, b);
129    }
130
131    #[test]
132    fn cursor_trait_default_is_global() {
133        struct SomeCursor;
134        impl Cursor for SomeCursor {}
135        assert_eq!(SomeCursor.id(), CursorId::global());
136    }
137
138    #[test]
139    fn cursor_trait_named_example() {
140        struct NamedCursor;
141        impl Cursor for NamedCursor {
142            fn id(&self) -> CursorId {
143                CursorId::new("partition:100:17").unwrap()
144            }
145        }
146        assert_eq!(NamedCursor.id(), CursorId::partition(100, 17));
147    }
148
149    #[test]
150    fn serializes_as_plain_string() {
151        let id = CursorId::global();
152        let v = serde_json::to_value(id).unwrap();
153        assert_eq!(v.as_str(), Some("global"));
154
155        let id = CursorId::partition(4, 1);
156        let v = serde_json::to_value(id).unwrap();
157        assert_eq!(v.as_str(), Some("partition:4:1"));
158    }
159
160    #[test]
161    fn roundtrips_via_json() {
162        let id = CursorId::global();
163        let v = serde_json::to_value(id.clone()).unwrap();
164        let back: CursorId = serde_json::from_value(v).unwrap();
165        assert_eq!(back, id);
166
167        let id = CursorId::partition(4, 2);
168        let v = serde_json::to_value(id.clone()).unwrap();
169        let back: CursorId = serde_json::from_value(v).unwrap();
170        assert_eq!(back, id);
171    }
172
173    #[test]
174    fn display_output_matches_as_str() {
175        let id = CursorId::global();
176        assert_eq!(id.to_string(), "global");
177
178        let id = CursorId::partition(4, 1);
179        assert_eq!(id.to_string(), "partition:4:1");
180    }
181
182    #[test]
183    fn no_cursor_uses_global_cursor_id() {
184        assert_eq!(NoCursor.id(), CursorId::global());
185    }
186}