Skip to main content

rtcom_core/
command.rs

1//! Runtime commands and the keyboard state machine that produces them.
2//!
3//! Stub: only the public types are defined here. Behaviour is filled in
4//! by the next commit in the TDD cycle.
5
6/// One actionable command produced by [`CommandKeyParser`].
7///
8/// `Copy` because every variant carries only `Copy` data (currently a
9/// single `u32` for `SetBaud`). Passing by value is therefore cheap and
10/// matches how the dispatcher consumes the value via `match`.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum Command {
13    /// Show the help / command-key cheatsheet.
14    Help,
15    /// Quit the session.
16    Quit,
17    /// Print the current [`SerialConfig`](crate::SerialConfig).
18    ShowConfig,
19    /// Toggle the DTR output line.
20    ToggleDtr,
21    /// Toggle the RTS output line.
22    ToggleRts,
23    /// Send a line break (~250 ms by default in Issue #7's handler).
24    SendBreak,
25    /// Apply a new baud rate, parsed from the digits collected after `b`.
26    SetBaud(u32),
27}
28
29/// What [`CommandKeyParser::feed`] produced for a single input byte.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub enum ParseOutput {
32    /// Parser is buffering — nothing to emit for this byte.
33    None,
34    /// Pass this byte through to the device as user data.
35    Data(u8),
36    /// A command was recognised; dispatch it.
37    Command(Command),
38}
39
40/// State machine that splits stdin bytes into "data to send" vs.
41/// "commands to dispatch" using the configurable escape key.
42pub struct CommandKeyParser {
43    escape: u8,
44    state: State,
45}
46
47enum State {
48    Default,
49    AwaitingCommand,
50    AwaitingBaudDigits(String),
51}
52
53impl CommandKeyParser {
54    /// Builds a parser whose command key is `escape` (commonly `^T` =
55    /// `0x14`).
56    #[must_use]
57    pub const fn new(escape: u8) -> Self {
58        Self {
59            escape,
60            state: State::Default,
61        }
62    }
63
64    /// Returns the escape byte this parser was configured with.
65    #[must_use]
66    pub const fn escape_byte(&self) -> u8 {
67        self.escape
68    }
69
70    /// Feed a single input byte; returns whatever the parser decided to
71    /// emit for it.
72    ///
73    /// State transitions (with `^T` as the escape byte for illustration):
74    ///
75    /// | from \ byte         | `^T`              | `Esc` (`0x1b`)   | mapped command  | `b`                         | digit (in baud sub-state) | `\r` / `\n` (in baud sub-state) | other                |
76    /// |---------------------|-------------------|------------------|-----------------|-----------------------------|---------------------------|---------------------------------|----------------------|
77    /// | Default             | → AwaitingCommand | → Data(byte)     | → Data(byte)    | → Data(byte)                | n/a                       | n/a                             | → Data(byte)         |
78    /// | AwaitingCommand     | → Data(`^T`)      | → Default        | → Command(...)  | → AwaitingBaudDigits        | n/a                       | n/a                             | → Default (drop)     |
79    /// | AwaitingBaudDigits  | → Default (drop)  | → Default        | → Default (drop)| → Default (drop)            | append, stay              | → SetBaud / Default             | → Default (drop)     |
80    pub fn feed(&mut self, byte: u8) -> ParseOutput {
81        match std::mem::replace(&mut self.state, State::Default) {
82            State::Default => {
83                if byte == self.escape {
84                    self.state = State::AwaitingCommand;
85                    ParseOutput::None
86                } else {
87                    ParseOutput::Data(byte)
88                }
89            }
90            State::AwaitingCommand => self.handle_command_byte(byte),
91            State::AwaitingBaudDigits(buf) => self.handle_baud_byte(buf, byte),
92        }
93    }
94
95    fn handle_command_byte(&mut self, byte: u8) -> ParseOutput {
96        if byte == self.escape {
97            // Double-escape: pass the escape character through as data.
98            return ParseOutput::Data(self.escape);
99        }
100        match byte {
101            ESC_KEY => ParseOutput::None,
102            b'?' | b'h' => ParseOutput::Command(Command::Help),
103            // Picocom convention: Quit is bound to ^Q (0x11) and ^X
104            // (0x18) — control bytes — not the plain letters. That
105            // frees the letters to be sent to the wire as data without
106            // an extra escape dance.
107            CTRL_Q | CTRL_X => ParseOutput::Command(Command::Quit),
108            b'c' => ParseOutput::Command(Command::ShowConfig),
109            b't' => ParseOutput::Command(Command::ToggleDtr),
110            b'g' => ParseOutput::Command(Command::ToggleRts),
111            b'\\' => ParseOutput::Command(Command::SendBreak),
112            b'b' => {
113                self.state = State::AwaitingBaudDigits(String::new());
114                ParseOutput::None
115            }
116            _ => ParseOutput::None,
117        }
118    }
119
120    fn handle_baud_byte(&mut self, mut buf: String, byte: u8) -> ParseOutput {
121        match byte {
122            b'\r' | b'\n' => match buf.parse::<u32>() {
123                Ok(rate) if rate > 0 => ParseOutput::Command(Command::SetBaud(rate)),
124                _ => ParseOutput::None,
125            },
126            ESC_KEY => ParseOutput::None,
127            d if d.is_ascii_digit() => {
128                buf.push(d as char);
129                self.state = State::AwaitingBaudDigits(buf);
130                ParseOutput::None
131            }
132            _ => ParseOutput::None,
133        }
134    }
135}
136
137const ESC_KEY: u8 = 0x1b;
138/// Ctrl-Q. Picocom's "quit" key.
139const CTRL_Q: u8 = 0x11;
140/// Ctrl-X. Picocom's "terminate" key.
141const CTRL_X: u8 = 0x18;
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    const ESC: u8 = 0x14; // ^T
148
149    const fn parser() -> CommandKeyParser {
150        CommandKeyParser::new(ESC)
151    }
152
153    fn drive(p: &mut CommandKeyParser, bytes: &[u8]) -> Vec<ParseOutput> {
154        bytes.iter().map(|&b| p.feed(b)).collect()
155    }
156
157    #[test]
158    fn default_state_passes_bytes_through() {
159        let mut p = parser();
160        assert_eq!(
161            drive(&mut p, b"abc"),
162            vec![
163                ParseOutput::Data(b'a'),
164                ParseOutput::Data(b'b'),
165                ParseOutput::Data(b'c'),
166            ]
167        );
168    }
169
170    #[test]
171    fn escape_alone_produces_no_output() {
172        let mut p = parser();
173        assert_eq!(p.feed(ESC), ParseOutput::None);
174    }
175
176    /// `^Q` (0x11) and `^X` (0x18) are the picocom-style quit keys.
177    /// Lowercase `q`/`x` plain-letters fall through to "unknown" and
178    /// must NOT quit — that mirrors picocom and frees the letters to
179    /// be sent to the wire as data without an extra escape dance.
180    #[test]
181    fn escape_then_ctrl_q_or_ctrl_x_emits_quit() {
182        for key in [0x11_u8, 0x18_u8] {
183            let mut p = parser();
184            assert_eq!(p.feed(ESC), ParseOutput::None);
185            assert_eq!(p.feed(key), ParseOutput::Command(Command::Quit));
186        }
187    }
188
189    #[test]
190    fn escape_then_lowercase_q_or_x_does_not_quit() {
191        for key in [b'q', b'x'] {
192            let mut p = parser();
193            assert_eq!(p.feed(ESC), ParseOutput::None);
194            // Unmapped after escape -> drop and return to default.
195            assert_eq!(p.feed(key), ParseOutput::None);
196            // Default state: next byte passes through verbatim.
197            assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
198        }
199    }
200
201    #[test]
202    fn escape_then_help_keys_emit_help() {
203        for key in [b'?', b'h'] {
204            let mut p = parser();
205            p.feed(ESC);
206            assert_eq!(p.feed(key), ParseOutput::Command(Command::Help));
207        }
208    }
209
210    #[test]
211    fn escape_then_c_emits_show_config() {
212        let mut p = parser();
213        p.feed(ESC);
214        assert_eq!(p.feed(b'c'), ParseOutput::Command(Command::ShowConfig));
215    }
216
217    #[test]
218    fn escape_then_t_emits_toggle_dtr() {
219        let mut p = parser();
220        p.feed(ESC);
221        assert_eq!(p.feed(b't'), ParseOutput::Command(Command::ToggleDtr));
222    }
223
224    #[test]
225    fn escape_then_g_emits_toggle_rts() {
226        let mut p = parser();
227        p.feed(ESC);
228        assert_eq!(p.feed(b'g'), ParseOutput::Command(Command::ToggleRts));
229    }
230
231    #[test]
232    fn escape_then_backslash_emits_send_break() {
233        let mut p = parser();
234        p.feed(ESC);
235        assert_eq!(p.feed(b'\\'), ParseOutput::Command(Command::SendBreak));
236    }
237
238    #[test]
239    fn baud_change_collects_digits_and_emits_set_baud_on_cr() {
240        let mut p = parser();
241        p.feed(ESC);
242        assert_eq!(p.feed(b'b'), ParseOutput::None);
243        for &d in b"9600" {
244            assert_eq!(p.feed(d), ParseOutput::None);
245        }
246        assert_eq!(p.feed(b'\r'), ParseOutput::Command(Command::SetBaud(9600)));
247    }
248
249    #[test]
250    fn baud_change_lf_terminator_works_too() {
251        let mut p = parser();
252        p.feed(ESC);
253        p.feed(b'b');
254        for &d in b"115200" {
255            p.feed(d);
256        }
257        assert_eq!(
258            p.feed(b'\n'),
259            ParseOutput::Command(Command::SetBaud(115_200))
260        );
261    }
262
263    #[test]
264    fn baud_change_cancelled_by_esc_returns_to_default() {
265        let mut p = parser();
266        p.feed(ESC);
267        p.feed(b'b');
268        p.feed(b'9');
269        assert_eq!(p.feed(0x1b), ParseOutput::None);
270        // Default state again.
271        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
272    }
273
274    #[test]
275    fn baud_change_cancelled_by_non_digit() {
276        let mut p = parser();
277        p.feed(ESC);
278        p.feed(b'b');
279        p.feed(b'9');
280        assert_eq!(p.feed(b'x'), ParseOutput::None);
281        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
282    }
283
284    #[test]
285    fn baud_change_with_empty_digits_is_dropped() {
286        let mut p = parser();
287        p.feed(ESC);
288        p.feed(b'b');
289        // Immediate Enter with no digits — nothing to apply, return to default.
290        assert_eq!(p.feed(b'\r'), ParseOutput::None);
291        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
292    }
293
294    #[test]
295    fn double_escape_passes_escape_byte_through() {
296        let mut p = parser();
297        p.feed(ESC);
298        assert_eq!(p.feed(ESC), ParseOutput::Data(ESC));
299    }
300
301    #[test]
302    fn esc_in_command_state_cancels_quietly() {
303        let mut p = parser();
304        p.feed(ESC);
305        assert_eq!(p.feed(0x1b), ParseOutput::None);
306        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
307    }
308
309    #[test]
310    fn unknown_command_byte_silently_drops_and_resets() {
311        let mut p = parser();
312        p.feed(ESC);
313        assert_eq!(p.feed(b'z'), ParseOutput::None);
314        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
315    }
316
317    #[test]
318    fn pass_through_resumes_after_command() {
319        let mut p = parser();
320        p.feed(ESC);
321        // ^X (0x18) is one of the picocom-style quit keys.
322        assert_eq!(p.feed(0x18), ParseOutput::Command(Command::Quit));
323        assert_eq!(p.feed(b'a'), ParseOutput::Data(b'a'));
324    }
325
326    #[test]
327    fn escape_byte_is_observable() {
328        assert_eq!(parser().escape_byte(), ESC);
329    }
330}