diff --git a/Cargo.toml b/Cargo.toml index af271d7..864691a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ log = ["dep:log", "dep:simple_logger"] [dependencies] anyhow = "1.0.95" chrono = "0.4.41" -clap = { version = "4.5.26", features = ["derive"] } +clap = { version = "4.5.26", features = ["derive", "wrap_help"] } log = { version = "0.4.27", optional = true } rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 655d16a..d67ef3c 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -25,6 +25,19 @@ enum Commands { /// List issues. List { /// Filter string, describes issues to include in the list. + /// The filter string is composed of chunks separated by ":". + /// Each chunk is of the form "name=condition". The supported + /// names and their matching conditions are: + /// + /// "state": Comma-separated list of states to list. + /// + /// "assignee": Comma-separated list of assignees to list. + /// Defaults to all assignees if not set. + /// + /// "tag": Comma-separated list of tags to include or exclude + /// (if prefixed with "-"). If omitted, defaults to including + /// all tags and excluding none. + /// #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] filter: String, }, @@ -64,6 +77,13 @@ enum Commands { issue_id: String, new_assignee: Option, }, + + /// Add or remove a Tag to/from an Issue, or list the Tags on an Issue. + Tag { + issue_id: String, + #[arg(allow_hyphen_values = true)] + tag: Option, + }, } /// The main function looks at the command-line arguments and determines @@ -171,6 +191,17 @@ fn handle_command( } } + if filter.include_tags.len() > 0 { + if !issue.has_any_tag(&filter.include_tags) { + continue; + } + } + if filter.exclude_tags.len() > 0 { + if issue.has_any_tag(&filter.exclude_tags) { + continue; + } + } + // This issue passed all the filters, include it in list. uuids_by_state .entry(issue.state.clone()) @@ -207,7 +238,32 @@ fn handle_command( Some(assignee) => format!(" (👉 {})", assignee), None => String::from(""), }; - println!("{} {} {}{}", uuid, comments, issue.title(), assignee); + let tags = match &issue.tags.len() { + 0 => String::from(""), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + let mut tags = String::from(" ["); + let mut separator = ""; + for tag in &issue.tags { + tags.push_str(separator); + tags.push_str(tag); + separator = ", "; + } + tags.push_str("]"); + tags + } + }; + println!( + "{} {} {}{}{}", + uuid, + comments, + issue.title(), + assignee, + tags + ); } println!(""); } @@ -405,6 +461,51 @@ fn handle_command( } } } + + Commands::Tag { issue_id, tag } => { + let issues = read_issues_database(issues_database_source)?; + let Some(issue) = issues.issues.get(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + match tag { + Some(tag) => { + // Add or remove tag. + let issues_database = make_issues_database( + issues_database_source, + IssuesDatabaseAccess::ReadWrite, + )?; + let mut issues = + entomologist::issues::Issues::new_from_dir(&issues_database.dir)?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + if tag.len() == 0 { + return Err(anyhow::anyhow!("invalid zero-length tag")); + } + if tag.chars().nth(0).unwrap() == '-' { + let tag = &tag[1..]; + issue.remove_tag(tag)?; + } else { + issue.add_tag(tag)?; + } + } + None => { + // Just list the tags. + match &issue.tags.len() { + 0 => println!("no tags"), + _ => { + // Could use `format!(" {:?}", issue.tags)` + // here, but that results in `["tag1", "TAG2", + // "i-am-also-a-tag"]` and i don't want the + // double-quotes around each tag. + for tag in &issue.tags { + println!("{}", tag); + } + } + } + } + } + } } Ok(()) diff --git a/src/issue.rs b/src/issue.rs index b309cae..bd0632c 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -22,6 +22,7 @@ pub type IssueHandle = String; pub struct Issue { pub author: String, pub timestamp: chrono::DateTime, + pub tags: Vec, pub state: State, pub dependencies: Option>, pub assignee: Option, @@ -51,6 +52,8 @@ pub enum IssueError { EditorError, #[error("supplied description is empty")] EmptyDescription, + #[error("tag {0} not found")] + TagNotFound(String), } impl FromStr for State { @@ -97,6 +100,7 @@ impl Issue { let mut dependencies: Option> = None; let mut comments = Vec::::new(); let mut assignee: Option = None; + let mut tags = Vec::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -119,6 +123,13 @@ impl Issue { if deps.len() > 0 { dependencies = Some(deps); } + } else if file_name == "tags" { + let contents = std::fs::read_to_string(direntry.path())?; + tags = contents + .lines() + .filter(|s| s.len() > 0) + .map(|tag| String::from(tag.trim())) + .collect(); } else if file_name == "comments" && direntry.metadata()?.is_dir() { Self::read_comments(&mut comments, &direntry.path())?; } else { @@ -138,6 +149,7 @@ impl Issue { Ok(Self { author, timestamp, + tags, state: state, dependencies, assignee, @@ -192,6 +204,7 @@ impl Issue { let mut issue = Self { author: String::from(""), timestamp: chrono::Local::now(), + tags: Vec::::new(), state: State::New, dependencies: None, assignee: None, @@ -303,6 +316,51 @@ impl Issue { } Ok(()) } + + /// Add a new Tag to the Issue. Commits. + pub fn add_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + if self.tags.contains(&tag_string) { + return Ok(()); + } + self.tags.push(tag_string); + self.tags.sort(); + self.commit_tags(&format!( + "issue {} add tag {}", + self.dir.file_name().unwrap().to_string_lossy(), + tag + ))?; + Ok(()) + } + + /// Remove a Tag from the Issue. Commits. + pub fn remove_tag(&mut self, tag: &str) -> Result<(), IssueError> { + let tag_string = String::from(tag); + let Some(index) = self.tags.iter().position(|x| x == &tag_string) else { + return Err(IssueError::TagNotFound(tag_string)); + }; + self.tags.remove(index); + self.commit_tags(&format!( + "issue {} remove tag {}", + self.dir.file_name().unwrap().to_string_lossy(), + tag + ))?; + Ok(()) + } + + pub fn has_tag(&self, tag: &str) -> bool { + let tag_string = String::from(tag); + self.tags.iter().position(|x| x == &tag_string).is_some() + } + + pub fn has_any_tag(&self, tags: &std::collections::HashSet<&str>) -> bool { + for tag in tags.iter() { + if self.has_tag(tag) { + return true; + } + } + return false; + } } // This is the internal/private API of Issue. @@ -357,6 +415,18 @@ impl Issue { self.read_description()?; Ok(()) } + + fn commit_tags(&self, commit_message: &str) -> Result<(), IssueError> { + let mut tags_filename = self.dir.clone(); + tags_filename.push("tags"); + let mut tags_file = std::fs::File::create(&tags_filename)?; + for tag in &self.tags { + writeln!(tags_file, "{}", tag)?; + } + crate::git::add(&tags_filename)?; + crate::git::commit(&self.dir, commit_message)?; + Ok(()) + } } #[cfg(test)] @@ -372,6 +442,11 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::from([ + String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") + ]), state: State::New, dependencies: None, assignee: None, @@ -391,6 +466,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: State::InProgress, dependencies: None, assignee: Some(String::from("beep boop")), diff --git a/src/issues.rs b/src/issues.rs index 16dab55..5c314ac 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -100,6 +100,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::InProgress, dependencies: None, assignee: Some(String::from("beep boop")), @@ -119,6 +120,11 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T12:14:26-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::from([ + String::from("tag1"), + String::from("TAG2"), + String::from("i-am-also-a-tag") + ]), state: crate::issue::State::New, dependencies: None, assignee: None, @@ -147,6 +153,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::Done, dependencies: None, assignee: None, @@ -180,6 +187,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-03T11:59:44-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, assignee: None, @@ -208,6 +216,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::Done, dependencies: None, assignee: None, @@ -227,6 +236,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: None, assignee: None, @@ -246,6 +256,7 @@ mod tests { timestamp: chrono::DateTime::parse_from_rfc3339("2025-07-05T13:55:49-06:00") .unwrap() .with_timezone(&chrono::Local), + tags: Vec::::new(), state: crate::issue::State::WontDo, dependencies: Some(vec![ crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), diff --git a/src/lib.rs b/src/lib.rs index b28fb74..fa820b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ pub enum ParseFilterError { pub struct Filter<'a> { pub include_states: std::collections::HashSet, pub include_assignees: std::collections::HashSet<&'a str>, + pub include_tags: std::collections::HashSet<&'a str>, + pub exclude_tags: std::collections::HashSet<&'a str>, } impl<'a> Filter<'a> { @@ -33,6 +35,8 @@ impl<'a> Filter<'a> { State::New, ]), include_assignees: std::collections::HashSet::<&'a str>::new(), + include_tags: std::collections::HashSet::<&'a str>::new(), + exclude_tags: std::collections::HashSet::<&'a str>::new(), }; for filter_chunk_str in filter_str.split(":") { @@ -48,12 +52,29 @@ impl<'a> Filter<'a> { f.include_states.insert(crate::issue::State::from_str(s)?); } } + "assignee" => { f.include_assignees.clear(); for s in tokens[1].split(",") { f.include_assignees.insert(s); } } + + "tag" => { + f.include_tags.clear(); + f.exclude_tags.clear(); + for s in tokens[1].split(",") { + if s.len() == 0 { + return Err(ParseFilterError::ParseError); + } + if s.chars().nth(0).unwrap() == '-' { + f.exclude_tags.insert(&s[1..]); + } else { + f.include_tags.insert(s); + } + } + } + _ => { println!("unknown filter chunk '{}'", filter_chunk_str); return Err(ParseFilterError::ParseError); diff --git a/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags new file mode 100644 index 0000000..04e82a6 --- /dev/null +++ b/test/0000/3943fc5c173fdf41c0a22251593cd476d96e6c9f/tags @@ -0,0 +1,3 @@ +tag1 +TAG2 +i-am-also-a-tag