Skip to main content

aperture_cli/interactive/
mock.rs

1use crate::error::Error;
2use std::io::{BufRead, Write};
3use std::sync::mpsc;
4use std::thread;
5use std::time::Duration;
6
7#[cfg(test)]
8use mockall::predicate::*;
9
10/// Trait abstraction for input/output operations to enable mocking
11#[cfg_attr(test, mockall::automock)]
12pub trait InputOutput {
13    /// Print text to output
14    ///
15    /// # Errors
16    /// Returns an error if the output operation fails
17    fn print(&self, text: &str) -> Result<(), Error>;
18
19    /// Print text to output with newline
20    ///
21    /// # Errors
22    /// Returns an error if the output operation fails
23    fn println(&self, text: &str) -> Result<(), Error>;
24
25    /// Flush output buffer
26    ///
27    /// # Errors
28    /// Returns an error if the flush operation fails
29    fn flush(&self) -> Result<(), Error>;
30
31    /// Read a line of input from user
32    ///
33    /// # Errors
34    /// Returns an error if the input operation fails
35    fn read_line(&self) -> Result<String, Error>;
36
37    /// Read a line of input from user with timeout
38    ///
39    /// # Errors
40    /// Returns an error if the input operation fails or times out
41    fn read_line_with_timeout(&self, timeout: Duration) -> Result<String, Error>;
42}
43
44/// Real implementation of `InputOutput` that uses stdin/stdout
45pub struct RealInputOutput;
46
47impl InputOutput for RealInputOutput {
48    fn print(&self, text: &str) -> Result<(), Error> {
49        print!("{text}");
50        Ok(())
51    }
52
53    fn println(&self, text: &str) -> Result<(), Error> {
54        println!("{text}");
55        Ok(())
56    }
57
58    fn flush(&self) -> Result<(), Error> {
59        std::io::stdout()
60            .flush()
61            .map_err(|e| Error::io_error(format!("Failed to flush stdout: {e}")))
62    }
63
64    fn read_line(&self) -> Result<String, Error> {
65        let stdin = std::io::stdin();
66        let mut line = String::new();
67        stdin
68            .lock()
69            .read_line(&mut line)
70            .map_err(|e| Error::io_error(format!("Failed to read from stdin: {e}")))?;
71        Ok(line)
72    }
73
74    fn read_line_with_timeout(&self, timeout: Duration) -> Result<String, Error> {
75        let (tx, rx) = mpsc::channel();
76
77        // Spawn a thread to read from stdin
78        // Note: This thread will continue running even after timeout due to blocking stdin read
79        let read_thread = thread::spawn(move || {
80            let stdin = std::io::stdin();
81            let mut line = String::new();
82            let result = stdin.lock().read_line(&mut line);
83            match result {
84                Ok(_) => tx.send(Ok(line)).unwrap_or(()),
85                Err(e) => tx
86                    .send(Err(Error::io_error(format!(
87                        "Failed to read from stdin: {e}"
88                    ))))
89                    .unwrap_or(()),
90            }
91        });
92
93        // Wait for either input or timeout
94        match rx.recv_timeout(timeout) {
95            Ok(result) => {
96                // Join the thread to clean up
97                let _ = read_thread.join();
98                result
99            }
100            Err(mpsc::RecvTimeoutError::Timeout) => {
101                // Important: The read thread will continue running until user provides input.
102                // This is a known limitation of stdin reading in Rust - blocking reads cannot
103                // be cancelled. The thread will eventually clean up when:
104                // 1. The user provides input (thread completes normally)
105                // 2. The process exits (OS cleans up all threads)
106                // This is the standard approach for stdin timeout handling in Rust.
107                Err(Error::interactive_timeout())
108            }
109            Err(mpsc::RecvTimeoutError::Disconnected) => {
110                Err(Error::invalid_config("Input channel disconnected"))
111            }
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_mock_input_output() {
122        let mut mock = MockInputOutput::new();
123
124        // Set up expectations
125        mock.expect_print()
126            .with(eq("Hello"))
127            .times(1)
128            .returning(|_| Ok(()));
129
130        mock.expect_read_line()
131            .times(1)
132            .returning(|| Ok("test input\n".to_string()));
133
134        // Test the mock
135        assert!(mock.print("Hello").is_ok());
136        assert_eq!(mock.read_line().unwrap(), "test input\n");
137    }
138}