cli/init: use Clap

This commit is contained in:
Richard Levitte 2025-10-03 22:01:04 +02:00 committed by Erik Kundt
parent 10e7b94c30
commit 8ba3087cb9
4 changed files with 185 additions and 189 deletions

View File

@ -58,7 +58,10 @@ const COMMANDS: &[CommandItem] = &[
},
CommandItem::Lexopt(crate::commands::help::HELP),
CommandItem::Lexopt(crate::commands::id::HELP),
CommandItem::Lexopt(crate::commands::init::HELP),
CommandItem::Clap {
name: "init",
about: crate::commands::init::ABOUT,
},
CommandItem::Lexopt(crate::commands::inbox::HELP),
CommandItem::Lexopt(crate::commands::inspect::HELP),
CommandItem::Clap {

View File

@ -1,10 +1,14 @@
#![allow(clippy::or_fun_call)]
#![allow(clippy::collapsible_else_if)]
mod args;
pub use args::Args;
pub(crate) use args::ABOUT;
use std::collections::HashSet;
use std::convert::TryFrom;
use std::env;
use std::ffi::OsString;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{anyhow, bail, Context as _};
@ -18,7 +22,6 @@ use radicle::git::RefString;
use radicle::identity::project::ProjectName;
use radicle::identity::{Doc, RepoId, Visibility};
use radicle::node::events::UploadPack;
use radicle::node::policy::Scope;
use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
use radicle::storage::ReadStorage as _;
use radicle::{profile, Node};
@ -26,168 +29,12 @@ use radicle::{profile, Node};
use crate::commands;
use crate::git;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Interactive;
pub const HELP: Help = Help {
name: "init",
description: "Initialize a Radicle repository",
version: env!("RADICLE_VERSION"),
usage: r#"
Usage
rad init [<path>] [<option>...]
Options
--name <string> Name of the repository
--description <string> Description of the repository
--default-branch <name> The default branch of the repository
--scope <scope> Repository follow scope: `followed` or `all` (default: all)
--private Set repository visibility to *private*
--public Set repository visibility to *public*
--existing <rid> Setup repository as an existing Radicle repository
-u, --set-upstream Setup the upstream of the default branch
--setup-signing Setup the radicle key as a signing key for this repository
--no-confirm Don't ask for confirmation during setup
--no-seed Don't seed this repository after initializing it
-v, --verbose Verbose mode
--help Print help
"#,
};
#[derive(Default)]
pub struct Options {
pub path: Option<PathBuf>,
pub name: Option<ProjectName>,
pub description: Option<String>,
pub branch: Option<String>,
pub interactive: Interactive,
pub visibility: Option<Visibility>,
pub existing: Option<RepoId>,
pub setup_signing: bool,
pub scope: Scope,
pub set_upstream: bool,
pub verbose: bool,
pub seed: bool,
}
impl Args for Options {
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
use lexopt::prelude::*;
let mut parser = lexopt::Parser::from_args(args);
let mut path: Option<PathBuf> = None;
let mut name = None;
let mut description = None;
let mut branch = None;
let mut interactive = Interactive::Yes;
let mut set_upstream = false;
let mut setup_signing = false;
let mut scope = Scope::All;
let mut existing = None;
let mut seed = true;
let mut verbose = false;
let mut visibility = None;
while let Some(arg) = parser.next()? {
match arg {
Long("name") if name.is_none() => {
let value = parser.value()?;
let value = term::args::string(&value);
let value = ProjectName::try_from(value)?;
name = Some(value);
}
Long("description") if description.is_none() => {
let value = parser
.value()?
.to_str()
.ok_or(anyhow::anyhow!(
"invalid repository description specified with `--description`"
))?
.to_owned();
description = Some(value);
}
Long("default-branch") if branch.is_none() => {
let value = parser
.value()?
.to_str()
.ok_or(anyhow::anyhow!(
"invalid branch specified with `--default-branch`"
))?
.to_owned();
branch = Some(value);
}
Long("scope") => {
let value = parser.value()?;
scope = term::args::parse_value("scope", value)?;
}
Long("set-upstream") | Short('u') => {
set_upstream = true;
}
Long("setup-signing") => {
setup_signing = true;
}
Long("no-confirm") => {
interactive = Interactive::No;
}
Long("no-seed") => {
seed = false;
}
Long("private") => {
visibility = Some(Visibility::private([]));
}
Long("public") => {
visibility = Some(Visibility::Public);
}
Long("existing") if existing.is_none() => {
let val = parser.value()?;
let rid = term::args::rid(&val)?;
existing = Some(rid);
}
Long("verbose") | Short('v') => {
verbose = true;
}
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
Value(val) if path.is_none() => {
path = Some(val.into());
}
_ => anyhow::bail!(arg.unexpected()),
}
}
Ok((
Options {
path,
name,
description,
branch,
scope,
existing,
interactive,
set_upstream,
setup_signing,
seed,
visibility,
verbose,
},
vec![],
))
}
}
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
let profile = ctx.profile()?;
let cwd = env::current_dir()?;
let path = options.path.as_deref().unwrap_or(cwd.as_path());
let path = args.path.as_deref().unwrap_or(cwd.as_path());
let repo = match git::Repository::open(path) {
Ok(r) => r,
Err(e) if e.is_not_found() => {
@ -201,20 +48,18 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
}
}
if let Some(rid) = options.existing {
init_existing(repo, rid, options, &profile)
if let Some(rid) = args.existing {
init_existing(repo, rid, args, &profile)
} else {
init(repo, options, &profile)
init(repo, args, &profile)
}
}
pub fn init(
repo: git::Repository,
options: Options,
profile: &profile::Profile,
) -> anyhow::Result<()> {
pub fn init(repo: git::Repository, args: Args, profile: &profile::Profile) -> anyhow::Result<()> {
let path = dunce::canonicalize(repo.workdir().unwrap_or_else(|| repo.path()))?;
let interactive = options.interactive;
let interactive = args.interactive();
let visibility = args.visibility();
let seed = args.seed();
let default_branch = match find_default_branch(&repo) {
Err(err @ DefaultBranchError::Head) => {
@ -233,21 +78,22 @@ pub fn init(
term::headline(format!(
"Initializing{}radicle 👾 repository in {}..",
if let Some(visibility) = &options.visibility {
term::format::spaced(term::format::visibility(visibility))
} else {
term::format::default(" ").into()
match visibility {
Some(ref visibility) => term::format::spaced(term::format::visibility(visibility)),
None => term::format::default(" ").into(),
},
term::format::dim(path.display())
));
let name: ProjectName = match options.name {
let name: ProjectName = match args.name {
Some(name) => name,
None => {
let default = path
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| ProjectName::try_from(f).ok());
// TODO(finto): this is interactive without checking `interactive`
// this should check if interactive and use the default if not
let name = term::input(
"Name",
default,
@ -257,13 +103,13 @@ pub fn init(
name.ok_or_else(|| anyhow::anyhow!("A project name is required."))?
}
};
let description = match options.description {
let description = match args.description {
Some(desc) => desc,
None => {
term::input("Description", None, Some("You may leave this blank"))?.unwrap_or_default()
}
};
let branch = match options.branch {
let branch = match args.branch {
Some(branch) => branch,
None if interactive.yes() => term::input(
"Default branch",
@ -275,9 +121,11 @@ pub fn init(
};
let branch = RefString::try_from(branch.clone())
.map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
let visibility = if let Some(v) = options.visibility {
let visibility = if let Some(v) = visibility {
v
} else {
// TODO(finto): this is interactive without checking `interactive`
// this should check if interactive and use the `private` if not
let selected = term::select(
"Visibility",
&["public", "private"],
@ -309,20 +157,20 @@ pub fn init(
));
spinner.finish();
if options.verbose {
if args.verbose {
term::blob(json::to_string_pretty(&proj)?);
}
// It's important to seed our own repositories to make sure that our node signals
// interest for them. This ensures that messages relating to them are relayed to us.
if options.seed {
profile.seed(rid, options.scope, &mut node)?;
if seed {
profile.seed(rid, args.scope, &mut node)?;
if doc.is_public() {
profile.add_inventory(rid, &mut node)?;
}
}
if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
if args.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
// Setup eg. `master` -> `rad/master`
radicle::git::set_upstream(
&repo,
@ -334,7 +182,7 @@ pub fn init(
push_cmd = format!("git push {} {branch}", *radicle::rad::REMOTE_NAME);
}
if options.setup_signing {
if args.setup_signing {
// Setup radicle signing key.
self::setup_signing(profile.id(), &repo, interactive)?;
}
@ -379,12 +227,13 @@ pub fn init(
pub fn init_existing(
working: git::Repository,
rid: RepoId,
options: Options,
args: Args,
profile: &profile::Profile,
) -> anyhow::Result<()> {
let stored = profile.storage.repository(rid)?;
let project = stored.project()?;
let url = radicle::git::Url::from(rid);
let interactive = args.interactive();
radicle::git::configure_repository(&working)?;
radicle::git::configure_remote(
@ -394,7 +243,7 @@ pub fn init_existing(
&url.clone().with_namespace(profile.public_key),
)?;
if options.set_upstream {
if args.set_upstream {
// Setup eg. `master` -> `rad/master`
radicle::git::set_upstream(
&working,
@ -404,9 +253,9 @@ pub fn init_existing(
)?;
}
if options.setup_signing {
if args.setup_signing {
// Setup radicle signing key.
self::setup_signing(profile.id(), &working, options.interactive)?;
self::setup_signing(profile.id(), &working, interactive)?;
}
term::success!(

View File

@ -0,0 +1,141 @@
use std::path::PathBuf;
use clap::Parser;
use radicle::{
identity::{project::ProjectName, Visibility},
node::policy::Scope,
prelude::RepoId,
};
use radicle_term::Interactive;
pub(crate) const ABOUT: &str = "Initialize a Radicle repository";
#[derive(Debug, Parser)]
#[command(about = ABOUT, disable_version_flag = true)]
pub struct Args {
/// Directory to be initialized
pub(super) path: Option<PathBuf>,
/// Name of the repository
#[arg(long, value_name = "STRING")]
pub(super) name: Option<ProjectName>,
/// Description of the repository
#[arg(long, value_name = "STRING")]
pub(super) description: Option<String>,
/// The default branch of the repository
#[arg(long = "default-branch", value_name = "STRING")]
pub(super) branch: Option<String>,
/// Repository follow scope
#[arg(
long,
default_value_t = Scope::All,
value_name = "SCOPE",
value_parser = ScopeParser,
)]
pub(super) scope: Scope,
/// Set repository visibility to *private*
#[arg(long, conflicts_with = "public")]
private: bool,
/// Set repository visibility to *public*
#[arg(long, conflicts_with = "private")]
public: bool,
/// Setup repository as an existing Radicle repository
///
/// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
#[arg(long, value_name = "RID")]
pub(super) existing: Option<RepoId>,
/// Setup the upstream of the default branch
#[arg(short = 'u', long)]
pub(super) set_upstream: bool,
/// Setup the radicle key as a signing key for this repository
#[arg(long)]
pub(super) setup_signing: bool,
/// Don't ask for confirmation during setup
#[arg(long)]
no_confirm: bool,
/// Don't seed this repository after initializing it
#[arg(long)]
no_seed: bool,
/// Verbose mode
#[arg(short, long)]
pub(super) verbose: bool,
}
impl Args {
pub(super) fn interactive(&self) -> Interactive {
if self.no_confirm {
Interactive::No
} else {
Interactive::Yes
}
}
pub(super) fn visibility(&self) -> Option<Visibility> {
if self.private {
debug_assert!(!self.public, "BUG: `private` and `public` should conflict");
Some(Visibility::private([]))
} else if self.public {
Some(Visibility::Public)
} else {
None
}
}
pub(super) fn seed(&self) -> bool {
!self.no_seed
}
}
// TODO(finto): this is duplicated from `clone::args`. Consolidate these once
// the `clap` migration has finished and we can organise the shared code.
#[derive(Clone, Debug)]
struct ScopeParser;
impl clap::builder::TypedValueParser for ScopeParser {
type Value = Scope;
fn parse_ref(
&self,
cmd: &clap::Command,
arg: Option<&clap::Arg>,
value: &std::ffi::OsStr,
) -> Result<Self::Value, clap::Error> {
<Scope as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
}
fn possible_values(
&self,
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
use clap::builder::PossibleValue;
Some(Box::new(
[PossibleValue::new("all"), PossibleValue::new("followed")].into_iter(),
))
}
}
#[cfg(test)]
mod test {
use super::Args;
use clap::error::ErrorKind;
use clap::Parser;
#[test]
fn should_parse_rid_non_urn() {
let args = Args::try_parse_from(["init", "--existing", "z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
assert!(args.is_ok())
}
#[test]
fn should_parse_rid_urn() {
let args =
Args::try_parse_from(["init", "--existing", "rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH"]);
assert!(args.is_ok())
}
#[test]
fn should_not_parse_rid_url() {
let err =
Args::try_parse_from(["init", "--existing", "rad://z3Tr6bC7ctEg2EHmLvknUr29mEDLH"])
.unwrap_err();
assert_eq!(err.kind(), ErrorKind::ValueValidation);
}
}

View File

@ -50,6 +50,7 @@ enum Commands {
Clone(clone::Args),
Debug(debug::Args),
Fork(fork::Args),
Init(init::Args),
Issue(issue::Args),
Path(path::Args),
Publish(publish::Args),
@ -231,7 +232,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
term::run_command_args::<inbox::Options, _>(inbox::HELP, inbox::run, args.to_vec())
}
"init" => {
term::run_command_args::<init::Options, _>(init::HELP, init::run, args.to_vec());
if let Some(Commands::Init(args)) = CliArgs::parse().command {
term::run_command_fn(init::run, args);
}
}
"inspect" => {
term::run_command_args::<inspect::Options, _>(