Skip to main content

yt/
ui.rs

1use std::future::Future;
2use std::io::{self, IsTerminal as _, Write as _};
3
4use secrecy::SecretString;
5
6use crate::env::Env;
7use crate::error::{Result, YtError};
8
9/// Writes a line to stderr with terminal control characters replaced; a closed stderr is ignored instead of panicking like `eprintln!`.
10pub fn eprint_line(text: &str) {
11    eprint_raw(&crate::output::printable(text));
12}
13
14/// Writes text that is already safe to show, such as a rendered error report with its colors.
15pub fn eprint_raw(text: &str) {
16    let _ = writeln!(io::stderr().lock(), "{text}");
17}
18
19pub struct Choice<T> {
20    pub value: T,
21    pub label: String,
22    pub description: Option<String>,
23}
24
25/// Decides whether yt may ask questions, and asks them.
26pub struct Ui {
27    interactive: bool,
28    assume_yes: bool,
29    spinner: bool,
30}
31
32impl Ui {
33    pub fn detect(env: &Env, no_input: bool, assume_yes: bool, spinner_allowed: bool) -> Self {
34        let terminal = io::stdin().is_terminal() && io::stderr().is_terminal();
35        let interactive = terminal && !no_input && env.get("CI").is_none();
36        Self {
37            interactive,
38            assume_yes,
39            spinner: interactive && spinner_allowed,
40        }
41    }
42
43    pub fn new(interactive: bool, assume_yes: bool) -> Self {
44        Self {
45            interactive,
46            assume_yes,
47            spinner: false,
48        }
49    }
50
51    pub fn is_interactive(&self) -> bool {
52        self.interactive
53    }
54
55    pub async fn select<T: Clone + Send + 'static>(
56        &self,
57        title: &str,
58        choices: Vec<Choice<T>>,
59        flag: &str,
60    ) -> Result<T> {
61        self.require(title, flag)?;
62        let title = title.to_string();
63        blocking(move || {
64            let options = choices
65                .into_iter()
66                .map(|choice| {
67                    let option = demand::DemandOption::with_label(
68                        crate::output::printable(&choice.label),
69                        choice.value,
70                    );
71                    match &choice.description {
72                        Some(description) => {
73                            option.description(&crate::output::printable(description))
74                        }
75                        None => option,
76                    }
77                })
78                .collect();
79            demand::Select::new(title)
80                .options(options)
81                .filterable(true)
82                .run()
83        })
84        .await
85    }
86
87    pub async fn input(
88        &self,
89        title: &str,
90        description: Option<&str>,
91        default: Option<&str>,
92        flag: &str,
93    ) -> Result<String> {
94        self.require(title, flag)?;
95        let title = title.to_string();
96        let description = description.map(str::to_string);
97        let default = default.map(str::to_string);
98        let value = blocking(move || {
99            let mut input = demand::Input::new(title);
100            if let Some(description) = &description {
101                input = input.description(description);
102            }
103            if let Some(default) = default {
104                input = input.default_value(default);
105            }
106            input.run()
107        })
108        .await?;
109        Ok(value.trim().to_string())
110    }
111
112    pub async fn secret(
113        &self,
114        title: &str,
115        description: Option<&str>,
116        flag: &str,
117    ) -> Result<SecretString> {
118        self.require(title, flag)?;
119        let title = title.to_string();
120        let description = description.map(str::to_string);
121        let value = blocking(move || {
122            let mut input = demand::Input::new(title).password(true);
123            if let Some(description) = &description {
124                input = input.description(description);
125            }
126            input.run()
127        })
128        .await?;
129        Ok(SecretString::from(value.trim().to_string()))
130    }
131
132    pub async fn confirm(&self, title: &str, default: bool, flag: &str) -> Result<bool> {
133        self.require(title, flag)?;
134        let title = crate::output::printable(title).into_owned();
135        blocking(move || demand::Confirm::new(title).selected(default).run()).await
136    }
137
138    /// Asks before something irreversible; `--yes` skips the question, and without a terminal it is required.
139    pub async fn confirm_destructive(&self, action: &str) -> Result<()> {
140        if self.assume_yes {
141            return Ok(());
142        }
143        if !self.interactive {
144            return Err(YtError::Usage {
145                message: format!("refusing to {action} without --yes"),
146            }
147            .into());
148        }
149        if self
150            .confirm(&format!("Really {action}?"), false, "--yes")
151            .await?
152        {
153            Ok(())
154        } else {
155            Err(YtError::Cancelled.into())
156        }
157    }
158
159    pub async fn with_spinner<F: Future>(&self, title: &str, future: F) -> F::Output {
160        if !self.spinner {
161            return future.await;
162        }
163        let (done, wait) = std::sync::mpsc::channel::<()>();
164        let title = title.to_string();
165        let spinner = tokio::task::spawn_blocking(move || {
166            demand::Spinner::new(title).run(move |_| {
167                let _ = wait.recv();
168            })
169        });
170        let output = future.await;
171        drop(done);
172        let _ = spinner.await;
173        output
174    }
175
176    fn require(&self, what: &str, flag: &str) -> Result<()> {
177        if self.interactive {
178            Ok(())
179        } else {
180            Err(YtError::InputRequired {
181                what: what.to_string(),
182                flag: flag.to_string(),
183            }
184            .into())
185        }
186    }
187}
188
189async fn blocking<T: Send + 'static>(
190    prompt: impl FnOnce() -> io::Result<T> + Send + 'static,
191) -> Result<T> {
192    match tokio::task::spawn_blocking(prompt).await {
193        Ok(Ok(value)) => Ok(value),
194        Ok(Err(error)) if error.kind() == io::ErrorKind::Interrupted => {
195            Err(YtError::Cancelled.into())
196        }
197        Ok(Err(error)) => Err(miette::miette!("prompt failed: {error}")),
198        Err(error) => Err(miette::miette!("prompt failed: {error}")),
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn exit(report: &miette::Report) -> u8 {
207        crate::error::exit_code(report)
208    }
209
210    #[tokio::test]
211    async fn without_a_terminal_a_choice_names_its_flag() {
212        let ui = Ui::new(false, false);
213        let choices = vec![Choice {
214            value: 1,
215            label: "one".into(),
216            description: None,
217        }];
218        let error = ui
219            .select("Sign-in method", choices, "--method")
220            .await
221            .unwrap_err();
222        assert_eq!(exit(&error), 2);
223        assert!(format!("{error:?}").contains("pass --method"));
224    }
225
226    #[tokio::test]
227    async fn without_a_terminal_input_and_secrets_are_refused() {
228        let ui = Ui::new(false, false);
229        assert_eq!(
230            exit(&ui.input("URL", None, None, "<URL>").await.unwrap_err()),
231            2
232        );
233        assert_eq!(
234            exit(&ui.secret("Token", None, "--token-stdin").await.unwrap_err()),
235            2
236        );
237    }
238
239    #[tokio::test]
240    async fn yes_skips_destructive_confirmation() {
241        Ui::new(false, true)
242            .confirm_destructive("remove profile acme")
243            .await
244            .unwrap();
245    }
246
247    #[tokio::test]
248    async fn destructive_actions_need_yes_without_a_terminal() {
249        let error = Ui::new(false, false)
250            .confirm_destructive("remove profile acme")
251            .await
252            .unwrap_err();
253        assert_eq!(exit(&error), 2);
254        assert!(
255            error
256                .to_string()
257                .contains("refusing to remove profile acme without --yes")
258        );
259    }
260
261    #[tokio::test]
262    async fn a_disabled_spinner_just_awaits() {
263        assert_eq!(
264            Ui::new(false, false).with_spinner("x", async { 7 }).await,
265            7
266        );
267    }
268
269    #[test]
270    fn ci_disables_prompts() {
271        let ui = Ui::detect(&Env::from_vars([("CI", "true")]), false, false, true);
272        assert!(!ui.is_interactive());
273    }
274}