mikrotik_rs/
macros.rs

1/// A minimal const validator that enforces some basic MikroTik command rules:
2/// 1. Must start with `/`.
3/// 2. No empty segments (no `//`).
4/// 3. Only allows [a-zA-Z0-9_-] plus space or slash as separators.
5/// 4. No consecutive spaces or slashes.
6///
7/// Panics **at compile time** if invalid.
8pub const fn check_mikrotik_command(cmd: &str) -> &str {
9    let bytes = cmd.as_bytes();
10    let len = bytes.len();
11
12    // Reject empty string
13    if len == 0 {
14        panic!("MikroTik command cannot be empty.");
15    }
16
17    // Must start with slash
18    if bytes[0] != b'/' {
19        panic!("MikroTik command must start with '/'.");
20    }
21
22    // Track if the previous character was a space or slash to detect duplicates
23    let mut prev_was_delimiter = true; // start true because first char is '/'
24
25    // Validate each character
26    let mut i = 1;
27    while i < len {
28        let c = bytes[i] as char;
29
30        // Check allowed delimiters vs allowed segment chars
31        if c == '/' || c == ' ' {
32            if prev_was_delimiter {
33                // Found "//" or double-space
34                panic!("No empty segments or consecutive delimiters allowed.");
35            }
36            prev_was_delimiter = true;
37        } else {
38            // Must be [a-zA-Z0-9_-]
39            let is_valid_char = c.is_ascii_alphanumeric() || c == '-' || c == '_';
40            if !is_valid_char {
41                panic!("Invalid character in MikroTik command. Must be [a-zA-Z0-9_-]");
42            }
43            prev_was_delimiter = false;
44        }
45
46        i += 1;
47    }
48
49    // If the command ends on a delimiter, we have a trailing slash or space
50    if prev_was_delimiter {
51        panic!("Command cannot end with a delimiter.");
52    }
53
54    // If we got here, it's valid
55    cmd
56}
57
58/// Macro that enforces Mikrotik command syntax **at compile time**.
59///
60/// Usage Examples:
61/// ```rust
62///fn main() {
63///    // OK
64///    let _ok = command!("/random command print");
65///
66///    let _with_attrs = command!("/random command", attr1="value1", attr2);
67///}
68/// ```
69#[macro_export]
70macro_rules! command {
71    // Case: command literal plus one or more attributes (with or without `= value`)
72    ($cmd:literal $(, $key:ident $(= $value:expr)? )* $(,)?) => {{
73        const VALIDATED: &str = $crate::macros::check_mikrotik_command($cmd);
74
75        #[allow(unused_mut)]
76        let mut builder = $crate::protocol::command::CommandBuilder::new()
77            .command(VALIDATED);
78
79        $(
80            builder = builder.attribute(
81                stringify!($key),
82                command!(@opt $($value)?)
83            );
84        )*
85
86        builder.build()
87    }};
88
89    // Internal rule that expands to `Some($value)` if given, otherwise `None`
90    (@opt $value:expr) => { Some($value) };
91    (@opt) => { None };
92}
93
94#[cfg(test)]
95mod test {
96    /// Helper to parse the RouterOS length-prefixed “words” out of the command data.
97    ///
98    /// The builder writes each word as:
99    ///   [1-byte length][word bytes] ...
100    /// with a final 0-length to signal the end.
101    fn parse_words(data: &[u8]) -> Vec<String> {
102        let mut words = Vec::new();
103        let mut i = 0;
104        while i < data.len() {
105            // read a single-byte length
106            if i >= data.len() {
107                break;
108            }
109            let len = data[i] as usize;
110            i += 1;
111            if len == 0 {
112                // length==0 signals end
113                break;
114            }
115            if i + len > data.len() {
116                panic!("Malformed command data: length prefix exceeds available data.");
117            }
118            let word = &data[i..i + len];
119            i += len;
120            // Convert to String for easier assertions
121            words.push(String::from_utf8_lossy(word).to_string());
122        }
123        words
124    }
125
126    #[test]
127    fn test_command_no_attributes() {
128        let cmd = command!("/system/resource/print");
129        let words = parse_words(&cmd.data);
130
131        // Word[0] => actual command
132        assert_eq!(words[0], "/system/resource/print");
133
134        // Word[1] => .tag=xxxx
135        // We can’t check the exact tag value because it's random, but we can ensure it starts with ".tag="
136        assert!(
137            words[1].starts_with(".tag="),
138            "Tag word should start with .tag="
139        );
140
141        // Should only have these two words (plus the 0-length terminator, which we skip).
142        assert_eq!(words.len(), 2, "Expected two words (command + .tag=).");
143    }
144
145    #[test]
146    fn test_command_with_one_attribute() {
147        let cmd = command!("/interface/ethernet/print", user = "admin");
148        let words = parse_words(&cmd.data);
149
150        assert_eq!(words[0], "/interface/ethernet/print");
151        assert!(
152            words[1].starts_with(".tag="),
153            "Expected .tag= as second word"
154        );
155        // Word[2] => "=user=admin"
156        assert_eq!(words[2], "=user=admin");
157        // So total 3 words plus 0-terminator
158        assert_eq!(words.len(), 3);
159    }
160
161    #[test]
162    fn test_command_with_multiple_attributes() {
163        let cmd = command!("/some/random", attribute_no_value, another = "value");
164        let words = parse_words(&cmd.data);
165
166        // Word[0] => "/some/random"
167        assert_eq!(words[0], "/some/random");
168        // Word[1] => ".tag=xxxx"
169        assert!(words[1].starts_with(".tag="));
170        // Word[2] => "=attribute_no_value="
171        assert_eq!(words[2], "=attribute_no_value=");
172        // Word[3] => "=another=value"
173        assert_eq!(words[3], "=another=value");
174        // Total 4 words plus terminator
175        assert_eq!(words.len(), 4);
176    }
177}