Skip to main content

rich_interact/
policy.rs

1//! When the terminal is not interactive (#492): detect it, and degrade
2//! without control sequences and without blocking.
3//!
4//! A component needs a terminal on both ends: keys from stdin, repaints on
5//! stdout. With either redirected, under CI, with `TERM=dumb`, or when the
6//! caller says so, [`run`](crate::run) does not start the terminal session.
7//! It follows the [`Fallback`] instead: ask line by line, return the
8//! component's default, or fail with the reason.
9
10#[cfg(target_arch = "wasm32")]
11use crate::crossterm_stub as crossterm;
12use std::fmt;
13use std::io::{BufRead, IsTerminal, Write};
14
15/// Why a session is not interactive.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Reason {
18    StdinNotTerminal,
19    StdoutNotTerminal,
20    /// Painting to standard error ([`Output::Stderr`](crate::session::Output)),
21    /// which is not a terminal.
22    StderrNotTerminal,
23    /// Neither standard input nor a controlling terminal to read keys from.
24    NoTerminal,
25    /// `CI` is set (and not `0` or `false`).
26    Ci,
27    /// `TERM=dumb`.
28    DumbTerminal,
29    /// The caller asked for it ([`Policy::interactive`] is `Some(false)`).
30    Requested,
31}
32
33impl fmt::Display for Reason {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(match self {
36            Reason::StdinNotTerminal => "standard input is not a terminal",
37            Reason::StdoutNotTerminal => "standard output is not a terminal",
38            Reason::StderrNotTerminal => "standard error is not a terminal",
39            Reason::NoTerminal => "there is no terminal to read keys from",
40            Reason::Ci => "running under CI",
41            Reason::DumbTerminal => "TERM is dumb",
42            Reason::Requested => "interactive mode is turned off",
43        })
44    }
45}
46
47/// What to do when the terminal is not interactive.
48#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum Fallback {
50    /// Ask line by line on stdin and stderr, through
51    /// [`Component::prompt`](crate::Component::prompt); a component without
52    /// a line form uses its default instead.
53    #[default]
54    Prompt,
55    /// Return [`Component::default_value`](crate::Component::default_value).
56    Default,
57    /// Fail with [`NotInteractive::Terminal`].
58    Error,
59}
60
61/// How [`run`](crate::run) decides whether to be interactive.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub struct Policy {
64    /// `Some(true)` forces interactive mode (when both ends are terminals
65    /// anyway: raw mode needs one), `Some(false)` forbids it, `None` detects.
66    pub interactive: Option<bool>,
67    pub fallback: Fallback,
68    /// Read keys from the controlling terminal when standard input is a
69    /// pipe, as pagers and pickers do: `ls | app` can still be driven from
70    /// the keyboard. Off by default, which degrades whenever standard input
71    /// is redirected.
72    pub tty_keys: bool,
73}
74
75impl Policy {
76    /// Detect from the process's own stdin, stdout and environment.
77    pub fn detect(&self) -> Result<(), Reason> {
78        self.decide(
79            std::io::stdin().is_terminal(),
80            std::io::stdout().is_terminal(),
81            |name| std::env::var(name).ok(),
82        )
83    }
84
85    /// Detect for a session that paints to `output`: that stream must be a
86    /// terminal. With [`tty_keys`](Policy::tty_keys), keys may come from the
87    /// controlling terminal while standard input is a pipe
88    /// (`ls | rich filter`).
89    pub fn detect_for(&self, output: crate::session::Output) -> Result<(), Reason> {
90        use std::io::IsTerminal;
91        let stdin = std::io::stdin().is_terminal();
92        if self.interactive == Some(false) {
93            return Err(Reason::Requested);
94        }
95        if !stdin && !(self.tty_keys && crate::session::keys_available()) {
96            return Err(if self.tty_keys {
97                Reason::NoTerminal
98            } else {
99                Reason::StdinNotTerminal
100            });
101        }
102        if !output.is_terminal() {
103            return Err(match output {
104                crate::session::Output::Stdout => Reason::StdoutNotTerminal,
105                crate::session::Output::Stderr => Reason::StderrNotTerminal,
106            });
107        }
108        self.decide(true, true, |name| std::env::var(name).ok())
109    }
110
111    /// The decision, from explicit inputs. `Ok` means interactive.
112    pub fn decide(
113        &self,
114        stdin_terminal: bool,
115        stdout_terminal: bool,
116        env: impl Fn(&str) -> Option<String>,
117    ) -> Result<(), Reason> {
118        if self.interactive == Some(false) {
119            return Err(Reason::Requested);
120        }
121        if !stdin_terminal {
122            return Err(Reason::StdinNotTerminal);
123        }
124        if !stdout_terminal {
125            return Err(Reason::StdoutNotTerminal);
126        }
127        if self.interactive == Some(true) {
128            return Ok(());
129        }
130        if env("TERM").is_some_and(|term| term == "dumb") {
131            return Err(Reason::DumbTerminal);
132        }
133        if env("CI")
134            .is_some_and(|ci| !matches!(ci.to_ascii_lowercase().as_str(), "" | "0" | "false"))
135        {
136            return Err(Reason::Ci);
137        }
138        Ok(())
139    }
140}
141
142/// Why a component could not produce a value without a terminal.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub enum NotInteractive {
145    /// The policy says to fail.
146    Terminal(Reason),
147    /// The policy asked for a default and the component has none.
148    NoDefault(Reason),
149    /// The component has no line-based form.
150    NoPrompt,
151    /// The line-based form got something it could not use.
152    Invalid(String),
153    /// Input ended before an answer and the component has no default.
154    /// [`degrade`](crate::degrade) reports it as [`NotInteractive::NoDefault`]
155    /// with the reason there is no terminal.
156    Ended,
157    /// Ctrl+C at a line prompt read without echo.
158    /// [`degrade`](crate::degrade) reports it as
159    /// [`Outcome::Interrupted`](crate::Outcome::Interrupted).
160    Interrupted,
161}
162
163impl fmt::Display for NotInteractive {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        match self {
166            NotInteractive::Terminal(reason) => write!(f, "not interactive: {reason}"),
167            NotInteractive::NoDefault(reason) => {
168                write!(f, "not interactive ({reason}) and no default value")
169            }
170            NotInteractive::NoPrompt => f.write_str("no line-based prompt for this component"),
171            NotInteractive::Invalid(message) => f.write_str(message),
172            NotInteractive::Ended => f.write_str("input ended without an answer"),
173            NotInteractive::Interrupted => f.write_str("interrupted"),
174        }
175    }
176}
177
178impl std::error::Error for NotInteractive {}
179
180/// Line-based input and output for a degraded component: plain text, no
181/// control sequences.
182pub trait LineIo {
183    fn write(&mut self, text: &str);
184    /// One line without its line break, or `None` at end of input.
185    fn read_line(&mut self) -> Option<String>;
186    /// One line for a secret, typed without echo where there is echo to
187    /// turn off: `Ok(Some(line))`, `Ok(None)` when the user backed out
188    /// (Escape), [`NotInteractive::Ended`] at end of input and
189    /// [`NotInteractive::Interrupted`] on Ctrl+C. The default reads a line
190    /// as usual.
191    fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
192        self.read_line().map(Some).ok_or(NotInteractive::Ended)
193    }
194    /// Write `prompt`, then read a secret as [`read_secret`](Self::read_secret)
195    /// does. One that turns echo off does so before it writes, so nothing
196    /// typed as soon as the prompt shows is echoed. The default writes,
197    /// then reads.
198    fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
199        self.write(prompt);
200        self.read_secret()
201    }
202}
203
204/// A [`LineIo`] whose writes show terminal controls as text, as a view
205/// paints them, line breaks kept: a prompt or header from the command line
206/// cannot drive the terminal (a title, a cleared screen) without a full
207/// session either. [`degrade`](crate::degrade) asks through it.
208pub(crate) struct Shown<'a>(pub(crate) &'a mut dyn LineIo);
209
210impl LineIo for Shown<'_> {
211    fn write(&mut self, text: &str) {
212        let shown: String = text
213            .chars()
214            .map(|c| match c {
215                '\n' => c,
216                _ => crate::paint::visible(c).unwrap_or(c),
217            })
218            .collect();
219        self.0.write(&shown);
220    }
221
222    fn read_line(&mut self) -> Option<String> {
223        self.0.read_line()
224    }
225
226    fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
227        self.0.read_secret()
228    }
229
230    fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
231        let shown: String = prompt
232            .chars()
233            .map(|c| match c {
234                '\n' => c,
235                _ => crate::paint::visible(c).unwrap_or(c),
236            })
237            .collect();
238        self.0.prompt_secret(&shown)
239    }
240}
241
242/// [`LineIo`] on the process: prompts to stderr (stdout may be the data a
243/// script is capturing), answers from stdin.
244pub struct StdLineIo;
245
246impl LineIo for StdLineIo {
247    fn write(&mut self, text: &str) {
248        let mut stderr = std::io::stderr().lock();
249        let _ = stderr.write_all(text.as_bytes());
250        let _ = stderr.flush();
251    }
252
253    fn read_line(&mut self) -> Option<String> {
254        let mut line = String::new();
255        match std::io::stdin().lock().read_line(&mut line) {
256            Ok(0) | Err(_) => None,
257            Ok(_) => Some(line.trim_end_matches(['\n', '\r']).to_string()),
258        }
259    }
260
261    /// With stdin a terminal (stdout redirected, say), read with echo off,
262    /// as Python's `getpass` does: the terminal is in raw mode for the one
263    /// line, and given back after it. Backspace and Ctrl+U edit; Escape
264    /// backs out, Ctrl+D on an empty line ends the input and Ctrl+C
265    /// interrupts. A terminating signal (SIGTERM, SIGHUP, SIGQUIT) while
266    /// the line is read gives the terminal back before the process ends.
267    /// From a pipe, read a line as usual.
268    fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
269        self.prompt_secret("")
270    }
271
272    /// The prompt is written once echo is off, so a key typed as soon as
273    /// it shows is not echoed either.
274    fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
275        if !std::io::stdin().is_terminal() {
276            self.write(prompt);
277            return self.read_line().map(Some).ok_or(NotInteractive::Ended);
278        }
279        let answer = read_hidden(|| self.write(prompt));
280        // The Enter was not echoed either: end the prompt's line.
281        self.write("\n");
282        answer
283    }
284}
285
286/// One line read in raw mode, so nothing typed is echoed; `shown` runs
287/// once raw mode is on (it writes the prompt).
288fn read_hidden(shown: impl FnOnce()) -> Result<Option<String>, NotInteractive> {
289    #[cfg(target_arch = "wasm32")]
290    use crate::crossterm_stub::from_crossterm;
291    #[cfg(not(target_arch = "wasm32"))]
292    use crate::event::from_crossterm;
293    use crate::event::{Event, KeyCode};
294
295    /// Raw mode off again on every way out, a panic and a terminating
296    /// signal included.
297    struct Raw;
298    impl Drop for Raw {
299        fn drop(&mut self) {
300            crate::session::raw_line(false);
301            let _ = crossterm::terminal::disable_raw_mode();
302        }
303    }
304    crate::session::raw_line(true);
305    if crossterm::terminal::enable_raw_mode().is_err() {
306        crate::session::raw_line(false);
307        return Err(NotInteractive::Ended);
308    }
309    let _raw = Raw;
310    shown();
311    let mut line = String::new();
312    loop {
313        let event = crossterm::event::read().map_err(|_| NotInteractive::Ended)?;
314        match from_crossterm(event) {
315            Some(Event::Key(key)) => {
316                let ctrl = key.modifiers.ctrl;
317                match key.code {
318                    KeyCode::Enter => return Ok(Some(line)),
319                    KeyCode::Escape => return Ok(None),
320                    KeyCode::Char('c') if ctrl => return Err(NotInteractive::Interrupted),
321                    KeyCode::Char('d') if ctrl && line.is_empty() => {
322                        return Err(NotInteractive::Ended);
323                    }
324                    KeyCode::Char('u') if ctrl => line.clear(),
325                    KeyCode::Backspace => {
326                        line.pop();
327                    }
328                    KeyCode::Char(c) if !ctrl => line.push(c),
329                    _ => {}
330                }
331            }
332            Some(Event::Paste(text)) => line.push_str(&crate::components::pasted(&text, "")),
333            _ => {}
334        }
335    }
336}
337
338/// [`LineIo`] from a script, for tests: answers in order, prompts kept.
339#[derive(Clone, Debug, Default)]
340pub struct ScriptedLineIo {
341    pub answers: std::collections::VecDeque<String>,
342    pub written: String,
343    /// How many answers were read as secrets.
344    pub secrets: usize,
345}
346
347impl ScriptedLineIo {
348    pub fn new<I: IntoIterator<Item = S>, S: Into<String>>(answers: I) -> ScriptedLineIo {
349        ScriptedLineIo {
350            answers: answers.into_iter().map(Into::into).collect(),
351            written: String::new(),
352            secrets: 0,
353        }
354    }
355}
356
357impl LineIo for ScriptedLineIo {
358    fn write(&mut self, text: &str) {
359        self.written.push_str(text);
360    }
361
362    fn read_line(&mut self) -> Option<String> {
363        self.answers.pop_front()
364    }
365
366    fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
367        self.secrets += 1;
368        self.read_line().map(Some).ok_or(NotInteractive::Ended)
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn env<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
377        move |name| {
378            pairs
379                .iter()
380                .find(|(key, _)| *key == name)
381                .map(|(_, value)| value.to_string())
382        }
383    }
384
385    #[test]
386    fn decides_from_terminals_and_environment() {
387        let auto = Policy::default();
388        assert_eq!(auto.decide(true, true, env(&[])), Ok(()));
389        assert_eq!(
390            auto.decide(false, true, env(&[])),
391            Err(Reason::StdinNotTerminal)
392        );
393        assert_eq!(
394            auto.decide(true, false, env(&[])),
395            Err(Reason::StdoutNotTerminal)
396        );
397        assert_eq!(
398            auto.decide(true, true, env(&[("TERM", "dumb")])),
399            Err(Reason::DumbTerminal)
400        );
401        assert_eq!(
402            auto.decide(true, true, env(&[("CI", "true")])),
403            Err(Reason::Ci)
404        );
405        assert_eq!(auto.decide(true, true, env(&[("CI", "false")])), Ok(()));
406        assert_eq!(auto.decide(true, true, env(&[("CI", "")])), Ok(()));
407        let forced = Policy {
408            interactive: Some(true),
409            ..Policy::default()
410        };
411        assert_eq!(forced.decide(true, true, env(&[("CI", "1")])), Ok(()));
412        // Forcing cannot conjure a terminal.
413        assert_eq!(
414            forced.decide(false, true, env(&[])),
415            Err(Reason::StdinNotTerminal)
416        );
417        let off = Policy {
418            interactive: Some(false),
419            ..Policy::default()
420        };
421        assert_eq!(off.decide(true, true, env(&[])), Err(Reason::Requested));
422    }
423
424    #[test]
425    fn detect_for_honours_a_request_first_whatever_the_output() {
426        let off = Policy {
427            interactive: Some(false),
428            tty_keys: true,
429            ..Policy::default()
430        };
431        for output in [crate::Output::Stdout, crate::Output::Stderr] {
432            assert_eq!(off.detect_for(output), Err(Reason::Requested));
433        }
434    }
435}