406 lines
10 KiB
Rust
406 lines
10 KiB
Rust
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password};
|
|
|
|
use radicle::cob::issue::Issue;
|
|
use radicle::cob::thread::CommentId;
|
|
use radicle::crypto::ssh::keystore::Passphrase;
|
|
use radicle::crypto::Signer;
|
|
use radicle::profile::env::RAD_PASSPHRASE;
|
|
use radicle::profile::Profile;
|
|
|
|
use radicle_crypto::ssh::keystore::MemorySigner;
|
|
|
|
use super::command;
|
|
use super::format;
|
|
use super::spinner::spinner;
|
|
use super::Error;
|
|
|
|
pub const TAB: &str = " ";
|
|
|
|
#[macro_export]
|
|
macro_rules! info {
|
|
($($arg:tt)*) => ({
|
|
println!("{}", format_args!($($arg)*));
|
|
})
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! success {
|
|
($($arg:tt)*) => ({
|
|
$crate::terminal::io::success_args(format_args!($($arg)*));
|
|
})
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! tip {
|
|
($($arg:tt)*) => ({
|
|
$crate::terminal::io::tip_args(format_args!($($arg)*));
|
|
})
|
|
}
|
|
|
|
pub use info;
|
|
pub use success;
|
|
pub use tip;
|
|
|
|
pub fn success_args(args: fmt::Arguments) {
|
|
println!("{} {}", style("ok").green().reverse(), args);
|
|
}
|
|
|
|
pub fn tip_args(args: fmt::Arguments) {
|
|
println!(
|
|
"{} {}",
|
|
style("=>").blue(),
|
|
style(format!("{}", args)).dim()
|
|
);
|
|
}
|
|
|
|
pub fn width() -> Option<usize> {
|
|
console::Term::stdout()
|
|
.size_checked()
|
|
.map(|(_, cols)| cols as usize)
|
|
}
|
|
|
|
pub fn headline(headline: &str) {
|
|
println!();
|
|
println!("{}", style(headline).bold());
|
|
println!();
|
|
}
|
|
|
|
pub fn blob(text: impl fmt::Display) {
|
|
println!("{}", style(text.to_string().trim()).dim());
|
|
}
|
|
|
|
pub fn blank() {
|
|
println!()
|
|
}
|
|
|
|
pub fn print(msg: impl fmt::Display) {
|
|
println!("{}", msg);
|
|
}
|
|
|
|
pub fn prefixed(prefix: &str, text: &str) -> String {
|
|
text.split('\n')
|
|
.map(|line| format!("{}{}\n", prefix, line))
|
|
.collect()
|
|
}
|
|
|
|
pub fn help(name: &str, version: &str, description: &str, usage: &str) {
|
|
println!("rad-{} {}\n{}\n{}", name, version, description, usage);
|
|
}
|
|
|
|
pub fn usage(name: &str, usage: &str) {
|
|
eprintln!(
|
|
"{} {}\n{}",
|
|
style("==").red(),
|
|
style(format!("Error: rad-{}: invalid usage", name)).red(),
|
|
style(prefixed(TAB, usage)).red().dim()
|
|
);
|
|
}
|
|
|
|
pub fn eprintln(prefix: impl fmt::Display, msg: impl fmt::Display) {
|
|
eprintln!("{} {}", prefix, msg);
|
|
}
|
|
|
|
pub fn indented(msg: &str) {
|
|
println!("{}{}", TAB, msg);
|
|
}
|
|
|
|
pub fn subcommand(msg: impl fmt::Display) {
|
|
println!("{} {}", style("$").dim(), style(msg).dim());
|
|
}
|
|
|
|
pub fn warning(warning: &str) {
|
|
eprintln!(
|
|
"{} {} {}",
|
|
style("**").yellow(),
|
|
style("Warning:").yellow().bold(),
|
|
style(warning).yellow()
|
|
);
|
|
}
|
|
|
|
pub fn error(error: impl fmt::Display) {
|
|
eprintln!("{} {}", style("==").red(), style(error).red());
|
|
}
|
|
|
|
pub fn fail(header: &str, error: &anyhow::Error) {
|
|
let err = error.to_string();
|
|
let err = err.trim_end();
|
|
let separator = if err.len() > 160 || err.contains('\n') {
|
|
"\n"
|
|
} else {
|
|
" "
|
|
};
|
|
|
|
eprintln!(
|
|
"{} {}{}{}",
|
|
style("==").red(),
|
|
style(header).red().reverse(),
|
|
separator,
|
|
style(error).red().bold(),
|
|
);
|
|
|
|
let cause = error.root_cause();
|
|
if cause.to_string() != error.to_string() {
|
|
eprintln!(
|
|
"{} {}",
|
|
style("==").red().dim(),
|
|
style(error.root_cause()).red().dim()
|
|
);
|
|
blank();
|
|
}
|
|
|
|
if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
|
|
eprintln!("{} {}", style("==").yellow(), style(hint).yellow(),);
|
|
blank();
|
|
}
|
|
}
|
|
|
|
pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
|
|
dialoguer::Confirm::new()
|
|
.with_prompt(format!("{} {}", style(" ⤷".to_owned()).cyan(), prompt))
|
|
.wait_for_newline(false)
|
|
.default(true)
|
|
.default(default)
|
|
.interact()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
|
|
ask(format::tertiary(prompt), true)
|
|
}
|
|
|
|
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
|
|
ask(format::tertiary(prompt), false)
|
|
}
|
|
|
|
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user.
|
|
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
|
|
let signer = if let Ok(passphrase) = read_passphrase_from_env_var() {
|
|
MemorySigner::load(&profile.keystore, passphrase)?.boxed()
|
|
} else if let Ok(signer) = profile.signer() {
|
|
signer.boxed()
|
|
} else {
|
|
let passphrase = secret_input();
|
|
let spinner = spinner("Unsealing key...");
|
|
let signer = MemorySigner::load(&profile.keystore, passphrase)?;
|
|
|
|
spinner.finish();
|
|
signer.boxed()
|
|
};
|
|
Ok(signer)
|
|
}
|
|
|
|
pub fn theme() -> ColorfulTheme {
|
|
ColorfulTheme {
|
|
success_prefix: style("ok".to_owned()).for_stderr().green().reverse(),
|
|
prompt_prefix: style(" ⤷".to_owned()).cyan().dim().for_stderr(),
|
|
prompt_suffix: style("·".to_owned()).cyan().for_stderr(),
|
|
prompt_style: Style::new().cyan().bold().for_stderr(),
|
|
active_item_style: Style::new().for_stderr().yellow().reverse(),
|
|
active_item_prefix: style("*".to_owned()).yellow().for_stderr(),
|
|
picked_item_prefix: style("*".to_owned()).yellow().for_stderr(),
|
|
inactive_item_prefix: style(" ".to_string()).for_stderr(),
|
|
inactive_item_style: Style::new().yellow().for_stderr(),
|
|
error_prefix: style("⤹ Error:".to_owned()).red().for_stderr(),
|
|
success_suffix: style("·".to_owned()).cyan().for_stderr(),
|
|
|
|
..ColorfulTheme::default()
|
|
}
|
|
}
|
|
|
|
pub fn text_input<S, E>(message: &str, default: Option<S>) -> anyhow::Result<S>
|
|
where
|
|
S: fmt::Display + std::str::FromStr<Err = E> + Clone,
|
|
E: fmt::Debug + fmt::Display,
|
|
{
|
|
let theme = theme();
|
|
let mut input: Input<S> = Input::with_theme(&theme);
|
|
|
|
let value = match default {
|
|
Some(default) => input
|
|
.with_prompt(message)
|
|
.with_initial_text(default.to_string())
|
|
.interact_text()?,
|
|
None => input.with_prompt(message).interact_text()?,
|
|
};
|
|
Ok(value)
|
|
}
|
|
|
|
#[derive(Debug, Default, Clone)]
|
|
pub struct Optional<T> {
|
|
option: Option<T>,
|
|
}
|
|
|
|
impl<T: fmt::Display> fmt::Display for Optional<T> {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
if let Some(val) = &self.option {
|
|
write!(f, "{}", val)
|
|
} else {
|
|
write!(f, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T: FromStr> FromStr for Optional<T> {
|
|
type Err = <T as FromStr>::Err;
|
|
|
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
|
if s.is_empty() {
|
|
return Ok(Optional { option: None });
|
|
}
|
|
let val: T = s.parse()?;
|
|
|
|
Ok(Self { option: Some(val) })
|
|
}
|
|
}
|
|
|
|
pub fn text_input_optional<S, E>(
|
|
message: &str,
|
|
initial: Option<String>,
|
|
) -> anyhow::Result<Option<S>>
|
|
where
|
|
S: fmt::Display + fmt::Debug + FromStr<Err = E> + Clone,
|
|
E: fmt::Debug + fmt::Display,
|
|
{
|
|
let theme = theme();
|
|
let mut input: Input<Optional<S>> = Input::with_theme(&theme);
|
|
|
|
if let Some(init) = initial {
|
|
input.with_initial_text(init);
|
|
}
|
|
let value = input
|
|
.with_prompt(message)
|
|
.allow_empty(true)
|
|
.interact_text()?;
|
|
|
|
Ok(value.option)
|
|
}
|
|
|
|
pub fn secret_input() -> Passphrase {
|
|
secret_input_with_prompt("Passphrase")
|
|
}
|
|
|
|
// TODO: This prompt shows success just for entering a password,
|
|
// even if the password is later found out to be wrong.
|
|
// We should handle this differently.
|
|
pub fn secret_input_with_prompt(prompt: &str) -> Passphrase {
|
|
Passphrase::from(
|
|
Password::with_theme(&theme())
|
|
.allow_empty_password(true)
|
|
.with_prompt(prompt)
|
|
.interact()
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
pub fn secret_input_with_confirmation() -> Passphrase {
|
|
Passphrase::from(
|
|
Password::with_theme(&theme())
|
|
.with_prompt("Passphrase")
|
|
.with_confirmation("Repeat passphrase", "Error: the passphrases don't match.")
|
|
.interact()
|
|
.unwrap(),
|
|
)
|
|
}
|
|
|
|
pub fn secret_stdin() -> Result<Passphrase, anyhow::Error> {
|
|
let mut input = String::new();
|
|
std::io::stdin().read_line(&mut input)?;
|
|
|
|
Ok(Passphrase::from(input.trim_end().to_owned()))
|
|
}
|
|
|
|
pub fn read_passphrase(stdin: bool, confirm: bool) -> Result<Passphrase, anyhow::Error> {
|
|
let passphrase = match read_passphrase_from_env_var() {
|
|
Ok(input) => input,
|
|
_ => {
|
|
if stdin {
|
|
secret_stdin()?
|
|
} else if confirm {
|
|
secret_input_with_confirmation()
|
|
} else {
|
|
secret_input()
|
|
}
|
|
}
|
|
};
|
|
|
|
Ok(passphrase)
|
|
}
|
|
|
|
pub fn read_passphrase_from_env_var() -> Result<Passphrase, anyhow::Error> {
|
|
let passphrase = std::env::var(RAD_PASSPHRASE)?;
|
|
|
|
Ok(Passphrase::from(passphrase.trim_end().to_owned()))
|
|
}
|
|
|
|
pub fn select<'a, T>(options: &'a [T], active: &'a T) -> Option<&'a T>
|
|
where
|
|
T: fmt::Display + Eq + PartialEq,
|
|
{
|
|
let theme = theme();
|
|
let active = options.iter().position(|o| o == active);
|
|
let mut selection = dialoguer::Select::with_theme(&theme);
|
|
|
|
if let Some(active) = active {
|
|
selection.default(active);
|
|
}
|
|
let result = selection
|
|
.items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
|
|
.interact_opt()
|
|
.unwrap();
|
|
|
|
result.map(|i| &options[i])
|
|
}
|
|
|
|
pub fn select_with_prompt<'a, T>(prompt: &str, options: &'a [T], active: &'a T) -> Option<&'a T>
|
|
where
|
|
T: fmt::Display + Eq + PartialEq,
|
|
{
|
|
let theme = theme();
|
|
let active = options.iter().position(|o| o == active);
|
|
let mut selection = dialoguer::Select::with_theme(&theme);
|
|
|
|
selection.with_prompt(prompt);
|
|
|
|
if let Some(active) = active {
|
|
selection.default(active);
|
|
}
|
|
let result = selection
|
|
.items(&options.iter().map(|p| p.to_string()).collect::<Vec<_>>())
|
|
.interact_opt()
|
|
.unwrap();
|
|
|
|
result.map(|i| &options[i])
|
|
}
|
|
|
|
pub fn comment_select(issue: &Issue) -> Option<CommentId> {
|
|
let selection = dialoguer::Select::with_theme(&theme())
|
|
.with_prompt("Which comment do you want to react to?")
|
|
.item(issue.description().unwrap_or_default())
|
|
.items(
|
|
&issue
|
|
.comments()
|
|
.map(|(_, i)| i.body().to_owned())
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
.default(0)
|
|
.interact_opt()
|
|
.unwrap();
|
|
|
|
selection
|
|
.and_then(|n| issue.comments().nth(n))
|
|
.map(|(id, _)| *id)
|
|
}
|
|
|
|
pub fn markdown(content: &str) {
|
|
if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() {
|
|
blob(content);
|
|
}
|
|
}
|
|
|
|
fn _info(args: std::fmt::Arguments) {
|
|
println!("{}", args);
|
|
}
|