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
9pub fn eprint_line(text: &str) {
11 eprint_raw(&crate::output::printable(text));
12}
13
14pub 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
25pub 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 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}