Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node, term: Remove dependency on `anyhow`
Merged lorenz opened 8 months ago

radicle-node

It has always bugged me that we resort to anyhow for just a handful of errors, really. It also turns out that we were holding lexopt wrong, it features parse_with which neatly integrates with FromStr, and luckily all our arguments implement that trait. This makes for cleaner option parsing.

For now, the execution errors are all “transparent”, which shouldn’t be much of a regression, if at all.

radicle-term

As a library crate, it is bad to return such generic errors.

To get there, errors from inquire are downcasted.

This allowed to clean up the public interface of radicle-term, so that it does also not leak the inquire::InquireError in its public API.

Notes

In both cases we touched parsing: For radicle-node it was about CLI options, for radicle-term it was user input prompts.

14 files changed +232 -145 4bf3ab6f 2127782b
modified Cargo.lock
@@ -2851,7 +2851,6 @@ name = "radicle-node"
version = "0.15.0"
dependencies = [
 "amplify",
-
 "anyhow",
 "bloomy",
 "bytes",
 "chrono",
@@ -2992,7 +2991,6 @@ name = "radicle-term"
version = "0.15.0"
dependencies = [
 "anstyle-query",
-
 "anyhow",
 "crossbeam-channel",
 "crossterm 0.29.0",
 "git2",
modified Cargo.toml
@@ -20,7 +20,6 @@ rust-version = "1.81.0"

[workspace.dependencies]
amplify = { version = "4.0.0", default-features = false }
-
anyhow = "1"
bstr = "1.3"
bytes = "1"
chrono = { version = "0.4.26", default-features = false }
modified crates/radicle-cli/Cargo.toml
@@ -14,7 +14,7 @@ name = "rad"
path = "src/main.rs"

[dependencies]
-
anyhow = { workspace = true }
+
anyhow = "1"
chrono = { workspace = true, features = ["clock", "std"] }
dunce = { workspace = true }
git-ref-format = { version = "0.3.0", features = ["macro"] }
modified crates/radicle-cli/src/commands/auth.rs
@@ -1,6 +1,5 @@
#![allow(clippy::or_fun_call)]
use std::ffi::OsString;
-
use std::ops::Not as _;
use std::str::FromStr;

use anyhow::{anyhow, Context};
@@ -90,26 +89,32 @@ pub fn init(options: Options) -> anyhow::Result<()> {
            term::blank();
        }
    } else {
-
        anyhow::bail!("a Git installation is required for Radicle to run");
+
        anyhow::bail!("A Git installation is required for Radicle to run.");
    }

    let alias: Alias = if let Some(alias) = options.alias {
        alias
    } else {
        let user = env::var("USER").ok().and_then(|u| Alias::from_str(&u).ok());
-
        term::input(
+
        let user = term::input(
            "Enter your alias:",
            user,
            Some("This is your node alias. You can always change it later"),
-
        )?
+
        )?;
+

+
        let Some(user) = user else {
+
            anyhow::bail!("An alias is required for Radicle to run.")
+
        };
+

+
        user
    };
    let home = profile::home()?;
    let passphrase = if options.stdin {
-
        term::passphrase_stdin()
+
        Some(term::passphrase_stdin()?)
    } else {
-
        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)
-
    }?;
-
    let passphrase = passphrase.trim().is_empty().not().then_some(passphrase);
+
        term::passphrase_confirm("Enter a passphrase:", env::RAD_PASSPHRASE)?
+
    };
+
    let passphrase = passphrase.filter(|passphrase| !passphrase.trim().is_empty());
    let spinner = term::spinner("Creating your Ed25519 keypair...");
    let profile = Profile::init(home, alias, passphrase.clone(), env::seed())?;
    let mut agent = true;
@@ -187,8 +192,14 @@ pub fn authenticate(options: Options, profile: &Profile) -> anyhow::Result<()> {
                phrase
            } else if options.stdin {
                term::passphrase_stdin()?
-
            } else {
+
            } else if let Some(passphrase) =
                term::io::passphrase(term::io::PassphraseValidator::new(profile.keystore.clone()))?
+
            {
+
                passphrase
+
            } else {
+
                anyhow::bail!(
+
                    "A passphrase is required to read your Radicle key. Unable to continue."
+
                )
            };
            register(&mut agent, profile, passphrase)?;

modified crates/radicle-cli/src/commands/init.rs
@@ -243,18 +243,28 @@ pub fn init(
    let name: ProjectName = match options.name {
        Some(name) => name,
        None => {
-
            let default = path.file_name().map(|f| f.to_string_lossy().to_string());
-
            term::input(
+
            let default = path
+
                .file_name()
+
                .and_then(|f| f.to_str())
+
                .and_then(|f| ProjectName::try_from(f).ok());
+
            let name = term::input(
                "Name",
                default,
                Some("The name of your repository, eg. 'acme'"),
-
            )?
-
            .try_into()?
+
            )?;
+

+
            let Some(name) = name else {
+
                anyhow::bail!("A project name is required.")
+
            };
+

+
            name
        }
    };
    let description = match options.description {
        Some(desc) => desc,
-
        None => term::input("Description", None, Some("You may leave this blank"))?,
+
        None => {
+
            term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
+
        }
    };
    let branch = match options.branch {
        Some(branch) => branch,
@@ -262,7 +272,8 @@ pub fn init(
            "Default branch",
            Some(default_branch),
            Some("Please specify an existing branch"),
-
        )?,
+
        )?
+
        .unwrap_or_default(),
        None => default_branch,
    };
    let branch = RefString::try_from(branch.clone())
modified crates/radicle-cli/src/commands/node/control.rs
@@ -12,6 +12,7 @@ use localtime::LocalTime;

use radicle::node;
use radicle::node::{Address, ConnectResult, Handle as _, NodeId};
+
use radicle::profile::env::RAD_PASSPHRASE;
use radicle::Node;
use radicle::{profile, Profile};

@@ -39,10 +40,12 @@ pub fn start(
        let validator = term::io::PassphraseValidator::new(profile.keystore.clone());
        let passphrase = if let Some(phrase) = profile::env::passphrase() {
            phrase
-
        } else if let Ok(phrase) = term::io::passphrase(validator) {
+
        } else if let Some(phrase) = term::io::passphrase(validator)? {
            phrase
        } else {
-
            anyhow::bail!("your radicle passphrase is required to start your node");
+
            anyhow::bail!(
+
                "A passphrase is required to read your Radicle key in order to start the node. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`."
+
            );
        };
        Some((profile::env::RAD_PASSPHRASE, passphrase))
    } else {
modified crates/radicle-cli/src/terminal/io.rs
@@ -49,14 +49,13 @@ pub fn signer(profile: &Profile) -> anyhow::Result<BoxedDevice> {
        return Ok(signer);
    }
    let validator = PassphraseValidator::new(profile.keystore.clone());
-
    let passphrase = match passphrase(validator) {
-
        Ok(p) => p,
-
        Err(inquire::InquireError::NotTTY) => {
+
    let passphrase = match passphrase(validator)? {
+
        Some(p) => p,
+
        None => {
            anyhow::bail!(
-
                "running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
+
                "A passphrase is required to read your Radicle key. Unable to continue. Consider setting the environment variable `{RAD_PASSPHRASE}`.",
            )
        }
-
        Err(e) => return Err(e.into()),
    };
    let spinner = spinner("Unsealing key...");
    let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
modified crates/radicle-node/Cargo.toml
@@ -16,7 +16,6 @@ test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "radi

[dependencies]
amplify = { workspace = true }
-
anyhow = { workspace = true }
bloomy = "1.2"
bytes = { workspace = true }
chrono = { workspace = true, features = ["clock"] }
modified crates/radicle-node/src/main.rs
@@ -1,11 +1,14 @@
use std::io;
-
use std::{env, fs, net, path::PathBuf, process};
+
use std::net::SocketAddr;
+
use std::path::PathBuf;
+
use std::process::exit;

-
use anyhow::Context;
use crossbeam_channel as chan;
+
use thiserror::Error;

use radicle::node::device::Device;
use radicle::profile;
+

use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
use radicle_node::{Runtime, VERSION};
#[cfg(unix)]
@@ -39,65 +42,74 @@ Options
    --help                              Print help
"#;

-
#[derive(Debug)]
struct Options {
    config: Option<PathBuf>,
-
    listen: Vec<net::SocketAddr>,
+
    listen: Vec<SocketAddr>,
    log: Option<log::Level>,
    force: bool,
}

-
impl Options {
-
    fn from_env() -> Result<Self, anyhow::Error> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_env();
-
        let mut listen = Vec::new();
-
        let mut config = None;
-
        let mut force = false;
-
        let mut log = None;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("force") => {
-
                    force = true;
-
                }
-
                Long("config") => {
-
                    let value = parser.value()?;
-
                    let path = PathBuf::from(value);
-
                    config = Some(path);
-
                }
-
                Long("listen") => {
-
                    let addr = parser.value()?.parse()?;
-
                    listen.push(addr);
-
                }
-
                Long("log") => {
-
                    log = Some(parser.value()?.parse()?);
-
                }
-
                Long("help") | Short('h') => {
-
                    println!("{HELP_MSG}");
-
                    process::exit(0);
-
                }
-
                Long("version") => {
-
                    VERSION.write(&mut io::stdout())?;
-
                    process::exit(0);
-
                }
-
                _ => anyhow::bail!(arg.unexpected()),
+
fn parse_options() -> Result<Options, lexopt::Error> {
+
    use lexopt::prelude::*;
+
    use std::str::FromStr as _;
+

+
    let mut parser = lexopt::Parser::from_env();
+
    let mut listen = Vec::new();
+
    let mut config = None;
+
    let mut force = false;
+
    let mut log = None;
+

+
    while let Some(arg) = parser.next()? {
+
        match arg {
+
            Long("force") => {
+
                force = true;
+
            }
+
            Long("config") => {
+
                config = Some(parser.value()?.parse_with(PathBuf::from_str)?);
+
            }
+
            Long("listen") => {
+
                let addr = parser.value()?.parse_with(SocketAddr::from_str)?;
+
                listen.push(addr);
+
            }
+
            Long("log") => {
+
                log = Some(parser.value()?.parse_with(log::Level::from_str)?);
+
            }
+
            Long("help") | Short('h') => {
+
                println!("{HELP_MSG}");
+
                exit(0);
+
            }
+
            Long("version") => {
+
                let _ = VERSION.write(&mut io::stdout());
+
                exit(0);
+
            }
+
            _ => {
+
                return Err(arg.unexpected());
            }
        }
-

-
        Ok(Self {
-
            force,
-
            listen,
-
            log,
-
            config,
-
        })
    }
+

+
    Ok(Options {
+
        force,
+
        listen,
+
        log,
+
        config,
+
    })
+
}
+

+
#[derive(Error, Debug)]
+
enum ExecutionError {
+
    #[error(transparent)]
+
    Io(#[from] io::Error),
+
    #[error(transparent)]
+
    ConfigurationLoading(#[from] profile::config::LoadError),
+
    #[error(transparent)]
+
    MemorySigner(#[from] radicle::crypto::ssh::keystore::MemorySignerError),
+
    #[error(transparent)]
+
    Runtime(#[from] radicle_node::runtime::Error),
}

-
fn execute() -> anyhow::Result<()> {
+
fn execute(options: Options) -> Result<(), ExecutionError> {
    let home = profile::home()?;
-
    let options = Options::from_env()?;

    // Up to now, the active log level was `LOG_LEVEL_DEFAULT`.
    // The first thing we do after reading command line options is
@@ -126,16 +138,14 @@ fn execute() -> anyhow::Result<()> {

    let passphrase = profile::env::passphrase();
    let keystore = Keystore::new(&home.keys());
-
    let signer = Device::from(
-
        MemorySigner::load(&keystore, passphrase).context("couldn't load secret key")?,
-
    );
+
    let signer = Device::from(MemorySigner::load(&keystore, passphrase)?);

    log::info!(target: "node", "Node ID is {}", signer.public_key());

    // Add the preferred seeds as persistent peers so that we reconnect to them automatically.
    config.node.connect.extend(config.preferred_seeds);

-
    let listen: Vec<std::net::SocketAddr> = if !options.listen.is_empty() {
+
    let listen = if !options.listen.is_empty() {
        options.listen.clone()
    } else {
        config.node.listen.clone()
@@ -161,7 +171,7 @@ fn execute() -> anyhow::Result<()> {

    if options.force {
        log::debug!(target: "node", "Removing existing control socket..");
-
        fs::remove_file(home.socket()).ok();
+
        std::fs::remove_file(home.socket()).ok();
    }
    Runtime::init(home, config.node, listen, signals, signer)?.run()?;

@@ -186,7 +196,7 @@ fn initialize_logging() {

            #[derive(Error, Debug)]
            #[error("Error connecting to systemd journal: {0}")]
-
            struct JournalError(std::io::Error);
+
            struct JournalError(io::Error);

            radicle_systemd::journal::logger::<&str, &str, _>("radicle-node".to_string(), [])
                .map_err(|err| Box::new(JournalError(err)) as Error)
@@ -226,8 +236,17 @@ fn main() {

    initialize_logging();

-
    if let Err(err) = execute() {
+
    let options = match parse_options() {
+
        Ok(options) => options,
+
        Err(err) => {
+
            // The lexopt errors read nicely with a comma.
+
            log::error!(target: "node", "Failed to parse options, {err:#}");
+
            exit(2);
+
        }
+
    };
+

+
    if let Err(err) = execute(options) {
        log::error!(target: "node", "{err:#}");
-
        process::exit(1);
+
        exit(1);
    }
}
modified crates/radicle-term/Cargo.toml
@@ -13,7 +13,6 @@ rust-version.workspace = true
default = ["git2"]

[dependencies]
-
anyhow = { workspace = true }
anstyle-query = "1.0.0"
crossterm = "0.29.0"
inquire = { version = "0.7.4", default-features = false, features = ["crossterm", "editor"] }
deleted crates/radicle-term/src/command.rs
@@ -1,19 +0,0 @@
-
use std::io::Write;
-
use std::process::{Command, Stdio};
-

-
pub fn bat<S: AsRef<std::ffi::OsStr>>(
-
    args: impl IntoIterator<Item = S>,
-
    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(())
-
}
modified crates/radicle-term/src/io.rs
@@ -8,9 +8,9 @@ use inquire::ui::{ErrorMessageRenderConfig, StyleSheet, Styled};
use inquire::validator;
use inquire::InquireError;
use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password};
+
use thiserror::Error;
use zeroize::Zeroizing;

-
use crate::command;
use crate::format;
use crate::{style, Paint, Size};

@@ -224,54 +224,123 @@ pub fn abort<D: fmt::Display>(prompt: D) -> bool {
    ask(prompt, false)
}

-
pub fn input<S, E>(message: &str, default: Option<S>, help: Option<&str>) -> anyhow::Result<S>
+
#[derive(Error, Debug)]
+
pub enum InputError<Custom> {
+
    #[error(transparent)]
+
    Io(#[from] io::Error),
+

+
    #[error(transparent)]
+
    Custom(Custom),
+
}
+

+
impl From<InputError<std::convert::Infallible>> for io::Error {
+
    fn from(val: InputError<std::convert::Infallible>) -> Self {
+
        match val {
+
            InputError::Io(err) => err,
+
            InputError::Custom(_) => unreachable!("infallible cannot be constructed"),
+
        }
+
    }
+
}
+

+
/// Prompts the user for input. If the user cancels the operation,
+
/// the operation is interrupted, or no suitable terminal is found,
+
/// then `Ok(None)` is returned.
+
pub fn input<T, E>(
+
    message: &str,
+
    default: Option<T>,
+
    help: Option<&str>,
+
) -> Result<Option<T>, InputError<E>>
where
-
    S: fmt::Display + std::str::FromStr<Err = E> + Clone,
-
    E: fmt::Debug + fmt::Display,
+
    T: fmt::Display + std::str::FromStr<Err = E> + Clone,
+
    E: std::error::Error + Send + Sync + 'static,
{
-
    let mut input = CustomType::<S>::new(message).with_render_config(*CONFIG);
+
    let mut input = CustomType::<T>::new(message).with_render_config(*CONFIG);

    input.default = default;
    input.help_message = help;

-
    let value = input.prompt()?;
+
    match input.prompt() {
+
        Ok(value) => Ok(Some(value)),
+
        Err(err) => unwrap_inquire_error(err),
+
    }
+
}

-
    Ok(value)
+
fn unwrap_inquire_error<T, E>(error: InquireError) -> Result<Option<T>, InputError<E>>
+
where
+
    E: std::error::Error + Send + Sync + 'static,
+
{
+
    use InquireError::*;
+

+
    let inner = match error {
+
        OperationCanceled | OperationInterrupted | NotTTY => None,
+
        InvalidConfiguration(err) => {
+
            // This case not reachable, as long as the configuration passed
+
            // to `prompt` is valid.
+
            // The configuration is *mostly* taken from `CONFIG`,
+
            // except for the added `CustomType` being prompted for.
+
            // We demand that these must not depend on user input in
+
            // a way that makes the configuration invalid.
+
            // If this is the case, `CONFIG` should be reassessed, or
+
            // the caller must control their input for the `CustomType`
+
            // better. In any case, such errors are not recoverable,
+
            // and certainly the user cannot do anything in that
+
            // situation. Their input should not affect the config,
+
            // that's the whole idea!
+
            panic!("{err}")
+
        }
+
        IO(err) => Some(InputError::Io(err)),
+
        Custom(err) => {
+
            match err.downcast::<E>() {
+
                Ok(err) => Some(InputError::Custom(*err)),
+
                Err(err) => {
+
                    // `inquire` guarantees that we do not end up here:
+
                    // https://github.com/mikaelmello/inquire/blob/4ac91f3e1fc8b29fc17845f9204ea1d1f9e335aa/README.md?plain=1#L109
+
                    panic!("inquire returned an unexpected error: {err:?}")
+
                }
+
            }
+
        }
+
    };
+

+
    match inner {
+
        Some(err) => Err(err),
+
        None => Ok(None),
+
    }
}

pub fn passphrase<V: validator::StringValidator + 'static>(
    validate: V,
-
) -> Result<Passphrase, inquire::InquireError> {
-
    Ok(Passphrase::from(
-
        Password::new("Passphrase:")
-
            .with_render_config(*CONFIG)
-
            .with_display_mode(inquire::PasswordDisplayMode::Masked)
-
            .without_confirmation()
-
            .with_validator(validate)
-
            .prompt()?,
-
    ))
-
}
-

-
pub fn passphrase_confirm<K: AsRef<OsStr>>(
-
    prompt: &str,
-
    var: K,
-
) -> Result<Passphrase, anyhow::Error> {
+
) -> io::Result<Option<Passphrase>> {
+
    match Password::new("Passphrase:")
+
        .with_render_config(*CONFIG)
+
        .with_display_mode(inquire::PasswordDisplayMode::Masked)
+
        .without_confirmation()
+
        .with_validator(validate)
+
        .prompt()
+
    {
+
        Ok(p) => Ok(Some(Passphrase::from(p))),
+
        Err(err) => unwrap_inquire_error(err).map_err(InputError::into),
+
    }
+
}
+

+
pub fn passphrase_confirm<K: AsRef<OsStr>>(prompt: &str, var: K) -> io::Result<Option<Passphrase>> {
    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("Leave this blank to keep your radicle key unencrypted")
-
                .prompt()?,
-
        ))
+
        return Ok(Some(Passphrase::from(p)));
+
    }
+

+
    match 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("Leave this blank to keep your radicle key unencrypted")
+
        .prompt()
+
    {
+
        Ok(p) => Ok(Some(Passphrase::from(p))),
+
        Err(err) => unwrap_inquire_error(err).map_err(InputError::into),
    }
}

-
pub fn passphrase_stdin() -> Result<Passphrase, anyhow::Error> {
+
pub fn passphrase_stdin() -> io::Result<Passphrase> {
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;

@@ -289,9 +358,3 @@ where

    selection.with_starting_cursor(0).prompt()
}
-

-
pub fn markdown(content: &str) {
-
    if !content.is_empty() && command::bat(["-p", "-l", "md"], content).is_err() {
-
        blob(content);
-
    }
-
}
modified crates/radicle-term/src/lib.rs
@@ -1,7 +1,6 @@
pub mod ansi;
pub mod cell;
pub mod colors;
-
pub mod command;
pub mod editor;
pub mod element;
pub mod format;
modified crates/radicle/src/identity/project.rs
@@ -29,6 +29,12 @@ pub enum ProjectError {
#[serde(try_from = "String", into = "String")]
pub struct ProjectName(String);

+
impl std::fmt::Display for ProjectName {
+
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

impl From<ProjectName> for String {
    fn from(value: ProjectName) -> Self {
        value.0