Skip to main content

orcs_runtime/io/
input.rs

1//! Input command types.
2//!
3//! Defines [`InputCommand`] enum representing parsed user input.
4//! Use [`super::InputParser`] to parse text into commands.
5
6use orcs_event::Signal;
7use orcs_types::Principal;
8
9/// Parsed input command from Human.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum InputCommand {
12    /// Approve a pending request.
13    Approve {
14        /// Optional specific approval ID. If None, approves the first pending.
15        approval_id: Option<String>,
16    },
17
18    /// Reject a pending request.
19    Reject {
20        /// Optional specific approval ID. If None, rejects the first pending.
21        approval_id: Option<String>,
22        /// Optional reason for rejection.
23        reason: Option<String>,
24    },
25
26    /// Pause execution.
27    Pause,
28
29    /// Resume paused execution.
30    Resume,
31
32    /// Steer with a message.
33    Steer {
34        /// Steering instruction.
35        message: String,
36    },
37
38    /// Graceful quit.
39    Quit,
40
41    /// Emergency stop (Veto).
42    Veto,
43
44    /// Empty input (blank line).
45    ///
46    /// Distinct from Unknown - indicates user pressed Enter without input.
47    Empty,
48
49    /// Direct message to a specific component.
50    ///
51    /// Format: `@component_name message`
52    ///
53    /// Routes to the named component only, bypassing broadcast.
54    ComponentMessage {
55        /// Target component name (lowercase).
56        target: String,
57        /// Message to send to the component.
58        message: String,
59    },
60
61    /// Unknown or invalid input.
62    Unknown {
63        /// The original input line.
64        input: String,
65    },
66}
67
68impl InputCommand {
69    /// Converts the command to a Signal if applicable.
70    ///
71    /// Some commands (like Quit, Unknown) don't map to signals.
72    ///
73    /// # Arguments
74    ///
75    /// * `principal` - The Human principal sending the signal
76    /// * `default_approval_id` - Default approval ID if none specified
77    #[must_use]
78    pub fn to_signal(
79        &self,
80        principal: Principal,
81        default_approval_id: Option<&str>,
82    ) -> Option<Signal> {
83        match self {
84            Self::Approve { approval_id } => {
85                let id = approval_id.as_deref().or(default_approval_id)?;
86                Some(Signal::approve(id, principal))
87            }
88            Self::Reject {
89                approval_id,
90                reason,
91            } => {
92                let id = approval_id.as_deref().or(default_approval_id)?;
93                Some(Signal::reject(id, reason.clone(), principal))
94            }
95            Self::Veto => Some(Signal::veto(principal)),
96            Self::Pause | Self::Resume | Self::Steer { .. } => {
97                // These require a channel ID which we don't have here
98                None
99            }
100            Self::Quit | Self::Empty | Self::Unknown { .. } | Self::ComponentMessage { .. } => None,
101        }
102    }
103
104    /// Returns `true` if this is an approval command.
105    #[must_use]
106    pub fn is_approval(&self) -> bool {
107        matches!(self, Self::Approve { .. })
108    }
109
110    /// Returns `true` if this is a rejection command.
111    #[must_use]
112    pub fn is_rejection(&self) -> bool {
113        matches!(self, Self::Reject { .. })
114    }
115
116    /// Returns `true` if this is a HIL response (approve or reject).
117    #[must_use]
118    pub fn is_hil_response(&self) -> bool {
119        self.is_approval() || self.is_rejection()
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::io::InputParser;
127    use orcs_event::SignalKind;
128    use orcs_types::PrincipalId;
129
130    #[test]
131    fn parse_approve_simple() {
132        let cmd = InputParser.parse("y");
133        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
134
135        let cmd = InputParser.parse("yes");
136        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
137
138        let cmd = InputParser.parse("approve");
139        assert!(matches!(cmd, InputCommand::Approve { approval_id: None }));
140    }
141
142    #[test]
143    fn parse_approve_with_id() {
144        let cmd = InputParser.parse("y req-123");
145        assert!(matches!(
146            cmd,
147            InputCommand::Approve {
148                approval_id: Some(ref id)
149            } if id == "req-123"
150        ));
151    }
152
153    #[test]
154    fn parse_reject_simple() {
155        let cmd = InputParser.parse("n");
156        assert!(matches!(
157            cmd,
158            InputCommand::Reject {
159                approval_id: None,
160                reason: None
161            }
162        ));
163    }
164
165    #[test]
166    fn parse_reject_with_id() {
167        let cmd = InputParser.parse("n req-456");
168        assert!(matches!(
169            cmd,
170            InputCommand::Reject {
171                approval_id: Some(ref id),
172                reason: None
173            } if id == "req-456"
174        ));
175    }
176
177    #[test]
178    fn parse_reject_with_reason() {
179        let cmd = InputParser.parse("n req-789 too dangerous");
180        assert!(matches!(
181            cmd,
182            InputCommand::Reject {
183                approval_id: Some(ref id),
184                reason: Some(ref r)
185            } if id == "req-789" && r == "too dangerous"
186        ));
187    }
188
189    #[test]
190    fn parse_pause_resume() {
191        assert!(matches!(InputParser.parse("p"), InputCommand::Pause));
192        assert!(matches!(InputParser.parse("pause"), InputCommand::Pause));
193        assert!(matches!(InputParser.parse("r"), InputCommand::Resume));
194        assert!(matches!(InputParser.parse("resume"), InputCommand::Resume));
195    }
196
197    #[test]
198    fn parse_steer() {
199        let cmd = InputParser.parse("s focus on error handling");
200        assert!(matches!(
201            cmd,
202            InputCommand::Steer { ref message } if message == "focus on error handling"
203        ));
204    }
205
206    #[test]
207    fn parse_steer_empty_is_unknown() {
208        let cmd = InputParser.parse("s");
209        assert!(matches!(cmd, InputCommand::Unknown { .. }));
210
211        let cmd = InputParser.parse("steer");
212        assert!(matches!(cmd, InputCommand::Unknown { .. }));
213    }
214
215    #[test]
216    fn parse_quit() {
217        assert!(matches!(InputParser.parse("q"), InputCommand::Quit));
218        assert!(matches!(InputParser.parse("quit"), InputCommand::Quit));
219        assert!(matches!(InputParser.parse("exit"), InputCommand::Quit));
220    }
221
222    #[test]
223    fn parse_veto() {
224        assert!(matches!(InputParser.parse("veto"), InputCommand::Veto));
225        assert!(matches!(InputParser.parse("stop"), InputCommand::Veto));
226        assert!(matches!(InputParser.parse("abort"), InputCommand::Veto));
227    }
228
229    #[test]
230    fn parse_unknown() {
231        let cmd = InputParser.parse("foobar");
232        assert!(matches!(cmd, InputCommand::Unknown { .. }));
233    }
234
235    #[test]
236    fn parse_empty() {
237        let cmd = InputParser.parse("");
238        assert!(matches!(cmd, InputCommand::Empty));
239
240        let cmd = InputParser.parse("   ");
241        assert!(matches!(cmd, InputCommand::Empty));
242    }
243
244    #[test]
245    fn to_signal_approve() {
246        let cmd = InputCommand::Approve {
247            approval_id: Some("req-123".to_string()),
248        };
249        let principal = Principal::User(PrincipalId::new());
250        let signal = cmd.to_signal(principal, None);
251
252        assert!(signal.is_some());
253        let signal = signal.expect("approve command with id should produce signal");
254        assert!(signal.is_approve());
255    }
256
257    #[test]
258    fn to_signal_approve_with_default() {
259        let cmd = InputCommand::Approve { approval_id: None };
260        let principal = Principal::User(PrincipalId::new());
261        let signal = cmd.to_signal(principal, Some("default-id"));
262
263        assert!(signal.is_some());
264        let signal = signal.expect("approve command with default id should produce signal");
265        assert!(signal.is_approve());
266        if let SignalKind::Approve { approval_id } = &signal.kind {
267            assert_eq!(approval_id, "default-id");
268        }
269    }
270
271    #[test]
272    fn to_signal_approve_no_id() {
273        let cmd = InputCommand::Approve { approval_id: None };
274        let principal = Principal::User(PrincipalId::new());
275        let signal = cmd.to_signal(principal, None);
276
277        // No approval ID available, should return None
278        assert!(signal.is_none());
279    }
280
281    #[test]
282    fn to_signal_reject() {
283        let cmd = InputCommand::Reject {
284            approval_id: Some("req-456".to_string()),
285            reason: Some("not allowed".to_string()),
286        };
287        let principal = Principal::User(PrincipalId::new());
288        let signal = cmd.to_signal(principal, None);
289
290        assert!(signal.is_some());
291        let signal = signal.expect("reject command with id should produce signal");
292        assert!(signal.is_reject());
293    }
294
295    #[test]
296    fn to_signal_veto() {
297        let cmd = InputCommand::Veto;
298        let principal = Principal::User(PrincipalId::new());
299        let signal = cmd.to_signal(principal, None);
300
301        assert!(signal.is_some());
302        let signal = signal.expect("veto command should produce signal");
303        assert!(signal.is_veto());
304        assert!(signal.is_global());
305    }
306
307    #[test]
308    fn to_signal_quit_returns_none() {
309        let cmd = InputCommand::Quit;
310        let principal = Principal::User(PrincipalId::new());
311        let signal = cmd.to_signal(principal, None);
312
313        assert!(signal.is_none());
314    }
315
316    #[test]
317    fn command_helpers() {
318        assert!(InputCommand::Approve { approval_id: None }.is_approval());
319        assert!(!InputCommand::Approve { approval_id: None }.is_rejection());
320        assert!(InputCommand::Approve { approval_id: None }.is_hil_response());
321
322        assert!(InputCommand::Reject {
323            approval_id: None,
324            reason: None
325        }
326        .is_rejection());
327        assert!(InputCommand::Reject {
328            approval_id: None,
329            reason: None
330        }
331        .is_hil_response());
332    }
333
334    #[test]
335    fn to_signal_component_message_returns_none() {
336        let cmd = InputCommand::ComponentMessage {
337            target: "shell".to_string(),
338            message: "ls".to_string(),
339        };
340        let principal = Principal::User(PrincipalId::new());
341        let signal = cmd.to_signal(principal, None);
342        assert!(signal.is_none());
343    }
344
345    #[test]
346    fn case_insensitive() {
347        assert!(matches!(
348            InputParser.parse("Y"),
349            InputCommand::Approve { .. }
350        ));
351        assert!(matches!(
352            InputParser.parse("YES"),
353            InputCommand::Approve { .. }
354        ));
355        assert!(matches!(
356            InputParser.parse("N"),
357            InputCommand::Reject { .. }
358        ));
359        assert!(matches!(InputParser.parse("QUIT"), InputCommand::Quit));
360    }
361}