Skip to main content

osp_cli/ui/
interactive.rs

1//! Interactive terminal helpers for prompts and transient status UI.
2//!
3//! This module exists to keep blocking prompts and live terminal widgets behind
4//! a small runtime-aware surface. Callers decide whether prompting is part of
5//! their workflow; this module only answers whether the current terminal makes
6//! that safe and provides the mechanics when it does.
7//!
8//! Contract:
9//!
10//! - this module owns prompt/runtime gating and spinner mechanics
11//! - it should not absorb command policy, validation rules, or higher-level
12//!   workflow decisions
13//!
14//! Public API shape:
15//!
16//! - [`InteractiveRuntime`] uses the crate-wide constructor/factory naming:
17//!   `new(...)` for exact runtime hints and `detect()` for process probing
18//! - [`Interactive`] is a lightweight wrapper over those runtime hints rather
19//!   than another place to encode workflow policy
20
21use dialoguer::{Confirm, Password, theme::ColorfulTheme};
22use indicatif::{ProgressBar, ProgressStyle};
23use std::env;
24use std::io::{self, IsTerminal};
25use std::time::Duration;
26
27/// Interactive runtime hints used to decide whether live terminal UI is safe.
28///
29/// This mirrors the render/runtime split elsewhere in `osp-ui`: callers can
30/// inject explicit values for tests or special hosts, while `detect()` remains
31/// the boring default for normal CLI entrypoints.
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct InteractiveRuntime {
35    /// Whether stdin is attached to a terminal.
36    pub stdin_is_tty: bool,
37    /// Whether stderr is attached to a terminal.
38    pub stderr_is_tty: bool,
39    /// Detected terminal identifier such as `xterm-256color`.
40    pub terminal: Option<String>,
41}
42
43impl InteractiveRuntime {
44    /// Creates explicit interactive runtime hints.
45    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    /// Detect interactive terminal capabilities from the current process.
54    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    /// Returns `true` when both stdin and stderr can support interactive
63    /// prompts.
64    ///
65    /// # Examples
66    ///
67    /// ```
68    /// use osp_cli::ui::InteractiveRuntime;
69    ///
70    /// let runtime = InteractiveRuntime::new(
71    ///     true,
72    ///     true,
73    ///     Some("xterm-256color".to_string()),
74    /// );
75    ///
76    /// assert!(runtime.allows_prompting());
77    /// ```
78    pub fn allows_prompting(&self) -> bool {
79        self.stdin_is_tty && self.stderr_is_tty
80    }
81
82    /// Returns `true` when transient terminal output such as spinners is safe
83    /// to show.
84    ///
85    /// # Examples
86    ///
87    /// ```
88    /// use osp_cli::ui::InteractiveRuntime;
89    ///
90    /// let runtime = InteractiveRuntime::new(true, true, Some("dumb".to_string()));
91    ///
92    /// assert!(!runtime.allows_live_output());
93    /// ```
94    pub fn allows_live_output(&self) -> bool {
95        self.stderr_is_tty && !matches!(self.terminal.as_deref(), Some("dumb"))
96    }
97}
98
99/// Result type used by interactive prompt helpers.
100pub type InteractiveResult<T> = io::Result<T>;
101
102#[derive(Debug, Clone)]
103/// Interactive prompt helper bound to a detected or injected terminal runtime.
104pub struct Interactive {
105    runtime: InteractiveRuntime,
106}
107
108impl Default for Interactive {
109    fn default() -> Self {
110        Self::detect()
111    }
112}
113
114impl Interactive {
115    /// Create an interaction helper from the current process runtime.
116    pub fn detect() -> Self {
117        Self::new(InteractiveRuntime::detect())
118    }
119
120    /// Creates an interaction helper from an explicit runtime description.
121    ///
122    /// # Examples
123    ///
124    /// ```
125    /// use osp_cli::ui::{Interactive, InteractiveRuntime};
126    ///
127    /// let ui = Interactive::new(InteractiveRuntime::new(
128    ///     true,
129    ///     false,
130    ///     Some("xterm-256color".to_string()),
131    /// ));
132    ///
133    /// assert!(!ui.runtime().allows_prompting());
134    /// ```
135    pub fn new(runtime: InteractiveRuntime) -> Self {
136        Self { runtime }
137    }
138
139    /// Returns the runtime hints used by this helper.
140    pub fn runtime(&self) -> &InteractiveRuntime {
141        &self.runtime
142    }
143
144    /// Prompts for confirmation with a default answer of `false`.
145    pub fn confirm(&self, prompt: &str) -> InteractiveResult<bool> {
146        self.confirm_default(prompt, false)
147    }
148
149    /// Prompt for a yes/no answer without baking business policy into the UI.
150    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    /// Prompt for a secret value. Blank handling is a caller policy.
160    pub fn password(&self, prompt: &str) -> InteractiveResult<String> {
161        self.password_with_options(prompt, false)
162    }
163
164    /// Prompts for a secret value and permits empty input.
165    pub fn password_allow_empty(&self, prompt: &str) -> InteractiveResult<String> {
166        self.password_with_options(prompt, true)
167    }
168
169    /// Creates a spinner that follows the current runtime policy.
170    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/// Handle for a transient spinner shown on stderr.
194#[must_use]
195pub struct Spinner {
196    pb: ProgressBar,
197}
198
199impl Spinner {
200    /// Convenience constructor for normal CLI entrypoints.
201    ///
202    /// Hosts that already resolved runtime policy should prefer
203    /// `Spinner::with_runtime(...)`.
204    pub fn new(message: impl Into<String>) -> Self {
205        Self::with_runtime(&InteractiveRuntime::detect(), message)
206    }
207
208    /// Build a spinner that respects explicit runtime hints.
209    pub fn with_runtime(runtime: &InteractiveRuntime, message: impl Into<String>) -> Self {
210        Self::with_enabled(runtime.allows_live_output(), message)
211    }
212
213    /// Build either a live spinner or a hidden no-op handle.
214    ///
215    /// Hidden spinners let callers keep the same lifecycle code path in tests,
216    /// pipes, and dumb terminals without branching on every update.
217    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    /// Updates the spinner message.
236    pub fn set_message(&self, message: impl Into<String>) {
237        self.pb.set_message(message.into());
238    }
239
240    /// Runs `f` while temporarily suspending the spinner from the terminal.
241    pub fn suspend<F, R>(&self, f: F) -> R
242    where
243        F: FnOnce() -> R,
244    {
245        self.pb.suspend(f)
246    }
247
248    /// Marks the spinner as successfully finished and leaves the final message visible.
249    pub fn finish_success(&self, message: impl Into<String>) {
250        self.pb.finish_with_message(message.into());
251    }
252
253    /// Marks the spinner as failed and leaves the final message visible.
254    pub fn finish_failure(&self, message: impl Into<String>) {
255        self.pb.abandon_with_message(message.into());
256    }
257
258    /// Backward-compatible success alias matching `indicatif` naming.
259    ///
260    /// New callers should prefer [`Spinner::finish_success`] so the public API
261    /// makes the success/failure distinction explicit.
262    pub fn finish_with_message(&self, message: impl Into<String>) {
263        self.finish_success(message);
264    }
265
266    /// Finishes the spinner and clears it from the terminal.
267    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}