use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use crate::providers::Provider;
use crate::update::REFRESH_ARG;
#[derive(Debug, Parser)]
#[command(
name = "repos-manager",
bin_name = "repos-manager",
version,
about = "Multi-provider Git repository manager",
long_about = "Multi-provider Git repository manager.\n\nClone and update every repository you can access on GitHub, GitLab, Forgejo/Gitea, Bitbucket and Radicle into <base-dir>/<host>/<owner>/<repo>.",
after_help = "Config: ~/.config/repos-manager/config.json\n\nExamples:\n repos-manager init\n repos-manager login\n repos-manager github sync\n repos-manager sync --all --parallel 8\n repos-manager status"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Github(ProviderCmd),
Gitlab(ProviderCmd),
#[command(alias = "gitea")]
Forgejo(ProviderCmd),
Bitbucket(ProviderCmd),
Radicle(ProviderCmd),
Login { provider: Option<ProviderArg> },
Sync(SyncAllArgs),
Status(StatusArgs),
Init,
Update {
#[arg(short, long)]
yes: bool,
},
Version,
Completions { shell: Shell },
#[command(name = REFRESH_ARG, hide = true)]
RefreshUpdateCache,
}
#[derive(Debug, Args)]
#[command(subcommand_required = true, arg_required_else_help = true)]
pub struct ProviderCmd {
#[command(subcommand)]
pub action: ProviderAction,
}
#[derive(Debug, Subcommand)]
pub enum ProviderAction {
Login,
Sync(SyncArgs),
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ProviderArg {
Github,
Gitlab,
#[value(alias = "gitea")]
Forgejo,
Bitbucket,
Radicle,
}
impl From<ProviderArg> for Provider {
fn from(p: ProviderArg) -> Self {
match p {
ProviderArg::Github => Provider::Github,
ProviderArg::Gitlab => Provider::Gitlab,
ProviderArg::Forgejo => Provider::Forgejo,
ProviderArg::Bitbucket => Provider::Bitbucket,
ProviderArg::Radicle => Provider::Radicle,
}
}
}
#[derive(Debug, Clone, Default, Args)]
pub struct CommonFlags {
#[arg(long, value_name = "PATH")]
pub base_dir: Option<PathBuf>,
#[arg(short, long)]
pub verbose: bool,
#[arg(short, long)]
pub quiet: bool,
}
#[derive(Debug, Clone, Default, Args)]
pub struct SyncArgs {
#[arg(long, value_name = "PATTERN")]
pub filter: Option<String>,
#[arg(long)]
pub https: bool,
#[arg(long)]
pub prune: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub host: Option<String>,
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u16).range(1..))]
pub parallel: Option<u16>,
#[command(flatten)]
pub common: CommonFlags,
}
#[derive(Debug, Args)]
pub struct SyncAllArgs {
#[arg(long, required = true)]
pub all: bool,
#[command(flatten)]
pub sync: SyncArgs,
}
#[derive(Debug, Args)]
pub struct StatusArgs {
#[command(flatten)]
pub common: CommonFlags,
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
Cli::try_parse_from(std::iter::once("repos-manager").chain(args.iter().copied()))
}
#[test]
fn cli_is_consistent() {
Cli::command().debug_assert();
}
#[test]
fn provider_sync_flags() {
let cli = parse(&[
"github",
"sync",
"--filter",
"Dxsk/*",
"--https",
"--prune",
"--dry-run",
"--parallel",
"8",
"-q",
])
.unwrap();
let Some(Command::Github(ProviderCmd {
action: ProviderAction::Sync(a),
})) = cli.command
else {
panic!("wrong parse");
};
assert_eq!(a.filter.as_deref(), Some("Dxsk/*"));
assert!(a.https && a.prune && a.dry_run && a.common.quiet);
assert_eq!(a.parallel, Some(8));
}
#[test]
fn equals_form_and_gitea_alias() {
let cli = parse(&[
"gitea",
"sync",
"--host=git.example.org",
"--base-dir=/tmp/x",
])
.unwrap();
let Some(Command::Forgejo(ProviderCmd {
action: ProviderAction::Sync(a),
})) = cli.command
else {
panic!("wrong parse");
};
assert_eq!(a.host.as_deref(), Some("git.example.org"));
assert_eq!(a.common.base_dir, Some(PathBuf::from("/tmp/x")));
}
#[test]
fn sync_requires_all() {
assert!(parse(&["sync"]).is_err());
assert!(parse(&["sync", "--all", "--parallel", "2"]).is_ok());
}
#[test]
fn rejects_unknown_flag_and_zero_parallel() {
assert!(parse(&["github", "sync", "--nope"]).is_err());
assert!(parse(&["github", "sync", "--parallel", "0"]).is_err());
}
#[test]
fn login_provider_is_validated() {
assert!(parse(&["login", "gitea"]).is_ok());
assert!(parse(&["login", "sourceforge"]).is_err());
}
#[test]
fn provider_arg_maps_to_provider() {
let all = [
ProviderArg::Github,
ProviderArg::Gitlab,
ProviderArg::Forgejo,
ProviderArg::Bitbucket,
ProviderArg::Radicle,
];
let mapped: Vec<Provider> = all.into_iter().map(Into::into).collect();
assert_eq!(mapped, Provider::ALL);
}
#[test]
fn status_and_update_flags() {
let cli = parse(&["status", "-v", "--base-dir", "/b"]).unwrap();
let Some(Command::Status(s)) = cli.command else {
panic!("wrong parse");
};
assert!(s.common.verbose && !s.common.quiet);
assert!(matches!(
parse(&["update", "-y"]).unwrap().command,
Some(Command::Update { yes: true })
));
}
}