use crate::cli::output::Output;
use scope::error::ScopeError;
use std::io::{self, BufRead};
use std::sync::atomic::AtomicBool;
#[cfg(any(unix, test))]
use std::sync::atomic::Ordering;
pub(crate) static CANCEL: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
const POLL_INTERVAL_MS: libc::c_int = 100;
#[cfg(any(unix, windows))]
fn cancelled() -> io::Error {
io::Error::new(io::ErrorKind::Interrupted, "cancelled")
}
pub(crate) fn ask(reader: &mut impl BufRead, out: &Output, question: &str) -> io::Result<String> {
crate::cli::progress::paused(|| {
crate::err!(out, "{question}")?;
let mut answer = String::new();
reader.read_line(&mut answer)?;
Ok(answer)
})
}
pub fn ask_stdin(out: &Output, question: &str) -> io::Result<String> {
ask(&mut stdin(), out, question)
}
pub fn ask_timeout(
out: &Output,
question: &str,
timeout: std::time::Duration,
) -> io::Result<Option<String>> {
crate::cli::progress::paused(|| {
crate::err!(out, "{question}")?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut answer = String::new();
let read = stdin().read_line(&mut answer).map(|_| answer);
let _ = tx.send(read);
});
match rx.recv_timeout(timeout) {
Ok(read) => read.map(Some),
Err(_) => Ok(None),
}
})
}
pub(crate) fn pick(
reader: &mut impl BufRead,
out: &Output,
list: &str,
what: &str,
count: usize,
) -> scope::error::Result<usize> {
let question = format!("{list}\nSelect {what} (1-{count}): ");
let answer = ask(reader, out, &question)?;
let n: usize = answer
.trim()
.parse()
.map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
if !(1..=count).contains(&n) {
return Err(ScopeError::Api(format!(
"Selection must be between 1 and {count}"
)));
}
Ok(n - 1)
}
#[cfg(unix)]
pub(crate) fn stdin() -> PromptInput<'static> {
PromptInput::new(libc::STDIN_FILENO, &CANCEL)
}
#[cfg(windows)]
pub(crate) fn stdin() -> console::ConsoleInput<'static> {
console::ConsoleInput::new(&CANCEL)
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn stdin() -> io::StdinLock<'static> {
io::stdin().lock()
}
#[cfg(windows)]
mod console {
use super::cancelled;
use std::io::{self, BufRead, Read};
use std::sync::atomic::{AtomicBool, Ordering};
use windows_sys::Win32::Foundation::{ERROR_OPERATION_ABORTED, GetLastError, HANDLE};
use windows_sys::Win32::System::Console::{
GetConsoleMode, GetStdHandle, ReadConsoleW, STD_INPUT_HANDLE,
};
pub(crate) struct ConsoleInput<'a> {
console: Option<HANDLE>,
std: io::StdinLock<'static>,
cancel: &'a AtomicBool,
pending: Vec<u8>,
}
impl<'a> ConsoleInput<'a> {
pub(crate) fn new(cancel: &'a AtomicBool) -> Self {
let console = unsafe {
let handle = GetStdHandle(STD_INPUT_HANDLE);
let mut mode = 0u32;
(GetConsoleMode(handle, &mut mode) != 0).then_some(handle)
};
Self {
console,
std: io::stdin().lock(),
cancel,
pending: Vec::new(),
}
}
fn read_console_line(&mut self, handle: HANDLE) -> io::Result<()> {
let mut line: Vec<u16> = Vec::new();
let mut buf = [0u16; 1024];
loop {
if self.cancel.load(Ordering::SeqCst) {
return Err(cancelled());
}
let mut read = 0u32;
let ok = unsafe {
ReadConsoleW(
handle,
buf.as_mut_ptr().cast(),
buf.len() as u32,
&mut read,
std::ptr::null(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
if read == 0 {
if unsafe { GetLastError() } == ERROR_OPERATION_ABORTED {
self.cancel.store(true, Ordering::SeqCst);
return Err(cancelled());
}
break;
}
let got = &buf[..read as usize];
line.extend_from_slice(got);
if line.first() == Some(&0x1a) {
line.clear();
break;
}
if got.last() == Some(&u16::from(b'\n')) {
break;
}
}
let text = String::from_utf16(&line).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"console input was not valid UTF-16",
)
})?;
self.pending = text.into_bytes();
Ok(())
}
}
impl Read for ConsoleInput<'_> {
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
let available = self.fill_buf()?;
let n = available.len().min(out.len());
out[..n].copy_from_slice(&available[..n]);
self.consume(n);
Ok(n)
}
}
impl BufRead for ConsoleInput<'_> {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
let Some(handle) = self.console else {
return self.std.fill_buf();
};
if self.pending.is_empty() {
match self.read_console_line(handle) {
Err(e) if e.kind() == io::ErrorKind::Interrupted => {
return Err(io::Error::other(e));
}
other => other?,
}
}
Ok(&self.pending)
}
fn consume(&mut self, amt: usize) {
if self.console.is_none() {
self.std.consume(amt);
} else {
self.pending.drain(..amt.min(self.pending.len()));
}
}
fn read_line(&mut self, buf: &mut String) -> io::Result<usize> {
let Some(handle) = self.console else {
return self.std.read_line(buf);
};
if self.pending.is_empty() {
self.read_console_line(handle)?;
}
let end = self
.pending
.iter()
.position(|&b| b == b'\n')
.map_or(self.pending.len(), |i| i + 1);
let line: Vec<u8> = self.pending.drain(..end).collect();
let text = String::from_utf8(line).expect("decoded from UTF-16");
buf.push_str(&text);
Ok(text.len())
}
}
}
#[cfg(unix)]
pub(crate) struct PromptInput<'a> {
fd: std::os::fd::RawFd,
cancel: &'a AtomicBool,
byte: [u8; 1],
has_byte: bool,
}
#[cfg(unix)]
impl<'a> PromptInput<'a> {
pub(crate) fn new(fd: std::os::fd::RawFd, cancel: &'a AtomicBool) -> Self {
Self {
fd,
cancel,
byte: [0],
has_byte: false,
}
}
fn read_byte(&mut self) -> io::Result<Option<u8>> {
if std::mem::take(&mut self.has_byte) {
return Ok(Some(self.byte[0]));
}
loop {
if self.cancel.load(Ordering::SeqCst) {
return Err(cancelled());
}
let mut pfd = libc::pollfd {
fd: self.fd,
events: libc::POLLIN,
revents: 0,
};
let ready = unsafe { libc::poll(&mut pfd, 1, POLL_INTERVAL_MS) };
if ready < 0 {
let e = io::Error::last_os_error();
if e.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(e);
}
if ready == 0 {
continue;
}
let mut b = 0u8;
let n = unsafe { libc::read(self.fd, (&mut b as *mut u8).cast(), 1) };
match n {
1 => return Ok(Some(b)),
0 => return Ok(None),
_ => {
let e = io::Error::last_os_error();
if matches!(
e.kind(),
io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock
) {
continue;
}
return Err(e);
}
}
}
}
}
#[cfg(unix)]
impl io::Read for PromptInput<'_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let Some(first) = buf.first_mut() else {
return Ok(0);
};
Ok(match self.read_byte()? {
Some(b) => {
*first = b;
1
}
None => 0,
})
}
}
#[cfg(unix)]
impl BufRead for PromptInput<'_> {
fn fill_buf(&mut self) -> io::Result<&[u8]> {
if !self.has_byte {
match self.read_byte() {
Ok(Some(b)) => {
self.byte[0] = b;
self.has_byte = true;
}
Ok(None) => {}
Err(e) if e.kind() == io::ErrorKind::Interrupted => {
return Err(io::Error::other(e));
}
Err(e) => return Err(e),
}
}
Ok(&self.byte[..usize::from(self.has_byte)])
}
fn consume(&mut self, amt: usize) {
if amt > 0 {
self.has_byte = false;
}
}
fn read_line(&mut self, buf: &mut String) -> io::Result<usize> {
let mut line = Vec::new();
while let Some(b) = self.read_byte()? {
line.push(b);
if b == b'\n' {
break;
}
}
let text = String::from_utf8(line).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)
})?;
buf.push_str(&text);
Ok(text.len())
}
}
#[cfg(test)]
mod ask_tests {
use super::*;
use crate::cli::progress::testing::{REGISTRY_LOCK, Screen, drawing_spinner};
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::time::Duration;
struct SlowAnswer<'a> {
screen: &'a Screen,
draws_at_answer: Option<usize>,
}
impl io::Read for SlowAnswer<'_> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if self.draws_at_answer.is_some() {
return Ok(0);
}
std::thread::sleep(Duration::from_millis(40));
self.draws_at_answer = Some(self.screen.ops().len());
buf[..2].copy_from_slice(b"y\n");
Ok(2)
}
}
struct Question {
screen: Screen,
draws_at_question: Arc<AtomicUsize>,
}
impl Write for Question {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.draws_at_question
.store(self.screen.ops().len(), Ordering::SeqCst);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[test]
fn test_ask_keeps_a_ticking_spinner_off_the_question() {
let _lock = REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let screen = Screen::default();
let sp = drawing_spinner("Searching...", &Output::capture().0, &screen);
std::thread::sleep(Duration::from_millis(30));
let mut answer = SlowAnswer {
screen: &screen,
draws_at_answer: None,
};
let mut reader = io::BufReader::new(&mut answer);
let draws_at_question = Arc::new(AtomicUsize::new(0));
let out = Output::from_writers(
Vec::new(),
Question {
screen: screen.clone(),
draws_at_question: draws_at_question.clone(),
},
);
let got = ask(&mut reader, &out, "Save? [y/N]: ").unwrap();
drop(reader);
assert_eq!(got, "y\n");
assert_eq!(
answer.draws_at_answer,
Some(draws_at_question.load(Ordering::SeqCst)),
"the spinner drew between the question and the answer"
);
sp.finish_and_clear();
}
#[test]
fn test_a_prompt_never_reaches_the_data_channel() {
let (out, cap) = Output::capture();
let got = ask(&mut io::Cursor::new(b"y\n".to_vec()), &out, "Save? [y/N]: ").unwrap();
assert_eq!(got, "y\n");
assert_eq!(cap.out(), "");
assert_eq!(cap.err(), "Save? [y/N]: ");
}
#[test]
fn test_ask_returns_the_write_error() {
let out = Output::from_writers(Vec::new(), crate::cli::output::tests::ClosedPipe);
let e = ask(&mut io::Cursor::new(b"y\n".to_vec()), &out, "q? ").unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::BrokenPipe);
}
#[test]
fn test_pick_returns_the_index_and_rejects_bad_answers() {
let pick_with = |answer: &str| {
let (out, cap) = Output::capture();
let got = pick(
&mut io::Cursor::new(answer.as_bytes().to_vec()),
&out,
"1 a\n2 b\n",
"token",
2,
);
assert_eq!(cap.err(), "1 a\n2 b\n\nSelect token (1-2): ");
assert_eq!(cap.out(), "", "the list is not data");
got.map_err(|e| e.to_string())
};
assert_eq!(pick_with("2\n"), Ok(1));
assert!(pick_with("x\n").unwrap_err().contains("Invalid selection"));
assert!(
pick_with("3\n")
.unwrap_err()
.contains("Selection must be between 1 and 2")
);
assert!(
pick_with("0\n")
.unwrap_err()
.contains("Selection must be between 1 and 2")
);
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::io::Read;
use std::os::fd::RawFd;
use std::time::{Duration, Instant};
struct Pipe(RawFd, RawFd);
impl Pipe {
fn new() -> Self {
let mut fds = [0; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
Pipe(fds[0], fds[1])
}
fn write(&self, bytes: &[u8]) {
let n = unsafe { libc::write(self.1, bytes.as_ptr().cast(), bytes.len()) };
assert_eq!(n, bytes.len() as isize);
}
fn close_write(&mut self) {
unsafe { libc::close(self.1) };
self.1 = -1;
}
fn rest(&mut self) -> Vec<u8> {
self.close_write();
let mut out = Vec::new();
let mut b = [0u8; 64];
loop {
let n = unsafe { libc::read(self.0, b.as_mut_ptr().cast(), b.len()) };
if n <= 0 {
return out;
}
out.extend_from_slice(&b[..n as usize]);
}
}
}
impl Drop for Pipe {
fn drop(&mut self) {
for fd in [self.0, self.1] {
if fd >= 0 {
unsafe { libc::close(fd) };
}
}
}
}
#[test]
fn test_reads_one_line_and_leaves_the_rest() {
let flag = AtomicBool::new(false);
let mut pipe = Pipe::new();
pipe.write(b"yes\nnext\n");
let mut input = PromptInput::new(pipe.0, &flag);
let mut line = String::from("old:");
assert_eq!(input.read_line(&mut line).unwrap(), 4);
assert_eq!(line, "old:yes\n", "appends, as read_line does");
assert_eq!(pipe.rest(), b"next\n");
}
#[test]
fn test_end_of_input_returns_the_partial_line_then_zero() {
let flag = AtomicBool::new(false);
let mut pipe = Pipe::new();
pipe.write(b"abc");
pipe.close_write();
let mut input = PromptInput::new(pipe.0, &flag);
let mut line = String::new();
assert_eq!(input.read_line(&mut line).unwrap(), 3);
assert_eq!(line, "abc");
assert_eq!(input.read_line(&mut line).unwrap(), 0);
}
#[test]
fn test_invalid_utf8_is_an_error_and_leaves_the_buffer() {
let flag = AtomicBool::new(false);
let pipe = Pipe::new();
pipe.write(b"\xff\n");
let mut input = PromptInput::new(pipe.0, &flag);
let mut line = String::from("kept");
let e = input.read_line(&mut line).unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::InvalidData);
assert_eq!(line, "kept");
}
#[test]
fn test_cancel_stops_a_waiting_read_and_takes_no_later_input() {
let flag = AtomicBool::new(false);
let mut pipe = Pipe::new();
let started = Instant::now();
let e = std::thread::scope(|s| {
s.spawn(|| {
std::thread::sleep(Duration::from_millis(150));
flag.store(true, Ordering::SeqCst);
});
let mut line = String::new();
PromptInput::new(pipe.0, &flag)
.read_line(&mut line)
.unwrap_err()
});
assert_eq!(e.kind(), io::ErrorKind::Interrupted);
assert!(started.elapsed() < Duration::from_secs(1), "stopped late");
pipe.write(b"k");
std::thread::sleep(Duration::from_millis(2 * POLL_INTERVAL_MS as u64));
assert_eq!(pipe.rest(), b"k", "the key after the cancel is still there");
}
#[test]
fn test_a_set_flag_stops_the_read_before_it_waits() {
let flag = AtomicBool::new(true);
let mut pipe = Pipe::new();
pipe.write(b"y\n");
let mut line = String::new();
let e = PromptInput::new(pipe.0, &flag)
.read_line(&mut line)
.unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::Interrupted);
assert_eq!(pipe.rest(), b"y\n");
}
#[test]
fn test_read_and_fill_buf_take_one_byte_at_a_time() {
let flag = AtomicBool::new(false);
let mut pipe = Pipe::new();
pipe.write(b"ab");
let mut input = PromptInput::new(pipe.0, &flag);
assert_eq!(input.read(&mut []).unwrap(), 0);
assert_eq!(input.fill_buf().unwrap(), b"a");
assert_eq!(input.fill_buf().unwrap(), b"a", "not consumed yet");
let mut buf = [0u8; 4];
assert_eq!(input.read(&mut buf).unwrap(), 1, "the buffered byte");
assert_eq!(buf[0], b'a');
input.consume(0);
assert_eq!(input.fill_buf().unwrap(), b"b");
input.consume(1);
assert_eq!(pipe.rest(), b"");
assert_eq!(input.fill_buf().unwrap(), b"", "end of input");
assert_eq!(input.read(&mut buf).unwrap(), 0);
}
#[test]
fn test_fill_buf_cancel_is_not_interrupted() {
let flag = AtomicBool::new(true);
let pipe = Pipe::new();
let mut input = PromptInput::new(pipe.0, &flag);
let e = input.fill_buf().unwrap_err();
assert_eq!(e.kind(), io::ErrorKind::Other);
let mut v = Vec::new();
assert!(input.read_until(b'\n', &mut v).is_err(), "no endless retry");
}
#[test]
fn test_a_read_error_is_returned() {
let flag = AtomicBool::new(false);
let fd = unsafe { libc::open(c"/".as_ptr(), libc::O_RDONLY) };
assert!(fd >= 0);
let mut line = String::new();
let r = PromptInput::new(fd, &flag).read_line(&mut line);
unsafe { libc::close(fd) };
assert!(r.is_err());
assert_ne!(r.unwrap_err().kind(), io::ErrorKind::Interrupted);
}
#[test]
fn test_stdin_reads_from_the_process_cancel_flag() {
let input = stdin();
assert_eq!(input.fd, libc::STDIN_FILENO);
assert!(std::ptr::eq(input.cancel, &CANCEL));
}
#[test]
fn test_fill_buf_returns_a_read_error_as_it_is() {
let flag = AtomicBool::new(false);
let fd = unsafe { libc::open(c"/".as_ptr(), libc::O_RDONLY) };
assert!(fd >= 0);
let r = PromptInput::new(fd, &flag).fill_buf().map(<[u8]>::to_vec);
unsafe { libc::close(fd) };
assert_eq!(r.unwrap_err().raw_os_error(), Some(libc::EISDIR));
}
extern "C" fn ignore_signal(_: libc::c_int) {}
#[test]
fn test_a_signal_during_the_wait_does_not_end_the_read() {
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = ignore_signal as extern "C" fn(libc::c_int) as usize;
libc::sigemptyset(&mut action.sa_mask);
assert_eq!(
libc::sigaction(libc::SIGUSR1, &action, std::ptr::null_mut()),
0
);
}
let flag = AtomicBool::new(false);
let pipe = Pipe::new();
let fd = pipe.0;
let (tx, rx) = std::sync::mpsc::channel();
let line = std::thread::scope(|s| {
let reader = s.spawn(|| {
tx.send(unsafe { libc::pthread_self() }).unwrap();
let mut line = String::new();
PromptInput::new(fd, &flag)
.read_line(&mut line)
.map(|_| line)
});
let thread = rx.recv().unwrap();
for _ in 0..10 {
std::thread::sleep(Duration::from_millis(20));
assert_eq!(unsafe { libc::pthread_kill(thread, libc::SIGUSR1) }, 0);
}
pipe.write(b"still here\n");
reader.join().unwrap()
});
assert_eq!(line.unwrap(), "still here\n");
}
struct PipedStdin {
pipe: Pipe,
saved: RawFd,
_lock: tokio::sync::MutexGuard<'static, ()>,
}
impl PipedStdin {
fn new() -> Self {
let lock = crate::cli::tui::DIAG_LOCK.blocking_lock();
let pipe = Pipe::new();
let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
assert!(saved >= 0);
assert_eq!(unsafe { libc::dup2(pipe.0, libc::STDIN_FILENO) }, 0);
Self {
pipe,
saved,
_lock: lock,
}
}
fn unread(&self) -> usize {
let mut n: libc::c_int = 0;
assert_eq!(
unsafe { libc::ioctl(self.pipe.0, libc::FIONREAD, &mut n) },
0
);
n as usize
}
}
impl Drop for PipedStdin {
fn drop(&mut self) {
unsafe {
libc::dup2(self.saved, libc::STDIN_FILENO);
libc::close(self.saved);
}
}
}
#[test]
fn test_ask_stdin_asks_on_the_diagnostic_channel_and_reads_stdin() {
let stdin = PipedStdin::new();
stdin.pipe.write(b"yes\nnext\n");
let (out, cap) = Output::capture();
assert_eq!(ask_stdin(&out, "Continue? ").unwrap(), "yes\n");
assert_eq!(cap.err(), "Continue? ");
assert_eq!(cap.out(), "");
assert_eq!(
stdin.unread(),
5,
"the next line is left for the next prompt"
);
}
#[test]
fn test_ask_timeout_returns_the_answer_or_none_when_time_runs_out() {
let stdin = PipedStdin::new();
let (out, cap) = Output::capture();
stdin.pipe.write(b"n\n");
let got = ask_timeout(&out, "Stop Ghola? ", Duration::from_secs(5)).unwrap();
assert_eq!(got.as_deref(), Some("n\n"));
assert_eq!(cap.err(), "Stop Ghola? ");
let started = Instant::now();
let got = ask_timeout(&out, "Again? ", Duration::from_millis(50)).unwrap();
assert_eq!(got, None);
assert!(
started.elapsed() < Duration::from_secs(2),
"waited too long"
);
assert_eq!(cap.err(), "Stop Ghola? Again? ");
stdin.pipe.write(b"late\n");
let deadline = Instant::now() + Duration::from_secs(5);
while stdin.unread() > 0 && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(stdin.unread(), 0);
}
}