Skip to main content

rskit_cli/prompt/terminal/
scripted.rs

1//! A deterministic in-memory [`Terminal`] for tests and examples.
2//!
3//! `ScriptedTerminal` is a first-class injectable double, not a test-only escape hatch:
4//! it plays a canned sequence of [`Key`]s or lines and records everything written,
5//! so both the key-driven and line-driven prompt paths can be exercised without a real terminal.
6//! Choose the interaction model with [`ScriptedTerminal::key_driven`]
7//! or [`ScriptedTerminal::line_driven`], queue input with the `with_*` builders,
8//! then read back rendered output via [`ScriptedTerminal::output`].
9
10use std::collections::VecDeque;
11
12use rskit_errors::{AppError, AppResult};
13
14use super::{Capabilities, Terminal};
15use crate::prompt::key::Key;
16
17/// One scripted input event: a whole line or a single key.
18#[derive(Debug, Clone)]
19enum Input {
20    Line(String),
21    Key(Key),
22}
23
24/// A scripted, in-memory terminal that replays canned input and captures output.
25#[derive(Debug, Default)]
26pub struct ScriptedTerminal {
27    capabilities: Capabilities,
28    inputs: VecDeque<Input>,
29    output: String,
30    raw: bool,
31}
32
33impl ScriptedTerminal {
34    /// A key-driven scripted terminal (advertises key input, like a raw TTY).
35    #[must_use]
36    pub fn key_driven() -> Self {
37        Self {
38            capabilities: Capabilities::key_driven(),
39            ..Self::default()
40        }
41    }
42
43    /// A line-driven scripted terminal (advertises line input, like cooked stdio).
44    #[must_use]
45    pub fn line_driven() -> Self {
46        Self {
47            capabilities: Capabilities::line_driven(),
48            ..Self::default()
49        }
50    }
51
52    /// Queue one input line (line-driven scripts).
53    #[must_use]
54    pub fn with_line(mut self, line: impl Into<String>) -> Self {
55        self.inputs.push_back(Input::Line(line.into()));
56        self
57    }
58
59    /// Queue several input lines in order.
60    #[must_use]
61    pub fn with_lines<I, S>(mut self, lines: I) -> Self
62    where
63        I: IntoIterator<Item = S>,
64        S: Into<String>,
65    {
66        for line in lines {
67            self.inputs.push_back(Input::Line(line.into()));
68        }
69        self
70    }
71
72    /// Queue one keystroke (key-driven scripts).
73    #[must_use]
74    pub fn with_key(mut self, key: Key) -> Self {
75        self.inputs.push_back(Input::Key(key));
76        self
77    }
78
79    /// Queue several keystrokes in order.
80    #[must_use]
81    pub fn with_keys<I>(mut self, keys: I) -> Self
82    where
83        I: IntoIterator<Item = Key>,
84    {
85        for key in keys {
86            self.inputs.push_back(Input::Key(key));
87        }
88        self
89    }
90
91    /// Everything written to the terminal so far, for assertions.
92    #[must_use]
93    pub fn output(&self) -> &str {
94        &self.output
95    }
96
97    /// Whether the terminal is currently in interactive (raw) mode.
98    #[must_use]
99    pub const fn is_interactive(&self) -> bool {
100        self.raw
101    }
102}
103
104impl Terminal for ScriptedTerminal {
105    fn capabilities(&self) -> Capabilities {
106        self.capabilities
107    }
108
109    fn read_line(&mut self) -> AppResult<Option<String>> {
110        match self.inputs.pop_front() {
111            Some(Input::Line(line)) => Ok(Some(line)),
112            Some(Input::Key(_)) => Err(AppError::invalid_input(
113                "prompt",
114                "scripted terminal expected a line but the next input is a key",
115            )),
116            None => Ok(None),
117        }
118    }
119
120    fn read_key(&mut self) -> AppResult<Key> {
121        match self.inputs.pop_front() {
122            Some(Input::Key(key)) => Ok(key),
123            Some(Input::Line(_)) => Err(AppError::invalid_input(
124                "prompt",
125                "scripted terminal expected a key but the next input is a line",
126            )),
127            None => Err(AppError::invalid_input(
128                "prompt",
129                "scripted terminal ran out of keys",
130            )),
131        }
132    }
133
134    fn write(&mut self, text: &str) -> AppResult<()> {
135        self.output.push_str(text);
136        Ok(())
137    }
138
139    fn write_line(&mut self, text: &str) -> AppResult<()> {
140        self.output.push_str(text);
141        self.output.push('\n');
142        Ok(())
143    }
144
145    fn flush(&mut self) -> AppResult<()> {
146        Ok(())
147    }
148
149    fn clear_last_lines(&mut self, _count: u16) -> AppResult<()> {
150        Ok(())
151    }
152
153    fn begin_interactive(&mut self) -> AppResult<()> {
154        self.raw = true;
155        Ok(())
156    }
157
158    fn end_interactive(&mut self) -> AppResult<()> {
159        self.raw = false;
160        Ok(())
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::{Key, ScriptedTerminal, Terminal};
167
168    #[test]
169    fn replays_lines_then_eof() {
170        let mut term = ScriptedTerminal::line_driven().with_lines(["one", "two"]);
171        assert_eq!(term.read_line().expect("line"), Some("one".to_string()));
172        assert_eq!(term.read_line().expect("line"), Some("two".to_string()));
173        assert_eq!(term.read_line().expect("eof"), None);
174    }
175
176    #[test]
177    fn replays_keys_and_captures_output() {
178        let mut term = ScriptedTerminal::key_driven().with_keys([Key::Down, Key::Enter]);
179        term.write_line("frame").expect("write");
180        assert_eq!(term.read_key().expect("key"), Key::Down);
181        assert_eq!(term.read_key().expect("key"), Key::Enter);
182        assert!(term.read_key().is_err());
183        assert_eq!(term.output(), "frame\n");
184    }
185
186    #[test]
187    fn interactive_lifecycle_toggles() {
188        let mut term = ScriptedTerminal::key_driven();
189        assert!(!term.is_interactive());
190        term.begin_interactive().expect("begin");
191        assert!(term.is_interactive());
192        term.end_interactive().expect("end");
193        assert!(!term.is_interactive());
194    }
195
196    #[test]
197    fn reading_a_line_when_a_key_is_queued_is_an_error() {
198        let mut term = ScriptedTerminal::line_driven().with_key(Key::Enter);
199        let err = term.read_line().expect_err("mismatched input");
200        assert!(err.message().contains("expected a line"));
201    }
202
203    #[test]
204    fn reading_a_key_when_a_line_is_queued_is_an_error() {
205        let mut term = ScriptedTerminal::key_driven().with_line("typed");
206        let err = term.read_key().expect_err("mismatched input");
207        assert!(err.message().contains("expected a key"));
208    }
209}