Skip to main content

radicle_term/
io.rs

1use std::ffi::OsStr;
2use std::fmt::Write;
3use std::process::Stdio;
4use std::sync::LazyLock;
5use std::{env, fmt, io, process};
6
7use inquire::InquireError;
8use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
9use inquire::validator;
10use inquire::{Confirm, CustomType, Password, ui::Color, ui::RenderConfig};
11use thiserror::Error;
12use zeroize::Zeroizing;
13
14use crate::format;
15use crate::{Paint, Size, style};
16
17pub use inquire;
18pub use inquire::Select;
19
20pub(crate) const SYMBOL_ERROR: &str = "✗";
21pub(crate) const SYMBOL_SUCCESS: &str = "✓";
22pub(crate) const SYMBOL_WARNING: &str = "!";
23
24pub const PREFIX_ERROR: Paint<&str> = Paint::red(SYMBOL_ERROR);
25pub const PREFIX_SUCCESS: Paint<&str> = Paint::green(SYMBOL_SUCCESS);
26pub const PREFIX_WARNING: Paint<&str> = Paint::yellow(SYMBOL_WARNING);
27
28pub const TAB: &str = "    ";
29
30/// Passphrase input.
31pub type Passphrase = Zeroizing<String>;
32
33/// Render configuration.
34pub static CONFIG: LazyLock<RenderConfig> = LazyLock::new(|| RenderConfig {
35    prompt: StyleSheet::new().with_fg(Color::LightCyan),
36    prompt_prefix: Styled::new("?").with_fg(Color::LightBlue),
37    answered_prompt_prefix: Styled::new(SYMBOL_SUCCESS).with_fg(Color::LightGreen),
38    answer: StyleSheet::new(),
39    highlighted_option_prefix: Styled::new(SYMBOL_SUCCESS).with_fg(Color::LightYellow),
40    selected_option: Some(StyleSheet::new().with_fg(Color::LightYellow)),
41    option: StyleSheet::new(),
42    help_message: StyleSheet::new().with_fg(Color::DarkGrey),
43    default_value: StyleSheet::new().with_fg(Color::LightBlue),
44    error_message: ErrorMessageRenderConfig::default_colored()
45        .with_prefix(Styled::new(SYMBOL_ERROR).with_fg(Color::LightRed)),
46    ..RenderConfig::default_colored()
47});
48
49/// Target for paint operations.
50///
51/// This tells a [`Spinner`] object where to paint to.
52///
53/// [`Spinner`]: crate::Spinner
54#[derive(Clone)]
55pub enum PaintTarget {
56    Stdout,
57    Stderr,
58    Hidden,
59}
60
61impl PaintTarget {
62    pub fn writer(&self) -> Box<dyn io::Write> {
63        match self {
64            PaintTarget::Stdout => Box::new(io::stdout()),
65            PaintTarget::Stderr => Box::new(io::stderr()),
66            PaintTarget::Hidden => Box::new(io::sink()),
67        }
68    }
69}
70
71#[macro_export]
72macro_rules! info {
73    ($writer:expr_2021; $($arg:tt)*) => ({
74        writeln!($writer, $($arg)*).ok();
75    });
76    ($($arg:tt)*) => ({
77        $crate::io::println(format_args!($($arg)*));
78    })
79}
80
81#[macro_export]
82macro_rules! success {
83    // Pattern when a writer is provided.
84    ($writer:expr_2021; $($arg:tt)*) => ({
85        $crate::io::success_args($writer, format_args!($($arg)*));
86    });
87    // Pattern without writer.
88    ($($arg:tt)*) => ({
89        $crate::io::success_args(&mut std::io::stdout(), format_args!($($arg)*));
90    });
91}
92
93#[macro_export]
94macro_rules! tip {
95    ($($arg:tt)*) => ({
96        $crate::io::tip_args(format_args!($($arg)*));
97    })
98}
99
100#[macro_export]
101macro_rules! notice {
102    // Pattern when a writer is provided.
103    ($writer:expr_2021; $($arg:tt)*) => ({
104        $crate::io::notice_args($writer, format_args!($($arg)*));
105    });
106    ($($arg:tt)*) => ({
107        $crate::io::notice_args(&mut std::io::stdout(), format_args!($($arg)*));
108    })
109}
110
111pub use info;
112pub use notice;
113pub use success;
114pub use tip;
115
116pub fn success_args<W: io::Write>(w: &mut W, args: fmt::Arguments) {
117    writeln!(w, "{PREFIX_SUCCESS} {args}").ok();
118}
119
120pub fn tip_args(args: fmt::Arguments) {
121    println(format_args!(
122        "{} {}",
123        format::yellow("*"),
124        style(format!("{args}")).italic()
125    ));
126}
127
128pub fn notice_args<W: io::Write>(w: &mut W, args: fmt::Arguments) {
129    writeln!(w, "{} {args}", Paint::new(SYMBOL_WARNING).dim()).ok();
130}
131
132pub fn columns() -> Option<usize> {
133    crossterm::terminal::size()
134        .map(|(cols, _)| cols as usize)
135        .ok()
136}
137
138pub fn rows() -> Option<usize> {
139    crossterm::terminal::size()
140        .map(|(_, rows)| rows as usize)
141        .ok()
142}
143
144pub fn viewport() -> Option<Size> {
145    crossterm::terminal::size()
146        .map(|(cols, rows)| Size::new(cols as usize, rows as usize))
147        .ok()
148}
149
150pub fn headline(headline: impl fmt::Display) {
151    println("");
152    println(style(headline).bold());
153    println("");
154}
155
156pub fn header(header: &str) {
157    println("");
158    println(style(format::yellow(header)).bold().underline());
159    println("");
160}
161
162pub fn blob(text: impl fmt::Display) {
163    println(style(text.to_string().trim()).dim());
164}
165
166pub fn blank() {
167    println("");
168}
169
170/// Print a line to stdout, silently ignoring broken pipe errors.
171///
172/// Use this function instead of [`println!`] when you want to print to standard
173/// output, but silently ignore broken pipe errors.
174///
175/// See also [`self::print`].
176///
177/// # Panics
178///
179/// If writing to standard output fails with an error not of kind [`io::ErrorKind::BrokenPipe`].
180pub fn println(msg: impl fmt::Display) {
181    use io::Write;
182
183    let mut stdout = io::stdout().lock();
184    let _ = writeln!(stdout, "{msg}").or_else(swallow_broken_pipe_stdout);
185}
186
187/// Print to stdout without a trailing newline, silently ignoring broken pipe
188/// errors.
189///
190/// Use this function instead of [`print!`] when you want to print to standard
191/// output, but silently ignore broken pipe errors.
192///
193/// See also [`self::println`].
194///
195/// # Panics
196///
197/// If writing to standard output fails with an error not of kind [`io::ErrorKind::BrokenPipe`].
198pub fn print(msg: impl fmt::Display) {
199    use io::Write;
200
201    let mut stdout = io::stdout().lock();
202    let _ = write!(stdout, "{msg}").or_else(swallow_broken_pipe_stdout);
203}
204
205/// If the given `err` is of kind [`io::ErrorKind::BrokenPipe`], return `Ok(())`
206/// to silently ignore it. Otherwise, panic saying "failed printing to stdout",
207/// followed by the error message.
208///
209/// This may be used with [`Result::or_else`] to ignore broken pipes when
210/// writing to standard output.
211///
212/// # Panics
213///
214/// If `err` is not of kind [`io::ErrorKind::BrokenPipe`].
215pub(crate) fn swallow_broken_pipe_stdout(err: io::Error) -> io::Result<()> {
216    if err.kind() == io::ErrorKind::BrokenPipe {
217        Ok(())
218    } else {
219        panic!("failed printing to stdout: {err}")
220    }
221}
222
223pub fn prefixed(prefix: &str, text: &str) -> String {
224    text.split('\n').fold(String::new(), |mut s, line| {
225        writeln!(&mut s, "{prefix}{line}").ok();
226        s
227    })
228}
229
230pub fn help(name: &str, version: &str, description: &str, usage: &str) {
231    println(format_args!("rad-{name} {version}\n{description}\n{usage}"));
232}
233
234pub fn manual(name: &str) -> io::Result<process::ExitStatus> {
235    let mut child = process::Command::new("man")
236        .arg(name)
237        .stderr(Stdio::null())
238        .spawn()?;
239
240    child.wait()
241}
242
243pub fn usage(name: &str, usage: &str) {
244    println(format_args!(
245        "{} {}\n{}",
246        PREFIX_ERROR,
247        Paint::red(format!("Error: rad-{name}: invalid usage")),
248        Paint::red(prefixed(TAB, usage)).dim()
249    ));
250}
251
252pub fn println_prefixed(prefix: impl fmt::Display, msg: impl fmt::Display) {
253    println(format_args!("{prefix} {msg}"));
254}
255
256pub fn indented(msg: impl fmt::Display) {
257    println(format_args!("{TAB}{msg}"));
258}
259
260pub fn subcommand(msg: impl fmt::Display) {
261    println(style(format!("Running `{msg}`...")).dim());
262}
263
264pub fn warning(warning: impl fmt::Display) {
265    println(format_args!(
266        "{} {} {warning}",
267        PREFIX_WARNING,
268        Paint::yellow("Warning:").bold(),
269    ));
270}
271
272pub fn error(error: impl fmt::Display) {
273    println(format_args!(
274        "{PREFIX_ERROR} {} {error}",
275        Paint::red("Error:")
276    ));
277}
278
279pub fn hint(hint: impl fmt::Display) {
280    println(format::hint(format!("{SYMBOL_ERROR} Hint: {hint}")));
281}
282
283pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
284    let prompt = prompt.to_string();
285
286    Confirm::new(&prompt)
287        .with_default(default)
288        .with_render_config(*CONFIG)
289        .prompt()
290        .unwrap_or_default()
291}
292
293pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
294    ask(prompt, true)
295}
296
297pub fn abort<D: fmt::Display>(prompt: D) -> bool {
298    ask(prompt, false)
299}
300
301#[non_exhaustive]
302#[derive(Error, Debug)]
303pub enum InputError<Custom> {
304    #[error(transparent)]
305    Io(#[from] io::Error),
306
307    #[error(transparent)]
308    Custom(Custom),
309}
310
311impl From<InputError<std::convert::Infallible>> for io::Error {
312    fn from(val: InputError<std::convert::Infallible>) -> Self {
313        match val {
314            InputError::Io(err) => err,
315            InputError::Custom(_) => unreachable!("infallible cannot be constructed"),
316        }
317    }
318}
319
320/// Prompts the user for input. If the user cancels the operation,
321/// the operation is interrupted, or no suitable terminal is found,
322/// then `Ok(None)` is returned.
323pub fn input<T, E>(
324    message: &str,
325    default: Option<T>,
326    help: Option<&str>,
327) -> Result<Option<T>, InputError<E>>
328where
329    T: fmt::Display + std::str::FromStr<Err = E> + Clone,
330    E: std::error::Error + Send + Sync + 'static,
331{
332    let mut input = CustomType::<T>::new(message).with_render_config(*CONFIG);
333
334    input.default = default;
335    input.help_message = help;
336
337    match input.prompt() {
338        Ok(value) => Ok(Some(value)),
339        Err(err) => handle_inquire_error(err),
340    }
341}
342
343/// If the [`InquireError`] value is one of the variants:
344/// [`InquireError::OperationCanceled`], [`InquireError::OperationInterrupted`],
345/// [`InquireError::NotTTY`], then the returned result is `None` – note that no
346/// `Some` value is returned.
347///
348/// Otherwise, the error is converted into our own domain error: [`InputError`].
349fn handle_inquire_error<T, E>(error: InquireError) -> Result<Option<T>, InputError<E>>
350where
351    E: std::error::Error + Send + Sync + 'static,
352{
353    use InquireError::*;
354
355    let inner = match error {
356        OperationCanceled | OperationInterrupted | NotTTY => None,
357        InvalidConfiguration(err) => {
358            // This case not reachable, as long as the configuration passed
359            // to `prompt` is valid.
360            // The configuration is *mostly* taken from `CONFIG`,
361            // except for the added `CustomType` being prompted for.
362            // We demand that these must not depend on user input in
363            // a way that makes the configuration invalid.
364            // If this is the case, `CONFIG` should be reassessed, or
365            // the caller must control their input for the `CustomType`
366            // better. In any case, such errors are not recoverable,
367            // and certainly the user cannot do anything in that
368            // situation. Their input should not affect the config,
369            // that's the whole idea!
370            panic!("{err}")
371        }
372        IO(err) => Some(InputError::Io(err)),
373        Custom(err) => {
374            match err.downcast::<E>() {
375                Ok(err) => Some(InputError::Custom(*err)),
376                Err(err) => {
377                    // `inquire` guarantees that we do not end up here:
378                    // https://github.com/mikaelmello/inquire/blob/4ac91f3e1fc8b29fc17845f9204ea1d1f9e335aa/README.md?plain=1#L109
379                    panic!("inquire returned an unexpected error: {err:?}")
380                }
381            }
382        }
383    };
384
385    match inner {
386        Some(err) => Err(err),
387        None => Ok(None),
388    }
389}
390
391pub fn passphrase<V: validator::StringValidator + 'static>(
392    validate: V,
393) -> io::Result<Option<Passphrase>> {
394    match Password::new("Passphrase:")
395        .with_render_config(*CONFIG)
396        .with_display_mode(inquire::PasswordDisplayMode::Masked)
397        .without_confirmation()
398        .with_validator(validate)
399        .prompt()
400    {
401        Ok(p) => Ok(Some(Passphrase::from(p))),
402        Err(err) => handle_inquire_error(err).map_err(InputError::into),
403    }
404}
405
406pub fn passphrase_confirm<K: AsRef<OsStr>>(prompt: &str, var: K) -> io::Result<Option<Passphrase>> {
407    if let Ok(p) = env::var(var) {
408        return Ok(Some(Passphrase::from(p)));
409    }
410
411    match Password::new(prompt)
412        .with_render_config(*CONFIG)
413        .with_display_mode(inquire::PasswordDisplayMode::Masked)
414        .with_custom_confirmation_message("Repeat passphrase:")
415        .with_custom_confirmation_error_message("The passphrases don't match.")
416        .with_help_message("Leave this blank to keep your Radicle key unencrypted")
417        .prompt()
418    {
419        Ok(p) => Ok(Some(Passphrase::from(p))),
420        Err(err) => handle_inquire_error(err).map_err(InputError::into),
421    }
422}
423
424pub fn passphrase_stdin() -> io::Result<Passphrase> {
425    let mut input = String::new();
426    std::io::stdin().read_line(&mut input)?;
427
428    Ok(Passphrase::from(input.trim_end().to_owned()))
429}
430
431pub fn select<'a, T>(prompt: &str, options: &'a [T], help: &str) -> Result<&'a T, InquireError>
432where
433    T: fmt::Display + Eq + PartialEq,
434{
435    let selection = Select::new(prompt, options.iter().collect::<Vec<_>>())
436        .with_vim_mode(true)
437        .with_help_message(help)
438        .with_render_config(*CONFIG);
439
440    selection.with_starting_cursor(0).prompt()
441}