From 6b550e7516f0ff7fae533e640537ec50f045ff49 Mon Sep 17 00:00:00 2001 From: "Dustin J. Mitchell" Date: Sun, 20 Dec 2020 18:42:21 -0500 Subject: [PATCH] implement cli help --- Cargo.lock | 12 +- cli/Cargo.toml | 1 + cli/src/argparse/mod.rs | 6 + cli/src/argparse/subcommand.rs | 261 +++++++++++++++++++++++++++------ cli/src/invocation/cmd/help.rs | 12 +- cli/src/lib.rs | 1 + cli/src/usage.rs | 83 +++++++++++ 7 files changed, 323 insertions(+), 53 deletions(-) create mode 100644 cli/src/usage.rs diff --git a/Cargo.lock b/Cargo.lock index 9f40e8688..a0786badd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -569,7 +569,7 @@ dependencies = [ "atty", "bitflags", "strsim", - "textwrap", + "textwrap 0.11.0", "unicode-width", "vec_map", ] @@ -2274,6 +2274,7 @@ dependencies = [ "prettytable-rs", "taskchampion", "tempdir", + "textwrap 0.12.1", ] [[package]] @@ -2346,6 +2347,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" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 58d154a94..949896a20 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -11,6 +11,7 @@ failure = "^0.1.8" log = "^0.4.11" nom = "*" prettytable-rs = "^0.8.0" +textwrap = "0.12.1" [dependencies.config] default-features = false diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 27444466e..428f7bb0a 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -24,4 +24,10 @@ 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/subcommand.rs b/cli/src/argparse/subcommand.rs index 8c71832c9..2ebf60644 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -1,7 +1,18 @@ 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)] @@ -45,19 +56,33 @@ pub(crate) enum Subcommand { impl Subcommand { pub(super) fn parse(input: ArgList) -> IResult { alt(( - Self::version, - Self::help, - Self::add, - Self::modify_prepend_append, - Self::start_stop_done, - Self::list, - Self::info, - Self::gc, - Self::sync, + Version::parse, + Help::parse, + Add::parse, + Modify::parse, + List::parse, + Info::parse, + Gc::parse, + Sync::parse, ))(input) } - fn version(input: ArgList) -> IResult { + 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) } @@ -70,7 +95,20 @@ impl Subcommand { )(input) } - fn help(input: ArgList) -> IResult { + 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", @@ -86,7 +124,13 @@ impl Subcommand { )(input) } - fn add(input: ArgList) -> IResult { + 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, @@ -98,7 +142,24 @@ impl Subcommand { )(input) } - fn modify_prepend_append(input: ArgList) -> IResult { + 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; @@ -114,6 +175,9 @@ impl Subcommand { modification.description = DescriptionMod::Append(s) } } + "start" => modification.active = Some(true), + "stop" => modification.active = Some(false), + "done" => modification.status = Some(Status::Completed), _ => {} } @@ -129,33 +193,6 @@ impl Subcommand { arg_matching(literal("modify")), arg_matching(literal("prepend")), arg_matching(literal("append")), - )), - Modification::parse, - )), - to_subcommand, - )(input) - } - - fn start_stop_done(input: ArgList) -> IResult { - // start, stop, and done are special cases of modify - fn to_subcommand(input: (Filter, &str, Modification)) -> Result { - let filter = input.0; - let mut modification = input.2; - match input.1 { - "start" => modification.active = Some(true), - "stop" => modification.active = Some(false), - "done" => modification.status = Some(Status::Completed), - _ => unreachable!(), - } - Ok(Subcommand::Modify { - filter, - modification, - }) - } - map_res( - tuple(( - Filter::parse, - alt(( arg_matching(literal("start")), arg_matching(literal("stop")), arg_matching(literal("done")), @@ -166,7 +203,70 @@ impl Subcommand { )(input) } - fn list(input: ArgList) -> IResult { + 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 }) } @@ -176,7 +276,23 @@ impl Subcommand { )(input) } - fn info(input: ArgList) -> IResult { + 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 { @@ -196,19 +312,76 @@ impl Subcommand { )(input) } - fn gc(input: ArgList) -> IResult { + 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 sync(input: ArgList) -> IResult { + 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)] diff --git a/cli/src/invocation/cmd/help.rs b/cli/src/invocation/cmd/help.rs index eca51efc6..a53e54897 100644 --- a/cli/src/invocation/cmd/help.rs +++ b/cli/src/invocation/cmd/help.rs @@ -1,14 +1,10 @@ +use crate::usage::Usage; use failure::Fallible; +use std::io; pub(crate) fn execute(command_name: String, summary: bool) -> Fallible<()> { - println!( - "TaskChampion {}: Personal task-tracking", - env!("CARGO_PKG_VERSION") - ); - if !summary { - println!(); - println!("USAGE: {} [args]\n(help output TODO)", command_name); // TODO - } + let usage = Usage::new(); + usage.write_help(io::stdout(), command_name, summary)?; Ok(()) } diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 190c8730f..01900fbc6 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -39,6 +39,7 @@ mod argparse; mod invocation; mod settings; mod table; +mod usage; /// 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. 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(()) + } +}