term: Remove dependency on `anyhow`

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.
This commit is contained in:
Lorenz Leutgeb 2025-08-27 18:01:27 +02:00 committed by Fintan Halpenny
parent edd88a59bf
commit 2127782b74
10 changed files with 157 additions and 61 deletions

1
Cargo.lock generated
View File

@ -2991,7 +2991,6 @@ name = "radicle-term"
version = "0.15.0"
dependencies = [
"anstyle-query",
"anyhow",
"crossbeam-channel",
"crossterm 0.29.0",
"git2",

View File

@ -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 }

View File

@ -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"] }

View File

@ -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,28 @@ 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"),
)?
)?;
user.ok_or_else(|| anyhow::anyhow!("An alias is required for Radicle to run."))?
};
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 +188,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)?;

View File

@ -243,18 +243,24 @@ 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()?
)?;
name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
}
};
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 +268,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())

View File

@ -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 {

View File

@ -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))?;

View File

@ -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"] }

View File

@ -8,6 +8,7 @@ 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::format;
@ -223,54 +224,130 @@ 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>
#[non_exhaustive]
#[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) => handle_inquire_error(err),
}
}
Ok(value)
/// If the [`InquireError`] value is one of the variants:
/// [`InquireError::OperationCanceled`], [`InquireError::OperationInterrupted`],
/// [`InquireError::NotTTY`], then the returned result is `None` note that no
/// `Some` value is returned.
///
/// Otherwise, the error is converted into our own domain error: [`InputError`].
fn handle_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:")
) -> io::Result<Option<Passphrase>> {
match Password::new("Passphrase:")
.with_render_config(*CONFIG)
.with_display_mode(inquire::PasswordDisplayMode::Masked)
.without_confirmation()
.with_validator(validate)
.prompt()?,
))
.prompt()
{
Ok(p) => Ok(Some(Passphrase::from(p))),
Err(err) => handle_inquire_error(err).map_err(InputError::into),
}
}
pub fn passphrase_confirm<K: AsRef<OsStr>>(
prompt: &str,
var: K,
) -> Result<Passphrase, anyhow::Error> {
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)
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()?,
))
.prompt()
{
Ok(p) => Ok(Some(Passphrase::from(p))),
Err(err) => handle_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)?;

View File

@ -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