mikrotik_rs/protocol/
word.rs

1use std::{
2    fmt::{self, Display, Formatter},
3    num::ParseIntError,
4    str::Utf8Error,
5};
6
7use super::error::WordType;
8
9/// Represents a word in a Mikrotik [`Sentence`].
10///
11/// Words can be of three types:
12/// - A category word, which represents the type of sentence, such as `!done`, `!re`, `!trap`, or `!fatal`.
13/// - A tag word, which represents a tag value like `.tag=123`.
14/// - An attribute word, which represents a key-value pair like `=name=ether1`.
15///
16/// The word can be converted into one of these types using the [`TryFrom`] trait.
17///
18/// # Examples
19///
20/// ```
21/// use mikrotik::command::reader::Word;
22///
23/// let word = Word::try_from(b"=name=ether1");
24/// assert_eq!(word.unwrap().attribute(), Some(("name", Some("ether1"))));
25/// ```
26#[derive(Debug, PartialEq)]
27pub enum Word<'a> {
28    /// A category word, such as `!done`, `!re`, `!trap`, or `!fatal`.
29    Category(WordCategory),
30    /// A tag word, such as `.tag=123`.
31    Tag(u16),
32    /// An attribute word, such as `=name=ether1`.
33    Attribute(WordAttribute<'a>),
34    /// An unrecognized word. Usually this is a `!fatal` reason message.
35    Message(&'a str),
36}
37
38impl Word<'_> {
39    /// Returns the category of the word, if it is a category word.
40    pub fn category(&self) -> Option<&WordCategory> {
41        match self {
42            Word::Category(category) => Some(category),
43            _ => None,
44        }
45    }
46
47    /// Returns the tag of the word, if it is a tag word.
48    pub fn tag(&self) -> Option<u16> {
49        match self {
50            Word::Tag(tag) => Some(*tag),
51            _ => None,
52        }
53    }
54
55    /// Returns the generic word, if it is a generic word.
56    /// This is usually a `!fatal` reason message.
57    pub fn generic(&self) -> Option<&str> {
58        match self {
59            Word::Message(generic) => Some(generic),
60            _ => None,
61        }
62    }
63
64    /// Returns the type of the Word.
65    pub fn word_type(&self) -> WordType {
66        match self {
67            Word::Category(_) => WordType::Category,
68            Word::Tag(_) => WordType::Tag,
69            Word::Attribute(_) => WordType::Attribute,
70            Word::Message(_) => WordType::Message,
71        }
72    }
73}
74
75impl Display for Word<'_> {
76    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
77        match self {
78            Word::Category(category) => write!(f, "{}", category),
79            Word::Tag(tag) => write!(f, ".tag={}", tag),
80            Word::Attribute(WordAttribute {
81                key,
82                value,
83                value_raw: _,
84            }) => {
85                write!(f, "={}={}", key, value.unwrap_or(""))
86            }
87            Word::Message(generic) => write!(f, "{}", generic),
88        }
89    }
90}
91
92impl<'a> TryFrom<&'a [u8]> for Word<'a> {
93    type Error = WordError;
94
95    fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
96        // First, check if it's a category or tag word by attempting UTF-8 conversion
97        // Categories and tags must be valid UTF-8 as they are fixed API words
98        if let Ok(s) = std::str::from_utf8(value) {
99            // Try to parse as category first
100            if let Ok(category) = WordCategory::try_from(s) {
101                return Ok(Word::Category(category));
102            }
103
104            // Try to parse as tag if it starts with ".tag="
105            if let Some(stripped) = s.strip_prefix(".tag=") {
106                let tag = stripped.parse::<u16>()?;
107                return Ok(Word::Tag(tag));
108            }
109        }
110
111        // Handle attributes - we know they start with = regardless of UTF-8 validity
112        if !value.is_empty() && value[0] == b'=' {
113            // Pass the raw bytes to WordAttribute which now handles UTF-8 validation internally
114            return Ok(Word::Attribute(WordAttribute::try_from(value)?));
115        }
116
117        // If all else fails, return as a message (must be valid UTF-8!)
118        Ok(Word::Message(std::str::from_utf8(value)?))
119    }
120}
121
122/// Represents the type of of a response.
123/// The type is derived from the first [`Word`] in a [`Sentence`].
124/// Valid types are `!done`, `!re`, `!trap`, and `!fatal`.
125#[derive(Debug, Clone, PartialEq)]
126pub enum WordCategory {
127    /// Represents a `!done` response.
128    Done,
129    /// Represents a `!re` response.
130    Reply,
131    /// Represents a `!trap` response.
132    Trap,
133    /// Represents a `!fatal` response.
134    Fatal,
135}
136
137impl TryFrom<&str> for WordCategory {
138    type Error = ();
139
140    fn try_from(value: &str) -> Result<Self, Self::Error> {
141        match value {
142            "!done" => Ok(Self::Done),
143            "!re" => Ok(Self::Reply),
144            "!trap" => Ok(Self::Trap),
145            "!fatal" => Ok(Self::Fatal),
146            _ => Err(()),
147        }
148    }
149}
150
151impl Display for WordCategory {
152    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
153        match self {
154            WordCategory::Done => write!(f, "!done"),
155            WordCategory::Reply => write!(f, "!re"),
156            WordCategory::Trap => write!(f, "!trap"),
157            WordCategory::Fatal => write!(f, "!fatal"),
158        }
159    }
160}
161
162/// Represents a key-value pair in a Mikrotik [`Sentence`].
163#[derive(Debug, PartialEq)]
164pub struct WordAttribute<'a> {
165    /// The key of the attribute.
166    pub key: &'a str,
167    /// The value of the attribute, if present and in valid UTF-8.
168    pub value: Option<&'a str>,
169    /// The value of the attribute, if present, in bytes.
170    pub value_raw: Option<&'a [u8]>,
171}
172
173impl<'a> TryFrom<&'a [u8]> for WordAttribute<'a> {
174    type Error = WordError;
175
176    fn try_from(word: &'a [u8]) -> Result<Self, Self::Error> {
177        // First byte must be '=' for attributes
178        if word.is_empty() || word[0] != b'=' {
179            return Err(WordError::Attribute);
180        }
181
182        // Find the second '=' that separates key from value
183        let mut parts = word[1..].splitn(2, |&b| b == b'=');
184
185        // Key part must exist and be valid UTF-8
186        let key_bytes = parts.next().ok_or(WordError::Attribute)?;
187        let key = std::str::from_utf8(key_bytes).map_err(|_| WordError::AttributeKeyNotUtf8)?;
188
189        // Value part is optional
190        let value_raw = parts.next();
191
192        // If we have a value, try to decode as UTF-8 but keep raw bytes regardless
193        let value = value_raw.and_then(|v| std::str::from_utf8(v).ok());
194
195        Ok(Self {
196            key,
197            value_raw,
198            value,
199        })
200    }
201}
202
203/// Represents an error that occurred while parsing a [`Word`].
204#[derive(Debug, PartialEq)]
205pub enum WordError {
206    /// The word is not a valid UTF-8 string.
207    Utf8(Utf8Error),
208    /// The word is a tag, but the tag value is invalid.
209    Tag(ParseIntError),
210    /// The word is an attribute pair, but the format is invalid.
211    Attribute,
212    /// The key part of the attribute pair is not valid UTF-8.
213    AttributeKeyNotUtf8,
214}
215
216impl From<Utf8Error> for WordError {
217    fn from(e: Utf8Error) -> Self {
218        Self::Utf8(e)
219    }
220}
221
222impl From<ParseIntError> for WordError {
223    fn from(e: ParseIntError) -> Self {
224        Self::Tag(e)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    impl<'a> From<(&'a str, Option<&'a str>)> for WordAttribute<'a> {
233        fn from(value: (&'a str, Option<&'a str>)) -> Self {
234            Self {
235                key: value.0,
236                value: value.1,
237                value_raw: value.1.map(|v| v.as_bytes()),
238            }
239        }
240    }
241
242    #[test]
243    fn test_word_parsing() {
244        // Test cases for `Word::try_from` function
245        assert_eq!(
246            Word::try_from(b"!done".as_ref()).unwrap(),
247            Word::Category(WordCategory::Done)
248        );
249
250        assert_eq!(
251            Word::try_from(b".tag=123".as_ref()).unwrap(),
252            Word::Tag(123)
253        );
254
255        assert_eq!(
256            Word::try_from(b"=name=ether1".as_ref()).unwrap(),
257            Word::Attribute(("name", Some("ether1")).into())
258        );
259
260        assert_eq!(
261            Word::try_from(b"!fatal".as_ref()).unwrap(),
262            Word::Category(WordCategory::Fatal)
263        );
264
265        assert_eq!(
266            Word::try_from(b"unknownword".as_ref()).unwrap(),
267            Word::Message("unknownword")
268        );
269
270        // Invalid tag value
271        assert!(Word::try_from(b".tag=notanumber".as_ref()).is_err());
272
273        // Invalid UTF-8 sequence
274        assert!(Word::try_from(b"\xFF\xFF".as_ref()).is_err());
275    }
276
277    #[test]
278    fn test_display_for_word() {
279        // Test cases for `Display` implementation for `Word`
280        let word = Word::Category(WordCategory::Done);
281        assert_eq!(format!("{}", word), "!done");
282
283        let word = Word::Tag(123);
284        assert_eq!(format!("{}", word), ".tag=123");
285
286        let word = Word::Attribute(("name", Some("ether1")).into());
287        assert_eq!(format!("{}", word), "=name=ether1");
288
289        let word = Word::Message("unknownword");
290        assert_eq!(format!("{}", word), "unknownword");
291    }
292}