1#[cfg(target_arch = "wasm32")]
11use crate::crossterm_stub as crossterm;
12use std::fmt;
13use std::io::{BufRead, IsTerminal, Write};
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum Reason {
18 StdinNotTerminal,
19 StdoutNotTerminal,
20 StderrNotTerminal,
23 NoTerminal,
25 Ci,
27 DumbTerminal,
29 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
49pub enum Fallback {
50 #[default]
54 Prompt,
55 Default,
57 Error,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub struct Policy {
64 pub interactive: Option<bool>,
67 pub fallback: Fallback,
68 pub tty_keys: bool,
73}
74
75impl Policy {
76 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
144pub enum NotInteractive {
145 Terminal(Reason),
147 NoDefault(Reason),
149 NoPrompt,
151 Invalid(String),
153 Ended,
157 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
180pub trait LineIo {
183 fn write(&mut self, text: &str);
184 fn read_line(&mut self) -> Option<String>;
186 fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
192 self.read_line().map(Some).ok_or(NotInteractive::Ended)
193 }
194 fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>, NotInteractive> {
199 self.write(prompt);
200 self.read_secret()
201 }
202}
203
204pub(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
242pub 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 fn read_secret(&mut self) -> Result<Option<String>, NotInteractive> {
269 self.prompt_secret("")
270 }
271
272 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 self.write("\n");
282 answer
283 }
284}
285
286fn 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 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#[derive(Clone, Debug, Default)]
340pub struct ScriptedLineIo {
341 pub answers: std::collections::VecDeque<String>,
342 pub written: String,
343 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 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}