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