Skip to main content

mikrotik_proto/
command.rs

1//! Command builder with typestate pattern and compile-time validation.
2//!
3//! Commands are built using the [`CommandBuilder`] which uses the typestate
4//! pattern to enforce at compile time that a command word is set before
5//! attributes can be added.
6//!
7//! # Examples
8//!
9//! ```rust
10//! use mikrotik_proto::command::CommandBuilder;
11//!
12//! let cmd = CommandBuilder::new()
13//!     .command("/system/resource/print")
14//!     .attribute("detail", None)
15//!     .build();
16//!
17//! // The command has wire-format data ready to send
18//! assert!(!cmd.data().is_empty());
19//! ```
20
21use alloc::vec::Vec;
22use core::marker::PhantomData;
23
24use crate::codec;
25use crate::tag::Tag;
26
27/// Marker type: no command word has been set yet.
28pub struct NoCmd;
29
30/// Marker type: a command word has been set, attributes can be added.
31#[derive(Clone)]
32pub struct Cmd;
33
34/// Builds `MikroTik` router commands using a fluent typestate API.
35///
36/// The type parameter ensures that only commands with a command word set
37/// can have attributes added or be built.
38///
39/// # Type states
40///
41/// - `CommandBuilder<NoCmd>` — initial state, call `.command()` to transition
42/// - `CommandBuilder<Cmd>` — command word set, can add attributes and `.build()`
43#[derive(Clone)]
44#[must_use]
45pub struct CommandBuilder<State> {
46    tag: Tag,
47    buf: Vec<u8>,
48    state: PhantomData<State>,
49}
50
51impl Default for CommandBuilder<NoCmd> {
52    fn default() -> Self {
53        Self::new()
54    }
55}
56
57impl CommandBuilder<NoCmd> {
58    /// Begin building a new [`Command`] with a randomly generated tag.
59    pub fn new() -> Self {
60        Self {
61            tag: Tag::new(),
62            buf: Vec::new(),
63            state: PhantomData,
64        }
65    }
66
67    /// Begin building a new [`Command`] with a specified tag.
68    ///
69    /// # Arguments
70    ///
71    /// * `tag` — A [`Tag`] that identifies the command for `RouterOS` correlation.
72    ///   **Must be unique** within a connection.
73    pub fn with_tag(tag: Tag) -> Self {
74        Self {
75            tag,
76            buf: Vec::new(),
77            state: PhantomData,
78        }
79    }
80
81    /// Builds a login command with the provided username and optional password.
82    pub fn login(username: &str, password: Option<&str>) -> Command {
83        Self::new()
84            .command("/login")
85            .attribute("name", Some(username))
86            .attribute("password", password)
87            .build()
88    }
89
90    /// Builds a command to cancel a specific running command identified by `tag`.
91    pub fn cancel(tag: Tag) -> Command {
92        // Use the same tag so the cancel is correlated
93        Self::with_tag(tag)
94            .command("/cancel")
95            .attribute_tag("tag", tag)
96            .build()
97    }
98
99    /// Specify the command path to be executed, transitioning to the `Cmd` state.
100    ///
101    /// # Arguments
102    ///
103    /// * `command` — The `MikroTik` command path (e.g., `/system/resource/print`).
104    pub fn command(self, command: &str) -> CommandBuilder<Cmd> {
105        let Self { tag, mut buf, .. } = self;
106
107        // Write the command word
108        codec::encode_word(command.as_bytes(), &mut buf);
109
110        // Write the tag word — avoid format!() allocation by building directly
111        // ".tag=" (5 bytes) + UUID hyphenated (36 bytes) = 41 bytes
112        let mut tag_buf = [0u8; 41];
113        tag_buf[..5].copy_from_slice(b".tag=");
114        tag.encode_lower(&mut tag_buf[5..]);
115        codec::encode_word(&tag_buf, &mut buf);
116
117        CommandBuilder {
118            tag,
119            buf,
120            state: PhantomData,
121        }
122    }
123}
124
125impl CommandBuilder<Cmd> {
126    /// Adds an attribute to the command being built.
127    ///
128    /// # Arguments
129    ///
130    /// * `key` — The attribute's key.
131    /// * `value` — The attribute's value. If `None`, the attribute is treated
132    ///   as a flag (e.g., `=key=`).
133    pub fn attribute(self, key: &str, value: Option<&str>) -> Self {
134        let Self { tag, mut buf, .. } = self;
135
136        // Build the attribute word directly: "=" + key + "=" + value
137        // Avoid format!() allocation
138        let value_bytes = value.unwrap_or("");
139        let word_len = 1 + key.len() + 1 + value_bytes.len();
140        codec::encode_length(word_len as u32, &mut buf);
141        buf.push(b'=');
142        buf.extend_from_slice(key.as_bytes());
143        buf.push(b'=');
144        buf.extend_from_slice(value_bytes.as_bytes());
145
146        CommandBuilder {
147            tag,
148            buf,
149            state: PhantomData,
150        }
151    }
152
153    /// Adds an attribute with a raw byte value to the command being built.
154    ///
155    /// Use this method when your attribute values might contain non-UTF-8 or binary data.
156    pub fn attribute_raw(self, key: &str, value: Option<&[u8]>) -> Self {
157        let Self { tag, mut buf, .. } = self;
158
159        let value_bytes = value.unwrap_or(&[]);
160        let word_len = 1 + key.len() + 1 + value_bytes.len();
161        codec::encode_length(word_len as u32, &mut buf);
162        buf.push(b'=');
163        buf.extend_from_slice(key.as_bytes());
164        buf.push(b'=');
165        buf.extend_from_slice(value_bytes);
166
167        CommandBuilder {
168            tag,
169            buf,
170            state: PhantomData,
171        }
172    }
173
174    /// Adds an attribute with a Tag value (used internally for cancel tags).
175    fn attribute_tag(self, key: &str, value: Tag) -> Self {
176        let mut tag_str = [0u8; 36];
177        let s = value.encode_lower(&mut tag_str);
178        self.attribute(key, Some(s))
179    }
180
181    /// Adds a query to check if a property is present.
182    ///
183    /// Pushes `true` if an item has a value for the property, `false` if it does not.
184    pub fn query_is_present(self, name: &str) -> Self {
185        let Self { tag, mut buf, .. } = self;
186        let word_len = 1 + name.len(); // "?" + name
187        codec::encode_length(word_len as u32, &mut buf);
188        buf.push(b'?');
189        buf.extend_from_slice(name.as_bytes());
190        CommandBuilder {
191            tag,
192            buf,
193            state: PhantomData,
194        }
195    }
196
197    /// Adds a query to check if a property is absent.
198    pub fn query_not_present(self, name: &str) -> Self {
199        let Self { tag, mut buf, .. } = self;
200        let word_len = 2 + name.len(); // "?-" + name
201        codec::encode_length(word_len as u32, &mut buf);
202        buf.extend_from_slice(b"?-");
203        buf.extend_from_slice(name.as_bytes());
204        CommandBuilder {
205            tag,
206            buf,
207            state: PhantomData,
208        }
209    }
210
211    /// Adds a query to check if a property equals a value.
212    pub fn query_equal(self, name: &str, value: &str) -> Self {
213        let Self { tag, mut buf, .. } = self;
214        let word_len = 1 + name.len() + 1 + value.len(); // "?" + name + "=" + value
215        codec::encode_length(word_len as u32, &mut buf);
216        buf.push(b'?');
217        buf.extend_from_slice(name.as_bytes());
218        buf.push(b'=');
219        buf.extend_from_slice(value.as_bytes());
220        CommandBuilder {
221            tag,
222            buf,
223            state: PhantomData,
224        }
225    }
226
227    /// Adds a query to check if a property is greater than a value.
228    pub fn query_gt(self, key: &str, value: &str) -> Self {
229        let Self { tag, mut buf, .. } = self;
230        let word_len = 2 + key.len() + 1 + value.len(); // "?>" + key + "=" + value
231        codec::encode_length(word_len as u32, &mut buf);
232        buf.extend_from_slice(b"?>");
233        buf.extend_from_slice(key.as_bytes());
234        buf.push(b'=');
235        buf.extend_from_slice(value.as_bytes());
236        CommandBuilder {
237            tag,
238            buf,
239            state: PhantomData,
240        }
241    }
242
243    /// Adds a query to check if a property is less than a value.
244    pub fn query_lt(self, key: &str, value: &str) -> Self {
245        let Self { tag, mut buf, .. } = self;
246        let word_len = 2 + key.len() + 1 + value.len(); // "?<" + key + "=" + value
247        codec::encode_length(word_len as u32, &mut buf);
248        buf.extend_from_slice(b"?<");
249        buf.extend_from_slice(key.as_bytes());
250        buf.push(b'=');
251        buf.extend_from_slice(value.as_bytes());
252        CommandBuilder {
253            tag,
254            buf,
255            state: PhantomData,
256        }
257    }
258
259    /// Adds query operations (combination operators for the query stack).
260    ///
261    /// See [MikroTik API Queries](https://help.mikrotik.com/docs/spaces/ROS/pages/47579160/API#API-Queries).
262    pub fn query_operations(self, operations: impl Iterator<Item = QueryOperator>) -> Self {
263        let Self { tag, mut buf, .. } = self;
264
265        // Collect operation chars: "?#" + operator chars
266        let ops: Vec<u8> = operations.map(QueryOperator::code).collect();
267        let word_len = 2 + ops.len(); // "?#" + ops
268        codec::encode_length(word_len as u32, &mut buf);
269        buf.extend_from_slice(b"?#");
270        buf.extend_from_slice(&ops);
271
272        CommandBuilder {
273            tag,
274            buf,
275            state: PhantomData,
276        }
277    }
278
279    /// Finalizes the command construction, producing a [`Command`].
280    pub fn build(self) -> Command {
281        let Self { tag, mut buf, .. } = self;
282        // Terminate the sentence
283        codec::encode_terminator(&mut buf);
284        Command { tag, data: buf }
285    }
286}
287
288/// A finalized command ready to be sent to the router.
289///
290/// Created via [`CommandBuilder`]. Contains the complete wire-format bytes
291/// (length-prefixed words + null terminator).
292///
293/// The wire data is immutable after construction to preserve builder invariants.
294#[derive(Debug, Clone)]
295pub struct Command {
296    /// The tag identifying this command for response correlation.
297    pub tag: Tag,
298    /// The wire-format encoded command data.
299    pub(crate) data: Vec<u8>,
300}
301
302impl Command {
303    /// Returns the wire-format encoded command data.
304    pub fn data(&self) -> &[u8] {
305        &self.data
306    }
307
308    /// Consumes the command and returns the wire-format data.
309    pub fn into_data(self) -> Vec<u8> {
310        self.data
311    }
312}
313
314/// Represents a query operator for `MikroTik` API query expressions.
315#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
316pub enum QueryOperator {
317    /// Represents the `!` (NOT) operator.
318    Not,
319    /// Represents the `&` (AND) operator.
320    And,
321    /// Represents the `|` (OR) operator.
322    Or,
323    /// Represents the `.` (END) operator.
324    Dot,
325}
326
327impl QueryOperator {
328    #[inline]
329    fn code(self) -> u8 {
330        match self {
331            QueryOperator::Not => b'!',
332            QueryOperator::And => b'&',
333            QueryOperator::Or => b'|',
334            QueryOperator::Dot => b'.',
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use uuid::Uuid;
342
343    use super::*;
344    use alloc::string::String;
345
346    const TEST_TAG: Tag = Tag::from_uuid(Uuid::from_bytes([
347        0xa1, 0xa2, 0xa3, 0xa4, 0xb1, 0xb2, 0xc1, 0xc2, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7,
348        0xd8,
349    ]));
350    const TEST_TAG_WORD: &str = ".tag=a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8";
351
352    /// Helper to parse the RouterOS length-prefixed "words" out of the command data.
353    fn parse_words(data: &[u8]) -> Vec<String> {
354        let mut words = Vec::new();
355        let mut i = 0;
356        while i < data.len() {
357            let len = data[i] as usize;
358            i += 1;
359            if len == 0 {
360                break;
361            }
362            if i + len > data.len() {
363                panic!("Malformed command data: length prefix exceeds available data.");
364            }
365            let word = &data[i..i + len];
366            i += len;
367            words.push(String::from_utf8_lossy(word).into_owned());
368        }
369        words
370    }
371
372    #[test]
373    fn test_command_builder_new() {
374        let builder = CommandBuilder::<NoCmd>::new();
375        assert_eq!(builder.buf.len(), 0);
376        // Tag should be a random non-zero UUID
377        let a = builder.tag;
378        let b = CommandBuilder::<NoCmd>::new().tag;
379        assert_ne!(a, b);
380    }
381
382    #[test]
383    fn test_command_builder_with_tag() {
384        let builder = CommandBuilder::<NoCmd>::with_tag(TEST_TAG);
385        assert_eq!(builder.tag, TEST_TAG);
386    }
387
388    #[test]
389    fn test_command_builder_command() {
390        let builder = CommandBuilder::<NoCmd>::with_tag(TEST_TAG).command("/interface/print");
391        // Word 1: 1-byte len (0x10) + 16 bytes "/interface/print" = 17 bytes
392        // Word 2: 1-byte len (0x29) + 41 bytes ".tag=a1a2a3a4-..." = 42 bytes
393        // Total: 59 bytes
394        assert_eq!(builder.buf.len(), 59);
395        assert_eq!(&builder.buf[1..17], b"/interface/print");
396        assert_eq!(&builder.buf[18..59], TEST_TAG_WORD.as_bytes());
397    }
398
399    #[test]
400    fn test_command_builder_attribute() {
401        let builder = CommandBuilder::<NoCmd>::with_tag(TEST_TAG)
402            .command("/interface/print")
403            .attribute("name", Some("ether1"));
404
405        // Attribute starts at offset 59 (after command + tag words) + 1 byte len prefix = 60
406        assert_eq!(&builder.buf[60..72], b"=name=ether1");
407    }
408
409    #[test]
410    fn test_command_builder_login() {
411        let command = CommandBuilder::<NoCmd>::login("admin", Some("password"));
412        let s = core::str::from_utf8(&command.data).unwrap();
413        assert!(s.contains("/login"));
414        assert!(s.contains("name=admin"));
415        assert!(s.contains("password=password"));
416    }
417
418    #[test]
419    fn test_command_builder_cancel() {
420        let command = CommandBuilder::<NoCmd>::cancel(TEST_TAG);
421        let s = core::str::from_utf8(&command.data).unwrap();
422        assert!(s.contains("/cancel"));
423        assert!(s.contains("tag=a1a2a3a4-b1b2-c1c2-d1d2-d3d4d5d6d7d8"));
424    }
425
426    #[test]
427    fn test_command_no_attributes() {
428        let cmd = CommandBuilder::new()
429            .command("/system/resource/print")
430            .build();
431        let words = parse_words(&cmd.data);
432
433        assert_eq!(words[0], "/system/resource/print");
434        assert!(words[1].starts_with(".tag="));
435        assert_eq!(words.len(), 2);
436    }
437
438    #[test]
439    fn test_command_with_one_attribute() {
440        let cmd = CommandBuilder::new()
441            .command("/interface/ethernet/print")
442            .attribute("user", Some("admin"))
443            .build();
444        let words = parse_words(&cmd.data);
445
446        assert_eq!(words[0], "/interface/ethernet/print");
447        assert!(words[1].starts_with(".tag="));
448        assert_eq!(words[2], "=user=admin");
449        assert_eq!(words.len(), 3);
450    }
451
452    #[test]
453    fn test_command_with_multiple_attributes() {
454        let cmd = CommandBuilder::new()
455            .command("/some/random")
456            .attribute("attribute_no_value", None)
457            .attribute("another", Some("value"))
458            .build();
459        let words = parse_words(&cmd.data);
460
461        assert_eq!(words[0], "/some/random");
462        assert!(words[1].starts_with(".tag="));
463        assert_eq!(words[2], "=attribute_no_value=");
464        assert_eq!(words[3], "=another=value");
465        assert_eq!(words.len(), 4);
466    }
467
468    #[test]
469    fn test_encode_decode_roundtrip() {
470        // Build a command, then decode its wire format and verify the words
471        let cmd = CommandBuilder::with_tag(TEST_TAG)
472            .command("/interface/print")
473            .attribute("name", Some("ether1"))
474            .attribute("disabled", None)
475            .build();
476
477        // Decode the sentence
478        match codec::decode_sentence(&cmd.data).unwrap() {
479            codec::Decode::Complete { value: raw, .. } => {
480                let words: Vec<_> = raw.words().collect();
481                assert_eq!(words[0], b"/interface/print");
482                assert_eq!(words[1], TEST_TAG_WORD.as_bytes());
483                assert_eq!(words[2], b"=name=ether1");
484                assert_eq!(words[3], b"=disabled=");
485                assert_eq!(words.len(), 4);
486            }
487            _ => panic!("expected Complete"),
488        }
489    }
490}