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

43
cli/src/bin/task.rs Normal file
View File

@@ -0,0 +1,43 @@
use clap::{Error as ClapError, ErrorKind};
use std::process::exit;
use taskchampion_cli::parse_command_line;
enum Output {
Stdout,
Stderr,
}
use Output::*;
fn bail<E: std::fmt::Display>(err: E, output: Output, code: i32) -> ! {
match output {
Stdout => println!("{}", err),
Stderr => eprintln!("{}", err),
}
exit(code)
}
fn main() {
let command = match parse_command_line(std::env::args_os()) {
Ok(command) => command,
Err(err) => {
match err.downcast::<ClapError>() {
Ok(err) => {
if err.kind == ErrorKind::HelpDisplayed
|| err.kind == ErrorKind::VersionDisplayed
{
// --help and --version go to stdout and succeed
bail(err, Stdout, 0)
} else {
// other clap errors exit with failure
bail(err, Stderr, 1)
}
}
Err(err) => bail(err, Stderr, 1),
}
}
};
if let Err(err) = command.run() {
bail(err, Stderr, 1)
}
}

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| {});
}
}

57
cli/src/lib.rs Normal file
View File

@@ -0,0 +1,57 @@
use clap::{App, AppSettings};
use failure::Fallible;
use std::ffi::OsString;
mod cmd;
use cmd::ArgMatchResult;
pub use cmd::CommandInvocation;
/// Parse the given command line and return an as-yet un-executed CommandInvocation.
pub fn parse_command_line<I, T>(iter: I) -> Fallible<CommandInvocation>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let subcommands = cmd::subcommands();
let mut app = App::new("TaskChampion")
.version("0.1")
.about("Personal task-tracking")
.setting(AppSettings::ColoredHelp);
for subcommand in subcommands.iter() {
app = subcommand.decorate_app(app);
}
let matches = app.get_matches_from_safe(iter)?;
for subcommand in subcommands.iter() {
match subcommand.arg_match(&matches) {
ArgMatchResult::Ok(invocation) => return Ok(CommandInvocation::new(invocation)),
ArgMatchResult::Err(err) => return Err(err),
ArgMatchResult::None => {}
}
}
// one of the subcommands also matches the lack of subcommands, so this never
// occurrs.
unreachable!()
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_parse_command_line_success() -> Fallible<()> {
// This just verifies that one of the subcommands works; the subcommands themselves
// are tested in their own unit tests.
parse_command_line(vec!["task", "pending"].iter())?;
Ok(())
}
#[test]
fn test_parse_command_line_failure() {
assert!(parse_command_line(vec!["task", "--no-such-arg"].iter()).is_err());
}
}

View File

@@ -1,59 +0,0 @@
use clap::{App, Arg, SubCommand};
use std::path::Path;
use taskchampion::{taskstorage, Replica, Status};
use uuid::Uuid;
fn main() {
let matches = App::new("TaskChampion")
.version("0.1")
.author("Dustin J. Mitchell <dustin@v.igoro.us>")
.about("Personal task-tracking")
.subcommand(
SubCommand::with_name("add").about("adds a task").arg(
Arg::with_name("description")
.help("task description")
.required(true),
),
)
.subcommand(SubCommand::with_name("list").about("lists tasks"))
.subcommand(SubCommand::with_name("pending").about("lists pending tasks"))
.subcommand(SubCommand::with_name("gc").about("run garbage collection"))
.get_matches();
let mut replica = Replica::new(Box::new(
taskstorage::KVStorage::new(Path::new("/tmp/tasks")).unwrap(),
));
match matches.subcommand() {
("add", Some(matches)) => {
let uuid = Uuid::new_v4();
replica
.new_task(
uuid,
Status::Pending,
matches.value_of("description").unwrap().into(),
)
.unwrap();
}
("list", _) => {
for (uuid, task) in replica.all_tasks().unwrap() {
println!("{} - {:?}", uuid, task);
}
}
("pending", _) => {
let working_set = 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);
}
}
}
("gc", _) => {
replica.gc().unwrap();
}
("", None) => {
unreachable!();
}
_ => unreachable!(),
};
}