Skip to main content

osp_cli/ui/
interactive.rs

1use dialoguer::{Confirm, Password, theme::ColorfulTheme};
2use indicatif::{ProgressBar, ProgressStyle};
3use std::env;
4use std::io::{self, IsTerminal};
5use std::time::Duration;
6
7/// Blocking prompt helpers and transient status UI for interactive CLI hosts.
8///
9/// This module is intentionally small. It is not a second rendering system and
10/// it should not own command-level policy. Callers decide when prompting is
11/// appropriate, whether blank input is valid, and when a spinner should be
12/// shown; `osp-ui` only provides the terminal primitives.
13/// Interactive runtime hints used to decide whether live terminal UI is safe.
14///
15/// This mirrors the render/runtime split elsewhere in `osp-ui`: callers can
16/// inject explicit values for tests or special hosts, while `detect()` remains
17/// the boring default for normal CLI entrypoints.
18#[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    /// Detect interactive terminal capabilities from the current process.
27    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    /// Create an interaction helper from the current process runtime.
59    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    /// Prompt for a yes/no answer without baking business policy into the UI.
76    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    /// Prompt for a secret value. Blank handling is a caller policy.
86    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    /// Convenience constructor for normal CLI entrypoints.
123    ///
124    /// Hosts that already resolved runtime policy should prefer
125    /// `Spinner::with_runtime(...)`.
126    pub fn new(message: impl Into<String>) -> Self {
127        Self::with_runtime(&InteractiveRuntime::detect(), message)
128    }
129
130    /// Build a spinner that respects explicit runtime hints.
131    pub fn with_runtime(runtime: &InteractiveRuntime, message: impl Into<String>) -> Self {
132        Self::with_enabled(runtime.allows_live_output(), message)
133    }
134
135    /// Build either a live spinner or a hidden no-op handle.
136    ///
137    /// Hidden spinners let callers keep the same lifecycle code path in tests,
138    /// pipes, and dumb terminals without branching on every update.
139    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}