Skip to main content

wekan_cli/input/
confirmation.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2
3use crate::{command_result::DestructiveOperation, error::AppError};
4
5#[derive(Clone, Copy, Debug, Default, clap::Args)]
6pub struct ConfirmationArgs {
7    /// Skip the destructive-operation confirmation prompt.
8    #[arg(long)]
9    yes: bool,
10}
11
12impl ConfirmationArgs {
13    pub const fn assume_yes() -> Self {
14        Self { yes: true }
15    }
16
17    pub const fn yes(&self) -> bool {
18        self.yes
19    }
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub enum ConfirmationDecision {
24    Proceed,
25    Cancelled,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct ConfirmationRequest {
30    operation: DestructiveOperation,
31    prompt: String,
32}
33
34impl ConfirmationRequest {
35    pub fn new(operation: DestructiveOperation, prompt: impl Into<String>) -> Self {
36        Self {
37            operation,
38            prompt: prompt.into(),
39        }
40    }
41
42    pub const fn operation(&self) -> DestructiveOperation {
43        self.operation
44    }
45
46    pub fn prompt(&self) -> &str {
47        &self.prompt
48    }
49}
50
51pub trait ConfirmationProvider: Send + Sync {
52    fn confirm(&self, request: &ConfirmationRequest) -> Result<bool, AppError>;
53}
54
55pub fn confirm_or_skip(
56    args: &ConfirmationArgs,
57    provider: &dyn ConfirmationProvider,
58    request: &ConfirmationRequest,
59) -> Result<ConfirmationDecision, AppError> {
60    if args.yes {
61        return Ok(ConfirmationDecision::Proceed);
62    }
63
64    provider.confirm(request).map(|confirmed| {
65        if confirmed {
66            ConfirmationDecision::Proceed
67        } else {
68            ConfirmationDecision::Cancelled
69        }
70    })
71}
72
73pub fn escape_terminal_text(value: &str) -> String {
74    value.chars().flat_map(char::escape_default).collect()
75}
76
77#[derive(Clone, Copy, Debug)]
78pub struct SystemConfirmationProvider {
79    allow_interactive: bool,
80}
81
82impl SystemConfirmationProvider {
83    pub const fn interactive() -> Self {
84        Self {
85            allow_interactive: true,
86        }
87    }
88
89    pub const fn for_output(allow_interactive: bool) -> Self {
90        Self { allow_interactive }
91    }
92}
93
94impl Default for SystemConfirmationProvider {
95    fn default() -> Self {
96        Self::interactive()
97    }
98}
99
100impl ConfirmationProvider for SystemConfirmationProvider {
101    fn confirm(&self, request: &ConfirmationRequest) -> Result<bool, AppError> {
102        if !self.allow_interactive || !io::stdin().is_terminal() || !io::stderr().is_terminal() {
103            return Err(AppError::invalid_input(format!(
104                "confirmation is required for {}; rerun with --yes",
105                request.operation().as_command()
106            )));
107        }
108
109        read_confirmation(io::stdin().lock(), io::stderr().lock(), request.prompt())
110    }
111}
112
113fn read_confirmation(
114    mut reader: impl BufRead,
115    mut writer: impl Write,
116    prompt: &str,
117) -> Result<bool, AppError> {
118    loop {
119        write!(writer, "{prompt} [y/N] ")
120            .and_then(|()| writer.flush())
121            .map_err(|error| {
122                AppError::invalid_input(format!("could not write the confirmation prompt: {error}"))
123            })?;
124
125        let mut answer = String::new();
126        let bytes_read = reader.read_line(&mut answer).map_err(|error| {
127            AppError::invalid_input(format!("could not read the confirmation: {error}"))
128        })?;
129        if bytes_read == 0 {
130            return Ok(false);
131        }
132
133        match answer.trim().to_ascii_lowercase().as_str() {
134            "y" | "yes" => return Ok(true),
135            "" | "n" | "no" => return Ok(false),
136            _ => {
137                writeln!(writer, "Please answer yes or no.").map_err(|error| {
138                    AppError::invalid_input(format!(
139                        "could not write the confirmation prompt: {error}"
140                    ))
141                })?;
142            }
143        }
144    }
145}
146
147#[cfg(test)]
148pub(crate) mod tests {
149    use std::{
150        io::{self, BufRead, Cursor, Write},
151        sync::{Arc, Mutex},
152    };
153
154    use super::{
155        ConfirmationArgs, ConfirmationDecision, ConfirmationProvider, ConfirmationRequest,
156        confirm_or_skip, read_confirmation,
157    };
158
159    #[derive(Clone)]
160    pub(crate) struct FakeConfirmationProvider {
161        response: Arc<Mutex<Result<bool, crate::error::AppError>>>,
162        requests: Arc<Mutex<Vec<ConfirmationRequest>>>,
163    }
164
165    impl FakeConfirmationProvider {
166        pub(crate) fn accepting() -> Self {
167            Self::with_response(Ok(true))
168        }
169
170        pub(crate) fn declining() -> Self {
171            Self::with_response(Ok(false))
172        }
173
174        pub(crate) fn failing(error: crate::error::AppError) -> Self {
175            Self::with_response(Err(error))
176        }
177
178        fn with_response(response: Result<bool, crate::error::AppError>) -> Self {
179            Self {
180                response: Arc::new(Mutex::new(response)),
181                requests: Arc::default(),
182            }
183        }
184
185        pub(crate) fn requests(&self) -> Vec<ConfirmationRequest> {
186            self.requests.lock().unwrap().clone()
187        }
188    }
189
190    impl ConfirmationProvider for FakeConfirmationProvider {
191        fn confirm(&self, request: &ConfirmationRequest) -> Result<bool, crate::error::AppError> {
192            self.requests.lock().unwrap().push(request.clone());
193            self.response.lock().unwrap().clone()
194        }
195    }
196
197    #[test]
198    fn disabled_interactivity_requires_yes() {
199        let provider = super::SystemConfirmationProvider::for_output(false);
200        let request = ConfirmationRequest::new(
201            crate::command_result::DestructiveOperation::AuthLogout,
202            "Continue?",
203        );
204
205        let error = provider.confirm(&request).unwrap_err();
206
207        assert_eq!(error.code(), crate::error::ErrorCode::InvalidInput);
208        assert!(error.message().contains("--yes"));
209        assert!(error.message().contains("auth logout"));
210    }
211
212    #[test]
213    fn shared_gate_maps_yes_provider_answers_and_errors() {
214        let request = ConfirmationRequest::new(
215            crate::command_result::DestructiveOperation::ProfileRemove,
216            "Continue?",
217        );
218        let bypassed = FakeConfirmationProvider::failing(crate::error::AppError::invalid_input(
219            "must not be called",
220        ));
221        assert_eq!(
222            confirm_or_skip(&ConfirmationArgs::assume_yes(), &bypassed, &request).unwrap(),
223            ConfirmationDecision::Proceed
224        );
225        assert!(bypassed.requests().is_empty());
226
227        let accepting = FakeConfirmationProvider::accepting();
228        assert_eq!(
229            confirm_or_skip(&ConfirmationArgs::default(), &accepting, &request).unwrap(),
230            ConfirmationDecision::Proceed
231        );
232        let declining = FakeConfirmationProvider::declining();
233        assert_eq!(
234            confirm_or_skip(&ConfirmationArgs::default(), &declining, &request).unwrap(),
235            ConfirmationDecision::Cancelled
236        );
237        let failing = FakeConfirmationProvider::failing(crate::error::AppError::invalid_input(
238            "test failure",
239        ));
240        assert_eq!(
241            confirm_or_skip(&ConfirmationArgs::default(), &failing, &request)
242                .unwrap_err()
243                .message(),
244            "test failure"
245        );
246    }
247
248    #[test]
249    fn accepts_case_insensitive_yes_forms() {
250        for answer in ["y\n", "YES\n", " Yes \r\n"] {
251            assert!(read_confirmation(Cursor::new(answer), Vec::new(), "Continue?").unwrap());
252        }
253    }
254
255    #[test]
256    fn no_empty_and_eof_cancel() {
257        for answer in ["n\n", "NO\n", "\n", ""] {
258            assert!(!read_confirmation(Cursor::new(answer), Vec::new(), "Continue?").unwrap());
259        }
260    }
261
262    #[test]
263    fn invalid_answers_are_retried() {
264        let mut output = Vec::new();
265        assert!(read_confirmation(Cursor::new("maybe\nyes\n"), &mut output, "Continue?").unwrap());
266        let output = String::from_utf8(output).unwrap();
267        assert_eq!(output.matches("Continue? [y/N]").count(), 2);
268        assert!(output.contains("Please answer yes or no."));
269    }
270
271    struct FailingWriter;
272
273    impl Write for FailingWriter {
274        fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
275            Err(io::Error::other("test write failure"))
276        }
277
278        fn flush(&mut self) -> io::Result<()> {
279            Ok(())
280        }
281    }
282
283    #[test]
284    fn prompt_write_failures_are_invalid_input() {
285        let error =
286            read_confirmation(Cursor::new("yes\n"), FailingWriter, "Continue?").unwrap_err();
287        assert!(error.message().contains("test write failure"));
288    }
289
290    struct FailingReader;
291
292    impl io::Read for FailingReader {
293        fn read(&mut self, _buffer: &mut [u8]) -> io::Result<usize> {
294            Err(io::Error::other("test read failure"))
295        }
296    }
297
298    impl BufRead for FailingReader {
299        fn fill_buf(&mut self) -> io::Result<&[u8]> {
300            Err(io::Error::other("test read failure"))
301        }
302
303        fn consume(&mut self, _amount: usize) {}
304    }
305
306    #[test]
307    fn prompt_read_failures_are_invalid_input() {
308        let error = read_confirmation(FailingReader, Vec::new(), "Continue?").unwrap_err();
309        assert!(error.message().contains("test read failure"));
310    }
311
312    #[test]
313    fn terminal_text_escapes_control_characters() {
314        assert_eq!(
315            super::escape_terminal_text("board\u{1b}]52;c;clipboard\u{7}\n"),
316            r"board\u{1b}]52;c;clipboard\u{7}\n"
317        );
318    }
319}