Refactor command-line handling into modules per subcommands

This commit is contained in:
Dustin J. Mitchell
2020-11-23 19:33:04 -05:00
parent e0b69a62b1
commit fe4183c3ca
12 changed files with 560 additions and 59 deletions

60
cli/src/cmd/add.rs Normal file
View File

@@ -0,0 +1,60 @@
use clap::{App, Arg, ArgMatches, SubCommand as ClapSubCommand};
use failure::{format_err, Fallible};
use taskchampion::Status;
use uuid::Uuid;
use crate::cmd::{ArgMatchResult, CommandInvocation};
#[derive(Debug)]
struct Invocation {
description: String,
}
define_subcommand! {
fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> {
app.subcommand(
ClapSubCommand::with_name("add").about("adds a task").arg(
Arg::with_name("description")
.help("task description")
.required(true),
),
)
}
fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult {
match matches.subcommand() {
("add", Some(matches)) => {
// TODO: .unwrap() would be safe here as description is required above
let description: String = match matches.value_of("description") {
Some(v) => v.into(),
None => return ArgMatchResult::Err(format_err!("no description provided")),
};
ArgMatchResult::Ok(Box::new(Invocation { description }))
}
_ => ArgMatchResult::None,
}
}
}
subcommand_invocation! {
fn run(&self, command: &CommandInvocation) -> Fallible<()> {
let uuid = Uuid::new_v4();
command
.get_replica()
.new_task(uuid, Status::Pending, self.description.clone())
.unwrap();
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn parse_command() {
with_subcommand_invocation!(vec!["task", "add", "foo bar"], |inv: &Invocation| {
assert_eq!(inv.description, "foo bar".to_string());
});
}
}

37
cli/src/cmd/gc.rs Normal file
View File

@@ -0,0 +1,37 @@
use crate::cmd::{ArgMatchResult, CommandInvocation};
use clap::{App, ArgMatches, SubCommand as ClapSubCommand};
use failure::Fallible;
#[derive(Debug)]
struct Invocation {}
define_subcommand! {
fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> {
app.subcommand(ClapSubCommand::with_name("gc").about("run garbage collection"))
}
fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult {
match matches.subcommand() {
("gc", _) => ArgMatchResult::Ok(Box::new(Invocation {})),
_ => ArgMatchResult::None,
}
}
}
subcommand_invocation! {
fn run(&self, command: &CommandInvocation) -> Fallible<()> {
command.get_replica().gc()?;
println!("garbage collected.");
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn parse_command() {
with_subcommand_invocation!(vec!["task", "gc"], |_inv| {});
}
}

39
cli/src/cmd/list.rs Normal file
View File

@@ -0,0 +1,39 @@
use clap::{App, ArgMatches, SubCommand as ClapSubCommand};
use failure::Fallible;
use crate::cmd::{ArgMatchResult, CommandInvocation};
#[derive(Debug)]
struct Invocation {}
define_subcommand! {
fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> {
app.subcommand(ClapSubCommand::with_name("list").about("lists tasks"))
}
fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult {
match matches.subcommand() {
("list", _) => ArgMatchResult::Ok(Box::new(Invocation {})),
_ => ArgMatchResult::None,
}
}
}
subcommand_invocation! {
fn run(&self, command: &CommandInvocation) -> Fallible<()> {
for (uuid, task) in command.get_replica().all_tasks().unwrap() {
println!("{} - {:?}", uuid, task);
}
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn parse_command() {
with_subcommand_invocation!(vec!["task", "list"], |_inv| {});
}
}

45
cli/src/cmd/macros.rs Normal file
View File

@@ -0,0 +1,45 @@
/// Define a Command type implementing SubCommand with the enclosed methods (`decorate_app` and
/// `arg_match`), along with a module-level `cmd` function as the parent module expects.
macro_rules! define_subcommand {
($($f:item) +) => {
struct Command;
pub(super) fn cmd() -> Box<dyn crate::cmd::SubCommand> {
Box::new(Command)
}
impl crate::cmd::SubCommand for Command {
$($f)+
}
}
}
/// Define an Invocation type implementing SubCommandInvocation with the enclosed methods.
macro_rules! subcommand_invocation {
($($f:item) +) => {
impl crate::cmd::SubCommandInvocation for Invocation {
$($f)+
#[cfg(test)]
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
}
}
/// Parse the first argument as a command line and convert the result to an Invocation (which must
/// be in scope). If the conversion works, calls the second argument with it.
#[cfg(test)]
macro_rules! with_subcommand_invocation {
($args:expr, $check:expr) => {
let parsed = crate::parse_command_line($args).unwrap();
let si = parsed
.subcommand
.as_any()
.downcast_ref::<Invocation>()
.expect("SubComand is not of the expected type");
($check)(si);
};
}

69
cli/src/cmd/mod.rs Normal file
View File

@@ -0,0 +1,69 @@
use clap::{App, ArgMatches};
use failure::{Error, Fallible};
use std::path::Path;
use taskchampion::{taskstorage, Replica};
#[macro_use]
mod macros;
mod add;
mod gc;
mod list;
mod pending;
/// Get a list of all subcommands in this crate
pub(crate) fn subcommands() -> Vec<Box<dyn SubCommand>> {
vec![add::cmd(), gc::cmd(), list::cmd(), pending::cmd()]
}
/// The result of a [`crate::cmd::SubCommand::arg_match`] call
pub(crate) enum ArgMatchResult {
/// No match
None,
/// A good match
Ok(Box<dyn SubCommandInvocation>),
/// A match, but an issue with the command line
Err(Error),
}
/// A subcommand represents a defined subcommand, and is typically a singleton.
pub(crate) trait SubCommand {
/// Decorate the given [`clap::App`] appropriately for this subcommand
fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a>;
/// If this ArgMatches is for this command, return an appropriate invocation.
fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult;
}
/// A subcommand invocation is specialized to a subcommand
pub(crate) trait SubCommandInvocation: std::fmt::Debug {
fn run(&self, command: &CommandInvocation) -> Fallible<()>;
// tests use downcasting, which requires a function to cast to Any
#[cfg(test)]
fn as_any(&self) -> &dyn std::any::Any;
}
/// A command invocation contains all of the necessary regarding a single invocation of the CLI.
#[derive(Debug)]
pub struct CommandInvocation {
pub(crate) subcommand: Box<dyn SubCommandInvocation>,
}
impl CommandInvocation {
pub(crate) fn new(subcommand: Box<dyn SubCommandInvocation>) -> Self {
Self { subcommand }
}
pub fn run(self) -> Fallible<()> {
self.subcommand.run(&self)
}
fn get_replica(&self) -> Replica {
Replica::new(Box::new(
taskstorage::KVStorage::new(Path::new("/tmp/tasks")).unwrap(),
))
}
}

49
cli/src/cmd/pending.rs Normal file
View File

@@ -0,0 +1,49 @@
use clap::{App, ArgMatches, SubCommand as ClapSubCommand};
use failure::Fallible;
use crate::cmd::{ArgMatchResult, CommandInvocation};
#[derive(Debug)]
struct Invocation {}
define_subcommand! {
fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> {
app.subcommand(ClapSubCommand::with_name("pending").about("lists pending tasks"))
}
fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult {
match matches.subcommand() {
("pending", _) => ArgMatchResult::Ok(Box::new(Invocation {})),
// default to this command when no subcommand is given
("", _) => ArgMatchResult::Ok(Box::new(Invocation {})),
_ => ArgMatchResult::None,
}
}
}
subcommand_invocation! {
fn run(&self, command: &CommandInvocation) -> Fallible<()> {
let working_set = command.get_replica().working_set().unwrap();
for i in 1..working_set.len() {
if let Some(ref task) = working_set[i] {
println!("{}: {} - {:?}", i, task.get_uuid(), task);
}
}
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn parse_command() {
with_subcommand_invocation!(vec!["task", "pending"], |_inv| {});
}
#[test]
fn parse_command_default() {
with_subcommand_invocation!(vec!["task"], |_inv| {});
}
}