radicle-cli 0.20.0

Radicle CLI
Documentation
use std::ffi::OsString;
use std::fmt::Display;
use std::io;
use std::io::Write;
use std::{io::ErrorKind, process};

use anyhow::anyhow;
use clap::builder::styling::AnsiColor;
use clap::builder::Styles;
use clap::{CommandFactory as _, Parser, Subcommand};

use radicle::version::Version;
use radicle_cli::commands::*;
use radicle_cli::terminal as term;

pub const NAME: &str = "rad";
pub const GIT_HEAD: &str = env!("GIT_HEAD");
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
pub const RADICLE_VERSION_LONG: &str =
    concat!(env!("RADICLE_VERSION"), " (", env!("GIT_HEAD"), ")");
pub const DESCRIPTION: &str = "Radicle command line interface";
pub const LONG_DESCRIPTION: &str = "
Radicle is a sovereign code forge built on Git.

See `rad <COMMAND> --help` to learn about a specific command.

Do you have feedback?
 - Chat <\x1b]8;;https://radicle.zulipchat.com\x1b\\radicle.zulipchat.com\x1b]8;;\x1b\\>
 - Mail <\x1b]8;;mailto:feedback@radicle.xyz\x1b\\feedback@radicle.xyz\x1b]8;;\x1b\\>
   (Messages are automatically posted to the public #feedback channel on Zulip.)\
";
pub const TIMESTAMP: &str = env!("SOURCE_DATE_EPOCH");
pub const VERSION: Version = Version {
    name: NAME,
    version: RADICLE_VERSION,
    commit: GIT_HEAD,
    timestamp: TIMESTAMP,
};
const STYLES: Styles = Styles::styled()
    .header(AnsiColor::Magenta.on_default().bold())
    .usage(AnsiColor::Magenta.on_default().bold())
    .placeholder(AnsiColor::Cyan.on_default());

/// Radicle command line interface
#[derive(Parser, Debug)]
#[command(name = NAME)]
#[command(version = RADICLE_VERSION)]
#[command(long_version = RADICLE_VERSION_LONG)]
#[command(about = DESCRIPTION)]
#[command(long_about = LONG_DESCRIPTION)]
#[command(propagate_version = true)]
#[command(styles = STYLES)]
struct CliArgs {
    #[command(subcommand)]
    pub command: Command,
}

#[derive(Subcommand, Debug)]
enum Command {
    Auth(auth::Args),
    Block(block::Args),
    Checkout(checkout::Args),
    Clean(clean::Args),
    Clone(clone::Args),
    #[command(hide = true)]
    Cob(cob::Args),
    Config(config::Args),
    Debug(debug::Args),
    Follow(follow::Args),
    #[command(hide = true)] // `rad fork` command is deprecated
    Fork(fork::Args),
    Id(id::Args),
    Inbox(inbox::Args),
    Init(init::Args),
    #[command(alias = ".")]
    Inspect(inspect::Args),
    Issue(issue::Args),
    Ls(ls::Args),
    Node(node::Args),
    Patch(patch::Args),
    Path(path::Args),
    Publish(publish::Args),
    Remote(remote::Args),
    Seed(seed::Args),
    #[command(name = "self")]
    RadSelf(rad_self::Args),
    Stats(stats::Args),
    Sync(sync::Args),
    Unblock(unblock::Args),
    Unfollow(unfollow::Args),
    Unseed(unseed::Args),
    Watch(watch::Args),

    /// Print the version information of the CLI
    Version {
        /// Print the version information in JSON format
        #[arg(long)]
        json: bool,
    },

    /// Print static completion information for a given shell
    #[command(hide = true)]
    Completion {
        /// The type of shell to output a static completion script for.
        shell: clap_complete::Shell,
    },

    #[command(external_subcommand)]
    External(Vec<OsString>),
}

fn main() {
    human_panic::setup_panic!(human_panic::Metadata::new(
        env!("CARGO_PKG_NAME"),
        env!("CARGO_PKG_VERSION")
    )
    .homepage(env!("CARGO_PKG_HOMEPAGE"))
    .support("Open a support request at https://radicle.zulipchat.com/ or file an issue via Radicle itself, or e-mail to team@radicle.xyz"));

    if let Some(lvl) = radicle::logger::env_level() {
        let logger = Box::new(radicle::logger::Logger::new(lvl));
        log::set_boxed_logger(logger).expect("no other logger should have been set already");
        log::set_max_level(lvl.to_level_filter());
    }
    if let Err(e) = radicle::io::set_file_limit(4096) {
        log::warn!(target: "cli", "Unable to set open file limit: {e}");
    }
    let CliArgs { command } = CliArgs::parse();
    run(command, term::DefaultContext)
}

fn write_version(as_json: bool) -> anyhow::Result<()> {
    let mut stdout = io::stdout();
    if as_json {
        VERSION.write_json(&mut stdout)?;
        writeln!(&mut stdout)?;
        Ok(())
    } else {
        VERSION.write(&mut stdout)?;
        Ok(())
    }
}

fn run(command: Command, ctx: impl term::Context) -> ! {
    match run_command(command, ctx) {
        Ok(()) => process::exit(0),
        Err(err) => {
            term::fail(&err);
            process::exit(1);
        }
    }
}

fn run_command(command: Command, ctx: impl term::Context) -> Result<(), anyhow::Error> {
    match command {
        Command::Auth(args) => auth::run(args, ctx),
        Command::Block(args) => block::run(args, ctx),
        Command::Checkout(args) => checkout::run(args, ctx),
        Command::Clean(args) => clean::run(args, ctx),
        Command::Clone(args) => clone::run(args, ctx),
        Command::Cob(args) => cob::run(args, ctx),
        Command::Config(args) => config::run(args, ctx),
        Command::Debug(args) => debug::run(args, ctx),
        Command::Follow(args) => follow::run(args, ctx),
        Command::Fork(args) => fork::run(args, ctx),
        Command::Id(args) => id::run(args, ctx),
        Command::Inbox(args) => inbox::run(args, ctx),
        Command::Init(args) => init::run(args, ctx),
        Command::Inspect(args) => inspect::run(args, ctx),
        Command::Issue(args) => issue::run(args, ctx),
        Command::Ls(args) => ls::run(args, ctx),
        Command::Node(args) => node::run(args, ctx),
        Command::Patch(args) => patch::run(args, ctx),
        Command::Path(args) => path::run(args, ctx),
        Command::Publish(args) => publish::run(args, ctx),
        Command::Remote(args) => remote::run(args, ctx),
        Command::Seed(args) => seed::run(args, ctx),
        Command::RadSelf(args) => rad_self::run(args, ctx),
        Command::Stats(args) => stats::run(args, ctx),
        Command::Sync(args) => sync::run(args, ctx),
        Command::Unblock(args) => unblock::run(args, ctx),
        Command::Unfollow(args) => unfollow::run(args, ctx),
        Command::Unseed(args) => unseed::run(args, ctx),
        Command::Watch(args) => watch::run(args, ctx),
        Command::Version { json } => write_version(json),
        Command::Completion { shell } => {
            print_completion(shell, &mut CliArgs::command());
            Ok(())
        }
        Command::External(args) => ExternalCommand::new(args).run(),
    }
}

fn print_completion<G: clap_complete::Generator>(generator: G, cmd: &mut clap::Command) {
    clap_complete::generate(
        generator,
        cmd,
        cmd.get_name().to_string(),
        &mut io::stdout(),
    );
}

struct ExternalCommand {
    command: OsString,
    args: Vec<OsString>,
}

impl ExternalCommand {
    fn new(mut args: Vec<OsString>) -> Self {
        let command = args.remove(0);
        Self { command, args }
    }

    fn is_diff(&self) -> bool {
        self.command == "diff"
    }

    fn exe(&self) -> OsString {
        let mut exe = OsString::from(NAME);
        exe.push("-");
        exe.push(self.command.clone());
        exe
    }

    fn display_exe(&self) -> impl Display {
        match self.exe().into_string() {
            Ok(exe) => exe,
            Err(exe) => format!("{exe:?}"),
        }
    }

    fn run(self) -> anyhow::Result<()> {
        // This command is deprecated and delegates to `git diff`.
        // Even before it was deprecated, it was not printed by
        // `rad -h`.
        //
        // Since it is external, `--help` will delegate to `git diff --help`.
        if self.is_diff() {
            return diff::run(self.args);
        }

        let status = process::Command::new(self.exe()).args(&self.args).status();
        match status {
            Ok(status) => {
                if !status.success() {
                    return Err(anyhow!("`{}` exited with an error.", self.display_exe()));
                }
                Ok(())
            }
            Err(err) => {
                if let ErrorKind::NotFound = err.kind() {
                    Err(anyhow!(
                        "`{}` is not a known command. See `rad --help` for a list of commands.",
                        self.display_exe(),
                    ))
                } else {
                    Err(err.into())
                }
            }
        }
    }
}