Skip to main content

rskit_cli/prompt/terminal/
line.rs

1//! Plain cooked-stdio terminal: the dependency-free, always-available default.
2
3use std::io::{self, BufRead, Write};
4
5use rskit_errors::{AppError, AppResult};
6
7use super::{Capabilities, Terminal};
8use crate::prompt::key::Key;
9
10/// A line-driven [`Terminal`] over an injected reader and writer.
11///
12/// It uses the operating system's cooked line discipline: the user types a whole
13/// line and presses Enter. It never enters raw mode, works over pipes, and needs
14/// no extra dependencies, so it is the default terminal. Bind it to real streams
15/// with [`LineTerminal::stdio`], or to in-memory buffers with [`LineTerminal::new`].
16pub struct LineTerminal<R, W> {
17    reader: R,
18    writer: W,
19}
20
21impl LineTerminal<io::BufReader<io::Stdin>, io::Stderr> {
22    /// Build a line terminal bound to process stdin and stderr.
23    #[must_use]
24    pub fn stdio() -> Self {
25        Self::new(io::BufReader::new(io::stdin()), io::stderr())
26    }
27}
28
29impl<R: BufRead, W: Write> LineTerminal<R, W> {
30    /// Build a line terminal from an explicit reader and writer.
31    #[must_use]
32    pub const fn new(reader: R, writer: W) -> Self {
33        Self { reader, writer }
34    }
35}
36
37impl<R: BufRead, W: Write> Terminal for LineTerminal<R, W> {
38    fn capabilities(&self) -> Capabilities {
39        Capabilities::line_driven()
40    }
41
42    fn read_line(&mut self) -> AppResult<Option<String>> {
43        let mut buffer = String::new();
44        let read = self
45            .reader
46            .read_line(&mut buffer)
47            .map_err(AppError::internal)?;
48        if read == 0 {
49            return Ok(None);
50        }
51        Ok(Some(buffer.trim_end_matches(['\n', '\r']).to_string()))
52    }
53
54    fn read_key(&mut self) -> AppResult<Key> {
55        Err(AppError::invalid_input(
56            "prompt",
57            "line terminal does not support key input",
58        ))
59    }
60
61    fn write(&mut self, text: &str) -> AppResult<()> {
62        write!(self.writer, "{text}").map_err(AppError::internal)
63    }
64
65    fn write_line(&mut self, text: &str) -> AppResult<()> {
66        writeln!(self.writer, "{text}").map_err(AppError::internal)
67    }
68
69    fn flush(&mut self) -> AppResult<()> {
70        self.writer.flush().map_err(AppError::internal)
71    }
72
73    fn clear_last_lines(&mut self, _count: u16) -> AppResult<()> {
74        Ok(())
75    }
76
77    fn begin_interactive(&mut self) -> AppResult<()> {
78        Ok(())
79    }
80
81    fn end_interactive(&mut self) -> AppResult<()> {
82        Ok(())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::{Capabilities, LineTerminal, Terminal};
89
90    #[test]
91    fn reads_trimmed_lines_then_eof() {
92        let input = b"hello\nworld\n";
93        let mut out = Vec::new();
94        let mut term = LineTerminal::new(&input[..], &mut out);
95        assert_eq!(term.capabilities(), Capabilities::line_driven());
96        assert_eq!(term.read_line().expect("line"), Some("hello".to_string()));
97        assert_eq!(term.read_line().expect("line"), Some("world".to_string()));
98        assert_eq!(term.read_line().expect("eof"), None);
99    }
100
101    #[test]
102    fn key_input_is_unsupported() {
103        let mut out = Vec::new();
104        let mut term = LineTerminal::new(&b""[..], &mut out);
105        assert!(term.read_key().is_err());
106    }
107
108    #[test]
109    fn writes_pass_through() {
110        let mut out = Vec::new();
111        {
112            let mut term = LineTerminal::new(&b""[..], &mut out);
113            term.write("a").expect("write");
114            term.write_line("b").expect("writeln");
115        }
116        assert_eq!(String::from_utf8(out).expect("utf8"), "ab\n");
117    }
118
119    #[test]
120    fn line_control_operations_are_no_ops_over_cooked_stdio() {
121        let mut out = Vec::new();
122        let mut term = LineTerminal::new(&b""[..], &mut out);
123        term.flush().expect("flush");
124        term.clear_last_lines(3).expect("clear is a no-op");
125        term.begin_interactive().expect("begin is a no-op");
126        term.end_interactive().expect("end is a no-op");
127    }
128
129    #[test]
130    fn stdio_binds_to_process_streams() {
131        // Constructing over the real process streams must succeed; capabilities
132        // stay line-driven without touching stdin/stderr.
133        let term = LineTerminal::stdio();
134        assert_eq!(term.capabilities(), Capabilities::line_driven());
135    }
136}