Skip to main content

proc_cli/ui/
prompt.rs

1//! The one place proc asks a yes/no question.
2//!
3//! Every command that changes processes asks through [`ask`]: `kill`, `stop`,
4//! `freeze`, `thaw`, `free`, `unstick`, `stuck --kill` and `orphans --kill`.
5//! (`clippy.toml` bans dialoguer's `interact` methods everywhere else.)
6//!
7//! A person who approves something must be able to see it. The list of what
8//! would change used to go to stdout while the question went to stderr, so
9//! with stdout in a file, or a pipe whose reader had left (`proc kill node |
10//! head -n1`), the question came with no list and `y` was a blind yes. Now:
11//!
12//! - **stdout is a terminal**: the list goes to stdout, as it always did.
13//! - **stdout is anything else** (a file, a pipe): the list goes to stderr,
14//!   right above the question, which is on stderr and so is a terminal here
15//!   (see [`must_confirm`](crate::ui::must_confirm)). The person sees what they
16//!   approve and stdout stays what the command's output is.
17//! - **the list could not be written** where it was meant to go: nothing is
18//!   asked and nothing is changed. It is exit 4, `needs_confirmation`, like
19//!   any other case where nobody can be asked.
20//!
21//! The question also restores the terminal when a signal ends proc while it
22//! is waiting (see [`interrupt`]), and shows the cursor again when it is over
23//! however it ended.
24
25use crate::core::Process;
26use crate::error::{ProcError, Result};
27use crate::ui::{interrupt, out};
28use crossterm::{cursor, execute};
29use dialoguer::Confirm;
30
31/// Show `show`, the list of `processes` that `action` is about to change, and
32/// ask `question`. `Ok(true)` is a yes and `Ok(false)` a no.
33///
34/// `show` prints the list with the usual output macros; this decides which
35/// stream they reach. When the list cannot be shown the result is the error
36/// `ConfirmationRequired`, carrying `processes` and `not_found` as every
37/// refusal does.
38pub fn ask(
39    action: &str,
40    processes: &[Process],
41    not_found: Option<&[String]>,
42    question: &str,
43    show: impl FnOnce(),
44) -> Result<bool> {
45    let shown = list_shown(out::stdout_is_terminal(), show);
46    answer_if_shown(shown, action, processes, not_found, || interact(question))
47}
48
49/// Run `show` where the person will see it, and say whether it got there
50fn list_shown(stdout_is_terminal: bool, show: impl FnOnce()) -> bool {
51    if stdout_is_terminal {
52        show();
53        out::stdout_usable()
54    } else {
55        out::on_stderr(show);
56        out::stderr_usable()
57    }
58}
59
60/// Ask only if the list was shown; refuse otherwise
61fn answer_if_shown(
62    shown: bool,
63    action: &str,
64    processes: &[Process],
65    not_found: Option<&[String]>,
66    answer: impl FnOnce() -> Result<bool>,
67) -> Result<bool> {
68    if !shown {
69        return Err(ProcError::ConfirmationRequired {
70            message: format!(
71                "{action} asks before changing processes, and the list of what it would \
72                 change could not be shown; pass --yes (or --dry-run to preview)"
73            ),
74            processes: processes.to_vec(),
75            not_found: not_found.map(<[String]>::to_vec),
76        });
77    }
78    answer()
79}
80
81/// The question itself: the one call to dialoguer
82#[allow(clippy::disallowed_methods)]
83fn interact(question: &str) -> Result<bool> {
84    let _protected = interrupt::protect_prompt();
85    let answer = Confirm::new()
86        .with_prompt(question)
87        .default(false)
88        .interact();
89    // Ctrl+C typed at the prompt is an interrupted read, not a signal
90    if let Err(dialoguer::Error::IO(error)) = &answer {
91        if error.kind() == std::io::ErrorKind::Interrupted {
92            interrupt::ctrl_c();
93        }
94    }
95    // dialoguer hides the cursor while it waits; a prompt that ended in an
96    // error leaves it hidden
97    let _ = execute!(out::stderr_unrecorded(), cursor::Show);
98    Ok(answer?)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use std::cell::Cell;
105
106    #[test]
107    fn the_list_is_shown_on_either_stream_and_the_question_waits_for_it() {
108        for stdout_is_terminal in [true, false] {
109            let shown = Cell::new(0);
110            assert!(list_shown(stdout_is_terminal, || shown.set(shown.get() + 1)));
111            assert_eq!(shown.get(), 1);
112        }
113    }
114
115    #[test]
116    fn a_list_that_was_not_shown_is_a_refusal_with_what_would_have_changed() {
117        let missing = vec!["ghost".to_string()];
118        let refused = answer_if_shown(false, "kill", &[], Some(&missing), || panic!("asked"))
119            .expect_err("nothing is asked when the list was not shown");
120        assert_eq!(crate::error::ExitCode::from(&refused) as i32, 4);
121        assert_eq!(refused.reason(), Some("needs_confirmation"));
122        assert!(
123            refused.to_string().contains("could not be shown"),
124            "{refused}"
125        );
126        assert!(format!("{refused:?}").contains("ghost"), "{refused:?}");
127    }
128
129    #[test]
130    fn a_shown_list_leaves_the_answer_to_the_person() {
131        assert!(answer_if_shown(true, "kill", &[], None, || Ok(true)).unwrap());
132        assert!(!answer_if_shown(true, "kill", &[], None, || Ok(false)).unwrap());
133    }
134}