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 #[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}