1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Error, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(try_from = "String", into = "String")]
9pub struct Topic(String);
10
11impl Topic {
12 pub fn new(s: impl Into<String>) -> Result<Self> {
13 let s = s.into();
14 if s.is_empty() {
15 return Err(Error::InvalidTopic("must not be empty".into()));
16 }
17 for part in s.split('.') {
18 if part.is_empty() {
19 return Err(Error::InvalidTopic("empty segment".into()));
20 }
21 if !part
22 .chars()
23 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
24 {
25 return Err(Error::InvalidTopic(format!("invalid segment: {part}")));
26 }
27 }
28 Ok(Self(s))
29 }
30
31 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34}
35
36impl fmt::Display for Topic {
37 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 write!(f, "{}", self.0)
39 }
40}
41
42impl TryFrom<String> for Topic {
43 type Error = Error;
44 fn try_from(s: String) -> Result<Self> {
45 Self::new(s)
46 }
47}
48
49impl From<Topic> for String {
50 fn from(t: Topic) -> Self {
51 t.0
52 }
53}
54
55#[cfg(test)]
56mod tests {
57 use super::*;
58
59 #[test]
60 fn valid_topics() {
61 assert!(Topic::new("task.created").is_ok());
62 assert!(Topic::new("agent.roles_changed").is_ok());
63 assert!(Topic::new("memory.updated").is_ok());
64 }
65
66 #[test]
67 fn invalid_topics() {
68 assert!(Topic::new("").is_err());
69 assert!(Topic::new("task..created").is_err());
70 assert!(Topic::new("Task.Created").is_err());
71 }
72}