From 223d36086ec861e181c43ee413a51baf141b2813 Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Mon, 7 Nov 2022 13:38:16 +0100 Subject: [PATCH] Add `radicle-cli` crate's `terminal` module Signed-off-by: Alexis Sellier Co-authored-by: xphoniex --- Cargo.lock | 76 ++++++ Cargo.toml | 1 + radicle-cli/Cargo.toml | 28 ++ radicle-cli/src/lib.rs | 2 + radicle-cli/src/terminal.rs | 174 +++++++++++++ radicle-cli/src/terminal/args.rs | 73 ++++++ radicle-cli/src/terminal/command.rs | 19 ++ radicle-cli/src/terminal/format.rs | 138 ++++++++++ radicle-cli/src/terminal/io.rs | 382 ++++++++++++++++++++++++++++ radicle-cli/src/terminal/patch.rs | 86 +++++++ radicle-cli/src/terminal/spinner.rs | 77 ++++++ radicle-cli/src/terminal/table.rs | 80 ++++++ radicle-cli/src/terminal/textbox.rs | 74 ++++++ radicle-node/src/service.rs | 4 +- radicle/src/node.rs | 4 + 15 files changed, 1215 insertions(+), 3 deletions(-) create mode 100644 radicle-cli/Cargo.toml create mode 100644 radicle-cli/src/lib.rs create mode 100644 radicle-cli/src/terminal.rs create mode 100644 radicle-cli/src/terminal/args.rs create mode 100644 radicle-cli/src/terminal/command.rs create mode 100644 radicle-cli/src/terminal/format.rs create mode 100644 radicle-cli/src/terminal/io.rs create mode 100644 radicle-cli/src/terminal/patch.rs create mode 100644 radicle-cli/src/terminal/spinner.rs create mode 100644 radicle-cli/src/terminal/table.rs create mode 100644 radicle-cli/src/terminal/textbox.rs diff --git a/Cargo.lock b/Cargo.lock index 900c97c5..214fe36a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "console" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c050367d967ced717c04b65d8c619d863ef9292ce0c5760028655a2fb298718c" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "terminal_size", + "unicode-width", + "winapi", +] + [[package]] name = "const-oid" version = "0.9.0" @@ -459,6 +473,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dialoguer" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92e7e37ecef6857fdc0c0c5d42fd5b0938e46590c2183cc92dd310a6d078eb1" +dependencies = [ + "console", + "tempfile", + "zeroize", +] + [[package]] name = "digest" version = "0.9.0" @@ -544,6 +569,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "fastrand" version = "1.8.0" @@ -893,6 +924,18 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indicatif" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d207dc617c7a380ab07ff572a6e52fa202a2a8f355860ac9c38e23f8196be1b" +dependencies = [ + "console", + "lazy_static", + "number_prefix", + "regex", +] + [[package]] name = "inout" version = "0.1.3" @@ -1189,6 +1232,12 @@ dependencies = [ "libc", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "olpc-cjson" version = "0.1.2" @@ -1442,6 +1491,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "radicle-cli" +version = "0.8.0" +dependencies = [ + "anyhow", + "console", + "dialoguer", + "indicatif", + "lexopt", + "log", + "radicle", + "radicle-cob", + "radicle-crypto", + "thiserror", + "zeroize", +] + [[package]] name = "radicle-cob" version = "0.1.0" @@ -2013,6 +2079,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "thiserror" version = "1.0.37" diff --git a/Cargo.toml b/Cargo.toml index 4615bfc5..fbf7bead 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "radicle", "radicle-cob", + "radicle-cli", "radicle-crypto", "radicle-httpd", "radicle-node", diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml new file mode 100644 index 00000000..1ffdbbb9 --- /dev/null +++ b/radicle-cli/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "radicle-cli" +license = "MIT OR Apache-2.0" +version = "0.8.0" +authors = ["Alexis Sellier "] +edition = "2021" + +[dependencies] +anyhow = { version = "1" } +console = { version = "0.15" } +dialoguer = { version = "0.10.0" } +indicatif = { version = "0.16.2" } +lexopt = { version = "0.2" } +log = { version = "0.4", features = ["std"] } +thiserror = { version = "1" } +zeroize = { version = "1.1" } + +[dependencies.radicle] +version = "0" +path = "../radicle" + +[dependencies.radicle-cob] +version = "0" +path = "../radicle-cob" + +[dependencies.radicle-crypto] +version = "0" +path = "../radicle-crypto" diff --git a/radicle-cli/src/lib.rs b/radicle-cli/src/lib.rs new file mode 100644 index 00000000..f73f5c00 --- /dev/null +++ b/radicle-cli/src/lib.rs @@ -0,0 +1,2 @@ +#![allow(clippy::collapsible_if)] +pub mod terminal; diff --git a/radicle-cli/src/terminal.rs b/radicle-cli/src/terminal.rs new file mode 100644 index 00000000..2302883e --- /dev/null +++ b/radicle-cli/src/terminal.rs @@ -0,0 +1,174 @@ +pub mod args; +pub mod command; +pub mod format; +pub mod io; +pub mod patch; +pub mod spinner; +pub mod table; +pub mod textbox; + +use std::ffi::OsString; +use std::process; + +use dialoguer::console::style; +use radicle::profile::Profile; + +pub use args::{Args, Error, Help}; +pub use console::measure_text_width as text_width; +pub use dialoguer::Editor; +pub use io::*; +pub use spinner::{spinner, Spinner}; +pub use table::Table; +pub use textbox::TextBox; + +/// Context passed to all commands. +pub trait Context { + /// Return the currently active profile, or an error if no profile is active. + fn profile(&self) -> Result; +} + +impl Context for Profile { + fn profile(&self) -> Result { + Ok(self.clone()) + } +} + +impl Context for F +where + F: Fn() -> Result, +{ + fn profile(&self) -> Result { + self() + } +} + +/// A command that can be run. +pub trait Command { + /// Run the command, given arguments and a context. + fn run(self, args: A, context: C) -> anyhow::Result<()>; +} + +impl Command for F +where + F: FnOnce(A, C) -> anyhow::Result<()>, +{ + fn run(self, args: A, context: C) -> anyhow::Result<()> { + self(args, context) + } +} + +pub fn run_command(help: Help, action: &str, cmd: C) -> ! +where + A: Args, + C: Command anyhow::Result>, +{ + let args = std::env::args_os().into_iter().skip(1).collect(); + + run_command_args(help, action, cmd, args) +} + +pub fn run_command_args(help: Help, action: &str, cmd: C, args: Vec) -> ! +where + A: Args, + C: Command anyhow::Result>, +{ + use io as term; + + let options = match A::from_args(args) { + Ok((opts, unparsed)) => { + if let Err(err) = args::finish(unparsed) { + term::error(err); + process::exit(1); + } + opts + } + Err(err) => { + match err.downcast_ref::() { + Some(Error::Help) => { + term::help(help.name, help.version, help.description, help.usage); + process::exit(0); + } + Some(Error::Usage) => { + term::usage(help.name, help.usage); + process::exit(1); + } + _ => {} + }; + eprintln!( + "{} {} {} {}", + style("==").red(), + style("Error:").red(), + style(format!("rad-{}:", help.name)).red(), + style(&err).red() + ); + + if let Some(Error::WithHint { hint, .. }) = err.downcast_ref::() { + eprintln!("{}", style(hint).yellow()); + } + + process::exit(1); + } + }; + + match cmd.run(options, self::profile) { + Ok(()) => process::exit(0), + Err(err) => { + term::fail(&format!("{} failed", action), &err); + process::exit(1); + } + } +} + +/// Get the default profile. Fails if there is no profile. +fn profile() -> Result { + let error = args::Error::WithHint { + err: anyhow::anyhow!("Could not load radicle profile"), + hint: "To setup your radicle profile, run `rad auth`.", + }; + + match Profile::load() { + Ok(profile) => Ok(profile), + Err(_) => Err(error.into()), + } +} + +#[derive(Debug, PartialEq, Eq, Copy, Clone)] +pub enum Interactive { + Yes, + No, +} + +impl Default for Interactive { + fn default() -> Self { + Interactive::No + } +} + +impl Interactive { + pub fn yes(&self) -> bool { + (*self).into() + } + + pub fn no(&self) -> bool { + !self.yes() + } +} + +impl From for bool { + fn from(c: Interactive) -> Self { + match c { + Interactive::Yes => true, + Interactive::No => false, + } + } +} + +impl From for Interactive { + fn from(b: bool) -> Self { + if b { + Interactive::Yes + } else { + Interactive::No + } + } +} diff --git a/radicle-cli/src/terminal/args.rs b/radicle-cli/src/terminal/args.rs new file mode 100644 index 00000000..99ea922c --- /dev/null +++ b/radicle-cli/src/terminal/args.rs @@ -0,0 +1,73 @@ +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::anyhow; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + /// If this error is returned from argument parsing, help is displayed. + #[error("help invoked")] + Help, + /// If this error is returned from argument parsing, usage is displayed. + #[error("usage invoked")] + Usage, + /// An error with a hint. + #[error("{err}")] + WithHint { + err: anyhow::Error, + hint: &'static str, + }, +} + +pub struct Help { + pub name: &'static str, + pub description: &'static str, + pub version: &'static str, + pub usage: &'static str, +} + +pub trait Args: Sized { + fn from_env() -> anyhow::Result { + let args: Vec<_> = std::env::args_os().into_iter().skip(1).collect(); + + match Self::from_args(args) { + Ok((opts, unparsed)) => { + self::finish(unparsed)?; + + Ok(opts) + } + Err(err) => Err(err), + } + } + + fn from_args(args: Vec) -> anyhow::Result<(Self, Vec)>; +} + +pub fn parse_value(flag: &str, value: OsString) -> anyhow::Result +where + ::Err: std::error::Error, +{ + value + .into_string() + .map_err(|_| anyhow!("the value specified for '--{}' is not valid unicode", flag))? + .parse() + .map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e)) +} + +pub fn format(arg: lexopt::Arg) -> OsString { + match arg { + lexopt::Arg::Long(flag) => format!("--{}", flag).into(), + lexopt::Arg::Short(flag) => format!("-{}", flag).into(), + lexopt::Arg::Value(val) => val, + } +} + +pub fn finish(unparsed: Vec) -> anyhow::Result<()> { + if let Some(arg) = unparsed.first() { + return Err(anyhow::anyhow!( + "unexpected argument `{}`", + arg.to_string_lossy() + )); + } + Ok(()) +} diff --git a/radicle-cli/src/terminal/command.rs b/radicle-cli/src/terminal/command.rs new file mode 100644 index 00000000..83844ea8 --- /dev/null +++ b/radicle-cli/src/terminal/command.rs @@ -0,0 +1,19 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +pub fn bat>( + args: impl IntoIterator, + stdin: &str, +) -> anyhow::Result<()> { + let mut child = Command::new("bat") + .stdin(Stdio::piped()) + .args(args) + .spawn()?; + + let writer = child.stdin.as_mut().unwrap(); + writer.write_all(stdin.as_bytes())?; + + child.wait()?; + + Ok(()) +} diff --git a/radicle-cli/src/terminal/format.rs b/radicle-cli/src/terminal/format.rs new file mode 100644 index 00000000..399dabe2 --- /dev/null +++ b/radicle-cli/src/terminal/format.rs @@ -0,0 +1,138 @@ +use std::fmt; + +use dialoguer::console::style; +use radicle::node::NodeId; +use radicle::profile::Profile; +use radicle_cob::ObjectId; + +use crate::terminal as term; + +/// Format a node id to be more compact. +pub fn node(node: &NodeId) -> String { + let node = node.to_human(); + let start = node.chars().take(7).collect::(); + let end = node.chars().skip(node.len() - 7).collect::(); + + format!("{}…{}", start, end) +} + +/// Format a git Oid. +pub fn oid(oid: &radicle::git::Oid) -> String { + format!("{:.7}", oid) +} + +/// Format a COB id. +pub fn cob(id: &ObjectId) -> String { + format!("{:.11}", id.to_string()) +} + +/// Identity formatter that takes a profile and displays it as +/// ` ()` depending on the configuration. +pub struct Identity<'a> { + profile: &'a Profile, + /// If true, node id is printed in its compact form. + short: bool, + /// If true, node id and username are printed using the terminal's + /// styled formatters. + styled: bool, +} + +impl<'a> Identity<'a> { + pub fn new(profile: &'a Profile) -> Self { + Self { + profile, + short: false, + styled: false, + } + } + + pub fn short(mut self) -> Self { + self.short = true; + self + } + + pub fn styled(mut self) -> Self { + self.styled = true; + self + } +} + +impl<'a> fmt::Display for Identity<'a> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let username = "(me)"; + let node_id = match self.short { + true => self::node(self.profile.id()), + false => self.profile.id().to_human(), + }; + + if self.styled { + write!( + f, + "{} {}", + term::format::highlight(node_id), + term::format::dim(username) + ) + } else { + write!(f, "{} {}", node_id, username) + } + } +} + +pub fn negative(msg: D) -> String { + style(msg).red().bright().to_string() +} + +pub fn positive(msg: D) -> String { + style(msg).green().bright().to_string() +} + +pub fn secondary(msg: D) -> String { + style(msg).blue().bright().to_string() +} + +pub fn tertiary(msg: D) -> String { + style(msg).cyan().to_string() +} + +pub fn tertiary_bold(msg: D) -> String { + style(msg).cyan().bold().to_string() +} + +pub fn yellow(msg: D) -> String { + style(msg).yellow().to_string() +} + +pub fn highlight(input: D) -> String { + style(input).green().bright().to_string() +} + +pub fn badge_primary(input: D) -> String { + style(format!(" {} ", input)) + .magenta() + .reverse() + .to_string() +} + +pub fn badge_positive(input: D) -> String { + style(format!(" {} ", input)).green().reverse().to_string() +} + +pub fn badge_negative(input: D) -> String { + style(format!(" {} ", input)).red().reverse().to_string() +} + +pub fn badge_secondary(input: D) -> String { + style(format!(" {} ", input)).blue().reverse().to_string() +} + +pub fn bold(input: D) -> String { + style(input).white().bright().bold().to_string() +} + +pub fn dim(input: D) -> String { + style(input).dim().to_string() +} + +pub fn italic(input: D) -> String { + style(input).italic().dim().to_string() +} diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs new file mode 100644 index 00000000..ed3197c0 --- /dev/null +++ b/radicle-cli/src/terminal/io.rs @@ -0,0 +1,382 @@ +use std::fmt; +use std::str::FromStr; + +use radicle::crypto::ssh::keystore::Passphrase; +use radicle::crypto::Signer; +use radicle::profile::env::RAD_PASSPHRASE; +use radicle::profile::Profile; + +use dialoguer::{console::style, console::Style, theme::ColorfulTheme, Input, Password}; +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::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() -> usize { + let (_, rows) = console::Term::stdout().size(); + rows 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::() { + eprintln!("{} {}", style("==").yellow(), style(hint).yellow(),); + blank(); + } +} + +pub fn ask(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(prompt: D) -> bool { + ask(format::tertiary(prompt), true) +} + +pub fn abort(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> { + let signer = 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(message: &str, default: Option) -> anyhow::Result +where + S: fmt::Display + std::str::FromStr + Clone, + E: fmt::Debug + fmt::Display, +{ + let theme = theme(); + let mut input: Input = 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 { + option: Option, +} + +impl fmt::Display for Optional { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(val) = &self.option { + write!(f, "{}", val) + } else { + write!(f, "") + } + } +} + +impl FromStr for Optional { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + if s.is_empty() { + return Ok(Optional { option: None }); + } + let val: T = s.parse()?; + + Ok(Self { option: Some(val) }) + } +} + +pub fn text_input_optional( + message: &str, + initial: Option, +) -> anyhow::Result> +where + S: fmt::Display + fmt::Debug + FromStr + Clone, + E: fmt::Debug + fmt::Display, +{ + let theme = theme(); + let mut input: Input> = 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 { + 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 { + 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 { + 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::>()) + .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::>()) + .interact_opt() + .unwrap(); + + result.map(|i| &options[i]) +} + +pub fn markdown(content: &str) { + if !content.is_empty() { + if command::bat(["-p", "-l", "md"], content).is_err() { + blob(content); + } + } +} + +fn _info(args: std::fmt::Arguments) { + println!("{}", args); +} diff --git a/radicle-cli/src/terminal/patch.rs b/radicle-cli/src/terminal/patch.rs new file mode 100644 index 00000000..b6453864 --- /dev/null +++ b/radicle-cli/src/terminal/patch.rs @@ -0,0 +1,86 @@ +use radicle::git; + +use crate::terminal as term; + +/// How a comment is to be supplied by the user for a patch or issue on the terminal. +#[derive(Clone, Debug)] +pub enum Comment { + /// Prompt user to write comment in editor. + Edit, + /// Don't leave a comment. + Blank, + /// Use the following string as comment. + Text(String), +} + +impl Comment { + /// Get the comment as a string according to the method. + pub fn get(self, help: &str) -> String { + let comment = match self { + Comment::Edit => term::Editor::new() + .require_save(true) + .trim_newlines(true) + .extension(".markdown") + .edit(help) + .unwrap(), + Comment::Blank => None, + Comment::Text(c) => Some(c), + }; + let comment = comment.unwrap_or_default().replace(help, ""); + let comment = comment.trim(); + + comment.to_owned() + } + + pub fn append(&mut self, arg: &str) { + if let Comment::Text(v) = self { + v.extend(["\n\n", arg]); + } else { + *self = Comment::Text(arg.into()); + }; + } +} + +impl Default for Comment { + fn default() -> Self { + Self::Edit + } +} + +/// List the given commits in a table. +pub fn list_commits(commits: &[git::raw::Commit]) -> anyhow::Result<()> { + let mut table = term::Table::default(); + + for commit in commits { + let message = commit + .summary_bytes() + .unwrap_or_else(|| commit.message_bytes()); + table.push([ + term::format::secondary(term::format::oid(&commit.id().into())), + term::format::italic(String::from_utf8_lossy(message)), + ]); + } + table.render(); + + Ok(()) +} + +/// Print commits ahead and behind. +pub fn print_commits_ahead_behind( + repo: &git::raw::Repository, + left: git::raw::Oid, + right: git::raw::Oid, +) -> anyhow::Result<()> { + let (ahead, behind) = repo.graph_ahead_behind(left, right)?; + + term::info!( + "{} commit(s) ahead, {} commit(s) behind", + term::format::positive(ahead), + if behind > 0 { + term::format::negative(behind) + } else { + term::format::dim(behind) + } + ); + Ok(()) +} diff --git a/radicle-cli/src/terminal/spinner.rs b/radicle-cli/src/terminal/spinner.rs new file mode 100644 index 00000000..348f6fd9 --- /dev/null +++ b/radicle-cli/src/terminal/spinner.rs @@ -0,0 +1,77 @@ +use dialoguer::console::style; +use indicatif::{ProgressBar, ProgressFinish, ProgressStyle}; + +use crate::terminal as term; + +pub struct Spinner { + progress: ProgressBar, + message: String, +} + +impl Drop for Spinner { + fn drop(&mut self) { + // TODO: Set error that will be output on fail. + if !self.progress.is_finished() { + self.set_failed(); + } + } +} + +impl Spinner { + pub fn finish(&self) { + self.progress.finish_and_clear(); + term::success!("{}", &self.message); + } + + pub fn done(self) { + self.progress.finish_and_clear(); + term::info!("{}", &self.message); + } + + pub fn failed(mut self) { + self.set_failed(); + } + + pub fn error(self, err: anyhow::Error) -> anyhow::Error { + self.progress.finish_and_clear(); + term::eprintln(style("!!").red().reverse(), style(&err).red()); + + err + } + + pub fn clear(self) { + self.progress.finish_and_clear(); + } + + pub fn message(&mut self, msg: impl Into) { + let msg = msg.into(); + + self.progress.set_message(msg.clone()); + self.message = msg; + } + + pub fn set_failed(&mut self) { + self.progress.finish_and_clear(); + term::eprintln(style("!!").red().reverse(), &self.message); + } +} + +pub fn spinner(message: impl ToString) -> Spinner { + let message = message.to_string(); + let style = ProgressStyle::default_spinner() + .tick_strings(&[ + &style("\\ ").yellow().to_string(), + &style("| ").yellow().to_string(), + &style("/ ").yellow().to_string(), + &style("| ").yellow().to_string(), + ]) + .template("{spinner} {msg}") + .on_finish(ProgressFinish::AndClear); + + let progress = ProgressBar::new(!0); + progress.set_style(style); + progress.enable_steady_tick(99); + progress.set_message(message.clone()); + + Spinner { message, progress } +} diff --git a/radicle-cli/src/terminal/table.rs b/radicle-cli/src/terminal/table.rs new file mode 100644 index 00000000..eabff7c0 --- /dev/null +++ b/radicle-cli/src/terminal/table.rs @@ -0,0 +1,80 @@ +use std::fmt::Write; + +use crate::terminal as term; + +#[derive(Debug, Default)] +pub struct TableOptions { + pub overflow: bool, +} + +#[derive(Debug)] +pub struct Table { + rows: Vec<[String; W]>, + widths: [usize; W], + opts: TableOptions, +} + +impl Table { + pub fn new(opts: TableOptions) -> Self { + Self { + rows: Vec::new(), + widths: [0; W], + opts, + } + } + + pub fn default() -> Self { + Self { + rows: Vec::new(), + widths: [0; W], + opts: TableOptions::default(), + } + } + + pub fn push(&mut self, row: [String; W]) { + for (i, cell) in row.iter().enumerate() { + self.widths[i] = self.widths[i].max(console::measure_text_width(cell)); + } + self.rows.push(row); + } + + pub fn render(self) { + let width = term::width(); // Terminal width. + + for row in &self.rows { + let mut output = String::new(); + let cells = row.len(); + + for (i, cell) in row.iter().enumerate() { + if i == cells - 1 || self.opts.overflow { + write!(output, "{}", cell).ok(); + } else { + write!( + output, + "{} ", + console::pad_str(cell, self.widths[i], console::Alignment::Left, None) + ) + .ok(); + } + } + println!("{}", console::truncate_str(&output, width - 1, "…")); + } + } + + pub fn render_tree(self) { + for (r, row) in self.rows.iter().enumerate() { + if r != self.rows.len() - 1 { + print!("├── "); + } else { + print!("└── "); + } + for (i, cell) in row.iter().enumerate() { + print!( + "{} ", + console::pad_str(cell, self.widths[i], console::Alignment::Left, None) + ); + } + println!(); + } + } +} diff --git a/radicle-cli/src/terminal/textbox.rs b/radicle-cli/src/terminal/textbox.rs new file mode 100644 index 00000000..0d947265 --- /dev/null +++ b/radicle-cli/src/terminal/textbox.rs @@ -0,0 +1,74 @@ +use std::fmt; + +use crate::terminal as term; + +pub struct TextBox { + pub body: String, + first: bool, + last: bool, +} + +impl TextBox { + pub fn new(body: String) -> Self { + Self { + body, + first: true, + last: true, + } + } + + /// Is this text box the last one in the list? + pub fn last(mut self, connect: bool) -> Self { + self.last = connect; + self + } + + /// Is this text box the first one in the list? + pub fn first(mut self, connect: bool) -> Self { + self.first = connect; + self + } +} + +impl fmt::Display for TextBox { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut width = self + .body + .lines() + .map(console::measure_text_width) + .max() + .unwrap_or(0) + + 2; + if term::width() < width + 2 { + width = term::width() - 2 + } + + let (connector, header_width) = if !self.first { + ("┴", width - 1) + } else { + ("", width) + }; + writeln!(f, "┌{}{}┐", connector, "─".repeat(header_width))?; + + for l in self.body.lines() { + writeln!( + f, + "│ {}│", + console::pad_str(l, width - 1, console::Alignment::Left, Some("…")) + )?; + } + + let (connector, footer_width) = if !self.last { + ("┬", width - 1) + } else { + ("", width) + }; + + writeln!(f, "└{}{}┘", connector, "─".repeat(footer_width))?; + + if !self.last { + writeln!(f, " │")?; + } + Ok(()) + } +} diff --git a/radicle-node/src/service.rs b/radicle-node/src/service.rs index 1aa4e3ca..cc8e0915 100644 --- a/radicle-node/src/service.rs +++ b/radicle-node/src/service.rs @@ -35,6 +35,7 @@ use crate::service::message::{NodeAnnouncement, RefsAnnouncement}; use crate::storage; use crate::storage::{Inventory, ReadRepository, RefUpdate, WriteRepository, WriteStorage}; +pub use crate::node::NodeId; pub use crate::service::config::{Config, Network}; pub use crate::service::message::{Message, ZeroBytes}; pub use crate::service::session::Session; @@ -66,9 +67,6 @@ pub const MAX_TIME_DELTA: LocalDuration = LocalDuration::from_mins(60); /// Maximum attempts to connect to a peer before we give up. pub const MAX_CONNECTION_ATTEMPTS: usize = 3; -/// Network node identifier. -pub type NodeId = crypto::PublicKey; - /// A service event. #[derive(Debug, Clone)] pub enum Event { diff --git a/radicle/src/node.rs b/radicle/src/node.rs index 6aed0356..b061ec2f 100644 --- a/radicle/src/node.rs +++ b/radicle/src/node.rs @@ -6,6 +6,7 @@ use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::Path; +use crate::crypto::PublicKey; use crate::identity::Id; pub use features::Features; @@ -33,6 +34,9 @@ pub trait Handle { fn shutdown(self) -> Result<(), Error>; } +/// Public node & device identifier. +pub type NodeId = PublicKey; + /// Node controller. #[derive(Debug)] pub struct Node {