From 02304875ce4a03ef1e40e22673b80136804e0619 Mon Sep 17 00:00:00 2001 From: Alexis Sellier Date: Thu, 2 Mar 2023 18:15:24 +0100 Subject: [PATCH] Initialize `radicle-term` crate We split out all non-radicle terminal functionality so that it can be accessed by `radicle-node` without importing `radicle-cli`. --- Cargo.lock | 17 ++ Cargo.toml | 1 + radicle-cli/Cargo.toml | 4 + radicle-cli/src/commands/auth.rs | 5 +- radicle-cli/src/commands/id.rs | 3 +- radicle-cli/src/commands/issue.rs | 2 +- radicle-cli/src/terminal.rs | 75 ++---- radicle-cli/src/terminal/format.rs | 79 +----- radicle-cli/src/terminal/io.rs | 252 +----------------- radicle-term/Cargo.toml | 20 ++ .../src/terminal => radicle-term/src}/ansi.rs | 0 .../src}/ansi/color.rs | 0 .../src}/ansi/paint.rs | 0 .../src}/ansi/style.rs | 0 .../src}/ansi/tests.rs | 0 .../src}/ansi/windows.rs | 0 radicle-term/src/args.rs | 97 +++++++ .../src/terminal => radicle-term/src}/cell.rs | 0 radicle-term/src/cob.rs | 15 ++ .../terminal => radicle-term/src}/command.rs | 0 .../terminal => radicle-term/src}/editor.rs | 0 radicle-term/src/format.rs | 77 ++++++ radicle-term/src/io.rs | 236 ++++++++++++++++ radicle-term/src/lib.rs | 62 +++++ radicle-term/src/patch.rs | 105 ++++++++ .../terminal => radicle-term/src}/spinner.rs | 4 +- .../terminal => radicle-term/src}/table.rs | 6 +- .../terminal => radicle-term/src}/textbox.rs | 4 +- 28 files changed, 672 insertions(+), 392 deletions(-) create mode 100644 radicle-term/Cargo.toml rename {radicle-cli/src/terminal => radicle-term/src}/ansi.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/ansi/color.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/ansi/paint.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/ansi/style.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/ansi/tests.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/ansi/windows.rs (100%) create mode 100644 radicle-term/src/args.rs rename {radicle-cli/src/terminal => radicle-term/src}/cell.rs (100%) create mode 100644 radicle-term/src/cob.rs rename {radicle-cli/src/terminal => radicle-term/src}/command.rs (100%) rename {radicle-cli/src/terminal => radicle-term/src}/editor.rs (100%) create mode 100644 radicle-term/src/format.rs create mode 100644 radicle-term/src/io.rs create mode 100644 radicle-term/src/lib.rs create mode 100644 radicle-term/src/patch.rs rename {radicle-cli/src/terminal => radicle-term/src}/spinner.rs (98%) rename {radicle-cli/src/terminal => radicle-term/src}/table.rs (98%) rename {radicle-cli/src/terminal => radicle-term/src}/textbox.rs (95%) diff --git a/Cargo.lock b/Cargo.lock index 2e1aa738..c2a8260a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1835,6 +1835,7 @@ dependencies = [ "radicle-cob", "radicle-crypto", "radicle-node", + "radicle-term", "serde", "serde_json", "serde_yaml", @@ -2050,6 +2051,22 @@ dependencies = [ "thiserror", ] +[[package]] +name = "radicle-term" +version = "0.1.0" +dependencies = [ + "anyhow", + "concolor", + "inquire", + "once_cell", + "pretty_assertions", + "tempfile", + "termion 2.0.1", + "unicode-segmentation", + "unicode-width", + "zeroize", +] + [[package]] name = "radicle-tools" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 242765d9..6814b72b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ default-members = [ "radicle-node", "radicle-ssh", "radicle-remote-helper", + "radicle-term", ] [profile.container] diff --git a/radicle-cli/Cargo.toml b/radicle-cli/Cargo.toml index 8bb84ae0..79aa8352 100644 --- a/radicle-cli/Cargo.toml +++ b/radicle-cli/Cargo.toml @@ -47,6 +47,10 @@ path = "../radicle-cob" version = "0" path = "../radicle-crypto" +[dependencies.radicle-term] +version = "0" +path = "../radicle-term" + [dev-dependencies] pretty_assertions = { version = "1.3.0" } radicle = { path = "../radicle", features = ["test"] } diff --git a/radicle-cli/src/commands/auth.rs b/radicle-cli/src/commands/auth.rs index 7eccabb3..88821f30 100644 --- a/radicle-cli/src/commands/auth.rs +++ b/radicle-cli/src/commands/auth.rs @@ -5,6 +5,7 @@ use anyhow::anyhow; use radicle::crypto::ssh; use radicle::crypto::ssh::Passphrase; +use radicle::profile::env::RAD_PASSPHRASE; use radicle::{profile, Profile}; use crate::terminal as term; @@ -87,7 +88,7 @@ pub fn init(options: Options) -> anyhow::Result<()> { let passphrase = if options.stdin { term::passphrase_stdin() } else { - term::passphrase_confirm("Enter a passphrase:") + term::passphrase_confirm("Enter a passphrase:", RAD_PASSPHRASE) }?; let spinner = term::spinner("Creating your Ed25519 keypair..."); let profile = Profile::init(home, passphrase.clone())?; @@ -132,7 +133,7 @@ pub fn authenticate(profile: &Profile, options: Options) -> anyhow::Result<()> { let passphrase = if options.stdin { term::passphrase_stdin() } else { - term::passphrase() + term::passphrase(RAD_PASSPHRASE) }?; let spinner = term::spinner("Unlocking..."); register(profile, passphrase)?; diff --git a/radicle-cli/src/commands/id.rs b/radicle-cli/src/commands/id.rs index 533b6ea2..7510e447 100644 --- a/radicle-cli/src/commands/id.rs +++ b/radicle-cli/src/commands/id.rs @@ -8,8 +8,9 @@ use radicle::prelude::{Did, Doc}; use radicle::storage::ReadStorage as _; use radicle_crypto::Verified; +use crate::terminal as term; use crate::terminal::args::{Args, Error, Help}; -use crate::terminal::{self as term, Interactive}; +use crate::terminal::Interactive; pub const HELP: Help = Help { name: "id", diff --git a/radicle-cli/src/commands/issue.rs b/radicle-cli/src/commands/issue.rs index ccecffea..a394ac44 100644 --- a/radicle-cli/src/commands/issue.rs +++ b/radicle-cli/src/commands/issue.rs @@ -218,7 +218,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> { } Operation::React { id, reaction } => { if let Ok(mut issue) = issues.get_mut(&id) { - let (comment_id, _) = term::comment_select(&issue).unwrap(); + let (comment_id, _) = term::io::comment_select(&issue).unwrap(); issue.react(*comment_id, reaction, &signer)?; } } diff --git a/radicle-cli/src/terminal.rs b/radicle-cli/src/terminal.rs index e1af1ac2..06d38836 100644 --- a/radicle-cli/src/terminal.rs +++ b/radicle-cli/src/terminal.rs @@ -1,29 +1,18 @@ -pub mod ansi; pub mod args; -pub mod cell; +pub use args::{Args, Error, Help}; pub mod cob; -pub mod command; -pub mod editor; pub mod format; pub mod io; +pub use io::{proposal, signer}; pub mod patch; -pub mod spinner; -pub mod table; -pub mod textbox; +pub use radicle_term::*; use std::ffi::OsString; use std::process; use radicle::profile::Profile; -pub use ansi::{paint, Paint}; -pub use args::{Args, Error, Help}; -pub use editor::Editor; -pub use inquire::ui::Styled; -pub use io::*; -pub use spinner::{spinner, Spinner}; -pub use table::Table; -pub use textbox::TextBox; +use crate::terminal; /// Context passed to all commands. pub trait Context { @@ -117,7 +106,7 @@ where match cmd.run(options, self::profile) { Ok(()) => process::exit(0), Err(err) => { - term::fail(&format!("{action} failed"), &err); + terminal::fail(&format!("{action} failed"), &err); process::exit(1); } } @@ -136,47 +125,19 @@ pub fn profile() -> Result { } } -#[derive(Debug, PartialEq, Eq, Copy, Clone)] -pub enum Interactive { - Yes, - No, -} +pub fn fail(header: &str, error: &anyhow::Error) { + let err = error.to_string(); + let err = err.trim_end(); + let separator = if err.contains('\n') { ":\n" } else { ": " }; -impl Default for Interactive { - fn default() -> Self { - Interactive::No + println!( + "{ERROR_PREFIX} {}{}{error}", + Paint::red(header).bold(), + Paint::red(separator), + ); + + if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::() { + println!("{} {}", ERROR_HINT_PREFIX, Paint::yellow(hint)); + blank(); } } - -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 - } - } -} - -pub fn style(item: T) -> Paint { - paint(item) -} diff --git a/radicle-cli/src/terminal/format.rs b/radicle-cli/src/terminal/format.rs index 3ae7f4c3..543cf85e 100644 --- a/radicle-cli/src/terminal/format.rs +++ b/radicle-cli/src/terminal/format.rs @@ -1,6 +1,7 @@ use std::{fmt, time}; -pub use crate::terminal::{style, Paint}; +pub use radicle_term::format::*; +pub use radicle_term::{style, Paint}; use radicle::cob::{ObjectId, Timestamp}; use radicle::node::NodeId; @@ -87,79 +88,3 @@ impl<'a> fmt::Display for Identity<'a> { } } } - -pub fn wrap(msg: D) -> Paint { - Paint::wrapping(msg) -} - -pub fn negative(msg: D) -> Paint { - Paint::red(msg).bold() -} - -pub fn positive(msg: D) -> Paint { - Paint::green(msg).bold() -} - -pub fn secondary(msg: D) -> Paint { - Paint::blue(msg).bold() -} - -pub fn tertiary(msg: D) -> Paint { - Paint::cyan(msg) -} - -pub fn tertiary_bold(msg: D) -> Paint { - Paint::cyan(msg).bold() -} - -pub fn yellow(msg: D) -> Paint { - Paint::yellow(msg) -} - -pub fn highlight(input: D) -> Paint { - Paint::green(input).bold() -} - -pub fn badge_primary(input: D) -> Paint { - if Paint::is_enabled() { - Paint::magenta(format!(" {input} ")).invert() - } else { - Paint::new(format!("❲{input}❳")) - } -} - -pub fn badge_positive(input: D) -> Paint { - if Paint::is_enabled() { - Paint::green(format!(" {input} ")).invert() - } else { - Paint::new(format!("❲{input}❳")) - } -} - -pub fn badge_negative(input: D) -> Paint { - if Paint::is_enabled() { - Paint::red(format!(" {input} ")).invert() - } else { - Paint::new(format!("❲{input}❳")) - } -} - -pub fn badge_secondary(input: D) -> Paint { - if Paint::is_enabled() { - Paint::blue(format!(" {input} ")).invert() - } else { - Paint::new(format!("❲{input}❳")) - } -} - -pub fn bold(input: D) -> Paint { - Paint::white(input).bold() -} - -pub fn dim(input: D) -> Paint { - Paint::new(input).dim() -} - -pub fn italic(input: D) -> Paint { - Paint::new(input).italic().dim() -} diff --git a/radicle-cli/src/terminal/io.rs b/radicle-cli/src/terminal/io.rs index 0dfeac64..5619a09c 100644 --- a/radicle-cli/src/terminal/io.rs +++ b/radicle-cli/src/terminal/io.rs @@ -1,186 +1,19 @@ -use std::fmt; - -use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled}; -use inquire::InquireError; -use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password, Select}; -use once_cell::sync::Lazy; - use radicle::cob::issue::Issue; use radicle::cob::thread::{Comment, CommentId}; -use radicle::crypto::ssh::keystore::{MemorySigner, Passphrase}; +use radicle::crypto::ssh::keystore::MemorySigner; use radicle::crypto::Signer; -use radicle::profile; +use radicle::profile::env::RAD_PASSPHRASE; use radicle::profile::Profile; -use super::command; -use super::format; -use super::spinner::spinner; -use super::Error; -use super::{style, Paint}; - -pub const ERROR_PREFIX: Paint<&str> = Paint::red("✗"); -pub const ERROR_HINT_PREFIX: Paint<&str> = Paint::yellow("✗"); -pub const WARNING_PREFIX: Paint<&str> = Paint::yellow("!"); -pub const TAB: &str = " "; - -/// Render configuration. -pub static CONFIG: Lazy = Lazy::new(|| RenderConfig { - prompt: StyleSheet::new().with_fg(Color::LightCyan), - prompt_prefix: Styled::new("?").with_fg(Color::LightBlue), - answered_prompt_prefix: Styled::new("✓").with_fg(Color::LightGreen), - answer: StyleSheet::new(), - highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow), - help_message: StyleSheet::new().with_fg(Color::DarkGrey), - error_message: ErrorMessageRenderConfig::default_colored() - .with_prefix(Styled::new("✗").with_fg(Color::LightRed)), - ..RenderConfig::default_colored() -}); - -#[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!("{} {args}", Paint::green("✓")); -} - -pub fn tip_args(args: fmt::Arguments) { - println!("👉 {}", style(format!("{args}")).italic()); -} - -pub fn columns() -> Option { - termion::terminal_size().map(|(cols, _)| cols as usize).ok() -} - -pub fn headline(headline: impl fmt::Display) { - println!(); - println!("{}", style(headline).bold()); - println!(); -} - -pub fn header(header: &str) { - println!(); - println!("{}", style(format::yellow(header)).bold().underline()); - 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!("{prefix}{line}\n")) - .collect() -} - -pub fn help(name: &str, version: &str, description: &str, usage: &str) { - println!("rad-{name} {version}\n{description}\n{usage}"); -} - -pub fn usage(name: &str, usage: &str) { - println!( - "{} {}\n{}", - ERROR_PREFIX, - Paint::red(format!("Error: rad-{name}: invalid usage")), - Paint::red(prefixed(TAB, usage)).dim() - ); -} - -pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) { - println!("{prefix} {msg}"); -} - -pub fn indented(msg: impl fmt::Display) { - println!("{TAB}{msg}"); -} - -pub fn subcommand(msg: impl fmt::Display) { - println!("{} {}", style("$").dim(), style(msg).dim()); -} - -pub fn warning(warning: &str) { - println!( - "{} {} {warning}", - WARNING_PREFIX, - Paint::yellow("Warning:").bold(), - ); -} - -pub fn error(error: impl fmt::Display) { - println!("{ERROR_PREFIX} {error}"); -} - -pub fn fail(header: &str, error: &anyhow::Error) { - let err = error.to_string(); - let err = err.trim_end(); - let separator = if err.contains('\n') { ":\n" } else { ": " }; - - println!( - "{ERROR_PREFIX} {}{}{error}", - Paint::red(header).bold(), - Paint::red(separator), - ); - - if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::() { - println!("{} {}", ERROR_HINT_PREFIX, Paint::yellow(hint)); - blank(); - } -} - -pub fn ask(prompt: D, default: bool) -> bool { - let prompt = prompt.to_string(); - - Confirm::new(&prompt) - .with_default(default) - .with_render_config(*CONFIG) - .prompt() - .unwrap_or_default() -} - -pub fn confirm(prompt: D) -> bool { - ask(prompt, true) -} - -pub fn abort(prompt: D) -> bool { - ask(prompt, false) -} +pub use radicle_term::io::*; +pub use radicle_term::spinner; /// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user. pub fn signer(profile: &Profile) -> anyhow::Result> { if let Ok(signer) = profile.signer() { return Ok(signer); } - let passphrase = passphrase()?; + let passphrase = passphrase(RAD_PASSPHRASE)?; let spinner = spinner("Unsealing key..."); let signer = MemorySigner::load(&profile.keystore, passphrase)?; @@ -189,75 +22,6 @@ pub fn signer(profile: &Profile) -> anyhow::Result> { Ok(signer.boxed()) } -pub fn input(message: &str, default: Option) -> anyhow::Result -where - S: fmt::Display + std::str::FromStr + Clone, - E: fmt::Debug + fmt::Display, -{ - let input = CustomType::::new(message).with_render_config(*CONFIG); - let value = match default { - Some(default) => input.with_default(default).prompt()?, - None => input.prompt()?, - }; - Ok(value) -} - -pub fn passphrase() -> Result { - if let Some(p) = profile::env::passphrase() { - Ok(p) - } else { - Ok(Passphrase::from( - Password::new("Passphrase:") - .with_render_config(*CONFIG) - .with_display_mode(inquire::PasswordDisplayMode::Masked) - .without_confirmation() - .prompt()?, - )) - } -} - -pub fn passphrase_confirm(prompt: &str) -> Result { - if let Some(p) = profile::env::passphrase() { - Ok(p) - } else { - Ok(Passphrase::from( - Password::new(prompt) - .with_render_config(*CONFIG) - .with_display_mode(inquire::PasswordDisplayMode::Masked) - .with_custom_confirmation_message("Repeat passphrase:") - .with_custom_confirmation_error_message("The passphrases don't match.") - .with_help_message("This passphrase protects your radicle identity") - .prompt()?, - )) - } -} - -pub fn passphrase_stdin() -> Result { - let mut input = String::new(); - std::io::stdin().read_line(&mut input)?; - - Ok(Passphrase::from(input.trim_end().to_owned())) -} - -pub fn select<'a, T>( - prompt: &str, - options: &'a [T], - active: &'a T, -) -> Result, InquireError> -where - T: fmt::Display + Eq + PartialEq, -{ - let active = options.iter().position(|o| o == active); - let selection = - Select::new(prompt, options.iter().collect::>()).with_render_config(*CONFIG); - - if let Some(active) = active { - selection.with_starting_cursor(active).prompt_skippable() - } else { - selection.prompt_skippable() - } -} - pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> { let comments = issue.comments().collect::>(); let selection = Select::new( @@ -272,12 +36,6 @@ pub fn comment_select(issue: &Issue) -> Option<(&CommentId, &Comment)> { comments.get(selection).copied() } -pub fn markdown(content: &str) { - if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() { - blob(content); - } -} - pub mod proposal { use std::fmt::Write as _; diff --git a/radicle-term/Cargo.toml b/radicle-term/Cargo.toml new file mode 100644 index 00000000..e29a9ba0 --- /dev/null +++ b/radicle-term/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "radicle-term" +license = "MIT OR Apache-2.0" +version = "0.1.0" +authors = ["Alexis Sellier "] +edition = "2021" + +[dependencies] +anyhow = { version = "1" } +concolor = { version = "0", features = ["api_unstable"] } +inquire = { version = "0.5.3", default-features = false, features = ["termion", "editor"] } +once_cell = { version = "1.13" } +termion = { version = "2" } +unicode-width = { version = "0.1.10", default-features = false } +unicode-segmentation = { version = "1.7.1" } +zeroize = { version = "1.1" } + +[dev-dependencies] +pretty_assertions = { version = "1.3.0" } +tempfile = { version = "3.3.0" } diff --git a/radicle-cli/src/terminal/ansi.rs b/radicle-term/src/ansi.rs similarity index 100% rename from radicle-cli/src/terminal/ansi.rs rename to radicle-term/src/ansi.rs diff --git a/radicle-cli/src/terminal/ansi/color.rs b/radicle-term/src/ansi/color.rs similarity index 100% rename from radicle-cli/src/terminal/ansi/color.rs rename to radicle-term/src/ansi/color.rs diff --git a/radicle-cli/src/terminal/ansi/paint.rs b/radicle-term/src/ansi/paint.rs similarity index 100% rename from radicle-cli/src/terminal/ansi/paint.rs rename to radicle-term/src/ansi/paint.rs diff --git a/radicle-cli/src/terminal/ansi/style.rs b/radicle-term/src/ansi/style.rs similarity index 100% rename from radicle-cli/src/terminal/ansi/style.rs rename to radicle-term/src/ansi/style.rs diff --git a/radicle-cli/src/terminal/ansi/tests.rs b/radicle-term/src/ansi/tests.rs similarity index 100% rename from radicle-cli/src/terminal/ansi/tests.rs rename to radicle-term/src/ansi/tests.rs diff --git a/radicle-cli/src/terminal/ansi/windows.rs b/radicle-term/src/ansi/windows.rs similarity index 100% rename from radicle-cli/src/terminal/ansi/windows.rs rename to radicle-term/src/ansi/windows.rs diff --git a/radicle-term/src/args.rs b/radicle-term/src/args.rs new file mode 100644 index 00000000..eeddd054 --- /dev/null +++ b/radicle-term/src/args.rs @@ -0,0 +1,97 @@ +use std::ffi::OsString; +use std::str::FromStr; + +use anyhow::anyhow; +use radicle::crypto; +use radicle::prelude::{Did, Id, NodeId}; + +#[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(()) +} + +pub fn did(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + let Ok(peer) = Did::from_str(&val) else { + if crypto::PublicKey::from_str(&val).is_ok() { + return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?")); + } else { + return Err(anyhow!("invalid DID '{}', expected 'did:key'", val)); + } + }; + Ok(peer) +} + +pub fn nid(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val)) +} + +pub fn rid(val: &OsString) -> anyhow::Result { + let val = val.to_string_lossy(); + Id::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val)) +} diff --git a/radicle-cli/src/terminal/cell.rs b/radicle-term/src/cell.rs similarity index 100% rename from radicle-cli/src/terminal/cell.rs rename to radicle-term/src/cell.rs diff --git a/radicle-term/src/cob.rs b/radicle-term/src/cob.rs new file mode 100644 index 00000000..6682c939 --- /dev/null +++ b/radicle-term/src/cob.rs @@ -0,0 +1,15 @@ +use std::str::FromStr; + +use super::*; +use radicle::cob::patch; + +use anyhow::anyhow; + +pub fn parse_patch_id(val: OsString) -> Result { + let val = val + .to_str() + .ok_or_else(|| anyhow!("patch id specified is not UTF-8"))?; + let patch_id = + patch::PatchId::from_str(val).map_err(|_| anyhow!("invalid patch id '{}'", val))?; + Ok(patch_id) +} diff --git a/radicle-cli/src/terminal/command.rs b/radicle-term/src/command.rs similarity index 100% rename from radicle-cli/src/terminal/command.rs rename to radicle-term/src/command.rs diff --git a/radicle-cli/src/terminal/editor.rs b/radicle-term/src/editor.rs similarity index 100% rename from radicle-cli/src/terminal/editor.rs rename to radicle-term/src/editor.rs diff --git a/radicle-term/src/format.rs b/radicle-term/src/format.rs new file mode 100644 index 00000000..80e726d5 --- /dev/null +++ b/radicle-term/src/format.rs @@ -0,0 +1,77 @@ +use crate::Paint; + +pub fn wrap(msg: D) -> Paint { + Paint::wrapping(msg) +} + +pub fn negative(msg: D) -> Paint { + Paint::red(msg).bold() +} + +pub fn positive(msg: D) -> Paint { + Paint::green(msg).bold() +} + +pub fn secondary(msg: D) -> Paint { + Paint::blue(msg).bold() +} + +pub fn tertiary(msg: D) -> Paint { + Paint::cyan(msg) +} + +pub fn tertiary_bold(msg: D) -> Paint { + Paint::cyan(msg).bold() +} + +pub fn yellow(msg: D) -> Paint { + Paint::yellow(msg) +} + +pub fn highlight(input: D) -> Paint { + Paint::green(input).bold() +} + +pub fn badge_primary(input: D) -> Paint { + if Paint::is_enabled() { + Paint::magenta(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } +} + +pub fn badge_positive(input: D) -> Paint { + if Paint::is_enabled() { + Paint::green(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } +} + +pub fn badge_negative(input: D) -> Paint { + if Paint::is_enabled() { + Paint::red(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } +} + +pub fn badge_secondary(input: D) -> Paint { + if Paint::is_enabled() { + Paint::blue(format!(" {input} ")).invert() + } else { + Paint::new(format!("❲{input}❳")) + } +} + +pub fn bold(input: D) -> Paint { + Paint::white(input).bold() +} + +pub fn dim(input: D) -> Paint { + Paint::new(input).dim() +} + +pub fn italic(input: D) -> Paint { + Paint::new(input).italic().dim() +} diff --git a/radicle-term/src/io.rs b/radicle-term/src/io.rs new file mode 100644 index 00000000..aaccdd36 --- /dev/null +++ b/radicle-term/src/io.rs @@ -0,0 +1,236 @@ +use std::ffi::OsStr; +use std::{env, fmt}; + +use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled}; +use inquire::InquireError; +use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password}; +use once_cell::sync::Lazy; +use zeroize::Zeroizing; + +use crate::command; +use crate::format; +use crate::{style, Paint}; + +// TODO: Try not to export this. +pub use inquire::Select; + +pub const ERROR_PREFIX: Paint<&str> = Paint::red("✗"); +pub const ERROR_HINT_PREFIX: Paint<&str> = Paint::yellow("✗"); +pub const WARNING_PREFIX: Paint<&str> = Paint::yellow("!"); +pub const TAB: &str = " "; + +/// Passphrase input. +pub type Passphrase = Zeroizing; + +/// Render configuration. +pub static CONFIG: Lazy = Lazy::new(|| RenderConfig { + prompt: StyleSheet::new().with_fg(Color::LightCyan), + prompt_prefix: Styled::new("?").with_fg(Color::LightBlue), + answered_prompt_prefix: Styled::new("✓").with_fg(Color::LightGreen), + answer: StyleSheet::new(), + highlighted_option_prefix: Styled::new("*").with_fg(Color::LightYellow), + help_message: StyleSheet::new().with_fg(Color::DarkGrey), + error_message: ErrorMessageRenderConfig::default_colored() + .with_prefix(Styled::new("✗").with_fg(Color::LightRed)), + ..RenderConfig::default_colored() +}); + +#[macro_export] +macro_rules! info { + ($($arg:tt)*) => ({ + println!("{}", format_args!($($arg)*)); + }) +} + +#[macro_export] +macro_rules! success { + ($($arg:tt)*) => ({ + $crate::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!("{} {args}", Paint::green("✓")); +} + +pub fn tip_args(args: fmt::Arguments) { + println!("👉 {}", style(format!("{args}")).italic()); +} + +pub fn columns() -> Option { + termion::terminal_size().map(|(cols, _)| cols as usize).ok() +} + +pub fn headline(headline: impl fmt::Display) { + println!(); + println!("{}", style(headline).bold()); + println!(); +} + +pub fn header(header: &str) { + println!(); + println!("{}", style(format::yellow(header)).bold().underline()); + 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!("{prefix}{line}\n")) + .collect() +} + +pub fn help(name: &str, version: &str, description: &str, usage: &str) { + println!("rad-{name} {version}\n{description}\n{usage}"); +} + +pub fn usage(name: &str, usage: &str) { + println!( + "{} {}\n{}", + ERROR_PREFIX, + Paint::red(format!("Error: rad-{name}: invalid usage")), + Paint::red(prefixed(TAB, usage)).dim() + ); +} + +pub fn println(prefix: impl fmt::Display, msg: impl fmt::Display) { + println!("{prefix} {msg}"); +} + +pub fn indented(msg: impl fmt::Display) { + println!("{TAB}{msg}"); +} + +pub fn subcommand(msg: impl fmt::Display) { + println!("{} {}", style("$").dim(), style(msg).dim()); +} + +pub fn warning(warning: &str) { + println!( + "{} {} {warning}", + WARNING_PREFIX, + Paint::yellow("Warning:").bold(), + ); +} + +pub fn error(error: impl fmt::Display) { + println!("{ERROR_PREFIX} {error}"); +} + +pub fn ask(prompt: D, default: bool) -> bool { + let prompt = prompt.to_string(); + + Confirm::new(&prompt) + .with_default(default) + .with_render_config(*CONFIG) + .prompt() + .unwrap_or_default() +} + +pub fn confirm(prompt: D) -> bool { + ask(prompt, true) +} + +pub fn abort(prompt: D) -> bool { + ask(prompt, false) +} + +pub fn input(message: &str, default: Option) -> anyhow::Result +where + S: fmt::Display + std::str::FromStr + Clone, + E: fmt::Debug + fmt::Display, +{ + let input = CustomType::::new(message).with_render_config(*CONFIG); + let value = match default { + Some(default) => input.with_default(default).prompt()?, + None => input.prompt()?, + }; + Ok(value) +} + +pub fn passphrase>(var: K) -> Result { + if let Ok(p) = env::var(var) { + Ok(Passphrase::from(p)) + } else { + Ok(Passphrase::from( + Password::new("Passphrase:") + .with_render_config(*CONFIG) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .without_confirmation() + .prompt()?, + )) + } +} + +pub fn passphrase_confirm>( + prompt: &str, + var: K, +) -> Result { + if let Ok(p) = env::var(var) { + Ok(Passphrase::from(p)) + } else { + Ok(Passphrase::from( + Password::new(prompt) + .with_render_config(*CONFIG) + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .with_custom_confirmation_message("Repeat passphrase:") + .with_custom_confirmation_error_message("The passphrases don't match.") + .with_help_message("This passphrase protects your radicle identity") + .prompt()?, + )) + } +} + +pub fn passphrase_stdin() -> Result { + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + + Ok(Passphrase::from(input.trim_end().to_owned())) +} + +pub fn select<'a, T>( + prompt: &str, + options: &'a [T], + active: &'a T, +) -> Result, InquireError> +where + T: fmt::Display + Eq + PartialEq, +{ + let active = options.iter().position(|o| o == active); + let selection = + Select::new(prompt, options.iter().collect::>()).with_render_config(*CONFIG); + + if let Some(active) = active { + selection.with_starting_cursor(active).prompt_skippable() + } else { + selection.prompt_skippable() + } +} + +pub fn markdown(content: &str) { + if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() { + blob(content); + } +} diff --git a/radicle-term/src/lib.rs b/radicle-term/src/lib.rs new file mode 100644 index 00000000..ad0d5a10 --- /dev/null +++ b/radicle-term/src/lib.rs @@ -0,0 +1,62 @@ +pub mod ansi; +pub mod cell; +pub mod command; +pub mod editor; +pub mod format; +pub mod io; +pub mod spinner; +pub mod table; +pub mod textbox; + +pub use ansi::{paint, Paint}; +pub use editor::Editor; +pub use inquire::ui::Styled; +pub use io::*; +pub use spinner::{spinner, Spinner}; +pub use table::Table; +pub use textbox::TextBox; + +#[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 + } + } +} + +pub fn style(item: T) -> Paint { + paint(item) +} diff --git a/radicle-term/src/patch.rs b/radicle-term/src/patch.rs new file mode 100644 index 00000000..39cf0cc5 --- /dev/null +++ b/radicle-term/src/patch.rs @@ -0,0 +1,105 @@ +use radicle::git; + +use crate::terminal as term; +use crate::terminal::cell::Cell as _; + +/// The user supplied `Patch` description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Message { + /// Prompt user to write comment in editor. + Edit, + /// Don't leave a comment. + Blank, + /// Use the following string as comment. + Text(String), +} + +impl Message { + /// Get the `Message` as a string according to the method. + pub fn get(self, help: &str) -> String { + let comment = match self { + Message::Edit => term::Editor::new() + .extension("markdown") + .edit(help) + .ok() + .flatten(), + Message::Blank => None, + Message::Text(c) => Some(c), + }; + let comment = comment.unwrap_or_default(); + let comment = comment.trim(); + + comment.to_owned() + } + + pub fn append(&mut self, arg: &str) { + if let Message::Text(v) = self { + v.extend(["\n\n", arg]); + } else { + *self = Message::Text(arg.into()); + }; + } +} + +impl Default for Message { + 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())), + term::format::italic(String::from_utf8_lossy(message).to_string()), + ]); + } + 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(()) +} + +/// Print title and description in a text box. +pub fn print_title_desc(title: &str, description: &str) { + let title_pretty = &term::format::dim(format!("╭─ {title} ───────")); + term::print(title_pretty); + term::blank(); + + if description.is_empty() { + term::print(term::format::italic("No description provided.")); + } else { + term::markdown(description); + } + + term::blank(); + term::print(term::format::dim(format!( + "╰{}", + "─".repeat(title_pretty.to_string().width() - 1) + ))); +} diff --git a/radicle-cli/src/terminal/spinner.rs b/radicle-term/src/spinner.rs similarity index 98% rename from radicle-cli/src/terminal/spinner.rs rename to radicle-term/src/spinner.rs index 16d44474..25e31bbd 100644 --- a/radicle-cli/src/terminal/spinner.rs +++ b/radicle-term/src/spinner.rs @@ -3,8 +3,8 @@ use std::mem::ManuallyDrop; use std::sync::{Arc, Mutex}; use std::{fmt, io, thread, time}; -use crate::terminal::io::{ERROR_PREFIX, WARNING_PREFIX}; -use crate::terminal::Paint; +use crate::io::{ERROR_PREFIX, WARNING_PREFIX}; +use crate::Paint; /// How much time to wait between spinner animation updates. pub const DEFAULT_TICK: time::Duration = time::Duration::from_millis(99); diff --git a/radicle-cli/src/terminal/table.rs b/radicle-term/src/table.rs similarity index 98% rename from radicle-cli/src/terminal/table.rs rename to radicle-term/src/table.rs index 9be8f34f..fdcc1c6d 100644 --- a/radicle-cli/src/terminal/table.rs +++ b/radicle-term/src/table.rs @@ -2,7 +2,7 @@ //! //! Example: //! ``` -//! use radicle_cli::terminal::table::*; +//! use radicle_term::table::*; //! //! let mut t = Table::new(TableOptions::default()); //! t.push(["pest", "biological control"]); @@ -18,8 +18,8 @@ //! ``` use std::io; -use crate::terminal as term; -use crate::terminal::cell::Cell; +use crate as term; +use crate::cell::Cell; /// Used to specify maximum width or height. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] diff --git a/radicle-cli/src/terminal/textbox.rs b/radicle-term/src/textbox.rs similarity index 95% rename from radicle-cli/src/terminal/textbox.rs rename to radicle-term/src/textbox.rs index 62241327..9d63ad76 100644 --- a/radicle-cli/src/terminal/textbox.rs +++ b/radicle-term/src/textbox.rs @@ -1,7 +1,7 @@ use std::fmt; -use crate::terminal as term; -use crate::terminal::cell::Cell as _; +use crate as term; +use crate::cell::Cell as _; pub struct TextBox { pub body: String,