1use dialoguer::{Confirm, Password, theme::ColorfulTheme};
22use indicatif::{ProgressBar, ProgressStyle};
23use std::env;
24use std::io::{self, IsTerminal};
25use std::time::Duration;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct InteractiveRuntime {
35 pub stdin_is_tty: bool,
37 pub stderr_is_tty: bool,
39 pub terminal: Option<String>,
41}
42
43impl InteractiveRuntime {
44 pub fn new(stdin_is_tty: bool, stderr_is_tty: bool, terminal: Option<String>) -> Self {
46 Self {
47 stdin_is_tty,
48 stderr_is_tty,
49 terminal,
50 }
51 }
52
53 pub fn detect() -> Self {
55 Self::new(
56 io::stdin().is_terminal(),
57 io::stderr().is_terminal(),
58 env::var("TERM").ok(),
59 )
60 }
61
62 pub fn allows_prompting(&self) -> bool {
79 self.stdin_is_tty && self.stderr_is_tty
80 }
81
82 pub fn allows_live_output(&self) -> bool {
95 self.stderr_is_tty && !matches!(self.terminal.as_deref(), Some("dumb"))
96 }
97}
98
99pub type InteractiveResult<T> = io::Result<T>;
101
102#[derive(Debug, Clone)]
103pub struct Interactive {
105 runtime: InteractiveRuntime,
106}
107
108impl Default for Interactive {
109 fn default() -> Self {
110 Self::detect()
111 }
112}
113
114impl Interactive {
115 pub fn detect() -> Self {
117 Self::new(InteractiveRuntime::detect())
118 }
119
120 pub fn new(runtime: InteractiveRuntime) -> Self {
136 Self { runtime }
137 }
138
139 pub fn runtime(&self) -> &InteractiveRuntime {
141 &self.runtime
142 }
143
144 pub fn confirm(&self, prompt: &str) -> InteractiveResult<bool> {
146 self.confirm_default(prompt, false)
147 }
148
149 pub fn confirm_default(&self, prompt: &str, default: bool) -> InteractiveResult<bool> {
151 self.require_prompting("confirmation prompt")?;
152 Confirm::with_theme(&ColorfulTheme::default())
153 .with_prompt(prompt)
154 .default(default)
155 .interact()
156 .map_err(io::Error::other)
157 }
158
159 pub fn password(&self, prompt: &str) -> InteractiveResult<String> {
161 self.password_with_options(prompt, false)
162 }
163
164 pub fn password_allow_empty(&self, prompt: &str) -> InteractiveResult<String> {
166 self.password_with_options(prompt, true)
167 }
168
169 pub fn spinner(&self, message: impl Into<String>) -> Spinner {
171 Spinner::with_runtime(&self.runtime, message)
172 }
173
174 fn password_with_options(&self, prompt: &str, allow_empty: bool) -> InteractiveResult<String> {
175 self.require_prompting("password prompt")?;
176 Password::with_theme(&ColorfulTheme::default())
177 .with_prompt(prompt)
178 .allow_empty_password(allow_empty)
179 .interact()
180 .map_err(io::Error::other)
181 }
182
183 fn require_prompting(&self, kind: &str) -> InteractiveResult<()> {
184 if self.runtime.allows_prompting() {
185 return Ok(());
186 }
187 Err(io::Error::other(format!(
188 "{kind} requires an interactive terminal"
189 )))
190 }
191}
192
193#[must_use]
195pub struct Spinner {
196 pb: ProgressBar,
197}
198
199impl Spinner {
200 pub fn new(message: impl Into<String>) -> Self {
205 Self::with_runtime(&InteractiveRuntime::detect(), message)
206 }
207
208 pub fn with_runtime(runtime: &InteractiveRuntime, message: impl Into<String>) -> Self {
210 Self::with_enabled(runtime.allows_live_output(), message)
211 }
212
213 pub fn with_enabled(enabled: bool, message: impl Into<String>) -> Self {
218 let pb = if enabled {
219 let pb = ProgressBar::new_spinner();
220 pb.enable_steady_tick(Duration::from_millis(120));
221 pb.set_style(
222 ProgressStyle::default_spinner()
223 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
224 .template("{spinner:.cyan} {msg}")
225 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
226 );
227 pb
228 } else {
229 ProgressBar::hidden()
230 };
231 pb.set_message(message.into());
232 Self { pb }
233 }
234
235 pub fn set_message(&self, message: impl Into<String>) {
237 self.pb.set_message(message.into());
238 }
239
240 pub fn suspend<F, R>(&self, f: F) -> R
242 where
243 F: FnOnce() -> R,
244 {
245 self.pb.suspend(f)
246 }
247
248 pub fn finish_success(&self, message: impl Into<String>) {
250 self.pb.finish_with_message(message.into());
251 }
252
253 pub fn finish_failure(&self, message: impl Into<String>) {
255 self.pb.abandon_with_message(message.into());
256 }
257
258 pub fn finish_with_message(&self, message: impl Into<String>) {
263 self.finish_success(message);
264 }
265
266 pub fn finish_and_clear(&self) {
268 self.pb.finish_and_clear();
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::{Interactive, InteractiveRuntime, Spinner};
275
276 fn runtime(
277 stdin_is_tty: bool,
278 stderr_is_tty: bool,
279 terminal: Option<&str>,
280 ) -> InteractiveRuntime {
281 InteractiveRuntime::new(stdin_is_tty, stderr_is_tty, terminal.map(str::to_string))
282 }
283
284 #[test]
285 fn runtime_capability_matrix_covers_prompting_and_live_output_unit() {
286 let cases = [
287 (runtime(true, true, Some("xterm-256color")), true, true),
288 (runtime(true, true, Some("dumb")), true, false),
289 (runtime(false, true, Some("xterm-256color")), false, true),
290 (runtime(true, false, Some("xterm-256color")), false, false),
291 (runtime(true, true, None), true, true),
292 ];
293
294 for (runtime, allows_prompting, allows_live_output) in cases {
295 assert_eq!(runtime.allows_prompting(), allows_prompting);
296 assert_eq!(runtime.allows_live_output(), allows_live_output);
297 }
298 }
299
300 #[test]
301 fn hidden_spinner_supports_full_lifecycle() {
302 let spinner = Spinner::with_enabled(false, "Working");
303 spinner.set_message("Still working");
304 spinner.suspend(|| ());
305 spinner.finish_success("Done");
306 spinner.finish_failure("Failed");
307 spinner.finish_and_clear();
308 }
309
310 #[test]
311 fn spinner_respects_runtime_policy_and_finish_alias() {
312 let live_runtime = runtime(true, true, Some("xterm-256color"));
313 let muted_runtime = runtime(true, true, Some("dumb"));
314
315 let live = Spinner::with_runtime(&live_runtime, "Working");
316 live.set_message("Still working");
317 live.finish_with_message("Done");
318
319 let muted = Spinner::with_runtime(&muted_runtime, "Muted");
320 muted.finish_with_message("Still muted");
321 }
322
323 #[test]
324 fn interactive_runtime_accessor_and_spinner_follow_runtime() {
325 let runtime = runtime(true, true, Some("xterm-256color"));
326 let interactive = Interactive::new(runtime.clone());
327
328 assert_eq!(interactive.runtime(), &runtime);
329 interactive.spinner("Working").finish_and_clear();
330 }
331
332 #[test]
333 fn prompting_helpers_fail_fast_without_interactive_terminal_unit() {
334 let interactive = Interactive::new(runtime(false, false, None));
335
336 for err in [
337 interactive
338 .confirm("Proceed?")
339 .expect_err("confirm should fail"),
340 interactive
341 .password("Password")
342 .expect_err("password should fail"),
343 interactive
344 .password_allow_empty("Password")
345 .expect_err("password prompt should still require a TTY"),
346 ] {
347 assert!(
348 err.to_string().contains("interactive terminal"),
349 "unexpected error: {err}"
350 );
351 }
352 }
353
354 #[test]
355 fn spinner_new_and_detect_paths_are_callable() {
356 let interactive = Interactive::detect();
357 interactive.spinner("Working").finish_and_clear();
358 Spinner::new("Booting").finish_and_clear();
359 }
360
361 #[test]
362 fn default_interactive_matches_detected_runtime_shape() {
363 let detected = Interactive::detect();
364 let defaulted = Interactive::default();
365
366 assert_eq!(
367 defaulted.runtime().stdin_is_tty,
368 detected.runtime().stdin_is_tty
369 );
370 assert_eq!(
371 defaulted.runtime().stderr_is_tty,
372 detected.runtime().stderr_is_tty
373 );
374 }
375}