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