1use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
10#[non_exhaustive]
11pub enum IdError {
12 #[error("{kind} is empty")]
14 Empty {
15 kind: &'static str,
17 },
18 #[error("{kind} `{value}` may only contain a-z, 0-9, `-` and `_`")]
20 Charset {
21 kind: &'static str,
23 value: String,
25 },
26 #[error("channel id `{0}` must look like `platform:instance`, for example `zalo:shop`")]
28 ChannelFormat(String),
29 #[error("invalid conversation key `{0}`")]
31 ConversationFormat(String),
32 #[error("notify target {0}")]
34 NotifyTarget(String),
35}
36
37fn check_slug(kind: &'static str, value: &str) -> Result<(), IdError> {
38 if value.is_empty() {
39 return Err(IdError::Empty { kind });
40 }
41 if !value
42 .bytes()
43 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
44 {
45 return Err(IdError::Charset {
46 kind,
47 value: value.to_string(),
48 });
49 }
50 Ok(())
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(try_from = "String", into = "String")]
58pub struct ChannelId {
59 platform: String,
60 instance: String,
61}
62
63impl ChannelId {
64 pub fn new(platform: &str, instance: &str) -> Result<Self, IdError> {
66 check_slug("platform", platform)?;
67 check_slug("instance", instance)?;
68 Ok(Self {
69 platform: platform.to_string(),
70 instance: instance.to_string(),
71 })
72 }
73
74 pub fn platform(&self) -> &str {
76 &self.platform
77 }
78
79 pub fn instance(&self) -> &str {
81 &self.instance
82 }
83
84 pub fn slug(&self) -> String {
86 format!("{}-{}", self.platform, self.instance)
87 }
88}
89
90impl fmt::Display for ChannelId {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 write!(f, "{}:{}", self.platform, self.instance)
93 }
94}
95
96impl FromStr for ChannelId {
97 type Err = IdError;
98
99 fn from_str(s: &str) -> Result<Self, Self::Err> {
100 let (platform, instance) = s
101 .split_once(':')
102 .ok_or_else(|| IdError::ChannelFormat(s.to_string()))?;
103 Self::new(platform, instance)
104 }
105}
106
107impl TryFrom<String> for ChannelId {
108 type Error = IdError;
109
110 fn try_from(value: String) -> Result<Self, Self::Error> {
111 value.parse()
112 }
113}
114
115impl From<ChannelId> for String {
116 fn from(value: ChannelId) -> Self {
117 value.to_string()
118 }
119}
120
121macro_rules! opaque_id {
122 ($(#[$doc:meta])* $name:ident) => {
123 $(#[$doc])*
124 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
125 #[serde(transparent)]
126 pub struct $name(String);
127
128 impl $name {
129 pub fn new(value: impl Into<String>) -> Self {
131 Self(value.into())
132 }
133
134 pub fn as_str(&self) -> &str {
136 &self.0
137 }
138 }
139
140 impl fmt::Display for $name {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 f.write_str(&self.0)
143 }
144 }
145
146 impl From<&str> for $name {
147 fn from(value: &str) -> Self {
148 Self(value.to_string())
149 }
150 }
151
152 impl From<String> for $name {
153 fn from(value: String) -> Self {
154 Self(value)
155 }
156 }
157 };
158}
159
160opaque_id!(
161 UserId
163);
164opaque_id!(
165 MessageId
167);
168opaque_id!(
169 RunId
171);
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct UserRef {
176 pub id: UserId,
178 pub display_name: String,
180 pub is_bot: bool,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
186pub struct ConversationKey {
187 pub channel: ChannelId,
189 pub space: Option<String>,
191 pub room: String,
193 pub thread: Option<String>,
195}
196
197impl ConversationKey {
198 pub fn room(channel: ChannelId, room: impl Into<String>) -> Self {
200 Self {
201 channel,
202 space: None,
203 room: room.into(),
204 thread: None,
205 }
206 }
207}
208
209impl FromStr for ConversationKey {
210 type Err = IdError;
211
212 fn from_str(value: &str) -> Result<Self, Self::Err> {
213 let invalid = || IdError::ConversationFormat(value.to_string());
214 let mut parts = Vec::new();
215 let mut part = String::new();
216 let mut chars = value.chars();
217 let mut escaped = false;
218 while let Some(ch) = chars.next() {
219 match ch {
220 '\\' => {
221 let next = chars.next().ok_or_else(invalid)?;
222 if !matches!(next, '\\' | '|' | '-') {
223 return Err(invalid());
224 }
225 escaped = true;
226 part.push(next);
227 }
228 '|' => {
229 parts.push(if part == "-" && !escaped {
230 None
231 } else {
232 Some(part)
233 });
234 part = String::new();
235 escaped = false;
236 }
237 _ => part.push(ch),
238 }
239 }
240 parts.push(if part == "-" && !escaped {
241 None
242 } else {
243 Some(part)
244 });
245 if parts.len() != 4 {
246 return Err(invalid());
247 }
248 let key = Self {
249 channel: parts[0].as_deref().ok_or_else(invalid)?.parse()?,
250 space: parts[1].clone(),
251 room: parts[2].clone().ok_or_else(invalid)?,
252 thread: parts[3].clone(),
253 };
254 if key.to_string() != value {
255 return Err(invalid());
256 }
257 Ok(key)
258 }
259}
260
261impl fmt::Display for ConversationKey {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 fn part(value: Option<&str>) -> String {
266 match value {
267 None => "-".to_string(),
268 Some(v) => {
269 let escaped = v.replace('\\', "\\\\").replace('|', "\\|");
270 if escaped == "-" {
271 "\\-".to_string()
272 } else {
273 escaped
274 }
275 }
276 }
277 }
278 write!(
279 f,
280 "{}|{}|{}|{}",
281 self.channel,
282 part(self.space.as_deref()),
283 part(Some(&self.room)),
284 part(self.thread.as_deref())
285 )
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use proptest::prelude::*;
293
294 #[test]
295 fn channel_id_round_trip() {
296 let id: ChannelId = "zalo:shop".parse().unwrap();
297 assert_eq!(id.platform(), "zalo");
298 assert_eq!(id.instance(), "shop");
299 assert_eq!(id.to_string(), "zalo:shop");
300 assert_eq!(id.slug(), "zalo-shop");
301 let json = serde_json::to_string(&id).unwrap();
302 assert_eq!(json, "\"zalo:shop\"");
303 assert_eq!(serde_json::from_str::<ChannelId>(&json).unwrap(), id);
304 }
305
306 #[test]
307 fn channel_id_rejects_bad_values() {
308 assert!("zalo".parse::<ChannelId>().is_err());
309 assert!("Zalo:shop".parse::<ChannelId>().is_err());
310 assert!("zalo:".parse::<ChannelId>().is_err());
311 assert!("zalo:a/b".parse::<ChannelId>().is_err());
312 }
313
314 #[test]
315 fn conversation_key_text_is_stable() {
316 let channel: ChannelId = "gchat:corp".parse().unwrap();
317 let key = ConversationKey {
318 channel,
319 space: Some("spaces/A".into()),
320 room: "r|1".into(),
321 thread: None,
322 };
323 assert_eq!(key.to_string(), r"gchat:corp|spaces/A|r\|1|-");
324 }
325
326 proptest! {
327 #[test]
328 fn conversation_key_round_trip(
329 space in proptest::option::of(".{0,20}"), room in ".{0,20}",
330 thread in proptest::option::of(".{0,20}"),
331 ) {
332 let key = ConversationKey { channel: "discord:main".parse().unwrap(), space, room, thread };
333 prop_assert_eq!(key.to_string().parse::<ConversationKey>().unwrap(), key);
334 }
335 #[test]
336 fn conversation_key_text_is_injective(
337 a in proptest::option::of(".{0,6}"), b in ".{0,6}", c in proptest::option::of(".{0,6}"),
338 d in proptest::option::of(".{0,6}"), e in ".{0,6}", g in proptest::option::of(".{0,6}"),
339 ) {
340 let channel: ChannelId = "x:y".parse().unwrap();
341 let k1 = ConversationKey { channel: channel.clone(), space: a, room: b, thread: c };
342 let k2 = ConversationKey { channel, space: d, room: e, thread: g };
343 prop_assert_eq!(k1 == k2, k1.to_string() == k2.to_string());
344 }
345 }
346
347 #[test]
348 fn conversation_parser_checks_canonical_escaping() {
349 for value in ["discord:main|-|a\\|b|\\-", "discord:main|\\-|\\\\|-"] {
350 assert_eq!(value.parse::<ConversationKey>().unwrap().to_string(), value);
351 }
352 for value in [
353 "bad",
354 "discord:main|-|-|-",
355 "discord:main|-|x|dangling\\",
356 "discord:main|-|a\\q|-",
357 ] {
358 assert!(value.parse::<ConversationKey>().is_err(), "{value}");
359 }
360 }
361}