cli/sync: migrate to clap

Due to the idiosyncracies of this command there are a few things to note about
this migration.

The `rad sync` command acts as a default subcommand in itself, in that it has
options that do not apply to its subcommand, `rad sync status`. This means that
the options will print with `rad sync --help`, but they will not print with `rad
sync status`.

The `--inventory` flag is not compatible with any of the other flags and
options, thus they are all marked with `conflicts_with`. This cannot be done for
a positional argument, so `clap` will "helpfully" print out the usage, for
example, as:

    error: the argument '--inventory' cannot be used with '--fetch'

    Usage: rad sync --inventory [RID]

    For more information, try '--help'.

Which is incorrect, since `RID` is ignored. This is explained in the `--help`
documentation for `--inventory`.

Co-authored-by: Lorenz Leutgeb <lorenz.leutgeb@radicle.xyz>
This commit is contained in:
Fintan Halpenny 2025-10-21 12:18:12 +01:00 committed by Lorenz Leutgeb
parent d1e19a87b0
commit 3c89525003
5 changed files with 313 additions and 297 deletions

View File

@ -142,7 +142,10 @@ const COMMANDS: &[CommandItem] = &[
name: "stats",
about: crate::commands::stats::ABOUT,
},
CommandItem::Lexopt(crate::commands::sync::HELP),
CommandItem::Clap {
name: "sync",
about: crate::commands::sync::ABOUT,
},
CommandItem::Clap {
name: "watch",
about: crate::commands::watch::ABOUT,

View File

@ -1,9 +1,8 @@
mod args;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::ffi::OsString;
use std::str::FromStr;
use std::time;
use anyhow::{anyhow, Context as _};
@ -23,266 +22,14 @@ use radicle_term::Element;
use crate::node::SyncReporting;
use crate::node::SyncSettings;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
use crate::terminal::format::Author;
use crate::terminal::{Table, TableOptions};
pub const HELP: Help = Help {
name: "sync",
description: "Sync repositories to the network",
version: env!("RADICLE_VERSION"),
usage: r#"
Usage
pub use args::Args;
pub(crate) use args::ABOUT;
use args::{Command, SortBy, SyncDirection, SyncMode};
rad sync [--fetch | --announce] [<rid>] [<option>...]
rad sync --inventory [<option>...]
rad sync status [<rid>] [<option>...]
By default, the current repository is synchronized both ways.
If an <rid> is specified, that repository is synced instead.
The process begins by fetching changes from connected seeds,
followed by announcing local refs to peers, thereby prompting
them to fetch from us.
When `--fetch` is specified, any number of seeds may be given
using the `--seed` option, eg. `--seed <nid>@<addr>:<port>`.
When `--replicas` is specified, the given replication factor will try
to be matched. For example, `--replicas 5` will sync with 5 seeds.
The synchronization process can be configured using `--replicas <min>` and
`--replicas-max <max>`. If these options are used independently, then the
replication factor is taken as the given `<min>`/`<max>` value. If the
options are used together, then the replication factor has a minimum and
maximum bound.
For fetching, the synchronization process will be considered successful if
at least `<min>` seeds were fetched from *or* all preferred seeds were
fetched from. If `<max>` is specified then the process will continue and
attempt to sync with `<max>` seeds.
For reference announcing, the synchronization process will be considered
successful if at least `<min>` seeds were pushed to *and* all preferred
seeds were pushed to.
When `--fetch` or `--announce` are specified on their own, this command
will only fetch or announce.
If `--inventory` is specified, the node's inventory is announced to
the network. This mode does not take an `<rid>`.
Commands
status Display the sync status of a repository
Options
--sort-by <field> Sort the table by column (options: nid, alias, status)
-f, --fetch Turn on fetching (default: true)
-a, --announce Turn on ref announcing (default: true)
-i, --inventory Turn on inventory announcing (default: false)
--timeout <secs> How many seconds to wait while syncing
--seed <nid> Sync with the given node (may be specified multiple times)
-r, --replicas <count> Sync with a specific number of seeds
--replicas-max <count> Sync with an upper bound number of seeds
-v, --verbose Verbose output
--debug Print debug information afer sync
--help Print help
"#,
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Operation {
Synchronize(SyncMode),
#[default]
Status,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SortBy {
Nid,
Alias,
#[default]
Status,
}
impl FromStr for SortBy {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"nid" => Ok(Self::Nid),
"alias" => Ok(Self::Alias),
"status" => Ok(Self::Status),
_ => Err("invalid `--sort-by` field"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncMode {
Repo {
settings: SyncSettings,
direction: SyncDirection,
},
Inventory,
}
impl Default for SyncMode {
fn default() -> Self {
Self::Repo {
settings: SyncSettings::default(),
direction: SyncDirection::default(),
}
}
}
#[derive(Debug, Default, PartialEq, Eq, Clone)]
pub enum SyncDirection {
Fetch,
Announce,
#[default]
Both,
}
#[derive(Default, Debug)]
pub struct Options {
pub rid: Option<RepoId>,
pub debug: bool,
pub verbose: bool,
pub sort_by: SortBy,
pub op: Operation,
}
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 verbose = false;
let mut timeout = time::Duration::from_secs(9);
let mut rid = None;
let mut fetch = false;
let mut announce = false;
let mut inventory = false;
let mut debug = false;
let mut replicas = None;
let mut max_replicas = None;
let mut seeds = BTreeSet::new();
let mut sort_by = SortBy::default();
let mut op: Option<Operation> = None;
while let Some(arg) = parser.next()? {
match arg {
Long("debug") => {
debug = true;
}
Long("verbose") | Short('v') => {
verbose = true;
}
Long("fetch") | Short('f') => {
fetch = true;
}
Long("replicas") | Short('r') => {
let val = parser.value()?;
let count = term::args::number(&val)?;
if count == 0 {
anyhow::bail!("value for `--replicas` must be greater than zero");
}
replicas = Some(count);
}
Long("replicas-max") => {
let val = parser.value()?;
let count = term::args::number(&val)?;
if count == 0 {
anyhow::bail!("value for `--replicas-max` must be greater than zero");
}
max_replicas = Some(count);
}
Long("seed") => {
let val = parser.value()?;
let nid = term::args::nid(&val)?;
seeds.insert(nid);
}
Long("announce") | Short('a') => {
announce = true;
}
Long("inventory") | Short('i') => {
inventory = true;
}
Long("sort-by") if matches!(op, Some(Operation::Status)) => {
let value = parser.value()?;
sort_by = value.parse()?;
}
Long("timeout") | Short('t') => {
let value = parser.value()?;
let secs = term::args::parse_value("timeout", value)?;
timeout = time::Duration::from_secs(secs);
}
Long("help") | Short('h') => {
return Err(Error::Help.into());
}
Value(val) if rid.is_none() => match val.to_string_lossy().as_ref() {
"s" | "status" => {
op = Some(Operation::Status);
}
_ => {
rid = Some(term::args::rid(&val)?);
}
},
arg => {
return Err(anyhow!(arg.unexpected()));
}
}
}
let sync = if inventory && fetch {
anyhow::bail!("`--inventory` cannot be used with `--fetch`");
} else if inventory {
SyncMode::Inventory
} else {
let direction = match (fetch, announce) {
(true, true) | (false, false) => SyncDirection::Both,
(true, false) => SyncDirection::Fetch,
(false, true) => SyncDirection::Announce,
};
let mut settings = SyncSettings::default().timeout(timeout);
let replicas = match (replicas, max_replicas) {
(None, None) => sync::ReplicationFactor::default(),
(None, Some(min)) => sync::ReplicationFactor::must_reach(min),
(Some(min), None) => sync::ReplicationFactor::must_reach(min),
(Some(min), Some(max)) => sync::ReplicationFactor::range(min, max),
};
settings.replicas = replicas;
if !seeds.is_empty() {
settings.seeds = seeds;
}
SyncMode::Repo {
settings,
direction,
}
};
Ok((
Options {
rid,
debug,
verbose,
sort_by,
op: op.unwrap_or(Operation::Synchronize(sync)),
},
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 mut node = radicle::Node::new(profile.socket());
if !node.is_running() {
@ -290,10 +37,12 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
"to sync a repository, your node must be running. To start it, run `rad node start`"
);
}
let verbose = args.verbose;
let debug = args.verbose;
match &options.op {
Operation::Status => {
let rid = match options.rid {
match args.command {
Some(Command::Status { rid, sort_by }) => {
let rid = match rid {
Some(rid) => rid,
None => {
let (_, rid) = radicle::rad::cwd()
@ -301,37 +50,41 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
rid
}
};
sync_status(rid, &mut node, &profile, &options)?;
sync_status(rid, &mut node, &profile, &sort_by, verbose)?;
}
Operation::Synchronize(SyncMode::Repo {
settings,
direction,
}) => {
let rid = match options.rid {
Some(rid) => rid,
None => {
let (_, rid) = radicle::rad::cwd()
.context("Current directory is not a Radicle repository")?;
rid
}
};
let settings = settings.clone().with_profile(&profile);
None => match SyncMode::from(args.sync) {
SyncMode::Repo {
rid,
settings,
direction,
} => {
let rid = match rid {
Some(rid) => rid,
None => {
let (_, rid) = radicle::rad::cwd()
.context("Current directory is not a Radicle repository")?;
rid
}
};
let settings = settings.clone().with_profile(&profile);
if [SyncDirection::Fetch, SyncDirection::Both].contains(direction) {
if !profile.policies()?.is_seeding(&rid)? {
anyhow::bail!("repository {rid} is not seeded");
if matches!(direction, SyncDirection::Fetch | SyncDirection::Both) {
if !profile.policies()?.is_seeding(&rid)? {
anyhow::bail!("repository {rid} is not seeded");
}
let result = fetch(rid, settings.clone(), &mut node, &profile)?;
display_fetch_result(&result, verbose)
}
if matches!(direction, SyncDirection::Announce | SyncDirection::Both) {
announce_refs(rid, settings, &mut node, &profile, verbose, debug)?;
}
let result = fetch(rid, settings.clone(), &mut node, &profile)?;
display_fetch_result(&result, options.verbose)
}
if [SyncDirection::Announce, SyncDirection::Both].contains(direction) {
announce_refs(rid, settings, &mut node, &profile, &options)?;
SyncMode::Inventory => {
announce_inventory(node)?;
}
}
Operation::Synchronize(SyncMode::Inventory) => {
announce_inventory(node)?;
}
},
}
Ok(())
}
@ -339,7 +92,8 @@ fn sync_status(
rid: RepoId,
node: &mut Node,
profile: &Profile,
options: &Options,
sort_by: &SortBy,
verbose: bool,
) -> anyhow::Result<()> {
const SYMBOL_STATE: &str = "?";
const SYMBOL_STATE_UNKNOWN: &str = "";
@ -358,7 +112,7 @@ fn sync_status(
]);
table.divider();
sort_seeds_by(local_nid, &mut seeds, &aliases, &options.sort_by);
sort_seeds_by(local_nid, &mut seeds, &aliases, sort_by);
let seeds = seeds.into_iter().flat_map(|seed| {
let (status, head, time) = match seed.sync {
@ -386,7 +140,7 @@ fn sync_status(
term::format::oid(oid),
term::format::timestamp(timestamp),
),
None if options.verbose => (
None if verbose => (
term::format::dim(SYMBOL_STATE_UNKNOWN),
term::paint(String::new()),
term::paint(String::new()),
@ -394,7 +148,7 @@ fn sync_status(
None => return None,
};
let (alias, nid) = Author::new(&seed.nid, profile, options.verbose).labels();
let (alias, nid) = Author::new(&seed.nid, profile, verbose).labels();
Some([
nid,
@ -448,7 +202,8 @@ fn announce_refs(
settings: SyncSettings,
node: &mut Node,
profile: &Profile,
options: &Options,
verbose: bool,
debug: bool,
) -> anyhow::Result<()> {
let Ok(repo) = profile.storage.repository(rid) else {
return Err(anyhow!(
@ -470,14 +225,14 @@ fn announce_refs(
&repo,
settings,
SyncReporting {
debug: options.debug,
debug,
..SyncReporting::default()
},
node,
profile,
)?;
if let Some(result) = result {
print_announcer_result(&result, options.verbose)
print_announcer_result(&result, verbose)
}
Ok(())

View File

@ -0,0 +1,253 @@
use std::str::FromStr;
use std::time;
use clap::{Parser, Subcommand, ValueEnum};
use radicle::{
node::{sync, NodeId},
prelude::RepoId,
};
use crate::node::SyncSettings;
pub(crate) const ABOUT: &str = "Sync repositories to the network";
const LONG_ABOUT: &str = r#"
By default, the current repository is synchronized both ways.
If an <RID> is specified, that repository is synced instead.
The process begins by fetching changes from connected seeds,
followed by announcing local refs to peers, thereby prompting
them to fetch from us.
When `--fetch` is specified, any number of seeds may be given
using the `--seed` option, eg. `--seed <NID>@<ADDR>:<PORT>`.
When `--replicas` is specified, the given replication factor will try
to be matched. For example, `--replicas 5` will sync with 5 seeds.
The synchronization process can be configured using `--replicas <MIN>` and
`--replicas-max <MAX>`. If these options are used independently, then the
replication factor is taken as the given `<MIN>`/`<MAX>` value. If the
options are used together, then the replication factor has a minimum and
maximum bound.
For fetching, the synchronization process will be considered successful if
at least `<MIN>` seeds were fetched from *or* all preferred seeds were
fetched from. If `<MAX>` is specified then the process will continue and
attempt to sync with `<MAX>` seeds.
For reference announcing, the synchronization process will be considered
successful if at least `<MIN>` seeds were pushed to *and* all preferred
seeds were pushed to.
When `--fetch` or `--announce` are specified on their own, this command
will only fetch or announce.
If `--inventory` is specified, the node's inventory is announced to
the network. This mode does not take an `<RID>`.
"#;
#[derive(Parser, Debug)]
#[clap(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
pub struct Args {
#[clap(subcommand)]
pub(super) command: Option<Command>,
#[clap(flatten)]
pub(super) sync: SyncArgs,
/// Enable debug information when synchronizing
#[arg(long)]
pub(super) debug: bool,
/// Enable verbose information when synchronizing
#[arg(long, short)]
pub(super) verbose: bool,
}
#[derive(Parser, Debug)]
pub(super) struct SyncArgs {
/// Enable fetching [default: true]
///
/// Providing `--announce` without `--fetch` will disable fetching
#[arg(long, short, conflicts_with = "inventory")]
fetch: bool,
/// Enable announcing [default: true]
///
/// Providing `--fetch` without `--announce` will disable announcing
#[arg(long, short, conflicts_with = "inventory")]
announce: bool,
/// Synchronize with the given node (may be specified multiple times)
#[arg(
long = "seed",
value_name = "NID",
action = clap::ArgAction::Append,
conflicts_with = "inventory",
)]
seeds: Vec<NodeId>,
/// How many seconds to wait while synchronizing
#[arg(
long,
short,
default_value_t = 9,
value_name = "SECS",
conflicts_with = "inventory"
)]
timeout: u64,
/// The repository to perform the synchronizing for [default: cwd]
rid: Option<RepoId>,
/// Synchronize with a specific number of seeds
///
/// The value must be greater than zero
#[arg(
long,
short,
value_name = "COUNT",
value_parser = replicas_non_zero,
conflicts_with = "inventory",
default_value_t = radicle::node::sync::DEFAULT_REPLICATION_FACTOR,
)]
replicas: usize,
/// Synchronize with an upper bound number of seeds
///
/// The value must be greater than zero
#[arg(
long,
value_name = "COUNT",
value_parser = replicas_non_zero,
conflicts_with = "inventory",
)]
max_replicas: Option<usize>,
/// Enable announcing inventory [default: false]
///
/// `--inventory` is a standalone mode and is not compatible with the other
/// options
///
/// <RID> is ignored with `--inventory`
#[arg(long, short)]
inventory: bool,
}
impl SyncArgs {
fn direction(&self) -> SyncDirection {
match (self.fetch, self.announce) {
(true, true) | (false, false) => SyncDirection::Both,
(true, false) => SyncDirection::Fetch,
(false, true) => SyncDirection::Announce,
}
}
fn timeout(&self) -> time::Duration {
time::Duration::from_secs(self.timeout)
}
fn replication(&self) -> sync::ReplicationFactor {
match (self.replicas, self.max_replicas) {
(min, None) => sync::ReplicationFactor::must_reach(min),
(min, Some(max)) => sync::ReplicationFactor::range(min, max),
}
}
}
#[derive(Subcommand, Debug)]
pub(super) enum Command {
/// Display the sync status of a repository
#[clap(alias = "s")]
Status {
/// The repository to display the status for [default: cwd]
rid: Option<RepoId>,
/// Sort the table by column
#[arg(long, value_name = "FIELD", value_enum, default_value_t)]
sort_by: SortBy,
},
}
/// Sort the status table by the provided field
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum SortBy {
/// The NID of the entry
Nid,
/// The alias of the entry
Alias,
/// The status of the entry
#[default]
Status,
}
impl FromStr for SortBy {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"nid" => Ok(Self::Nid),
"alias" => Ok(Self::Alias),
"status" => Ok(Self::Status),
_ => Err("invalid `--sort-by` field"),
}
}
}
/// Whether we are performing a fetch/announce of a repository or only
/// announcing the node's inventory
pub(super) enum SyncMode {
/// Fetch and/or announce a repositories references
Repo {
/// The repository being synchronized
rid: Option<RepoId>,
/// The settings for fetch/announce
settings: SyncSettings,
/// The direction of the synchronization
direction: SyncDirection,
},
/// Announce the node's inventory
Inventory,
}
impl From<SyncArgs> for SyncMode {
fn from(args: SyncArgs) -> Self {
if args.inventory {
Self::Inventory
} else {
assert!(!args.inventory);
let direction = args.direction();
let mut settings = SyncSettings::default()
.timeout(args.timeout())
.replicas(args.replication());
if !args.seeds.is_empty() {
settings.seeds = args.seeds.into_iter().collect();
}
Self::Repo {
rid: args.rid,
settings,
direction,
}
}
}
}
/// The direction of the [`SyncMode`]
#[derive(Debug, PartialEq, Eq)]
pub(super) enum SyncDirection {
/// Only fetching
Fetch,
/// Only announcing
Announce,
/// Both fetching and announcing
Both,
}
fn replicas_non_zero(s: &str) -> Result<usize, String> {
let r = usize::from_str(s).map_err(|_| format!("{s} is not a number"))?;
if r == 0 {
return Err(format!("{s} must be a value greater than zero"));
}
Ok(r)
}

View File

@ -83,6 +83,7 @@ enum Commands {
#[command(name = "self")]
RadSelf(rad_self::Args),
Stats(stats::Args),
Sync(sync::Args),
Unblock(unblock::Args),
Unfollow(unfollow::Args),
Unseed(unseed::Args),
@ -337,7 +338,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
}
}
"sync" => {
term::run_command_args::<sync::Options, _>(sync::HELP, sync::run, args.to_vec());
if let Some(Commands::Sync(args)) = CliArgs::parse().command {
term::run_command_fn(sync::run, args);
}
}
"seed" => {
if let Some(Commands::Seed(args)) = CliArgs::parse().command {

View File

@ -11,6 +11,8 @@ use crate::prelude::Doc;
use super::NodeId;
pub const DEFAULT_REPLICATION_FACTOR: usize = 3;
/// A set of nodes that form a private network for fetching from.
///
/// This could be the set of allowed nodes for a private repository, using
@ -72,7 +74,7 @@ pub struct ReplicationRange {
impl Default for ReplicationFactor {
fn default() -> Self {
Self::must_reach(3)
Self::must_reach(DEFAULT_REPLICATION_FACTOR)
}
}