Skip to content

Commit 620f1de

Browse files
fix(cli): replace dialoguer drop prompt with line-based read for accessibility
`sqlx database drop` used dialoguer's raw-mode Y/N toggle, which doesn't echo keypresses and repaints on each press — which screen readers and large-print users find disorienting (see the two failure modes described in #4236). Swap to a plain `stdin().read_line` with an explicit `(y/N)` hint: no raw mode, keys echo naturally, and screen readers get a predictable line-at-a-time interaction. `--force` / `-y` / non-TTY paths are unchanged. Closes #4236
1 parent b180eba commit 620f1de

1 file changed

Lines changed: 47 additions & 37 deletions

File tree

sqlx-cli/src/database.rs

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
use crate::opt::{ConnectOpts, MigrationSourceOpt};
22
use crate::{migrate, Config};
3-
use console::{style, Term};
4-
use dialoguer::Confirm;
3+
use console::style;
54
use sqlx::any::Any;
65
use sqlx::migrate::MigrateDatabase;
7-
use std::{io, mem};
6+
use std::io::{self, BufRead, Write};
87
use tokio::task;
98

109
pub async fn create(connect_opts: &ConnectOpts) -> anyhow::Result<()> {
@@ -66,45 +65,56 @@ pub async fn setup(
6665
}
6766

6867
async fn ask_to_continue_drop(db_url: String) -> bool {
69-
// If the setup operation is cancelled while we are waiting for the user to decide whether
70-
// or not to drop the database, this will restore the terminal's cursor to its normal state.
71-
struct RestoreCursorGuard {
72-
disarmed: bool,
73-
}
68+
task::spawn_blocking(move || {
69+
let stderr = io::stderr();
70+
let mut stderr_lock = stderr.lock();
71+
let _ = write!(
72+
stderr_lock,
73+
"Drop database at {}? (y/N): ",
74+
style(&db_url).cyan()
75+
);
76+
let _ = stderr_lock.flush();
77+
std::mem::drop(stderr_lock);
7478

75-
impl Drop for RestoreCursorGuard {
76-
fn drop(&mut self) {
77-
if !self.disarmed {
78-
Term::stderr().show_cursor().unwrap()
79-
}
79+
let stdin = io::stdin();
80+
let mut line = String::new();
81+
match stdin.lock().read_line(&mut line) {
82+
Ok(0) | Err(_) => false,
83+
Ok(_) => parse_drop_response(&line),
8084
}
81-
}
82-
83-
let mut guard = RestoreCursorGuard { disarmed: false };
84-
85-
let decision_result = task::spawn_blocking(move || {
86-
Confirm::new()
87-
.with_prompt(format!("Drop database at {}?", style(&db_url).cyan()))
88-
.wait_for_newline(true)
89-
.default(false)
90-
.show_default(true)
91-
.interact()
9285
})
9386
.await
94-
.expect("Confirm thread panicked");
95-
match decision_result {
96-
Ok(decision) => {
97-
guard.disarmed = true;
98-
decision
99-
}
100-
Err(dialoguer::Error::IO(err)) if err.kind() == io::ErrorKind::Interrupted => {
101-
// Sometimes CTRL + C causes this error to be returned
102-
mem::drop(guard);
103-
false
87+
.expect("Confirm thread panicked")
88+
}
89+
90+
fn parse_drop_response(line: &str) -> bool {
91+
let trimmed = line.trim();
92+
trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes")
93+
}
94+
95+
#[cfg(test)]
96+
mod tests {
97+
use super::parse_drop_response;
98+
99+
#[test]
100+
fn parse_drop_response_accepts_yes() {
101+
for input in ["y", "Y", "yes", "YES", "Yes", "y\n", "yes\r\n", " yes "] {
102+
assert!(
103+
parse_drop_response(input),
104+
"expected yes for input {input:?}"
105+
);
104106
}
105-
Err(err) => {
106-
mem::drop(guard);
107-
panic!("Confirm dialog failed with {err}")
107+
}
108+
109+
#[test]
110+
fn parse_drop_response_rejects_everything_else() {
111+
for input in [
112+
"", "\n", "\r\n", "n", "N", "no", "NO", "maybe", " ", "xyz", "yep",
113+
] {
114+
assert!(
115+
!parse_drop_response(input),
116+
"expected no for input {input:?}"
117+
);
108118
}
109119
}
110120
}

0 commit comments

Comments
 (0)