Skip to main content

orcs_runtime/io/
parser.rs

1//! Stateless input parser.
2//!
3//! Pure function for parsing user input into [`InputCommand`].
4//!
5//! This is an internal module. Use [`super::super::components::IOBridge`]
6//! which integrates the parser.
7
8use super::InputCommand;
9
10/// Maximum number of parts when splitting input line.
11/// Format: command [id] [reason/message]
12const MAX_INPUT_PARTS: usize = 3;
13
14/// Stateless input parser.
15///
16/// Converts raw text input into [`InputCommand`].
17/// Stateless - can be injected into IOBridge for testing.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct InputParser;
20
21impl InputParser {
22    /// Creates a new input parser.
23    #[must_use]
24    pub fn new() -> Self {
25        Self
26    }
27
28    /// Parses a line of input into a command.
29    ///
30    /// This is a pure function - the same input always produces the same output.
31    ///
32    /// # Arguments
33    ///
34    /// * `line` - The input line (will be trimmed)
35    ///
36    /// # Returns
37    ///
38    /// The parsed [`InputCommand`].
39    ///
40    /// # Input Format
41    ///
42    /// | Input | Command | Description |
43    /// |-------|---------|-------------|
44    /// | `y` or `yes` | Approve | Approve pending request |
45    /// | `n` or `no` | Reject | Reject pending request |
46    /// | `y <id>` | Approve ID | Approve specific request |
47    /// | `n <id> [reason]` | Reject ID | Reject with optional reason |
48    /// | `p` or `pause` | Pause | Pause current channel |
49    /// | `r` or `resume` | Resume | Resume paused channel |
50    /// | `s <msg>` or `steer <msg>` | Steer | Steer with message |
51    /// | `q` or `quit` | Quit | Graceful shutdown |
52    /// | `veto` | Veto | Emergency stop |
53    #[must_use]
54    pub fn parse(&self, line: &str) -> InputCommand {
55        let line = line.trim();
56
57        if line.is_empty() {
58            return InputCommand::Empty;
59        }
60
61        // Check for @component message (targeted routing)
62        if let Some(rest) = line.strip_prefix('@') {
63            let mut parts = rest.splitn(2, ' ');
64            let target = parts.next().unwrap_or("").to_lowercase();
65            if target.is_empty() {
66                return InputCommand::Unknown {
67                    input: line.to_string(),
68                };
69            }
70            let message = parts.next().unwrap_or("").to_string();
71            return InputCommand::ComponentMessage { target, message };
72        }
73
74        let parts: Vec<&str> = line.splitn(MAX_INPUT_PARTS, ' ').collect();
75        let cmd = parts[0].to_lowercase();
76
77        match cmd.as_str() {
78            // Approval
79            "y" | "yes" | "approve" => {
80                let approval_id = parts.get(1).map(|s| (*s).to_string());
81                InputCommand::Approve { approval_id }
82            }
83
84            // Rejection
85            "n" | "no" | "reject" => {
86                let approval_id = parts.get(1).map(|s| (*s).to_string());
87                let reason = parts.get(2).map(|s| (*s).to_string());
88                InputCommand::Reject {
89                    approval_id,
90                    reason,
91                }
92            }
93
94            // Pause
95            "p" | "pause" => InputCommand::Pause,
96
97            // Resume
98            "r" | "resume" => InputCommand::Resume,
99
100            // Steer (rest of line is the message)
101            "s" | "steer" => {
102                let message = if parts.len() > 1 {
103                    parts[1..].join(" ")
104                } else {
105                    // Empty steer message is invalid
106                    return InputCommand::Unknown {
107                        input: line.to_string(),
108                    };
109                };
110                InputCommand::Steer { message }
111            }
112
113            // Quit
114            "q" | "quit" | "exit" => InputCommand::Quit,
115
116            // Veto
117            "veto" | "stop" | "abort" => InputCommand::Veto,
118
119            // Unknown
120            _ => InputCommand::Unknown {
121                input: line.to_string(),
122            },
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn parse_approve_simple() {
133        let cmd = InputParser.parse("y");
134        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
135
136        let cmd = InputParser.parse("yes");
137        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
138
139        let cmd = InputParser.parse("approve");
140        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
141    }
142
143    #[test]
144    fn parse_approve_with_id() {
145        let cmd = InputParser.parse("y req-123");
146        assert!(matches!(
147            cmd,
148            InputCommand::Approve {
149                approval_id: Some(ref id)
150            } if id == "req-123"
151        ));
152    }
153
154    #[test]
155    fn parse_reject_simple() {
156        let cmd = InputParser.parse("n");
157        assert!(matches!(
158            cmd,
159            InputCommand::Reject {
160                approval_id: None,
161                reason: None
162            }
163        ));
164    }
165
166    #[test]
167    fn parse_reject_with_id() {
168        let cmd = InputParser.parse("n req-456");
169        assert!(matches!(
170            cmd,
171            InputCommand::Reject {
172                approval_id: Some(ref id),
173                reason: None
174            } if id == "req-456"
175        ));
176    }
177
178    #[test]
179    fn parse_reject_with_reason() {
180        let cmd = InputParser.parse("n req-789 too dangerous");
181        assert!(matches!(
182            cmd,
183            InputCommand::Reject {
184                approval_id: Some(ref id),
185                reason: Some(ref r)
186            } if id == "req-789" && r == "too dangerous"
187        ));
188    }
189
190    #[test]
191    fn parse_pause_resume() {
192        assert!(matches!(InputParser.parse("p"), InputCommand::Pause));
193        assert!(matches!(InputParser.parse("pause"), InputCommand::Pause));
194        assert!(matches!(InputParser.parse("r"), InputCommand::Resume));
195        assert!(matches!(InputParser.parse("resume"), InputCommand::Resume));
196    }
197
198    #[test]
199    fn parse_steer() {
200        let cmd = InputParser.parse("s focus on error handling");
201        assert!(matches!(
202            cmd,
203            InputCommand::Steer { ref message } if message == "focus on error handling"
204        ));
205    }
206
207    #[test]
208    fn parse_steer_empty_is_unknown() {
209        let cmd = InputParser.parse("s");
210        assert!(matches!(cmd, InputCommand::Unknown { .. }));
211
212        let cmd = InputParser.parse("steer");
213        assert!(matches!(cmd, InputCommand::Unknown { .. }));
214    }
215
216    #[test]
217    fn parse_quit() {
218        assert!(matches!(InputParser.parse("q"), InputCommand::Quit));
219        assert!(matches!(InputParser.parse("quit"), InputCommand::Quit));
220        assert!(matches!(InputParser.parse("exit"), InputCommand::Quit));
221    }
222
223    #[test]
224    fn parse_veto() {
225        assert!(matches!(InputParser.parse("veto"), InputCommand::Veto));
226        assert!(matches!(InputParser.parse("stop"), InputCommand::Veto));
227        assert!(matches!(InputParser.parse("abort"), InputCommand::Veto));
228    }
229
230    #[test]
231    fn parse_unknown() {
232        let cmd = InputParser.parse("foobar");
233        assert!(matches!(cmd, InputCommand::Unknown { .. }));
234    }
235
236    #[test]
237    fn parse_empty() {
238        let cmd = InputParser.parse("");
239        assert!(matches!(cmd, InputCommand::Empty));
240
241        let cmd = InputParser.parse("   ");
242        assert!(matches!(cmd, InputCommand::Empty));
243    }
244
245    #[test]
246    fn parse_component_message() {
247        let cmd = InputParser.parse("@shell ls -la");
248        assert!(matches!(
249            cmd,
250            InputCommand::ComponentMessage {
251                ref target,
252                ref message,
253            } if target == "shell" && message == "ls -la"
254        ));
255    }
256
257    #[test]
258    fn parse_component_message_uppercase() {
259        let cmd = InputParser.parse("@Shell echo hello");
260        assert!(matches!(
261            cmd,
262            InputCommand::ComponentMessage {
263                ref target,
264                ref message,
265            } if target == "shell" && message == "echo hello"
266        ));
267    }
268
269    #[test]
270    fn parse_component_message_no_message() {
271        let cmd = InputParser.parse("@shell");
272        assert!(matches!(
273            cmd,
274            InputCommand::ComponentMessage {
275                ref target,
276                ref message,
277            } if target == "shell" && message.is_empty()
278        ));
279    }
280
281    #[test]
282    fn parse_at_only_is_unknown() {
283        let cmd = InputParser.parse("@");
284        assert!(matches!(cmd, InputCommand::Unknown { .. }));
285    }
286
287    #[test]
288    fn case_insensitive() {
289        assert!(matches!(
290            InputParser.parse("Y"),
291            InputCommand::Approve { .. }
292        ));
293        assert!(matches!(
294            InputParser.parse("YES"),
295            InputCommand::Approve { .. }
296        ));
297        assert!(matches!(
298            InputParser.parse("N"),
299            InputCommand::Reject { .. }
300        ));
301        assert!(matches!(InputParser.parse("QUIT"), InputCommand::Quit));
302    }
303}