Skip to main content

osp_cli/ui/interact/
mod.rs

1//! Interactive terminal services for the canonical UI pipeline.
2//!
3//! This module owns only runtime gating and prompt/spinner mechanics. Callers
4//! decide whether a workflow should prompt; this module only answers whether
5//! the current terminal can support that and provides the mechanics when it can.
6
7use dialoguer::{Confirm, Password, theme::ColorfulTheme};
8use indicatif::{ProgressBar, ProgressStyle};
9use std::env;
10use std::io::{self, IsTerminal};
11use std::time::Duration;
12
13/// Runtime facts used to decide whether interactive UI is safe.
14#[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/// Prompt and transient-status service bound to an explicit runtime.
50#[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/// Handle for a transient spinner shown on stderr.
120#[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}