diff --git a/Cargo.lock b/Cargo.lock index 9b4189824..7b3aeae18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" +[[package]] +name = "bitvec" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ba35e9565969edb811639dbebfe34edc0368e472c5018474c8eb2543397f81" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2b_simd" version = "0.5.11" @@ -557,7 +569,7 @@ dependencies = [ "atty", "bitflags", "strsim", - "textwrap", + "textwrap 0.11.0", "unicode-width", "vec_map", ] @@ -587,7 +599,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b076e143e1d9538dde65da30f8481c2a6c44040edb8e02b9bf1351edb92ce3" dependencies = [ "lazy_static", - "nom", + "nom 5.1.2", "serde", "yaml-rust", ] @@ -890,6 +902,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" +[[package]] +name = "funty" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba62103ce691c2fd80fbae2213dfdda9ce60804973ac6b6e97de818ea7f52c8" + [[package]] name = "futures" version = "0.3.8" @@ -1373,6 +1391,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "nom" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88034cfd6b4a0d54dd14f4a507eceee36c0b70e5a02236c4e4df571102be17f0" +dependencies = [ + "bitvec", + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1649,6 +1679,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + [[package]] name = "rand" version = "0.4.6" @@ -2200,6 +2236,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "tap" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36474e732d1affd3a6ed582781b3683df3d0563714c59c39591e8ff707cf078e" + [[package]] name = "taskchampion" version = "0.2.0" @@ -2222,15 +2264,19 @@ name = "taskchampion-cli" version = "0.2.0" dependencies = [ "assert_cmd", - "clap", + "atty", "config", "dirs 3.0.1", "env_logger", "failure", "log", + "nom 6.0.1", "predicates", "prettytable-rs", "taskchampion", + "tempdir", + "termcolor", + "textwrap 0.12.1", ] [[package]] @@ -2303,6 +2349,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "textwrap" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "203008d98caf094106cfaba70acfed15e18ed3ddb7d94e49baec153a2b462789" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.22" @@ -2798,6 +2853,12 @@ dependencies = [ "winapi-build", ] +[[package]] +name = "wyz" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + [[package]] name = "yaml-rust" version = "0.4.4" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d1f5f99c4..9daeb1653 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,19 +1,29 @@ [package] -name = "taskchampion-cli" -version = "0.2.0" authors = ["Dustin J. Mitchell "] edition = "2018" +name = "taskchampion-cli" +version = "0.2.0" [dependencies] -clap = "^2.33.0" -taskchampion = { path = "../taskchampion" } -failure = "^0.1.8" -prettytable-rs = "^0.8.0" -config = { version="^0.10.1", default-features=false, features=["yaml"] } dirs = "^3.0.1" -log = "^0.4.11" env_logger = "^0.8.2" +failure = "^0.1.8" +log = "^0.4.11" +nom = "*" +prettytable-rs = "^0.8.0" +textwrap = "0.12.1" +termcolor = "1.1.2" +atty = "0.2.14" + +[dependencies.config] +default-features = false +features = ["yaml"] +version = "^0.10.1" + +[dependencies.taskchampion] +path = "../taskchampion" [dev-dependencies] assert_cmd = "^1.0.1" predicates = "^1.0.5" +tempdir = "^0.3.7" diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs new file mode 100644 index 000000000..c59a4d730 --- /dev/null +++ b/cli/src/argparse/args.rs @@ -0,0 +1,232 @@ +//! Parsers for argument lists -- arrays of strings +use super::ArgList; +use nom::bytes::complete::tag as nomtag; +use nom::{ + branch::*, + character::complete::*, + combinator::*, + error::{Error, ErrorKind}, + multi::*, + sequence::*, + Err, IResult, +}; + +/// Recognizes any argument +pub(super) fn any(input: &str) -> IResult<&str, &str> { + rest(input) +} + +/// Recognizes a literal string +pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| all_consuming(nomtag(literal))(input) +} + +/// Recognizes a comma-separated list of ID's (integers or UUID prefixes) +pub(super) fn id_list(input: &str) -> IResult<&str, Vec<&str>> { + fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input) + } + all_consuming(separated_list1( + char(','), + alt(( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(12), + ))), + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + ))), + recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), + recognize(tuple((hex_n(8), char('-'), hex_n(4)))), + hex_n(8), + digit1, + )), + ))(input) +} + +/// Recognizes a tag prefixed with `+` and returns the tag value +#[allow(dead_code)] // tags not implemented yet +pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple((char('+'), recognize(pair(alpha1, alphanumeric0))))), + to_tag, + )(input) +} + +/// Recognizes a tag prefixed with `-` and returns the tag value +#[allow(dead_code)] // tags not implemented yet +pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple((char('-'), recognize(pair(alpha1, alphanumeric0))))), + to_tag, + )(input) +} + +/// Recognizes a tag prefixed with either `-` or `+`, returning true for + and false for - +#[allow(dead_code)] // tags not implemented yet +pub(super) fn tag(input: &str) -> IResult<&str, (bool, &str)> { + fn to_plus(input: &str) -> Result<(bool, &str), ()> { + Ok((true, input)) + } + fn to_minus(input: &str) -> Result<(bool, &str), ()> { + Ok((false, input)) + } + alt((map_res(plus_tag, to_plus), map_res(minus_tag, to_minus)))(input) +} + +/// Consume a single argument from an argument list that matches the given string parser (one +/// of the other functions in this module). The given parser must consume the entire input. +pub(super) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult +where + F: Fn(&'a str) -> IResult<&'a str, O>, +{ + move |input: ArgList<'a>| { + if let Some(arg) = input.get(0) { + return match f(arg) { + Ok(("", rv)) => Ok((&input[1..], rv)), + // single-arg parsers must consume the entire arg + Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), + // single-arg parsers are all complete parsers + Err(Err::Incomplete(_)) => unreachable!(), + // for error and failure, rewrite to an error at this position in the arugment list + Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), + Err(Err::Failure(Error { input: _, code })) => { + Err(Err::Failure(Error { input, code })) + } + }; + } + + Err(Err::Error(Error { + input, + // since we're using nom's built-in Error, our choices here are limited, but tihs + // occurs when there's no argument where one is expected, so Eof seems appropriate + code: ErrorKind::Eof, + })) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], (true, "foo")) + ); + assert_eq!( + arg_matching(tag)(argv!["-foo", "bar"]).unwrap(), + (argv!["bar"], (false, "foo")) + ); + assert!(arg_matching(tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_plus_tag() { + assert_eq!(plus_tag("+abc").unwrap().1, "abc"); + assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); + assert!(plus_tag("-abc123").is_err()); + assert!(plus_tag("+abc123 ").is_err()); + assert!(plus_tag(" +abc123").is_err()); + assert!(plus_tag("+1abc").is_err()); + } + + #[test] + fn test_minus_tag() { + assert_eq!(minus_tag("-abc").unwrap().1, "abc"); + assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); + assert!(minus_tag("+abc123").is_err()); + assert!(minus_tag("-abc123 ").is_err()); + assert!(minus_tag(" -abc123").is_err()); + assert!(minus_tag("-1abc").is_err()); + } + + #[test] + fn test_tag() { + assert_eq!(tag("-abc").unwrap().1, (false, "abc")); + assert_eq!(tag("+abc123").unwrap().1, (true, "abc123")); + assert!(tag("+abc123 --").is_err()); + assert!(tag("-abc123 ").is_err()); + assert!(tag(" -abc123").is_err()); + assert!(tag("-1abc").is_err()); + } + + #[test] + fn test_literal() { + assert_eq!(literal("list")("list").unwrap().1, "list"); + assert!(literal("list")("listicle").is_err()); + assert!(literal("list")(" list ").is_err()); + assert!(literal("list")("LiSt").is_err()); + assert!(literal("list")("denylist").is_err()); + } + + #[test] + fn test_id_list_single() { + assert_eq!(id_list("123").unwrap().1, vec!["123".to_owned()]); + } + + #[test] + fn test_id_list_uuids() { + assert_eq!(id_list("12341234").unwrap().1, vec!["12341234".to_owned()]); + assert_eq!(id_list("1234abcd").unwrap().1, vec!["1234abcd".to_owned()]); + assert_eq!(id_list("abcd1234").unwrap().1, vec!["abcd1234".to_owned()]); + assert_eq!( + id_list("abcd1234-1234").unwrap().1, + vec!["abcd1234-1234".to_owned()] + ); + assert_eq!( + id_list("abcd1234-1234-2345").unwrap().1, + vec!["abcd1234-1234-2345".to_owned()] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456").unwrap().1, + vec!["abcd1234-1234-2345-3456".to_owned()] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec!["abcd1234-1234-2345-3456-0123456789ab".to_owned()] + ); + } + + #[test] + fn test_id_list_invalid_partial_uuids() { + assert!(id_list("abcd123").is_err()); + assert!(id_list("abcd12345").is_err()); + assert!(id_list("abcd1234-").is_err()); + assert!(id_list("abcd1234-123").is_err()); + assert!(id_list("abcd1234-1234-").is_err()); + assert!(id_list("abcd1234-12345-").is_err()); + assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); + } + + #[test] + fn test_id_list_uuids_mixed() { + assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec!["abcd1234".to_owned(), + "abcd1234-1234".to_owned(), + "abcd1234-1234-2345".to_owned(), + "abcd1234-1234-2345-3456".to_owned(), + "abcd1234-1234-2345-3456-0123456789ab".to_owned(), + ]); + } +} diff --git a/cli/src/argparse/command.rs b/cli/src/argparse/command.rs new file mode 100644 index 000000000..7d55616e7 --- /dev/null +++ b/cli/src/argparse/command.rs @@ -0,0 +1,62 @@ +use super::args::*; +use super::{ArgList, Subcommand}; +use failure::{format_err, Fallible}; +use nom::{combinator::*, sequence::*, Err, IResult}; + +/// A command is the overall command that the CLI should execute. +/// +/// It consists of some information common to all commands and a `Subcommand` identifying the +/// particular kind of behavior desired. +#[derive(Debug, PartialEq)] +pub(crate) struct Command { + pub(crate) command_name: String, + pub(crate) subcommand: Subcommand, +} + +impl Command { + pub(super) fn parse(input: ArgList) -> IResult { + fn to_command(input: (&str, Subcommand)) -> Result { + let command = Command { + command_name: input.0.to_owned(), + subcommand: input.1, + }; + Ok(command) + } + map_res( + all_consuming(tuple((arg_matching(any), Subcommand::parse))), + to_command, + )(input) + } + + /// Parse a command from the given list of strings. + pub fn from_argv(argv: &[&str]) -> Fallible { + match Command::parse(argv) { + Ok((&[], cmd)) => Ok(cmd), + Ok((trailing, _)) => Err(format_err!( + "command line has trailing arguments: {:?}", + trailing + )), + Err(Err::Incomplete(_)) => unreachable!(), + Err(Err::Error(e)) => Err(format_err!("command line not recognized: {:?}", e)), + Err(Err::Failure(e)) => Err(format_err!("command line not recognized: {:?}", e)), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + // NOTE: most testing of specific subcommands is handled in `subcommand.rs`. + + #[test] + fn test_version() { + assert_eq!( + Command::from_argv(argv!["task", "version"]).unwrap(), + Command { + subcommand: Subcommand::Version, + command_name: "task".to_owned(), + } + ); + } +} diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs new file mode 100644 index 000000000..24e0519eb --- /dev/null +++ b/cli/src/argparse/filter.rs @@ -0,0 +1,105 @@ +use super::args::{arg_matching, id_list}; +use super::ArgList; +use nom::{combinator::*, multi::fold_many0, IResult}; + +/// A filter represents a selection of a particular set of tasks. +#[derive(Debug, PartialEq, Default, Clone)] +pub(crate) struct Filter { + /// A list of numeric IDs or prefixes of UUIDs + pub(crate) id_list: Option>, +} + +enum FilterArg { + IdList(Vec), +} + +impl Filter { + pub(super) fn parse(input: ArgList) -> IResult { + fn fold(mut acc: Filter, mod_arg: FilterArg) -> Filter { + match mod_arg { + FilterArg::IdList(mut id_list) => { + if let Some(ref mut existing) = acc.id_list { + // given multiple ID lists, concatenate them to represent + // an "OR" between them. + existing.append(&mut id_list); + } else { + acc.id_list = Some(id_list); + } + } + } + acc + } + fold_many0( + Self::id_list, + Filter { + ..Default::default() + }, + fold, + )(input) + } + + fn id_list(input: ArgList) -> IResult { + fn to_filterarg(mut input: Vec<&str>) -> Result { + Ok(FilterArg::IdList( + input.drain(..).map(str::to_owned).collect(), + )) + } + map_res(arg_matching(id_list), to_filterarg)(input) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_empty() { + let (input, filter) = Filter::parse(argv![]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_id_list_single() { + let (input, filter) = Filter::parse(argv!["1"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + id_list: Some(vec!["1".to_owned()]), + ..Default::default() + } + ); + } + + #[test] + fn test_id_list_commas() { + let (input, filter) = Filter::parse(argv!["1,2,3"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + id_list: Some(vec!["1".to_owned(), "2".to_owned(), "3".to_owned()]), + ..Default::default() + } + ); + } + + #[test] + fn test_id_list_uuids() { + let (input, filter) = Filter::parse(argv!["1,abcd1234"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + id_list: Some(vec!["1".to_owned(), "abcd1234".to_owned()]), + ..Default::default() + } + ); + } +} diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs new file mode 100644 index 000000000..428f7bb0a --- /dev/null +++ b/cli/src/argparse/mod.rs @@ -0,0 +1,33 @@ +/*! + +This module is responsible for parsing command lines (`Arglist`, an alias for `&[&str]`) into `Command` instances. +It removes some redundancy from the command line, for example combining the multiple ways to modify a task into a single `Modification` struct. + +The module is organized as a nom parser over ArgList, and each struct has a `parse` method to parse such a list. + +The exception to this rule is the `args` sub-module, which contains string parsers that are applied to indivdual command-line elements. + +All of the structs produced by this module are fully-owned, data-only structs. +That is, they contain no references, and have no methods to aid in their execution -- that is the `invocation` module's job. + +*/ +mod args; +mod command; +mod filter; +mod modification; +mod report; +mod subcommand; + +pub(crate) use command::Command; +pub(crate) use filter::Filter; +pub(crate) use modification::{DescriptionMod, Modification}; +pub(crate) use report::Report; +pub(crate) use subcommand::Subcommand; + +use crate::usage::Usage; + +type ArgList<'a> = &'a [&'a str]; + +pub(crate) fn get_usage(usage: &mut Usage) { + Subcommand::get_usage(usage); +} diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs new file mode 100644 index 000000000..6778ba30b --- /dev/null +++ b/cli/src/argparse/modification.rs @@ -0,0 +1,119 @@ +use super::args::{any, arg_matching}; +use super::ArgList; +use nom::{combinator::*, multi::fold_many0, IResult}; +use taskchampion::Status; + +#[derive(Debug, PartialEq, Clone)] +pub enum DescriptionMod { + /// do not change the description + None, + + /// Prepend the given value to the description, with a space separator + Prepend(String), + + /// Append the given value to the description, with a space separator + Append(String), + + /// Set the description + Set(String), +} + +impl Default for DescriptionMod { + fn default() -> Self { + Self::None + } +} + +/// A modification represents a change to a task: adding or removing tags, setting the +/// description, and so on. +#[derive(Debug, PartialEq, Clone, Default)] +pub struct Modification { + /// Change the description + pub description: DescriptionMod, + + /// Set the status + pub status: Option, + + /// Set the "active" status, that is, start (true) or stop (false) the task. + pub active: Option, +} + +/// A single argument that is part of a modification, used internally to this module +enum ModArg<'a> { + Description(&'a str), +} + +impl Modification { + pub(super) fn parse(input: ArgList) -> IResult { + fn fold(mut acc: Modification, mod_arg: ModArg) -> Modification { + match mod_arg { + ModArg::Description(description) => { + if let DescriptionMod::Set(existing) = acc.description { + acc.description = + DescriptionMod::Set(format!("{} {}", existing, description)); + } else { + acc.description = DescriptionMod::Set(description.to_string()); + } + } + } + acc + } + fold_many0( + Self::description, + Modification { + ..Default::default() + }, + fold, + )(input) + } + + fn description(input: ArgList) -> IResult { + fn to_modarg(input: &str) -> Result { + Ok(ModArg::Description(input)) + } + map_res(arg_matching(any), to_modarg)(input) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_empty() { + let (input, modification) = Modification::parse(argv![]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + ..Default::default() + } + ); + } + + #[test] + fn test_single_arg_description() { + let (input, modification) = Modification::parse(argv!["newdesc"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + description: DescriptionMod::Set("newdesc".to_owned()), + ..Default::default() + } + ); + } + + #[test] + fn test_multi_arg_description() { + let (input, modification) = Modification::parse(argv!["new", "desc", "fun"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + description: DescriptionMod::Set("new desc fun".to_owned()), + ..Default::default() + } + ); + } +} diff --git a/cli/src/argparse/report.rs b/cli/src/argparse/report.rs new file mode 100644 index 000000000..b800cafb6 --- /dev/null +++ b/cli/src/argparse/report.rs @@ -0,0 +1,33 @@ +use super::{ArgList, Filter}; +use nom::IResult; + +/// A report specifies a filter as well as a sort order and information about which +/// task attributes to display +#[derive(Debug, PartialEq, Default)] +pub(crate) struct Report { + pub filter: Filter, +} + +impl Report { + pub(super) fn parse(input: ArgList) -> IResult { + let (input, filter) = Filter::parse(input)?; + Ok((input, Report { filter })) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_empty() { + let (input, report) = Report::parse(argv![]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + report, + Report { + ..Default::default() + } + ); + } +} diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs new file mode 100644 index 000000000..2ebf60644 --- /dev/null +++ b/cli/src/argparse/subcommand.rs @@ -0,0 +1,699 @@ +use super::args::*; +use super::{ArgList, DescriptionMod, Filter, Modification, Report}; +use crate::usage; +use nom::{branch::alt, combinator::*, sequence::*, IResult}; +use taskchampion::Status; +use textwrap::dedent; + +// IMPLEMENTATION NOTE: +// +// For each variant of Subcommand, there is a private, empty type of the same name with a `parse` +// method and a `get_usage` method. The parse methods may handle several subcommands, but always +// produce the variant of the same name as the type. +// +// This organization helps to gather the parsing and usage information into +// comprehensible chunks of code, to ensure that everything is documented. + +/// A subcommand is the specific operation that the CLI should execute. +#[derive(Debug, PartialEq)] +pub(crate) enum Subcommand { + /// Display the tool version + Version, + + /// Display the help output + Help { + /// Give the summary help (fitting on a few lines) + summary: bool, + }, + + /// Add a new task + Add { + modification: Modification, + }, + + /// Modify existing tasks + Modify { + filter: Filter, + modification: Modification, + }, + + /// Lists (reports) + List { + report: Report, + }, + + /// Per-task information (typically one task) + Info { + filter: Filter, + debug: bool, + }, + + /// Basic operations without args + Gc, + Sync, +} + +impl Subcommand { + pub(super) fn parse(input: ArgList) -> IResult { + alt(( + Version::parse, + Help::parse, + Add::parse, + Modify::parse, + List::parse, + Info::parse, + Gc::parse, + Sync::parse, + ))(input) + } + + pub(super) fn get_usage(u: &mut usage::Usage) { + Version::get_usage(u); + Help::get_usage(u); + Add::get_usage(u); + Modify::get_usage(u); + List::get_usage(u); + Info::get_usage(u); + Gc::get_usage(u); + Sync::get_usage(u); + } +} + +struct Version; + +impl Version { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Version) + } + map_res( + alt(( + arg_matching(literal("version")), + arg_matching(literal("--version")), + )), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "version".to_owned(), + syntax: "version".to_owned(), + summary: "Show the TaskChampion version".to_owned(), + description: "Show the version of the TaskChampion binary".to_owned(), + }); + } +} + +struct Help; + +impl Help { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: &str) -> Result { + Ok(Subcommand::Help { + summary: input == "-h", + }) + } + map_res( + alt(( + arg_matching(literal("help")), + arg_matching(literal("--help")), + arg_matching(literal("-h")), + )), + to_subcommand, + )(input) + } + + fn get_usage(_u: &mut usage::Usage) {} +} + +struct Add; + +impl Add { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, Modification)) -> Result { + Ok(Subcommand::Add { + modification: input.1, + }) + } + map_res( + pair(arg_matching(literal("add")), Modification::parse), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "add".to_owned(), + syntax: "add [modification]".to_owned(), + summary: "Add a new task".to_owned(), + description: dedent( + " + Add a new, pending task to the list of tasks. The modification must include a + description.", + ), + }); + } +} + +struct Modify; + +impl Modify { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (Filter, &str, Modification)) -> Result { + let filter = input.0; + let mut modification = input.2; + + match input.1 { + "prepend" => { + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::Prepend(s) + } + } + "append" => { + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::Append(s) + } + } + "start" => modification.active = Some(true), + "stop" => modification.active = Some(false), + "done" => modification.status = Some(Status::Completed), + _ => {} + } + + Ok(Subcommand::Modify { + filter, + modification, + }) + } + map_res( + tuple(( + Filter::parse, + alt(( + arg_matching(literal("modify")), + arg_matching(literal("prepend")), + arg_matching(literal("append")), + arg_matching(literal("start")), + arg_matching(literal("stop")), + arg_matching(literal("done")), + )), + Modification::parse, + )), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "modify".to_owned(), + syntax: "[filter] modify [modification]".to_owned(), + summary: "Modify tasks".to_owned(), + description: dedent( + " + Modify all tasks matching the filter.", + ), + }); + u.subcommands.push(usage::Subcommand { + name: "prepend".to_owned(), + syntax: "[filter] prepend [modification]".to_owned(), + summary: "Prepend task description".to_owned(), + description: dedent( + " + Modify all tasks matching the filter by inserting the given description before each + task's description.", + ), + }); + u.subcommands.push(usage::Subcommand { + name: "append".to_owned(), + syntax: "[filter] append [modification]".to_owned(), + summary: "Append task description".to_owned(), + description: dedent( + " + Modify all tasks matching the filter by adding the given description to the end + of each task's description.", + ), + }); + u.subcommands.push(usage::Subcommand { + name: "start".to_owned(), + syntax: "[filter] start [modification]".to_owned(), + summary: "Start tasks".to_owned(), + description: dedent( + " + Start all tasks matching the filter, additionally applying any given modifications."), + }); + u.subcommands.push(usage::Subcommand { + name: "stop".to_owned(), + syntax: "[filter] start [modification]".to_owned(), + summary: "Stop tasks".to_owned(), + description: dedent( + " + Stop all tasks matching the filter, additionally applying any given modifications.", + ), + }); + u.subcommands.push(usage::Subcommand { + name: "done".to_owned(), + syntax: "[filter] start [modification]".to_owned(), + summary: "Mark tasks as completed".to_owned(), + description: dedent( + " + Mark all tasks matching the filter as completed, additionally applying any given + modifications.", + ), + }); + } +} + +struct List; + +impl List { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (Report, &str)) -> Result { + Ok(Subcommand::List { report: input.0 }) + } + map_res( + pair(Report::parse, arg_matching(literal("list"))), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "list".to_owned(), + syntax: "[filter] list".to_owned(), + summary: "List tasks".to_owned(), + description: dedent( + " + Show a list of the tasks matching the filter", + ), + }); + } +} + +struct Info; + +impl Info { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (Filter, &str)) -> Result { + let debug = input.1 == "debug"; + Ok(Subcommand::Info { + filter: input.0, + debug, + }) + } + map_res( + pair( + Filter::parse, + alt(( + arg_matching(literal("info")), + arg_matching(literal("debug")), + )), + ), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "info".to_owned(), + syntax: "[filter] info".to_owned(), + summary: "Show tasks".to_owned(), + description: dedent( + " + Show information about all tasks matching the fiter.", + ), + }); + u.subcommands.push(usage::Subcommand { + name: "debug".to_owned(), + syntax: "[filter] debug".to_owned(), + summary: "Show task debug details".to_owned(), + description: dedent( + " + Show all key/value properties of the tasks matching the fiter.", + ), + }); + } +} + +struct Gc; + +impl Gc { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Gc) + } + map_res(arg_matching(literal("gc")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "gc".to_owned(), + syntax: "gc".to_owned(), + summary: "Perform 'garbage collection'".to_owned(), + description: dedent( + " + Perform 'garbage collection'. This refreshes the list of pending tasks + and their short id's.", + ), + }); + } +} + +struct Sync; + +impl Sync { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Sync) + } + map_res(arg_matching(literal("sync")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "sync".to_owned(), + syntax: "sync".to_owned(), + summary: "Synchronize this replica".to_owned(), + description: dedent( + " + Synchronize this replica locally or against a remote server, as configured. + + Synchronization is a critical part of maintaining the task database, and should + be done regularly, even if only locally. It is typically run in a crontask.", + ), + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + + const EMPTY: Vec<&str> = vec![]; + + #[test] + fn test_version() { + assert_eq!( + Subcommand::parse(argv!["version"]).unwrap(), + (&EMPTY[..], Subcommand::Version) + ); + } + + #[test] + fn test_dd_version() { + assert_eq!( + Subcommand::parse(argv!["--version"]).unwrap(), + (&EMPTY[..], Subcommand::Version) + ); + } + + #[test] + fn test_d_h() { + assert_eq!( + Subcommand::parse(argv!["-h"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: true }) + ); + } + + #[test] + fn test_help() { + assert_eq!( + Subcommand::parse(argv!["help"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: false }) + ); + } + + #[test] + fn test_dd_help() { + assert_eq!( + Subcommand::parse(argv!["--help"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: false }) + ); + } + + #[test] + fn test_add_description() { + let subcommand = Subcommand::Add { + modification: Modification { + description: DescriptionMod::Set("foo".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["add", "foo"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_add_description_multi() { + let subcommand = Subcommand::Add { + modification: Modification { + description: DescriptionMod::Set("foo bar".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["add", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_modify_description_multi() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + description: DescriptionMod::Set("foo bar".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "modify", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_append() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + description: DescriptionMod::Append("foo bar".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "append", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_prepend() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + description: DescriptionMod::Prepend("foo bar".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "prepend", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_done() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + status: Some(Status::Completed), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "done"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_done_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + description: DescriptionMod::Set("now-finished".to_owned()), + status: Some(Status::Completed), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "done", "now-finished"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_start() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + active: Some(true), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "start"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_start_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + active: Some(true), + description: DescriptionMod::Set("mod".to_owned()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "start", "mod"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_stop() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + active: Some(false), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "stop"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_stop_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + id_list: Some(vec!["123".to_owned()]), + }, + modification: Modification { + description: DescriptionMod::Set("mod".to_owned()), + active: Some(false), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "stop", "mod"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_list() { + let subcommand = Subcommand::List { + report: Report { + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["list"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_list_filter() { + let subcommand = Subcommand::List { + report: Report { + filter: Filter { + id_list: Some(vec!["12".to_owned(), "13".to_owned()]), + }, + }, + }; + assert_eq!( + Subcommand::parse(argv!["12,13", "list"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_info_filter() { + let subcommand = Subcommand::Info { + debug: false, + filter: Filter { + id_list: Some(vec!["12".to_owned(), "13".to_owned()]), + }, + }; + assert_eq!( + Subcommand::parse(argv!["12,13", "info"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_debug_filter() { + let subcommand = Subcommand::Info { + debug: true, + filter: Filter { + id_list: Some(vec!["12".to_owned()]), + }, + }; + assert_eq!( + Subcommand::parse(argv!["12", "debug"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_gc() { + let subcommand = Subcommand::Gc; + assert_eq!( + Subcommand::parse(argv!["gc"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_gc_extra_args() { + let subcommand = Subcommand::Gc; + assert_eq!( + Subcommand::parse(argv!["gc", "foo"]).unwrap(), + (&vec!["foo"][..], subcommand) + ); + } + + #[test] + fn test_sync() { + let subcommand = Subcommand::Sync; + assert_eq!( + Subcommand::parse(argv!["sync"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } +} diff --git a/cli/src/bin/task.rs b/cli/src/bin/task.rs index ab21c54bc..8d8a756cb 100644 --- a/cli/src/bin/task.rs +++ b/cli/src/bin/task.rs @@ -1,44 +1,8 @@ -use clap::{Error as ClapError, ErrorKind}; use std::process::exit; -use taskchampion_cli::parse_command_line; -enum Output { - Stdout, - Stderr, -} -use Output::*; - -fn bail(err: E, output: Output, code: i32) -> ! { - match output { - Stdout => println!("{}", err), - Stderr => eprintln!("{}", err), - } - exit(code) -} - -fn main() { - env_logger::init(); - let command = match parse_command_line(std::env::args_os()) { - Ok(command) => command, - Err(err) => { - match err.downcast::() { - 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) +pub fn main() { + if let Err(err) = taskchampion_cli::main() { + eprintln!("{}", err); + exit(1); } } diff --git a/cli/src/cmd/add.rs b/cli/src/cmd/add.rs deleted file mode 100644 index 88e7a76f5..000000000 --- a/cli/src/cmd/add.rs +++ /dev/null @@ -1,59 +0,0 @@ -use clap::{App, Arg, ArgMatches, SubCommand as ClapSubCommand}; -use failure::{format_err, Fallible}; -use taskchampion::Status; - -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 t = command - .get_replica()? - .new_task(Status::Pending, self.description.clone()) - .unwrap(); - println!("added task {}", t.get_uuid()); - 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()); - }); - } -} diff --git a/cli/src/cmd/append.rs b/cli/src/cmd/append.rs deleted file mode 100644 index e32470475..000000000 --- a/cli/src/cmd/append.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::cmd::shared; -use clap::{App, Arg, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; - -use crate::cmd::{ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, - description: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("append").about("appends to a task description") - .arg(shared::task_arg()) - .arg( - Arg::with_name("description") - .help("extra task description") - .required(true), - ), - ) - } - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("append", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - description: matches.value_of("description").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } - -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - - let mut task = task.into_mut(&mut replica); - - let new_description = format!("{} {}", task.get_description(), self.description.clone()); - task.set_description(new_description)?; - println!("appended to task {}", task.get_uuid()); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!( - vec!["task", "append", "1", "foo bar"], - |inv: &Invocation| { - assert_eq!(inv.description, "foo bar".to_string()); - } - ); - } -} diff --git a/cli/src/cmd/debug.rs b/cli/src/cmd/debug.rs deleted file mode 100644 index 8ebee75c6..000000000 --- a/cli/src/cmd/debug.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::table; -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use prettytable::{cell, row, Table}; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("debug") - .about("debug info for the given task") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("debug", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - - let mut t = Table::new(); - t.set_format(table::format()); - t.set_titles(row![b->"key", b->"value"]); - for (k, v) in task.get_taskmap().iter() { - t.add_row(row![k, v]); - } - t.printstd(); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "debug", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/delete.rs b/cli/src/cmd/delete.rs deleted file mode 100644 index 67acf4693..000000000 --- a/cli/src/cmd/delete.rs +++ /dev/null @@ -1,51 +0,0 @@ -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use taskchampion::Status; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("delete") - .about("mark the given task as deleted") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("delete", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - let mut task = task.into_mut(&mut replica); - task.stop()?; - task.set_status(Status::Deleted)?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "delete", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/done.rs b/cli/src/cmd/done.rs deleted file mode 100644 index 713a747a8..000000000 --- a/cli/src/cmd/done.rs +++ /dev/null @@ -1,51 +0,0 @@ -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use taskchampion::Status; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("done") - .about("finish the given task (status Completed)") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("done", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - let mut task = task.into_mut(&mut replica); - task.stop()?; - task.set_status(Status::Completed)?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "done", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/gc.rs b/cli/src/cmd/gc.rs deleted file mode 100644 index 15bdd288b..000000000 --- a/cli/src/cmd/gc.rs +++ /dev/null @@ -1,37 +0,0 @@ -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| {}); - } -} diff --git a/cli/src/cmd/info.rs b/cli/src/cmd/info.rs deleted file mode 100644 index 919e47a76..000000000 --- a/cli/src/cmd/info.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::table; -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use prettytable::{cell, row, Table}; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("info") - .about("info on the given task") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("info", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - let uuid = task.get_uuid(); - - let mut t = Table::new(); - t.set_format(table::format()); - t.add_row(row![b->"Uuid", uuid]); - if let Some(i) = replica.get_working_set_index(uuid)? { - t.add_row(row![b->"Id", i]); - } - t.add_row(row![b->"Description", task.get_description()]); - t.add_row(row![b->"Status", task.get_status()]); - t.add_row(row![b->"Active", task.is_active()]); - t.printstd(); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "info", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/list.rs b/cli/src/cmd/list.rs deleted file mode 100644 index 7b1b5078d..000000000 --- a/cli/src/cmd/list.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::table; -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use prettytable::{cell, row, Table}; -use taskchampion::Status; - -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<()> { - let mut replica = command.get_replica()?; - let mut t = Table::new(); - t.set_format(table::format()); - t.set_titles(row![b->"id", b->"act", b->"description"]); - for (uuid, task) in replica.all_tasks().unwrap() { - if task.get_status() != Status::Pending { - continue; - } - let mut id = uuid.to_string(); - if let Some(i) = replica.get_working_set_index(&uuid)? { - id = i.to_string(); - } - let active = match task.is_active() { - true => "*", - false => "", - }; - t.add_row(row![id, active, task.get_description()]); - } - t.printstd(); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "list"], |_inv| {}); - } -} diff --git a/cli/src/cmd/macros.rs b/cli/src/cmd/macros.rs deleted file mode 100644 index e0fbcd47c..000000000 --- a/cli/src/cmd/macros.rs +++ /dev/null @@ -1,45 +0,0 @@ -/// 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 { - 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::() - .expect("SubComand is not of the expected type"); - ($check)(si); - }; -} diff --git a/cli/src/cmd/mod.rs b/cli/src/cmd/mod.rs deleted file mode 100644 index e4e3851ea..000000000 --- a/cli/src/cmd/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -use clap::{App, ArgMatches}; -use failure::{Error, Fallible}; - -#[macro_use] -mod macros; -mod shared; - -mod add; -mod append; -mod debug; -mod delete; -mod done; -mod gc; -mod info; -mod list; -mod modify; -mod pending; -mod prepend; -mod start; -mod stop; -mod sync; - -/// Get a list of all subcommands in this crate -pub(crate) fn subcommands() -> Vec> { - vec![ - add::cmd(), - append::cmd(), - debug::cmd(), - delete::cmd(), - done::cmd(), - gc::cmd(), - info::cmd(), - list::cmd(), - modify::cmd(), - pending::cmd(), - prepend::cmd(), - start::cmd(), - stop::cmd(), - sync::cmd(), - ] -} - -/// The result of a [`crate::cmd::SubCommand::arg_match`] call -pub(crate) enum ArgMatchResult { - /// No match - None, - - /// A good match - Ok(Box), - - /// 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; -} - -pub use shared::CommandInvocation; diff --git a/cli/src/cmd/modify.rs b/cli/src/cmd/modify.rs deleted file mode 100644 index 662941e34..000000000 --- a/cli/src/cmd/modify.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::cmd::shared; -use clap::{App, Arg, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; - -use crate::cmd::{ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, - description: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("modify").about("modifies a task") - .arg(shared::task_arg()) - .arg( - Arg::with_name("description") - .help("task description") - .required(true), - ), - ) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("modify", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - description: matches.value_of("description").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - - let mut task = task.into_mut(&mut replica); - task.set_description(self.description.clone())?; - println!("modified task {}", task.get_uuid()); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!( - vec!["task", "modify", "2", "foo bar"], - |inv: &Invocation| { - assert_eq!(inv.task, "2".to_string()); - assert_eq!(inv.description, "foo bar".to_string()); - } - ); - } -} diff --git a/cli/src/cmd/pending.rs b/cli/src/cmd/pending.rs deleted file mode 100644 index f4f99974a..000000000 --- a/cli/src/cmd/pending.rs +++ /dev/null @@ -1,59 +0,0 @@ -use crate::table; -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; -use prettytable::{cell, row, Table}; - -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(); - let mut t = Table::new(); - t.set_format(table::format()); - t.set_titles(row![b->"id", b->"act", b->"description"]); - for (i, item) in working_set.iter().enumerate() { - if let Some(ref task) = item { - let active = match task.is_active() { - true => "*", - false => "", - }; - t.add_row(row![i, active, task.get_description()]); - } - } - t.printstd(); - 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| {}); - } -} diff --git a/cli/src/cmd/prepend.rs b/cli/src/cmd/prepend.rs deleted file mode 100644 index f827ac33a..000000000 --- a/cli/src/cmd/prepend.rs +++ /dev/null @@ -1,64 +0,0 @@ -use crate::cmd::shared; -use clap::{App, Arg, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; - -use crate::cmd::{ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, - description: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("prepend").about("add to the start of a task description") - .arg(shared::task_arg()) - .arg( - Arg::with_name("description") - .help("extra task description") - .required(true), - ), - ) - } - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("prepend", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - description: matches.value_of("description").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } - -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - - let mut task = task.into_mut(&mut replica); - - let new_description = format!("{} {}", self.description.clone(), task.get_description()); - task.set_description(new_description)?; - println!("prepended to task {}", task.get_uuid()); - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!( - vec!["task", "prepend", "1", "foo bar"], - |inv: &Invocation| { - assert_eq!(inv.description, "foo bar".to_string()); - } - ); - } -} diff --git a/cli/src/cmd/shared.rs b/cli/src/cmd/shared.rs deleted file mode 100644 index 006b08ac5..000000000 --- a/cli/src/cmd/shared.rs +++ /dev/null @@ -1,87 +0,0 @@ -use crate::settings; -use clap::Arg; -use config::{Config, ConfigError}; -use failure::{format_err, Fallible}; -use std::cell::{Ref, RefCell}; -use taskchampion::{server, Replica, ReplicaConfig, ServerConfig, Task, Uuid}; - -pub(super) fn task_arg<'a>() -> Arg<'a, 'a> { - Arg::with_name("task") - .help("task id or uuid") - .required(true) -} - -pub(super) fn get_task>(replica: &mut Replica, task_arg: S) -> Fallible { - let task_arg = task_arg.as_ref(); - - // first try treating task as a working-set reference - if let Ok(i) = task_arg.parse::() { - if let Some(task) = replica.get_working_set_task(i)? { - return Ok(task); - } - } - - if let Ok(uuid) = Uuid::parse_str(task_arg) { - if let Some(task) = replica.get_task(&uuid)? { - return Ok(task); - } - } - - Err(format_err!("Cannot interpret {:?} as a task", task_arg)) -} - -/// A command invocation contains all of the necessary regarding a single invocation of the CLI. -#[derive(Debug)] -pub struct CommandInvocation { - pub(crate) subcommand: Box, - settings: RefCell, -} - -impl CommandInvocation { - pub(crate) fn new(subcommand: Box) -> Self { - Self { - subcommand, - settings: RefCell::new(Config::default()), - } - } - - pub fn run(self) -> Fallible<()> { - self.subcommand.run(&self) - } - - // -- utilities for command invocations - - pub(super) fn get_settings(&self) -> Fallible> { - { - // use the special `_loaded" config value to detect whether we have - // loaded the configuration yet - let mut settings = self.settings.borrow_mut(); - if let Err(ConfigError::NotFound(_)) = settings.get_bool("_loaded") { - settings.merge(settings::read_settings()?)?; - settings.set("_loaded", true)?; - } - } - Ok(self.settings.borrow()) - } - - pub(super) fn get_replica(&self) -> Fallible { - let settings = self.get_settings()?; - let taskdb_dir = settings.get_str("data_dir")?.into(); - log::debug!("Replica data_dir: {:?}", taskdb_dir); - let replica_config = ReplicaConfig { taskdb_dir }; - Ok(Replica::from_config(replica_config)?) - } - - pub(super) fn get_server(&self) -> Fallible> { - let settings = self.get_settings()?; - let client_id = settings.get_str("server_client_id")?; - let client_id = Uuid::parse_str(&client_id)?; - let origin = settings.get_str("server_origin")?; - log::debug!("Using sync-server with origin {}", origin); - log::debug!("Sync client ID: {}", client_id); - Ok(server::from_config(ServerConfig::Remote { - origin, - client_id, - })?) - } -} diff --git a/cli/src/cmd/start.rs b/cli/src/cmd/start.rs deleted file mode 100644 index 5e09a5c13..000000000 --- a/cli/src/cmd/start.rs +++ /dev/null @@ -1,48 +0,0 @@ -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("start") - .about("start the given task") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("start", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - task.into_mut(&mut replica).start()?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "start", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/stop.rs b/cli/src/cmd/stop.rs deleted file mode 100644 index baf278734..000000000 --- a/cli/src/cmd/stop.rs +++ /dev/null @@ -1,48 +0,0 @@ -use clap::{App, ArgMatches, SubCommand as ClapSubCommand}; -use failure::Fallible; - -use crate::cmd::{shared, ArgMatchResult, CommandInvocation}; - -#[derive(Debug)] -struct Invocation { - task: String, -} - -define_subcommand! { - fn decorate_app<'a>(&self, app: App<'a, 'a>) -> App<'a, 'a> { - app.subcommand( - ClapSubCommand::with_name("stop") - .about("stop the given task") - .arg(shared::task_arg())) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("stop", Some(matches)) => ArgMatchResult::Ok(Box::new(Invocation { - task: matches.value_of("task").unwrap().into(), - })), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let task = shared::get_task(&mut replica, &self.task)?; - task.into_mut(&mut replica).stop()?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "stop", "1"], |inv: &Invocation| { - assert_eq!(inv.task, "1".to_string()); - }); - } -} diff --git a/cli/src/cmd/sync.rs b/cli/src/cmd/sync.rs deleted file mode 100644 index 37c3a76d6..000000000 --- a/cli/src/cmd/sync.rs +++ /dev/null @@ -1,39 +0,0 @@ -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("sync").about("sync with the server")) - } - - fn arg_match<'a>(&self, matches: &ArgMatches<'a>) -> ArgMatchResult { - match matches.subcommand() { - ("sync", _) => ArgMatchResult::Ok(Box::new(Invocation {})), - _ => ArgMatchResult::None, - } - } -} - -subcommand_invocation! { - fn run(&self, command: &CommandInvocation) -> Fallible<()> { - let mut replica = command.get_replica()?; - let mut server = command.get_server()?; - replica.sync(&mut server)?; - Ok(()) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn parse_command() { - with_subcommand_invocation!(vec!["task", "sync"], |_inv| {}); - } -} diff --git a/cli/src/invocation/cmd/add.rs b/cli/src/invocation/cmd/add.rs new file mode 100644 index 000000000..79cb5dd4b --- /dev/null +++ b/cli/src/invocation/cmd/add.rs @@ -0,0 +1,42 @@ +use crate::argparse::{DescriptionMod, Modification}; +use failure::Fallible; +use taskchampion::{Replica, Status}; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + modification: Modification, +) -> Fallible<()> { + let description = match modification.description { + DescriptionMod::Set(ref s) => s.clone(), + _ => "(no description)".to_owned(), + }; + let t = replica.new_task(Status::Pending, description).unwrap(); + write!(w, "added task {}\n", t.get_uuid())?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + + #[test] + fn test_add() { + let mut w = test_writer(); + let mut replica = test_replica(); + let modification = Modification { + description: DescriptionMod::Set("my description".to_owned()), + ..Default::default() + }; + execute(&mut w, &mut replica, modification).unwrap(); + + // check that the task appeared.. + let task = replica.get_working_set_task(1).unwrap().unwrap(); + assert_eq!(task.get_description(), "my description"); + assert_eq!(task.get_status(), Status::Pending); + + assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid())); + } +} diff --git a/cli/src/invocation/cmd/gc.rs b/cli/src/invocation/cmd/gc.rs new file mode 100644 index 000000000..644974eb5 --- /dev/null +++ b/cli/src/invocation/cmd/gc.rs @@ -0,0 +1,23 @@ +use failure::Fallible; +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Fallible<()> { + replica.gc()?; + write!(w, "garbage collected.\n")?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + + #[test] + fn test_gc() { + let mut w = test_writer(); + let mut replica = test_replica(); + execute(&mut w, &mut replica).unwrap(); + assert_eq!(&w.into_string(), "garbage collected.\n") + } +} diff --git a/cli/src/invocation/cmd/help.rs b/cli/src/invocation/cmd/help.rs new file mode 100644 index 000000000..a95e8415f --- /dev/null +++ b/cli/src/invocation/cmd/help.rs @@ -0,0 +1,31 @@ +use crate::usage::Usage; +use failure::Fallible; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + command_name: String, + summary: bool, +) -> Fallible<()> { + let usage = Usage::new(); + usage.write_help(w, command_name, summary)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + + #[test] + fn test_summary() { + let mut w = test_writer(); + execute(&mut w, "task".to_owned(), true).unwrap(); + } + + #[test] + fn test_long() { + let mut w = test_writer(); + execute(&mut w, "task".to_owned(), false).unwrap(); + } +} diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs new file mode 100644 index 000000000..852c8faa9 --- /dev/null +++ b/cli/src/invocation/cmd/info.rs @@ -0,0 +1,61 @@ +use crate::argparse::Filter; +use crate::invocation::filtered_tasks; +use crate::table; +use failure::Fallible; +use prettytable::{cell, row, Table}; +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + filter: Filter, + debug: bool, +) -> Fallible<()> { + for task in filtered_tasks(replica, &filter)? { + let uuid = task.get_uuid(); + + let mut t = Table::new(); + t.set_format(table::format()); + if debug { + t.set_titles(row![b->"key", b->"value"]); + for (k, v) in task.get_taskmap().iter() { + t.add_row(row![k, v]); + } + } else { + t.add_row(row![b->"Uuid", uuid]); + if let Some(i) = replica.get_working_set_index(uuid)? { + t.add_row(row![b->"Id", i]); + } + t.add_row(row![b->"Description", task.get_description()]); + t.add_row(row![b->"Status", task.get_status()]); + t.add_row(row![b->"Active", task.is_active()]); + } + t.print(w)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + use taskchampion::Status; + + #[test] + fn test_info() { + let mut w = test_writer(); + let mut replica = test_replica(); + replica + .new_task(Status::Pending, "my task".to_owned()) + .unwrap(); + + let filter = Filter { + ..Default::default() + }; + let debug = false; + execute(&mut w, &mut replica, filter, debug).unwrap(); + assert!(w.into_string().contains("my task")); + } +} diff --git a/cli/src/invocation/cmd/list.rs b/cli/src/invocation/cmd/list.rs new file mode 100644 index 000000000..4afcdab93 --- /dev/null +++ b/cli/src/invocation/cmd/list.rs @@ -0,0 +1,56 @@ +use crate::argparse::Report; +use crate::invocation::filtered_tasks; +use crate::table; +use failure::Fallible; +use prettytable::{cell, row, Table}; +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + report: Report, +) -> Fallible<()> { + let mut t = Table::new(); + t.set_format(table::format()); + t.set_titles(row![b->"id", b->"act", b->"description"]); + for task in filtered_tasks(replica, &report.filter)? { + let uuid = task.get_uuid(); + let mut id = uuid.to_string(); + if let Some(i) = replica.get_working_set_index(&uuid)? { + id = i.to_string(); + } + let active = match task.is_active() { + true => "*", + false => "", + }; + t.add_row(row![id, active, task.get_description()]); + } + t.print(w)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::Filter; + use crate::invocation::cmd::test::*; + use taskchampion::Status; + + #[test] + fn test_list() { + let mut w = test_writer(); + let mut replica = test_replica(); + replica + .new_task(Status::Pending, "my task".to_owned()) + .unwrap(); + + let report = Report { + filter: Filter { + ..Default::default() + }, + }; + execute(&mut w, &mut replica, report).unwrap(); + assert!(w.into_string().contains("my task")); + } +} diff --git a/cli/src/invocation/cmd/mod.rs b/cli/src/invocation/cmd/mod.rs new file mode 100644 index 000000000..1637968ef --- /dev/null +++ b/cli/src/invocation/cmd/mod.rs @@ -0,0 +1,13 @@ +//! Responsible for executing commands as parsed by [`crate::argparse`]. + +pub(crate) mod add; +pub(crate) mod gc; +pub(crate) mod help; +pub(crate) mod info; +pub(crate) mod list; +pub(crate) mod modify; +pub(crate) mod sync; +pub(crate) mod version; + +#[cfg(test)] +mod test; diff --git a/cli/src/invocation/cmd/modify.rs b/cli/src/invocation/cmd/modify.rs new file mode 100644 index 000000000..dadcd9319 --- /dev/null +++ b/cli/src/invocation/cmd/modify.rs @@ -0,0 +1,58 @@ +use crate::argparse::{Filter, Modification}; +use crate::invocation::{apply_modification, filtered_tasks}; +use failure::Fallible; +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + filter: Filter, + modification: Modification, +) -> Fallible<()> { + for task in filtered_tasks(replica, &filter)? { + let mut task = task.into_mut(replica); + + apply_modification(w, &mut task, &modification)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::DescriptionMod; + use crate::invocation::cmd::test::test_replica; + use crate::invocation::cmd::test::*; + use taskchampion::Status; + + #[test] + fn test_modify() { + let mut w = test_writer(); + let mut replica = test_replica(); + + let task = replica + .new_task(Status::Pending, "old description".to_owned()) + .unwrap(); + + let filter = Filter { + ..Default::default() + }; + let modification = Modification { + description: DescriptionMod::Set("new description".to_owned()), + ..Default::default() + }; + execute(&mut w, &mut replica, filter, modification).unwrap(); + + // check that the task appeared.. + let task = replica.get_task(task.get_uuid()).unwrap().unwrap(); + assert_eq!(task.get_description(), "new description"); + assert_eq!(task.get_status(), Status::Pending); + + assert_eq!( + w.into_string(), + format!("modified task {}\n", task.get_uuid()) + ); + } +} diff --git a/cli/src/invocation/cmd/sync.rs b/cli/src/invocation/cmd/sync.rs new file mode 100644 index 000000000..737406ae9 --- /dev/null +++ b/cli/src/invocation/cmd/sync.rs @@ -0,0 +1,32 @@ +use failure::Fallible; +use taskchampion::{server::Server, Replica}; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + server: &mut Box, +) -> Fallible<()> { + replica.sync(server)?; + write!(w, "sync complete.\n")?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + use tempdir::TempDir; + + #[test] + fn test_add() { + let mut w = test_writer(); + let mut replica = test_replica(); + let server_dir = TempDir::new("test").unwrap(); + let mut server = test_server(&server_dir); + + // Note that the details of the actual sync are tested thoroughly in the taskchampion crate + execute(&mut w, &mut replica, &mut server).unwrap(); + assert_eq!(&w.into_string(), "sync complete.\n") + } +} diff --git a/cli/src/invocation/cmd/test.rs b/cli/src/invocation/cmd/test.rs new file mode 100644 index 000000000..8f32723b0 --- /dev/null +++ b/cli/src/invocation/cmd/test.rs @@ -0,0 +1,50 @@ +use std::io; +use taskchampion::{server, taskstorage, Replica, ServerConfig}; +use tempdir::TempDir; + +pub(super) fn test_replica() -> Replica { + let storage = taskstorage::InMemoryStorage::new(); + Replica::new(Box::new(storage)) +} + +pub(super) fn test_server(dir: &TempDir) -> Box { + server::from_config(ServerConfig::Local { + server_dir: dir.path().to_path_buf(), + }) + .unwrap() +} + +pub(super) struct TestWriter { + data: Vec, +} + +impl TestWriter { + pub(super) fn into_string(self) -> String { + String::from_utf8(self.data).unwrap() + } +} + +impl io::Write for TestWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.data.write(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.data.flush() + } +} + +impl termcolor::WriteColor for TestWriter { + fn supports_color(&self) -> bool { + false + } + fn set_color(&mut self, _spec: &termcolor::ColorSpec) -> io::Result<()> { + Ok(()) + } + fn reset(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(super) fn test_writer() -> TestWriter { + TestWriter { data: vec![] } +} diff --git a/cli/src/invocation/cmd/version.rs b/cli/src/invocation/cmd/version.rs new file mode 100644 index 000000000..8a74a7681 --- /dev/null +++ b/cli/src/invocation/cmd/version.rs @@ -0,0 +1,23 @@ +use failure::Fallible; +use termcolor::{ColorSpec, WriteColor}; + +pub(crate) fn execute(w: &mut W) -> Fallible<()> { + write!(w, "TaskChampion ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + write!(w, "{}\n", env!("CARGO_PKG_VERSION"))?; + w.reset()?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::cmd::test::*; + + #[test] + fn test_version() { + let mut w = test_writer(); + execute(&mut w).unwrap(); + assert!(w.into_string().starts_with("TaskChampion ")); + } +} diff --git a/cli/src/invocation/filter.rs b/cli/src/invocation/filter.rs new file mode 100644 index 000000000..b5814dbc4 --- /dev/null +++ b/cli/src/invocation/filter.rs @@ -0,0 +1,38 @@ +use crate::argparse::Filter; +use failure::Fallible; +use taskchampion::{Replica, Task}; + +/// Return the tasks matching the given filter. +pub(super) fn filtered_tasks( + replica: &mut Replica, + filter: &Filter, +) -> Fallible> { + // For the moment, this gets the entire set of tasks and then iterates + // over the result. A few optimizations are possible: + // + // - id_list could be better parsed (id, uuid-fragment, uuid) in argparse + // - depending on the nature of the filter, we could just scan the working set + // - we could produce the tasks on-demand (but at the cost of holding a ref + // to the replica, preventing modifying tasks..) + let mut res = vec![]; + 'task: for (uuid, task) in replica.all_tasks()?.drain() { + if let Some(ref ids) = filter.id_list { + for id in ids { + if let Ok(index) = id.parse::() { + if replica.get_working_set_index(&uuid)? == Some(index) { + res.push(task); + continue 'task; + } + } else if uuid.to_string().starts_with(id) { + res.push(task); + continue 'task; + } + } + } else { + // default to returning all tasks + res.push(task); + continue 'task; + } + } + Ok(res.into_iter()) +} diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs new file mode 100644 index 000000000..18d8000b6 --- /dev/null +++ b/cli/src/invocation/mod.rs @@ -0,0 +1,121 @@ +//! The invocation module handles invoking the commands parsed by the argparse module. + +use crate::argparse::{Command, Subcommand}; +use config::Config; +use failure::Fallible; +use taskchampion::{server, Replica, ReplicaConfig, ServerConfig, Uuid}; +use termcolor::{ColorChoice, StandardStream}; + +mod cmd; +mod filter; +mod modify; + +use filter::filtered_tasks; +use modify::apply_modification; + +/// Invoke the given Command in the context of the given settings +pub(crate) fn invoke(command: Command, settings: Config) -> Fallible<()> { + log::debug!("command: {:?}", command); + log::debug!("settings: {:?}", settings); + + let mut w = get_writer()?; + + // This function examines the command and breaks out the necessary bits to call one of the + // `execute` functions in a submodule of `cmd`. + + // match the subcommands that do not require a replica first, before + // getting the replica + match command { + Command { + subcommand: Subcommand::Help { summary }, + command_name, + } => return cmd::help::execute(&mut w, command_name, summary), + Command { + subcommand: Subcommand::Version, + .. + } => return cmd::version::execute(&mut w), + _ => {} + }; + + let mut replica = get_replica(&settings)?; + match command { + Command { + subcommand: Subcommand::Add { modification }, + .. + } => return cmd::add::execute(&mut w, &mut replica, modification), + + Command { + subcommand: + Subcommand::Modify { + filter, + modification, + }, + .. + } => return cmd::modify::execute(&mut w, &mut replica, filter, modification), + + Command { + subcommand: Subcommand::List { report }, + .. + } => return cmd::list::execute(&mut w, &mut replica, report), + + Command { + subcommand: Subcommand::Info { filter, debug }, + .. + } => return cmd::info::execute(&mut w, &mut replica, filter, debug), + + Command { + subcommand: Subcommand::Gc, + .. + } => return cmd::gc::execute(&mut w, &mut replica), + + Command { + subcommand: Subcommand::Sync, + .. + } => { + let mut server = get_server(&settings)?; + return cmd::sync::execute(&mut w, &mut replica, &mut server); + } + + // handled in the first match, but here to ensure this match is exhaustive + Command { + subcommand: Subcommand::Help { .. }, + .. + } => unreachable!(), + Command { + subcommand: Subcommand::Version, + .. + } => unreachable!(), + } +} + +// utilities for invoke + +/// Get the replica for this invocation +fn get_replica(settings: &Config) -> Fallible { + let taskdb_dir = settings.get_str("data_dir")?.into(); + log::debug!("Replica data_dir: {:?}", taskdb_dir); + let replica_config = ReplicaConfig { taskdb_dir }; + Ok(Replica::from_config(replica_config)?) +} + +/// Get the server for this invocation +fn get_server(settings: &Config) -> Fallible> { + let client_id = settings.get_str("server_client_id")?; + let client_id = Uuid::parse_str(&client_id)?; + let origin = settings.get_str("server_origin")?; + log::debug!("Using sync-server with origin {}", origin); + log::debug!("Sync client ID: {}", client_id); + Ok(server::from_config(ServerConfig::Remote { + origin, + client_id, + })?) +} + +/// Get a WriteColor implementation based on whether the output is a tty. +fn get_writer() -> Fallible { + Ok(StandardStream::stdout(if atty::is(atty::Stream::Stdout) { + ColorChoice::Auto + } else { + ColorChoice::Never + })) +} diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs new file mode 100644 index 000000000..29802dbab --- /dev/null +++ b/cli/src/invocation/modify.rs @@ -0,0 +1,38 @@ +use crate::argparse::{DescriptionMod, Modification}; +use failure::Fallible; +use taskchampion::TaskMut; +use termcolor::WriteColor; + +/// Apply the given modification +pub(super) fn apply_modification( + w: &mut W, + task: &mut TaskMut, + modification: &Modification, +) -> Fallible<()> { + match modification.description { + DescriptionMod::Set(ref description) => task.set_description(description.clone())?, + DescriptionMod::Prepend(ref description) => { + task.set_description(format!("{} {}", description, task.get_description()))? + } + DescriptionMod::Append(ref description) => { + task.set_description(format!("{} {}", task.get_description(), description))? + } + DescriptionMod::None => {} + } + + if let Some(ref status) = modification.status { + task.set_status(status.clone())?; + } + + if let Some(true) = modification.active { + task.start()?; + } + + if let Some(false) = modification.active { + task.stop()?; + } + + write!(w, "modified task {}\n", task.get_uuid())?; + + Ok(()) +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 6c63d1674..01900fbc6 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -1,60 +1,64 @@ -use clap::{App, AppSettings}; +/*! +This crate implements the command-line interface to TaskChampion. + +## Design + +The crate is split into two parts: argument parsing (`argparse`) and command invocation (`invocation`). +Both are fairly complex operations, and the split serves both to isolate that complexity and to facilitate testing. + +### Argparse + +The TaskChampion command line API is modeled on TaskWarrior's API, which is far from that of a typical UNIX command. +Tools like `clap` and `structopt` are not flexible enough to handle this syntax. + +Instead, the `argparse` module uses [nom](https://crates.io/crates/nom) to parse command lines as a sequence of words. +These parsers act on a list of strings, `&[&str]`, and at the top level return a `crate::argparse::Command`. +This is a wholly-owned repesentation of the command line's meaning, but with some interpretation. +For example, `task start`, `task stop`, and `task append` all map to a `crate::argparse::Subcommand::Modify` variant. + +### Invocation + +The `invocation` module executes a `Command`, given some settings and other ancillary data. +Most of its functionality is in common functions to handle filtering tasks, modifying tasks, and so on. + +## Rust API + +Note that this crate does not expose a Rust API for use from other crates. +For the public TaskChampion Rust API, see the `taskchampion` crate. + +*/ + use failure::Fallible; -use std::ffi::OsString; +use std::os::unix::ffi::OsStringExt; +use std::string::FromUtf8Error; -mod cmd; -pub(crate) mod settings; +// NOTE: it's important that this 'mod' comes first so that the macros can be used in other modules +mod macros; + +mod argparse; +mod invocation; +mod settings; mod table; +mod usage; -use cmd::ArgMatchResult; -pub(crate) use cmd::CommandInvocation; +/// The main entry point for the command-line interface. This builds an Invocation +/// from the particulars of the operating-system interface, and then executes it. +pub fn main() -> Fallible<()> { + env_logger::init(); -/// Parse the given command line and return an as-yet un-executed CommandInvocation. -pub fn parse_command_line(iter: I) -> Fallible -where - I: IntoIterator, - T: Into + Clone, -{ - let subcommands = cmd::subcommands(); + // parse the command line into a vector of &str, failing if + // there are invalid utf-8 sequences. + let argv: Vec = std::env::args_os() + .map(|oss| String::from_utf8(oss.into_vec())) + .collect::>()?; + let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect(); - let mut app = App::new("TaskChampion") - .version(env!("CARGO_PKG_VERSION")) - .about("Personal task-tracking") - .setting(AppSettings::ColoredHelp); + // parse the command line + let command = argparse::Command::from_argv(&argv[..])?; - for subcommand in subcommands.iter() { - app = subcommand.decorate_app(app); - } + // load the application settings + let settings = settings::read_settings()?; - 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()); - } + invocation::invoke(command, settings)?; + Ok(()) } diff --git a/cli/src/macros.rs b/cli/src/macros.rs new file mode 100644 index 000000000..284a2f426 --- /dev/null +++ b/cli/src/macros.rs @@ -0,0 +1,12 @@ +#![macro_use] + +/// create a &[&str] from vec notation +#[cfg(test)] +macro_rules! argv { + () => ( + &[][..] + ); + ($($x:expr),* $(,)?) => ( + &[$($x),*][..] + ); +} diff --git a/cli/src/usage.rs b/cli/src/usage.rs new file mode 100644 index 000000000..d1b36d59d --- /dev/null +++ b/cli/src/usage.rs @@ -0,0 +1,83 @@ +//! This module handles creation of CLI usage documents (--help, manpages, etc.) in +//! a way that puts the source of that documentation near its implementation. + +use crate::argparse; +use std::io::{Result, Write}; +use textwrap::indent; + +/// A top-level structure containing usage/help information for the entire CLI. +#[derive(Debug, Default)] +pub(crate) struct Usage { + pub(crate) subcommands: Vec, +} + +impl Usage { + /// Get a new, completely-filled-out usage object + pub(crate) fn new() -> Self { + let mut rv = Self { + ..Default::default() + }; + + argparse::get_usage(&mut rv); + + // TODO: sort subcommands + + rv + } + + /// Write this usage to the given output as a help string, writing a short version if `summary` + /// is true. + pub(crate) fn write_help( + &self, + mut w: W, + command_name: String, + summary: bool, + ) -> Result<()> { + write!( + w, + "TaskChampion {}: Personal task-tracking\n\n", + env!("CARGO_PKG_VERSION") + )?; + write!(w, "USAGE:\n {} [args]\n\n", command_name)?; + write!(w, "TaskChampion subcommands:\n")?; + for subcommand in self.subcommands.iter() { + subcommand.write_help(&mut w, summary)?; + } + if !summary { + write!(w, "\nSee `task help` for more detail\n")?; + } + Ok(()) + } +} + +#[derive(Debug, Default)] +pub(crate) struct Subcommand { + /// Name of the subcommand + pub(crate) name: String, + + /// Syntax summary, without command_name + pub(crate) syntax: String, + + /// One-line description of the subcommand. Use an initial capital and no trailing period. + pub(crate) summary: String, + + /// Multi-line description of the subcommand. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: String, +} + +impl Subcommand { + fn write_help(&self, mut w: W, summary: bool) -> Result<()> { + if summary { + write!(w, " task {} - {}\n", self.name, self.summary)?; + } else { + write!( + w, + " task {}\n{}\n", + self.syntax, + indent(self.description.trim(), " ") + )?; + } + Ok(()) + } +} diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 711adc1a5..e4eb0250b 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -2,8 +2,8 @@ use assert_cmd::prelude::*; use predicates::prelude::*; use std::process::Command; -// This tests that the task binary is running and parsing arguments. The details of subcommands -// are handled with unit tests. +// NOTE: This tests that the task binary is running and parsing arguments. The details of +// subcommands are handled with unit tests. #[test] fn help() -> Result<(), Box> { @@ -36,7 +36,7 @@ fn invalid_option() -> Result<(), Box> { cmd.arg("--no-such-option"); cmd.assert() .failure() - .stderr(predicate::str::contains("USAGE")); + .stderr(predicate::str::contains("command line not recognized")); Ok(()) } diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 49ba279db..ac2baacf5 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -42,7 +42,7 @@ impl Priority { } /// The status of a task. The default status in "Pending". -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub enum Status { Pending, Completed, diff --git a/taskchampion/src/taskstorage/mod.rs b/taskchampion/src/taskstorage/mod.rs index b5f94c8ff..4ddd7df75 100644 --- a/taskchampion/src/taskstorage/mod.rs +++ b/taskchampion/src/taskstorage/mod.rs @@ -2,13 +2,11 @@ use failure::Fallible; use std::collections::HashMap; use uuid::Uuid; -#[cfg(test)] mod inmemory; mod kv; mod operation; pub use self::kv::KVStorage; -#[cfg(test)] pub use inmemory::InMemoryStorage; pub use operation::Operation;