Skip to main content

orcs_runtime/components/
io_bridge.rs

1//! IO Bridge - Bridge between View and Model layers.
2//!
3//! The [`IOBridge`] bridges the View layer (IOPort) with the Model layer (EventBus).
4//! It handles:
5//!
6//! - Input: Parsing user input into Signals
7//! - Output: Converting display instructions to IOOutput
8//!
9//! # Architecture
10//!
11//! ```text
12//! ┌─────────────────────────────────────────────────────────────────┐
13//! │                         View Layer                              │
14//! │  ┌─────────────────┐              ┌─────────────────┐          │
15//! │  │  IOInputHandle  │              │ IOOutputHandle  │          │
16//! │  └────────┬────────┘              └────────▲────────┘          │
17//! └───────────┼────────────────────────────────┼────────────────────┘
18//!             │ IOInput                        │ IOOutput
19//!             ▼                                │
20//! ┌───────────────────────────────────────────────────────────────┐
21//! │                      IOBridge (Bridge)                        │
22//! │  ┌─────────────────────────────────────────────────────────┐  │
23//! │  │                        IOPort                            │  │
24//! │  └─────────────────────────────────────────────────────────┘  │
25//! │                              │                                  │
26//! │  ┌─────────────────────────────────────────────────────────┐  │
27//! │  │  InputParser (parse line → InputCommand → Signal)        │  │
28//! │  └─────────────────────────────────────────────────────────┘  │
29//! └───────────────────────────────────────────────────────────────┘
30//!                                │
31//!                     Signal / Request
32//!                                ▼
33//! ┌───────────────────────────────────────────────────────────────┐
34//! │                       Model Layer                              │
35//! │                        EventBus                                │
36//! └───────────────────────────────────────────────────────────────┘
37//! ```
38//!
39//! # Note
40//!
41//! IOBridge does not hold a Principal. The Principal is provided by the
42//! owning [`ClientRunner`](crate::channel::ClientRunner) when converting
43//! input to Signals.
44//!
45//! # Example
46//!
47//! ```
48//! use orcs_runtime::components::IOBridge;
49//! use orcs_runtime::io::{IOPort, IOInput, IOOutput};
50//! use orcs_types::ChannelId;
51//!
52//! let channel_id = ChannelId::new();
53//! let (port, input_handle, output_handle) = IOPort::with_defaults(channel_id);
54//! let bridge = IOBridge::new(port);
55//!
56//! // View layer sends input via input_handle
57//! // IOBridge converts to Signals (with Principal from ClientRunner)
58//! // View layer receives output via output_handle
59//! ```
60
61use crate::io::{IOOutput, IOPort, InputCommand, InputParser};
62use orcs_event::{Signal, SignalKind};
63use orcs_types::{ChannelId, Principal, SignalScope};
64
65/// IO Bridge - Pure bridge between View and Model layers.
66///
67/// Converts between IO types (IOInput/IOOutput) and internal events (Signal).
68/// This is a stateless transformation layer.
69///
70/// **Note**: IOBridge does not hold a Principal. The Principal is provided
71/// by the owning [`ClientRunner`](crate::channel::ClientRunner) when
72/// converting input to Signals.
73///
74/// The parser can be injected for testing or customization.
75pub struct IOBridge {
76    /// IO port for communication with View layer.
77    io_port: IOPort,
78    /// Channel ID this belongs to.
79    channel_id: ChannelId,
80    /// Input parser for converting text to commands.
81    parser: InputParser,
82}
83
84impl IOBridge {
85    /// Creates a new IOBridge with default parser.
86    ///
87    /// # Arguments
88    ///
89    /// * `io_port` - IO port for View communication
90    #[must_use]
91    pub fn new(io_port: IOPort) -> Self {
92        Self::with_parser(io_port, InputParser)
93    }
94
95    /// Creates a new IOBridge with a custom parser.
96    ///
97    /// This allows injecting a custom parser for testing or customization.
98    #[must_use]
99    pub fn with_parser(io_port: IOPort, parser: InputParser) -> Self {
100        let channel_id = io_port.channel_id();
101        Self {
102            io_port,
103            channel_id,
104            parser,
105        }
106    }
107
108    /// Returns the channel ID.
109    #[must_use]
110    pub fn channel_id(&self) -> ChannelId {
111        self.channel_id
112    }
113
114    /// Converts an InputCommand to a Signal.
115    ///
116    /// # Arguments
117    ///
118    /// * `cmd` - The parsed input command
119    /// * `principal` - Principal to use for the signal
120    /// * `default_approval_id` - Fallback approval ID if command has none
121    #[must_use]
122    pub fn command_to_signal(
123        &self,
124        cmd: &InputCommand,
125        principal: &Principal,
126        default_approval_id: Option<&str>,
127    ) -> Option<Signal> {
128        cmd.to_signal(principal.clone(), default_approval_id)
129    }
130
131    /// Parses a line of input and converts to Signal.
132    ///
133    /// # Arguments
134    ///
135    /// * `line` - Raw input line
136    /// * `principal` - Principal to use for the signal
137    /// * `default_approval_id` - Fallback approval ID for HIL responses
138    #[must_use]
139    pub fn parse_line_to_signal(
140        &self,
141        line: &str,
142        principal: &Principal,
143        default_approval_id: Option<&str>,
144    ) -> Option<Signal> {
145        let cmd = self.parser.parse(line);
146        self.command_to_signal(&cmd, principal, default_approval_id)
147    }
148
149    /// Drains all available input and converts to Signals.
150    ///
151    /// Returns a vector of Signals ready to be dispatched to EventBus.
152    /// Also returns any InputCommands that couldn't be converted
153    /// (e.g., Quit, Unknown) for the caller to handle.
154    ///
155    /// Approval IDs are extracted from the input context provided by View layer.
156    ///
157    /// # Arguments
158    ///
159    /// * `principal` - Principal to use for signals
160    pub fn drain_input_to_signals(
161        &mut self,
162        principal: &Principal,
163    ) -> (Vec<Signal>, Vec<InputCommand>) {
164        let mut signals = Vec::new();
165        let mut other_commands = Vec::new();
166
167        for input in self.io_port.drain_input() {
168            match input {
169                crate::io::IOInput::Line { text, context } => {
170                    let cmd = self.parser.parse(&text);
171                    let approval_id = context.approval_id.as_deref();
172                    if let Some(signal) = self.command_to_signal(&cmd, principal, approval_id) {
173                        signals.push(signal);
174                    } else {
175                        other_commands.push(cmd);
176                    }
177                }
178                crate::io::IOInput::Signal(kind) => {
179                    let scope = match &kind {
180                        SignalKind::Veto => SignalScope::Global,
181                        _ => SignalScope::Channel(self.channel_id),
182                    };
183                    signals.push(Signal::new(kind, scope, principal.clone()));
184                }
185                crate::io::IOInput::Eof => {
186                    other_commands.push(InputCommand::Quit);
187                }
188            }
189        }
190
191        (signals, other_commands)
192    }
193
194    /// Receives a single input and processes it.
195    ///
196    /// Async version - waits for input.
197    ///
198    /// Approval IDs are extracted from the input context provided by View layer.
199    ///
200    /// # Arguments
201    ///
202    /// * `principal` - Principal to use for signals
203    ///
204    /// # Returns
205    ///
206    /// - `Some(Ok(signal))` - Input converted to signal
207    /// - `Some(Err(cmd))` - Input is a command that doesn't map to signal
208    /// - `None` - IO port closed
209    pub async fn recv_input(
210        &mut self,
211        principal: &Principal,
212    ) -> Option<Result<Signal, InputCommand>> {
213        let input = self.io_port.recv().await?;
214
215        match input {
216            crate::io::IOInput::Line { text, context } => {
217                let cmd = self.parser.parse(&text);
218                let approval_id = context.approval_id.as_deref();
219                if let Some(signal) = self.command_to_signal(&cmd, principal, approval_id) {
220                    Some(Ok(signal))
221                } else {
222                    Some(Err(cmd))
223                }
224            }
225            crate::io::IOInput::Signal(kind) => {
226                let scope = match &kind {
227                    SignalKind::Veto => SignalScope::Global,
228                    _ => SignalScope::Channel(self.channel_id),
229                };
230                Some(Ok(Signal::new(kind, scope, principal.clone())))
231            }
232            crate::io::IOInput::Eof => Some(Err(InputCommand::Quit)),
233        }
234    }
235
236    // === Output methods (OutputSink-compatible) ===
237
238    /// Sends an output to the View layer.
239    ///
240    /// # Errors
241    ///
242    /// Returns error if the output handle has been dropped.
243    pub async fn send_output(
244        &self,
245        output: IOOutput,
246    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
247        self.io_port.send(output).await
248    }
249
250    /// Displays a processing notification.
251    pub async fn show_processing(
252        &self,
253        component: &str,
254        operation: &str,
255    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
256        self.io_port
257            .send(IOOutput::processing(component, operation))
258            .await
259    }
260
261    /// Displays an approval request.
262    pub async fn show_approval_request(
263        &self,
264        request: &super::ApprovalRequest,
265    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
266        self.io_port.send(IOOutput::approval_request(request)).await
267    }
268
269    /// Displays approval confirmation.
270    pub async fn show_approved(
271        &self,
272        approval_id: &str,
273    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
274        self.io_port.send(IOOutput::approved(approval_id)).await
275    }
276
277    /// Displays rejection confirmation.
278    pub async fn show_rejected(
279        &self,
280        approval_id: &str,
281        reason: Option<&str>,
282    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
283        self.io_port
284            .send(IOOutput::rejected(approval_id, reason.map(String::from)))
285            .await
286    }
287
288    /// Displays an info message.
289    pub async fn info(
290        &self,
291        message: &str,
292    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
293        self.io_port.send(IOOutput::info(message)).await
294    }
295
296    /// Displays a warning message.
297    pub async fn warn(
298        &self,
299        message: &str,
300    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
301        self.io_port.send(IOOutput::warn(message)).await
302    }
303
304    /// Displays an error message.
305    pub async fn error(
306        &self,
307        message: &str,
308    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
309        self.io_port.send(IOOutput::error(message)).await
310    }
311
312    /// Displays a prompt message.
313    pub async fn prompt(
314        &self,
315        message: &str,
316    ) -> Result<(), tokio::sync::mpsc::error::SendError<IOOutput>> {
317        self.io_port.send(IOOutput::prompt(message)).await
318    }
319
320    /// Returns `true` if the output channel is closed.
321    #[must_use]
322    pub fn is_output_closed(&self) -> bool {
323        self.io_port.is_output_closed()
324    }
325}
326
327impl std::fmt::Debug for IOBridge {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        f.debug_struct("IOBridge")
330            .field("channel_id", &self.channel_id)
331            .finish_non_exhaustive()
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::io::IOInput;
339    use orcs_types::PrincipalId;
340
341    fn test_principal() -> Principal {
342        Principal::User(PrincipalId::new())
343    }
344
345    fn setup() -> (
346        IOBridge,
347        crate::io::IOInputHandle,
348        crate::io::IOOutputHandle,
349    ) {
350        let channel_id = ChannelId::new();
351        let (port, input_handle, output_handle) = IOPort::with_defaults(channel_id);
352        let bridge = IOBridge::new(port);
353        (bridge, input_handle, output_handle)
354    }
355
356    #[test]
357    fn io_bridge_creation() {
358        let channel_id = ChannelId::new();
359        let (port, _, _) = IOPort::with_defaults(channel_id);
360        let bridge = IOBridge::new(port);
361        assert_eq!(bridge.channel_id(), channel_id);
362    }
363
364    #[test]
365    fn parse_line_to_signal_approve() {
366        let (bridge, _, _) = setup();
367        let principal = test_principal();
368
369        // Without default approval ID, approve without ID returns None
370        let signal = bridge.parse_line_to_signal("y", &principal, None);
371        assert!(signal.is_none());
372
373        // With explicit ID in input
374        let signal = bridge.parse_line_to_signal("y req-123", &principal, None);
375        assert!(signal.is_some());
376        assert!(signal.expect("should produce approve signal").is_approve());
377    }
378
379    #[test]
380    fn parse_line_to_signal_with_default() {
381        let (bridge, _, _) = setup();
382        let principal = test_principal();
383
384        // With default approval ID passed as argument
385        let signal = bridge.parse_line_to_signal("y", &principal, Some("default-id"));
386        assert!(signal.is_some());
387
388        let signal = signal.expect("should produce signal with default approval id");
389        assert!(signal.is_approve());
390        if let SignalKind::Approve { approval_id } = &signal.kind {
391            assert_eq!(approval_id, "default-id");
392        }
393    }
394
395    #[test]
396    fn parse_line_to_signal_veto() {
397        let (bridge, _, _) = setup();
398        let principal = test_principal();
399
400        let signal = bridge.parse_line_to_signal("veto", &principal, None);
401        assert!(signal.is_some());
402        assert!(signal.expect("should produce veto signal").is_veto());
403    }
404
405    #[test]
406    fn parse_line_to_signal_quit_returns_none() {
407        let (bridge, _, _) = setup();
408        let principal = test_principal();
409
410        // Quit doesn't map to a signal
411        let signal = bridge.parse_line_to_signal("q", &principal, None);
412        assert!(signal.is_none());
413    }
414
415    #[tokio::test]
416    async fn drain_input_to_signals() {
417        use crate::io::InputContext;
418
419        let (mut bridge, input_handle, _output_handle) = setup();
420        let principal = test_principal();
421        let ctx = InputContext::with_approval_id("pending-1");
422
423        // Send various inputs with context
424        input_handle
425            .send(IOInput::line_with_context("y", ctx.clone()))
426            .await
427            .expect("send approve input should succeed");
428        input_handle
429            .send(IOInput::line_with_context("n", ctx.clone()))
430            .await
431            .expect("send reject input should succeed");
432        input_handle
433            .send(IOInput::line_with_context("veto", ctx.clone()))
434            .await
435            .expect("send veto input should succeed");
436        input_handle
437            .send(IOInput::line("q"))
438            .await
439            .expect("send quit input should succeed");
440        input_handle
441            .send(IOInput::line("unknown"))
442            .await
443            .expect("send unknown input should succeed");
444
445        // Small delay to ensure messages are received
446        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
447
448        let (signals, commands) = bridge.drain_input_to_signals(&principal);
449
450        // y, n, veto -> 3 signals
451        assert_eq!(signals.len(), 3);
452        assert!(signals[0].is_approve());
453        assert!(signals[1].is_reject());
454        assert!(signals[2].is_veto());
455
456        // q, unknown -> 2 commands
457        assert_eq!(commands.len(), 2);
458        assert!(matches!(commands[0], InputCommand::Quit));
459        assert!(matches!(commands[1], InputCommand::Unknown { .. }));
460    }
461
462    #[tokio::test]
463    async fn recv_input_signal() {
464        use crate::io::InputContext;
465
466        let (mut bridge, input_handle, _output_handle) = setup();
467        let principal = test_principal();
468        let ctx = InputContext::with_approval_id("req-1");
469
470        input_handle
471            .send(IOInput::line_with_context("y", ctx))
472            .await
473            .expect("send approve input should succeed");
474
475        let result = bridge.recv_input(&principal).await;
476        assert!(result.is_some());
477        assert!(result.expect("recv_input should return Some").is_ok());
478    }
479
480    #[tokio::test]
481    async fn recv_input_non_signal() {
482        let (mut bridge, input_handle, _output_handle) = setup();
483        let principal = test_principal();
484
485        input_handle
486            .send(IOInput::line("q"))
487            .await
488            .expect("send quit input should succeed");
489
490        let result = bridge.recv_input(&principal).await;
491        assert!(result.is_some());
492        let cmd = result
493            .expect("recv_input should return Some")
494            .expect_err("quit should not map to a signal");
495        assert!(matches!(cmd, InputCommand::Quit));
496    }
497
498    #[tokio::test]
499    async fn send_output() {
500        let (bridge, _input_handle, mut output_handle) = setup();
501
502        bridge
503            .info("test message")
504            .await
505            .expect("send info should succeed");
506
507        let output = output_handle.recv().await.expect("should receive output");
508        assert!(matches!(output, IOOutput::Print { .. }));
509    }
510
511    #[tokio::test]
512    async fn show_processing() {
513        let (bridge, _input_handle, mut output_handle) = setup();
514
515        bridge
516            .show_processing("agent_mgr", "input")
517            .await
518            .expect("send should succeed");
519
520        let output = output_handle.recv().await.expect("should receive output");
521        match output {
522            IOOutput::ShowProcessing {
523                component,
524                operation,
525            } => {
526                assert_eq!(component, "agent_mgr");
527                assert_eq!(operation, "input");
528            }
529            other => panic!("Expected ShowProcessing, got {:?}", other),
530        }
531    }
532
533    #[tokio::test]
534    async fn show_approval_request() {
535        let (bridge, _input_handle, mut output_handle) = setup();
536
537        let req = super::super::ApprovalRequest::with_id(
538            "req-123",
539            "write",
540            "Write file",
541            serde_json::json!({}),
542        );
543        bridge
544            .show_approval_request(&req)
545            .await
546            .expect("send approval request should succeed");
547
548        let output = output_handle
549            .recv()
550            .await
551            .expect("should receive approval request output");
552        if let IOOutput::ShowApprovalRequest { id, operation, .. } = output {
553            assert_eq!(id, "req-123");
554            assert_eq!(operation, "write");
555        } else {
556            panic!("Expected ShowApprovalRequest");
557        }
558    }
559
560    #[test]
561    fn debug_impl() {
562        let (bridge, _, _) = setup();
563        let debug_str = format!("{:?}", bridge);
564        assert!(debug_str.contains("IOBridge"));
565        assert!(debug_str.contains("channel_id"));
566    }
567}