eventuary_core/io/
cursor.rs1use 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}