arkham 0.1.0

A framework for CLI applications
Documentation
use crate::print_command_help;

use super::command::{help, Command, Handler};
use super::context::Context;
use super::opt::{Opt, OptError, OptKind};

use std::env;

type Result<T> = std::result::Result<T, OptError>;

pub struct App {
    name: Option<&'static str>,
    version: Option<&'static str>,
    pub root: Command,
    config_filename: Option<&'static str>,
    env_prefix: Option<&'static str>,
}

impl Default for App {
    fn default() -> Self {
        Self {
            name: None,
            version: None,
            root: Command::new("root"),
            env_prefix: None,
            config_filename: None,
        }
    }
}

impl App {
    pub fn application_header(&self) -> String {
        format!(
            "{} - {}",
            self.name.as_ref().unwrap_or(&env!("CARGO_PKG_NAME")),
            self.version.as_ref().unwrap_or(&env!("CARGO_PKG_VERSION"))
        )
    }
}

impl App {
    /// Contructs a new App instance which can have opts defined and subcommands attached.
    pub fn new() -> Self {
        App::default().command(
            Command::new("help")
                .handler(help)
                .short_desc("Displays help information"),
        )
    }

    /// Sets the name of the application. If not set the cargo package name will be used.
    ///
    /// Exmaple:
    ///
    /// ```rust
    /// use arkham::App;
    /// let app = App::new().name("new app");
    /// ```
    pub fn name(mut self, name: &'static str) -> Self {
        self.name = Some(name);
        self
    }

    /// Sets the version for the application. If not set the cargo package version is used.
    ///
    /// Example:
    ///
    /// ```rust
    /// use arkham::App;
    /// App::new().version("1.0.0");
    /// ```
    pub fn version(mut self, version: &'static str) -> Self {
        self.version = Some(version);
        self
    }

    /// Sets the environment variable prefix for option resolution. If set to something like
    /// APP_NAME a option with the name THING, will look for an environment variable named
    /// APP_NAME_THING.
    ///
    /// Example:
    /// ```rust
    /// use arkham::App;
    /// App::new().env_prefix("APP_NAME");
    ///
    /// ```
    pub fn env_prefix(mut self, prefix: &'static str) -> Self {
        self.env_prefix = Some(prefix);
        self
    }

    /// Adds a root level command to the application. This command can then be executed with:
    ///
    /// myapp command_name
    ///
    /// Help flags will also be generated for the command which will display command
    /// information for:
    ///
    /// myapp --help command_name or myapp help command_name
    ///
    /// Example:
    /// ```rust
    /// use arkham::{App, Command, Context};
    /// App::new().command(Command::new("subcommand").handler(my_handler));
    ///
    /// fn my_handler(app: &App, ctx: &Context, args: &[String]) {}
    /// ```
    pub fn command(mut self, cmd: Command) -> Self {
        self.root.commands.push(cmd);
        self
    }

    /// Adds a root level opt/flag that is available to all commands. Opts are given a name which
    /// is used to reference them, as well as a short and long identifier.
    ///
    /// Example:
    /// ```rust
    /// use arkham::{App, Opt};
    /// App::new().opt(Opt::flag("verbose").short("v").long("verbose"));
    /// ```
    pub fn opt(mut self, opt: Opt) -> Self {
        self.root.opts.push(opt);
        self
    }

    /// Sets a handler function for the bare root command. If this is not set an error will be
    /// generated and a help message will be displayed indicating the available subcommands.
    /// The handler function takes an instance of the app, the context which contains the opts and
    /// flags, and any additionally passeed arguments.
    ///
    /// Example:
    /// ```rust
    /// use arkham::{App, Command, Context};
    /// App::new().handler(my_handler);
    ///
    /// fn my_handler(app: &App, ctx: &Context, args: &[String]) {}
    /// ```
    pub fn handler(mut self, f: Handler) -> Self {
        self.root.handler = Some(f);
        self
    }

    /// Execute the app and any specified handlers based on the passed arguemnts. This function is
    /// mostly used for testing or any situation where you need to pass arbitrary arguments instead
    /// of using the ones passed to the application.
    /// Example:
    /// ```rust
    /// use arkham::{App, Command, Context, Opt};
    /// App::new()
    ///     .opt(Opt::scalar("name").short("n").long("name"))
    ///     .handler(my_handler)
    ///     .run_with(vec!["-n".to_string(), "alice".to_string()]);
    ///
    /// fn my_handler(app: &App, ctx: &Context, args: &[String]) {
    ///     println!("Hello, {}", ctx.get_string("name").unwrap());
    /// }
    /// ```
    pub fn run_with(&mut self, args: Vec<String>) -> Result<()> {
        let mut ctx = Context::new(self.root.clone());
        if let Some(filename) = self.config_filename {
            ctx.load_config_file(filename);
        }
        run_command(self, &self.root, &args, &mut ctx)
    }

    /// Execute the app and any specified handlers based on the arguments passsed to the
    /// application.
    ///
    /// Example:
    /// running with myapp --name alice
    /// ```rust
    /// use arkham::{App, Command, Context, Opt};
    /// App::new()
    ///     .opt(Opt::flag("name").short("n").long("name"))
    ///     .handler(my_handler)
    ///     .run();
    ///
    /// fn my_handler(app: &App, ctx: &Context, args: &[String]) {
    ///     println!("Hello, {}", ctx.get_string("name").unwrap_or_else(|| "unnamed".into()));
    /// }
    /// ```
    pub fn run(&mut self) -> Result<()> {
        self.run_with(env::args().skip(1).collect())
    }
}

/// This is the core logic for parsing arguments and executing handlers. It is ran but the App::run
/// and App::run_with functions.
fn run_command(app: &App, cmd: &Command, args: &[String], ctx: &mut Context) -> Result<()> {
    // Get an iterator for the incomming arguments
    let mut args = args.iter();
    // We will keep track of any arguments that arent consumed by the current command.
    // These will be either used to collect arguments from subcommands or passed as additional args
    // to the command.
    let mut ignored: Vec<String> = vec![];
    // Loop through all passed in args
    while let Some(arg) = args.next() {
        // Check for long args
        if arg.starts_with("--") {
            if let Some(opt) = cmd.opts.iter().find(|o| &o.long == &arg[2..]) {
                match opt.kind {
                    OptKind::Flag => {
                        ctx.set_flag(opt);
                    }
                    OptKind::String => {
                        if let Some(value) = args.next() {
                            ctx.set_opt(opt, value.clone());
                        } else {
                            return Err(OptError::InvalidOpt(opt.name.clone()));
                        }
                    }
                }
            } else {
                ignored.push(arg.clone());
            }
            continue;
        }
        // Check for short args
        if arg.starts_with("-") {
            if let Some(opt) = cmd.opts.iter().find(|o| &o.short == &arg[1..]) {
                match opt.kind {
                    OptKind::Flag => {
                        ctx.set_flag(opt);
                    }
                    OptKind::String => {
                        if let Some(value) = args.next() {
                            ctx.set_opt(opt, value.clone());
                        } else {
                            return Err(OptError::InvalidOpt(opt.name.clone()));
                        }
                    }
                }
            } else {
                ignored.push(arg.clone());
            }
            continue;
        }
        ignored.push(arg.clone());
    }

    // Find an recurse into sub commands if the remaining argumetns match any subcommand name
    if let Some(cmd) = cmd
        .commands
        .iter()
        .find(|cmd| ignored.iter().any(|a| *a == cmd.name))
    {
        ignored.retain(|a| *a != cmd.name);
        return run_command(app, cmd, &ignored, ctx);
    }

    // Automatic command help display
    if ignored.iter().any(|a| a == "-h" || a == "--help") {
        super::command::print_command_help(cmd, &vec![]);
        return Ok(());
    }

    // If any ignored parameters start with "-" we will throw an unknwon flag error.
    if let Some(arg) = ignored.iter().find(|a| a.starts_with("-")) {
        return Err(OptError::InvalidOpt(arg.clone()));
    }

    // Execute the command handler
    if let Some(handler) = cmd.handler {
        ctx.cmd = cmd.clone();
        if let Some(prefix) = app.env_prefix {
            ctx.env_prefix = Some(prefix.to_string());
        }
        handler(app, &ctx, &ignored);
    } else {
        crate::vox::print(app.application_header());
        print_command_help(cmd, &vec![])
    }

    Ok(())
}

#[cfg(not(feature = "config"))]
impl App {
    pub fn config_filename(&mut self, _filename: &'static str) -> Self {
        panic!("Not compiled with configuration capabilities. Add a config parser as a feature");
    }
}

#[cfg(feature = "config")]
impl App {
    pub fn config_filename(mut self, filename: &'static str) -> Self {
        self.config_filename = Some(filename);
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::App;
    #[test]
    fn test_long_string() {
        let args: Vec<String> = vec!["--user".into(), "joe".into()];
        App::new()
            .opt(Opt::scalar("user").short("u").long("user"))
            .handler(|_, ctx, _| {
                assert_eq!(ctx.get_string("user"), Some("joe".into()));
            })
            .run_with(args)
            .unwrap();
    }

    #[test]
    fn test_subcommand() {
        let args = vec![
            "--user".into(),
            "joe".into(),
            "--config".into(),
            "c.json".into(),
            "thing".into(),
        ];
        App::new()
            .opt(Opt::scalar("user").short("u").long("user"))
            .command(
                Command::new("thing")
                    .opt(Opt::scalar("config").short("c").long("config"))
                    .handler(|_, ctx, _| {
                        assert_eq!(ctx.get_string("user"), Some("joe".into()));
                        assert_eq!(ctx.get_string("config"), Some("c.json".into()));
                    }),
            )
            .run_with(args)
            .unwrap();
    }

    fn function_handler(_app: &App, _ctx: &Context, _args: &[String]) {
        assert!(true);
    }

    #[test]
    fn test_function_handler() {
        App::new().handler(function_handler);
    }

    #[test]
    fn test_extra_args() {
        let args = vec!["somefile".to_string()];
        App::new()
            .handler(|_, _, args| {
                assert_eq!(args.len(), 1);
                assert_eq!(args.first(), Some(&"somefile".to_string()));
            })
            .run_with(args)
            .expect("app error");
    }

    #[test]
    fn test_short_flag() {
        App::new()
            .opt(Opt::flag("verbose").short("v").long("verbose"))
            .handler(|_, ctx, _| {
                assert_eq!(ctx.flag("verbose"), true);
            })
            .run_with(vec!["-v".into()])
            .unwrap();
    }

    #[test]
    fn test_invalid_long_flag() {
        let r = App::new()
            .opt(Opt::flag("verbose").short("v").long("verbose"))
            .run_with(vec!["--user".into()]);
        assert!(r.is_err(), "Should error for invalid long flag");
    }

    #[test]
    fn test_invalid_short_flag() {
        let r = App::new()
            .opt(Opt::flag("verbose").short("v").long("verbose"))
            .run_with(vec!["-u".into()]);
        assert!(r.is_err(), "Should error for invalid short flag");
    }

    #[test]
    fn test_env_prefix() {
        std::env::set_var("ARKHAM_thing", "1");
        App::new()
            .env_prefix("ARKHAM")
            .opt(Opt::flag("thing").short("-t").long("--t"))
            .handler(|_, ctx, _| {
                assert_eq!(ctx.flag("thing"), true);
            })
            .run_with(vec![])
            .unwrap();
    }
}