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}
30
31fn check_slug(kind: &'static str, value: &str) -> Result<(), IdError> {
32 if value.is_empty() {
33 return Err(IdError::Empty { kind });
34 }
35 if !value
36 .bytes()
37 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
38 {
39 return Err(IdError::Charset {
40 kind,
41 value: value.to_string(),
42 });
43 }
44 Ok(())
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(try_from = "String", into = "String")]
52pub struct ChannelId {
53 platform: String,
54 instance: String,
55}
56
57impl ChannelId {
58 pub fn new(platform: &str, instance: &str) -> Result<Self, IdError> {
60 check_slug("platform", platform)?;
61 check_slug("instance", instance)?;
62 Ok(Self {
63 platform: platform.to_string(),
64 instance: instance.to_string(),
65 })
66 }
67
68 pub fn platform(&self) -> &str {
70 &self.platform
71 }
72
73 pub fn instance(&self) -> &str {
75 &self.instance
76 }
77
78 pub fn slug(&self) -> String {
80 format!("{}-{}", self.platform, self.instance)
81 }
82}
83
84impl fmt::Display for ChannelId {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 write!(f, "{}:{}", self.platform, self.instance)
87 }
88}
89
90impl FromStr for ChannelId {
91 type Err = IdError;
92
93 fn from_str(s: &str) -> Result<Self, Self::Err> {
94 let (platform, instance) = s
95 .split_once(':')
96 .ok_or_else(|| IdError::ChannelFormat(s.to_string()))?;
97 Self::new(platform, instance)
98 }
99}
100
101impl TryFrom<String> for ChannelId {
102 type Error = IdError;
103
104 fn try_from(value: String) -> Result<Self, Self::Error> {
105 value.parse()
106 }
107}
108
109impl From<ChannelId> for String {
110 fn from(value: ChannelId) -> Self {
111 value.to_string()
112 }
113}
114
115macro_rules! opaque_id {
116 ($(#[$doc:meta])* $name:ident) => {
117 $(#[$doc])*
118 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
119 #[serde(transparent)]
120 pub struct $name(String);
121
122 impl $name {
123 pub fn new(value: impl Into<String>) -> Self {
125 Self(value.into())
126 }
127
128 pub fn as_str(&self) -> &str {
130 &self.0
131 }
132 }
133
134 impl fmt::Display for $name {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 f.write_str(&self.0)
137 }
138 }
139
140 impl From<&str> for $name {
141 fn from(value: &str) -> Self {
142 Self(value.to_string())
143 }
144 }
145
146 impl From<String> for $name {
147 fn from(value: String) -> Self {
148 Self(value)
149 }
150 }
151 };
152}
153
154opaque_id!(
155 UserId
157);
158opaque_id!(
159 MessageId
161);
162opaque_id!(
163 RunId
165);
166
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct UserRef {
170 pub id: UserId,
172 pub display_name: String,
174 pub is_bot: bool,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
180pub struct ConversationKey {
181 pub channel: ChannelId,
183 pub space: Option<String>,
185 pub room: String,
187 pub thread: Option<String>,
189}
190
191impl ConversationKey {
192 pub fn room(channel: ChannelId, room: impl Into<String>) -> Self {
194 Self {
195 channel,
196 space: None,
197 room: room.into(),
198 thread: None,
199 }
200 }
201}
202
203impl fmt::Display for ConversationKey {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 fn part(value: Option<&str>) -> String {
208 match value {
209 None => "-".to_string(),
210 Some(v) => {
211 let escaped = v.replace('\\', "\\\\").replace('|', "\\|");
212 if escaped == "-" {
213 "\\-".to_string()
214 } else {
215 escaped
216 }
217 }
218 }
219 }
220 write!(
221 f,
222 "{}|{}|{}|{}",
223 self.channel,
224 part(self.space.as_deref()),
225 part(Some(&self.room)),
226 part(self.thread.as_deref())
227 )
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use proptest::prelude::*;
235
236 #[test]
237 fn channel_id_round_trip() {
238 let id: ChannelId = "zalo:shop".parse().unwrap();
239 assert_eq!(id.platform(), "zalo");
240 assert_eq!(id.instance(), "shop");
241 assert_eq!(id.to_string(), "zalo:shop");
242 assert_eq!(id.slug(), "zalo-shop");
243 let json = serde_json::to_string(&id).unwrap();
244 assert_eq!(json, "\"zalo:shop\"");
245 assert_eq!(serde_json::from_str::<ChannelId>(&json).unwrap(), id);
246 }
247
248 #[test]
249 fn channel_id_rejects_bad_values() {
250 assert!("zalo".parse::<ChannelId>().is_err());
251 assert!("Zalo:shop".parse::<ChannelId>().is_err());
252 assert!("zalo:".parse::<ChannelId>().is_err());
253 assert!("zalo:a/b".parse::<ChannelId>().is_err());
254 }
255
256 #[test]
257 fn conversation_key_text_is_stable() {
258 let channel: ChannelId = "gchat:corp".parse().unwrap();
259 let key = ConversationKey {
260 channel,
261 space: Some("spaces/A".into()),
262 room: "r|1".into(),
263 thread: None,
264 };
265 assert_eq!(key.to_string(), r"gchat:corp|spaces/A|r\|1|-");
266 }
267
268 proptest! {
269 #[test]
270 fn conversation_key_text_is_injective(
271 a in proptest::option::of(".{0,6}"), b in ".{0,6}", c in proptest::option::of(".{0,6}"),
272 d in proptest::option::of(".{0,6}"), e in ".{0,6}", g in proptest::option::of(".{0,6}"),
273 ) {
274 let channel: ChannelId = "x:y".parse().unwrap();
275 let k1 = ConversationKey { channel: channel.clone(), space: a, room: b, thread: c };
276 let k2 = ConversationKey { channel, space: d, room: e, thread: g };
277 prop_assert_eq!(k1 == k2, k1.to_string() == k2.to_string());
278 }
279 }
280}