diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs index 337bca763..a902fd690 100644 --- a/cli/src/argparse/args.rs +++ b/cli/src/argparse/args.rs @@ -10,7 +10,8 @@ use nom::{ sequence::*, Err, IResult, }; -use taskchampion::{Status, Uuid}; +use std::convert::TryFrom; +use taskchampion::{Status, Tag, Uuid}; /// A task identifier, as given in a filter command-line expression #[derive(Debug, PartialEq, Clone)] @@ -130,7 +131,10 @@ pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> { Ok(input.1) } map_res( - all_consuming(tuple((char('+'), recognize(pair(alpha1, alphanumeric0))))), + all_consuming(tuple(( + char('+'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), to_tag, )(input) } @@ -141,7 +145,10 @@ pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> { Ok(input.1) } map_res( - all_consuming(tuple((char('-'), recognize(pair(alpha1, alphanumeric0))))), + all_consuming(tuple(( + char('-'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), to_tag, )(input) } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index c8f56bdc6..d8112b285 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -5,6 +5,7 @@ * [Using the Task Command](./using-task-command.md) * [Configuration](./config-file.md) * [Reports](./reports.md) + * [Tags](./tags.md) * [Synchronization](./task-sync.md) * [Running the Sync Server](./running-sync-server.md) * [Debugging](./debugging.md) diff --git a/docs/src/tags.md b/docs/src/tags.md new file mode 100644 index 000000000..4148d5117 --- /dev/null +++ b/docs/src/tags.md @@ -0,0 +1,12 @@ +# Tags + +Each task has a collection of associated tags. +Tags are short words that categorize tasks, typically written with a leading `+`, such as `+next` or `+jobsearch`. + +Tags are useful for filtering tasks in reports or on the command line. +For example, when it's time to continue the job search, `task +jobsearch` will show pending tasks with the `jobsearch` tag. + +## Allowed Tags + +Specifically, tags must be at least one character long and cannot contain whitespace or any of the characters `+-*/(<>^! %=~`. +The first character cannot be a digit, and `:` is not allowed after the first character. diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 99b92873a..1434f452a 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -84,22 +84,35 @@ impl Status { } /// A Tag is a newtype around a String that limits its values to valid tags. +/// +/// Valid tags must not contain whitespace or any of the characters in [`INVALID_TAG_CHARACTERS`]. +/// The first characters additionally cannot be a digit, and subsequent characters cannot be `:`. +/// This definition is based on [that of +/// TaskWarrior](https://github.com/GothenburgBitFactory/taskwarrior/blob/663c6575ceca5bd0135ae884879339dac89d3142/src/Lexer.cpp#L146-L164). #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] pub struct Tag(String); +pub const INVALID_TAG_CHARACTERS: &str = "+-*/(<>^! %=~"; + impl Tag { fn from_str(value: &str) -> Result { + fn err(value: &str) -> Result { + Err(format_err!("invalid tag {:?}", value)) + } + if let Some(c) = value.chars().next() { - if !c.is_ascii_alphabetic() { - return Err(format_err!("first character of a tag must be alphabetic")); + if c.is_whitespace() || c.is_ascii_digit() || INVALID_TAG_CHARACTERS.contains(c) { + return err(value); } } else { - return Err(format_err!("tags must have at least one character")); + return err(value); } - if !value.chars().skip(1).all(|c| c.is_ascii_alphanumeric()) { - return Err(format_err!( - "characters of a tag after the first must be alphanumeric" - )); + if !value + .chars() + .skip(1) + .all(|c| !(c.is_whitespace() || c == ':' || INVALID_TAG_CHARACTERS.contains(c))) + { + return err(value); } Ok(Self(String::from(value))) } @@ -383,23 +396,23 @@ mod test { let tag: Tag = "abc".try_into().unwrap(); assert_eq!(tag, Tag("abc".to_owned())); + let tag: Tag = ":abc".try_into().unwrap(); + assert_eq!(tag, Tag(":abc".to_owned())); + + let tag: Tag = "a123_456".try_into().unwrap(); + assert_eq!(tag, Tag("a123_456".to_owned())); + let tag: Result = "".try_into(); - assert_eq!( - tag.unwrap_err().to_string(), - "tags must have at least one character" - ); + assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"\""); + + let tag: Result = "a:b".try_into(); + assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"a:b\""); let tag: Result = "999".try_into(); - assert_eq!( - tag.unwrap_err().to_string(), - "first character of a tag must be alphabetic" - ); + assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"999\""); let tag: Result = "abc!!".try_into(); - assert_eq!( - tag.unwrap_err().to_string(), - "characters of a tag after the first must be alphanumeric" - ); + assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"abc!!\""); } #[test]