1use dialoguer::{Confirm, Password, theme::ColorfulTheme};
2use indicatif::{ProgressBar, ProgressStyle};
3use std::env;
4use std::io::{self, IsTerminal};
5use std::time::Duration;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct InteractiveRuntime {
20 pub stdin_is_tty: bool,
21 pub stderr_is_tty: bool,
22 pub terminal: Option<String>,
23}
24
25impl InteractiveRuntime {
26 pub fn detect() -> Self {
28 Self {
29 stdin_is_tty: io::stdin().is_terminal(),
30 stderr_is_tty: io::stderr().is_terminal(),
31 terminal: env::var("TERM").ok(),
32 }
33 }
34
35 pub fn allows_prompting(&self) -> bool {
36 self.stdin_is_tty && self.stderr_is_tty
37 }
38
39 pub fn allows_live_output(&self) -> bool {
40 self.stderr_is_tty && !matches!(self.terminal.as_deref(), Some("dumb"))
41 }
42}
43
44pub type InteractiveResult<T> = io::Result<T>;
45
46#[derive(Debug, Clone)]
47pub struct Interactive {
48 runtime: InteractiveRuntime,
49}
50
51impl Default for Interactive {
52 fn default() -> Self {
53 Self::detect()
54 }
55}
56
57impl Interactive {
58 pub fn detect() -> Self {
60 Self::new(InteractiveRuntime::detect())
61 }
62
63 pub fn new(runtime: InteractiveRuntime) -> Self {
64 Self { runtime }
65 }
66
67 pub fn runtime(&self) -> &InteractiveRuntime {
68 &self.runtime
69 }
70
71 pub fn confirm(&self, prompt: &str) -> InteractiveResult<bool> {
72 self.confirm_default(prompt, false)
73 }
74
75 pub fn confirm_default(&self, prompt: &str, default: bool) -> InteractiveResult<bool> {
77 self.require_prompting("confirmation prompt")?;
78 Confirm::with_theme(&ColorfulTheme::default())
79 .with_prompt(prompt)
80 .default(default)
81 .interact()
82 .map_err(io::Error::other)
83 }
84
85 pub fn password(&self, prompt: &str) -> InteractiveResult<String> {
87 self.password_with_options(prompt, false)
88 }
89
90 pub fn password_allow_empty(&self, prompt: &str) -> InteractiveResult<String> {
91 self.password_with_options(prompt, true)
92 }
93
94 pub fn spinner(&self, message: impl Into<String>) -> Spinner {
95 Spinner::with_runtime(&self.runtime, message)
96 }
97
98 fn password_with_options(&self, prompt: &str, allow_empty: bool) -> InteractiveResult<String> {
99 self.require_prompting("password prompt")?;
100 Password::with_theme(&ColorfulTheme::default())
101 .with_prompt(prompt)
102 .allow_empty_password(allow_empty)
103 .interact()
104 .map_err(io::Error::other)
105 }
106
107 fn require_prompting(&self, kind: &str) -> InteractiveResult<()> {
108 if self.runtime.allows_prompting() {
109 return Ok(());
110 }
111 Err(io::Error::other(format!(
112 "{kind} requires an interactive terminal"
113 )))
114 }
115}
116
117pub struct Spinner {
118 pb: ProgressBar,
119}
120
121impl Spinner {
122 pub fn new(message: impl Into<String>) -> Self {
127 Self::with_runtime(&InteractiveRuntime::detect(), message)
128 }
129
130 pub fn with_runtime(runtime: &InteractiveRuntime, message: impl Into<String>) -> Self {
132 Self::with_enabled(runtime.allows_live_output(), message)
133 }
134
135 pub fn with_enabled(enabled: bool, message: impl Into<String>) -> Self {
140 let pb = if enabled {
141 let pb = ProgressBar::new_spinner();
142 pb.enable_steady_tick(Duration::from_millis(120));
143 pb.set_style(
144 ProgressStyle::default_spinner()
145 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
146 .template("{spinner:.cyan} {msg}")
147 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
148 );
149 pb
150 } else {
151 ProgressBar::hidden()
152 };
153 pb.set_message(message.into());
154 Self { pb }
155 }
156
157 pub fn set_message(&self, message: impl Into<String>) {
158 self.pb.set_message(message.into());
159 }
160
161 pub fn suspend<F, R>(&self, f: F) -> R
162 where
163 F: FnOnce() -> R,
164 {
165 self.pb.suspend(f)
166 }
167
168 pub fn finish_success(&self, message: impl Into<String>) {
169 self.pb.finish_with_message(message.into());
170 }
171
172 pub fn finish_failure(&self, message: impl Into<String>) {
173 self.pb.abandon_with_message(message.into());
174 }
175
176 pub fn finish_with_message(&self, message: impl Into<String>) {
177 self.finish_success(message);
178 }
179
180 pub fn finish_and_clear(&self) {
181 self.pb.finish_and_clear();
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::{Interactive, InteractiveRuntime, Spinner};
188
189 #[test]
190 fn runtime_blocks_live_output_for_dumb_term() {
191 let runtime = InteractiveRuntime {
192 stdin_is_tty: true,
193 stderr_is_tty: true,
194 terminal: Some("dumb".to_string()),
195 };
196
197 assert!(!runtime.allows_live_output());
198 assert!(runtime.allows_prompting());
199 }
200
201 #[test]
202 fn runtime_blocks_prompting_without_ttys() {
203 let runtime = InteractiveRuntime {
204 stdin_is_tty: false,
205 stderr_is_tty: true,
206 terminal: Some("xterm-256color".to_string()),
207 };
208
209 assert!(!runtime.allows_prompting());
210 }
211
212 #[test]
213 fn runtime_blocks_live_output_without_stderr_tty() {
214 let runtime = InteractiveRuntime {
215 stdin_is_tty: true,
216 stderr_is_tty: false,
217 terminal: Some("xterm-256color".to_string()),
218 };
219
220 assert!(!runtime.allows_live_output());
221 assert!(!runtime.allows_prompting());
222 }
223
224 #[test]
225 fn hidden_spinner_supports_full_lifecycle() {
226 let spinner = Spinner::with_enabled(false, "Working");
227 spinner.set_message("Still working");
228 spinner.suspend(|| ());
229 spinner.finish_success("Done");
230 spinner.finish_failure("Failed");
231 spinner.finish_and_clear();
232 }
233
234 #[test]
235 fn spinner_respects_runtime_policy_and_finish_alias() {
236 let live_runtime = InteractiveRuntime {
237 stdin_is_tty: true,
238 stderr_is_tty: true,
239 terminal: Some("xterm-256color".to_string()),
240 };
241 let muted_runtime = InteractiveRuntime {
242 stdin_is_tty: true,
243 stderr_is_tty: true,
244 terminal: Some("dumb".to_string()),
245 };
246
247 let live = Spinner::with_runtime(&live_runtime, "Working");
248 live.set_message("Still working");
249 live.finish_with_message("Done");
250
251 let muted = Spinner::with_runtime(&muted_runtime, "Muted");
252 muted.finish_with_message("Still muted");
253 }
254
255 #[test]
256 fn confirm_fails_fast_without_interactive_terminal() {
257 let interactive = Interactive::new(InteractiveRuntime {
258 stdin_is_tty: false,
259 stderr_is_tty: false,
260 terminal: None,
261 });
262
263 let err = interactive
264 .confirm("Proceed?")
265 .expect_err("confirm should fail");
266 assert!(
267 err.to_string().contains("interactive terminal"),
268 "unexpected error: {err}"
269 );
270 }
271
272 #[test]
273 fn interactive_runtime_accessor_and_spinner_follow_runtime() {
274 let runtime = InteractiveRuntime {
275 stdin_is_tty: true,
276 stderr_is_tty: true,
277 terminal: Some("xterm-256color".to_string()),
278 };
279 let interactive = Interactive::new(runtime.clone());
280
281 assert_eq!(interactive.runtime(), &runtime);
282 interactive.spinner("Working").finish_and_clear();
283 }
284
285 #[test]
286 fn password_fails_fast_without_interactive_terminal() {
287 let interactive = Interactive::new(InteractiveRuntime {
288 stdin_is_tty: false,
289 stderr_is_tty: false,
290 terminal: None,
291 });
292
293 let err = interactive
294 .password("Password")
295 .expect_err("password should fail");
296 assert!(
297 err.to_string().contains("interactive terminal"),
298 "unexpected error: {err}"
299 );
300 }
301
302 #[test]
303 fn password_allow_empty_fails_fast_without_interactive_terminal() {
304 let interactive = Interactive::new(InteractiveRuntime {
305 stdin_is_tty: false,
306 stderr_is_tty: false,
307 terminal: None,
308 });
309
310 let err = interactive
311 .password_allow_empty("Password")
312 .expect_err("password prompt should still require a TTY");
313 assert!(
314 err.to_string().contains("interactive terminal"),
315 "unexpected error: {err}"
316 );
317 }
318
319 #[test]
320 fn runtime_allows_live_output_when_term_is_missing_but_stderr_is_tty() {
321 let runtime = InteractiveRuntime {
322 stdin_is_tty: true,
323 stderr_is_tty: true,
324 terminal: None,
325 };
326
327 assert!(runtime.allows_prompting());
328 assert!(runtime.allows_live_output());
329 }
330
331 #[test]
332 fn spinner_new_and_detect_paths_are_callable() {
333 let interactive = Interactive::detect();
334 interactive.spinner("Working").finish_and_clear();
335 Spinner::new("Booting").finish_and_clear();
336 }
337
338 #[test]
339 fn default_interactive_matches_detected_runtime_shape() {
340 let detected = Interactive::detect();
341 let defaulted = Interactive::default();
342
343 assert_eq!(
344 defaulted.runtime().stdin_is_tty,
345 detected.runtime().stdin_is_tty
346 );
347 assert_eq!(
348 defaulted.runtime().stderr_is_tty,
349 detected.runtime().stderr_is_tty
350 );
351 }
352
353 #[test]
354 fn runtime_without_stdin_tty_can_still_allow_live_output() {
355 let runtime = InteractiveRuntime {
356 stdin_is_tty: false,
357 stderr_is_tty: true,
358 terminal: Some("xterm-256color".to_string()),
359 };
360
361 assert!(!runtime.allows_prompting());
362 assert!(runtime.allows_live_output());
363 }
364}