1use dialoguer::{Confirm, Password, theme::ColorfulTheme};
8use indicatif::{ProgressBar, ProgressStyle};
9use std::env;
10use std::io::{self, IsTerminal};
11use std::time::Duration;
12
13#[derive(Debug, Clone, Default, PartialEq, Eq)]
15pub struct InteractRuntime {
16 pub stdin_is_tty: bool,
17 pub stderr_is_tty: bool,
18 pub terminal: Option<String>,
19}
20
21impl InteractRuntime {
22 pub fn new(stdin_is_tty: bool, stderr_is_tty: bool, terminal: Option<String>) -> Self {
23 Self {
24 stdin_is_tty,
25 stderr_is_tty,
26 terminal,
27 }
28 }
29
30 pub fn detect() -> Self {
31 Self::new(
32 io::stdin().is_terminal(),
33 io::stderr().is_terminal(),
34 env::var("TERM").ok(),
35 )
36 }
37
38 pub fn allows_prompting(&self) -> bool {
39 self.stdin_is_tty && self.stderr_is_tty
40 }
41
42 pub fn allows_live_output(&self) -> bool {
43 self.stderr_is_tty && !matches!(self.terminal.as_deref(), Some("dumb"))
44 }
45}
46
47pub type InteractResult<T> = io::Result<T>;
48
49#[derive(Debug, Clone)]
51pub struct Interact {
52 runtime: InteractRuntime,
53}
54
55impl Default for Interact {
56 fn default() -> Self {
57 Self::detect()
58 }
59}
60
61impl Interact {
62 pub fn detect() -> Self {
63 Self::new(InteractRuntime::detect())
64 }
65
66 pub fn new(runtime: InteractRuntime) -> Self {
67 Self { runtime }
68 }
69
70 pub fn runtime(&self) -> &InteractRuntime {
71 &self.runtime
72 }
73
74 pub fn confirm(&self, prompt: &str) -> InteractResult<bool> {
75 self.confirm_default(prompt, false)
76 }
77
78 pub fn confirm_default(&self, prompt: &str, default: bool) -> InteractResult<bool> {
79 self.require_prompting("confirmation prompt")?;
80 Confirm::with_theme(&ColorfulTheme::default())
81 .with_prompt(prompt)
82 .default(default)
83 .interact()
84 .map_err(io::Error::other)
85 }
86
87 pub fn password(&self, prompt: &str) -> InteractResult<String> {
88 self.password_with_options(prompt, false)
89 }
90
91 pub fn password_allow_empty(&self, prompt: &str) -> InteractResult<String> {
92 self.password_with_options(prompt, true)
93 }
94
95 fn password_with_options(&self, prompt: &str, allow_empty: bool) -> InteractResult<String> {
96 self.require_prompting("password prompt")?;
97 Password::with_theme(&ColorfulTheme::default())
98 .with_prompt(prompt)
99 .allow_empty_password(allow_empty)
100 .interact()
101 .map_err(io::Error::other)
102 }
103
104 pub fn spinner(&self, message: impl Into<String>) -> Spinner {
105 Spinner::with_runtime(&self.runtime, message)
106 }
107
108 fn require_prompting(&self, kind: &str) -> InteractResult<()> {
109 if self.runtime.allows_prompting() {
110 Ok(())
111 } else {
112 Err(io::Error::other(format!(
113 "{kind} requires an interactive terminal"
114 )))
115 }
116 }
117}
118
119#[must_use]
121pub struct Spinner {
122 pb: ProgressBar,
123}
124
125impl Spinner {
126 pub fn new(message: impl Into<String>) -> Self {
127 Self::with_runtime(&InteractRuntime::detect(), message)
128 }
129
130 pub fn with_runtime(runtime: &InteractRuntime, message: impl Into<String>) -> Self {
131 Self::with_enabled(runtime.allows_live_output(), message)
132 }
133
134 pub fn set_message(&self, message: impl Into<String>) {
135 self.pb.set_message(message.into());
136 }
137
138 pub fn suspend<F, R>(&self, f: F) -> R
139 where
140 F: FnOnce() -> R,
141 {
142 self.pb.suspend(f)
143 }
144
145 pub fn finish_success(&self, message: impl Into<String>) {
146 self.pb.finish_with_message(message.into());
147 }
148
149 pub fn finish_failure(&self, message: impl Into<String>) {
150 self.pb.abandon_with_message(message.into());
151 }
152
153 pub fn finish_with_message(&self, message: impl Into<String>) {
154 self.finish_success(message);
155 }
156
157 pub fn finish_and_clear(&self) {
158 self.pb.finish_and_clear();
159 }
160
161 pub fn with_enabled(enabled: bool, message: impl Into<String>) -> Self {
162 let pb = if enabled {
163 let pb = ProgressBar::new_spinner();
164 pb.enable_steady_tick(Duration::from_millis(120));
165 pb.set_style(
166 ProgressStyle::default_spinner()
167 .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
168 .template("{spinner:.cyan} {msg}")
169 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
170 );
171 pb
172 } else {
173 ProgressBar::hidden()
174 };
175 pb.set_message(message.into());
176 Self { pb }
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::{Interact, InteractRuntime, Spinner};
183
184 fn runtime(stdin_is_tty: bool, stderr_is_tty: bool, terminal: Option<&str>) -> InteractRuntime {
185 InteractRuntime::new(stdin_is_tty, stderr_is_tty, terminal.map(str::to_string))
186 }
187
188 #[test]
189 fn runtime_capability_matrix_covers_prompting_and_live_output_unit() {
190 let cases = [
191 (runtime(true, true, Some("xterm-256color")), true, true),
192 (runtime(true, true, Some("dumb")), true, false),
193 (runtime(false, true, Some("xterm-256color")), false, true),
194 (runtime(true, false, Some("xterm-256color")), false, false),
195 (runtime(true, true, None), true, true),
196 ];
197
198 for (runtime, allows_prompting, allows_live_output) in cases {
199 assert_eq!(runtime.allows_prompting(), allows_prompting);
200 assert_eq!(runtime.allows_live_output(), allows_live_output);
201 }
202 }
203
204 #[test]
205 fn hidden_spinner_supports_full_lifecycle_unit() {
206 let spinner = Spinner::with_enabled(false, "Working");
207 spinner.set_message("Still working");
208 spinner.suspend(|| ());
209 spinner.finish_success("Done");
210 spinner.finish_failure("Failed");
211 spinner.finish_and_clear();
212 }
213
214 #[test]
215 fn spinner_respects_runtime_policy_unit() {
216 let live = Spinner::with_runtime(&runtime(true, true, Some("xterm-256color")), "Working");
217 live.set_message("Still working");
218 live.finish_success("Done");
219
220 let muted = Spinner::with_runtime(&runtime(true, true, Some("dumb")), "Muted");
221 muted.finish_failure("Still muted");
222 }
223
224 #[test]
225 fn interact_runtime_accessor_and_spinner_follow_runtime_unit() {
226 let runtime = runtime(true, true, Some("xterm-256color"));
227 let interact = Interact::new(runtime.clone());
228
229 assert_eq!(interact.runtime(), &runtime);
230 interact.spinner("Working").finish_and_clear();
231 }
232
233 #[test]
234 fn prompting_helpers_fail_fast_without_interactive_terminal_unit() {
235 let interact = Interact::new(runtime(false, false, None));
236
237 for err in [
238 interact
239 .confirm_default("Proceed?", false)
240 .expect_err("confirm should fail"),
241 interact
242 .confirm("Proceed?")
243 .expect_err("confirm default-false should fail"),
244 interact
245 .password("Password")
246 .expect_err("password should fail"),
247 interact
248 .password_allow_empty("Password")
249 .expect_err("password should fail"),
250 ] {
251 assert!(
252 err.to_string().contains("interactive terminal"),
253 "unexpected error: {err}"
254 );
255 }
256 }
257
258 #[test]
259 fn detect_and_default_are_callable_unit() {
260 let detected = Interact::detect();
261 let defaulted = Interact::default();
262
263 detected.spinner("Working").finish_and_clear();
264 Spinner::new("Booting").finish_and_clear();
265
266 assert_eq!(
267 defaulted.runtime().stdin_is_tty,
268 detected.runtime().stdin_is_tty
269 );
270 assert_eq!(
271 defaulted.runtime().stderr_is_tty,
272 detected.runtime().stderr_is_tty
273 );
274 }
275
276 #[test]
277 fn finish_with_message_alias_and_public_with_enabled_are_callable_unit() {
278 let spinner = Spinner::with_enabled(false, "Working");
279 spinner.finish_with_message("Done");
280 }
281}