From 50509dcf59219b4bfa7f5633e3f44308bb78e838 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 15:26:26 -0600 Subject: [PATCH 1/5] basic comment support in lib --- src/comment.rs | 104 ++++++++++++++++++ src/issue.rs | 42 +++++++ src/issues.rs | 20 ++++ src/lib.rs | 1 + .../description | 3 + 5 files changed, 170 insertions(+) create mode 100644 src/comment.rs create mode 100644 test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description diff --git a/src/comment.rs b/src/comment.rs new file mode 100644 index 0000000..ab2ced9 --- /dev/null +++ b/src/comment.rs @@ -0,0 +1,104 @@ +use std::io::Write; + +#[derive(Debug, PartialEq)] +pub struct Comment { + pub description: String, + + /// This is the directory that the comment lives in. Only used + /// internally by the entomologist library. + pub dir: std::path::PathBuf, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommentError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Failed to parse comment")] + CommentParseError, + #[error("Failed to run git")] + GitError(#[from] crate::git::GitError), + #[error("Failed to run editor")] + EditorError, +} + +impl Comment { + pub fn new_from_dir(comment_dir: &std::path::Path) -> Result { + let mut description: Option = None; + + for direntry in comment_dir.read_dir()? { + if let Ok(direntry) = direntry { + let file_name = direntry.file_name(); + if file_name == "description" { + description = Some(std::fs::read_to_string(direntry.path())?); + } else { + #[cfg(feature = "log")] + debug!( + "ignoring unknown file in comment directory: {:?}", + file_name + ); + } + } + } + + if description == None { + return Err(CommentError::CommentParseError); + } + + Ok(Self { + description: description.unwrap(), + dir: std::path::PathBuf::from(comment_dir), + }) + } + + pub fn set_description(&mut self, description: &str) -> Result<(), CommentError> { + self.description = String::from(description); + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let mut description_file = std::fs::File::create(&description_filename)?; + write!(description_file, "{}", description)?; + crate::git::git_commit_file(&description_filename)?; + Ok(()) + } + + pub fn read_description(&mut self) -> Result<(), CommentError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + self.description = std::fs::read_to_string(description_filename)?; + Ok(()) + } + + pub fn edit_description(&mut self) -> Result<(), CommentError> { + let mut description_filename = std::path::PathBuf::from(&self.dir); + description_filename.push("description"); + let result = std::process::Command::new("vi") + .arg(&description_filename.as_mut_os_str()) + .spawn()? + .wait_with_output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(CommentError::EditorError); + } + crate::git::git_commit_file(&description_filename)?; + self.read_description()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_comment_0() { + let comment_dir = + std::path::Path::new("test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347"); + let comment = Comment::new_from_dir(comment_dir).unwrap(); + let expected = Comment { + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + + dir: std::path::PathBuf::from(comment_dir), + }; + assert_eq!(comment, expected); + } +} diff --git a/src/issue.rs b/src/issue.rs index f931be3..c762c82 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -23,6 +23,7 @@ pub struct Issue { pub description: String, pub state: State, pub dependencies: Option>, + pub comments: std::collections::HashMap, /// This is the directory that the issue lives in. Only used /// internally by the entomologist library. @@ -33,6 +34,8 @@ pub struct Issue { pub enum IssueError { #[error(transparent)] StdIoError(#[from] std::io::Error), + #[error(transparent)] + CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, #[error("Failed to run git")] @@ -83,6 +86,7 @@ impl Issue { let mut description: Option = None; let mut state = State::New; // default state, if not specified in the issue let mut dependencies: Option> = None; + let mut comments = std::collections::HashMap::::new(); for direntry in dir.read_dir()? { if let Ok(direntry) = direntry { @@ -101,6 +105,8 @@ impl Issue { if deps.len() > 0 { dependencies = Some(deps); } + } else if file_name == "comments" && direntry.metadata()?.is_dir() { + Self::read_comments(&mut comments, &direntry.path())?; } else { #[cfg(feature = "log")] debug!("ignoring unknown file in issue directory: {:?}", file_name); @@ -116,10 +122,43 @@ impl Issue { description: description.unwrap(), state: state, dependencies, + comments, dir: std::path::PathBuf::from(dir), }) } + fn read_comments( + comments: &mut std::collections::HashMap, + dir: &std::path::Path, + ) -> Result<(), IssueError> { + for direntry in dir.read_dir()? { + if let Ok(direntry) = direntry { + let uuid = direntry.file_name(); + let comment = crate::comment::Comment::new_from_dir(&direntry.path())?; + comments.insert(String::from(uuid.to_string_lossy()), comment); + } + } + Ok(()) + } + + pub fn new_comment(&mut self) -> Result { + let mut dir = std::path::PathBuf::from(&self.dir); + dir.push("comments"); + if !dir.exists() { + println!("creating {}", dir.to_string_lossy()); + std::fs::create_dir(&dir)?; + } + + let rnd: u128 = rand::random(); + dir.push(&format!("{:032x}", rnd)); + std::fs::create_dir(&dir)?; + + Ok(crate::comment::Comment { + description: String::from(""), // FIXME + dir, + }) + } + pub fn new(dir: &std::path::Path) -> Result { let mut issue_dir = std::path::PathBuf::from(dir); let rnd: u128 = rand::random(); @@ -129,6 +168,7 @@ impl Issue { description: String::from(""), // FIXME: kind of bogus to use the empty string as None state: State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir: issue_dir, }) } @@ -204,6 +244,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); @@ -217,6 +258,7 @@ mod tests { description: String::from("minimal"), state: State::InProgress, dependencies: None, + comments: std::collections::HashMap::::new(), dir: std::path::PathBuf::from(issue_dir), }; assert_eq!(issue, expected); diff --git a/src/issues.rs b/src/issues.rs index 2e40930..28df2e9 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -99,6 +99,7 @@ mod tests { description: String::from("minimal"), state: crate::issue::State::InProgress, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -112,6 +113,7 @@ mod tests { description: String::from("this is the title of my issue\n\nThis is the description of my issue.\nIt is multiple lines.\n* Arbitrary contents\n* But let's use markdown by convention\n"), state: crate::issue::State::New, dependencies: None, + comments: std::collections::HashMap::::new(), dir, } ); @@ -134,6 +136,7 @@ mod tests { description: String::from("oh yeah we got titles"), state: crate::issue::State::Done, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -141,12 +144,26 @@ mod tests { let uuid = String::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"); let mut dir = std::path::PathBuf::from(issues_dir); dir.push(&uuid); + let mut comment_dir = dir.clone(); + let comment_uuid = String::from("9055dac36045fe36545bed7ae7b49347"); + comment_dir.push("comments"); + comment_dir.push(&comment_uuid); + let mut expected_comments = + std::collections::HashMap::::new(); + expected_comments.insert( + String::from(&comment_uuid), + crate::comment::Comment { + description: String::from("This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561\n\nIt has multiple lines\n"), + dir: std::path::PathBuf::from(comment_dir), + } + ); expected.add_issue( uuid, crate::issue::Issue { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + comments: expected_comments, dir, }, ); @@ -169,6 +186,7 @@ mod tests { description: String::from("oh yeah we got titles\n"), state: crate::issue::State::Done, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -182,6 +200,7 @@ mod tests { description: String::from("issues out the wazoo\n\nLots of words\nthat don't say much\nbecause this is just\na test\n"), state: crate::issue::State::WontDo, dependencies: None, + comments: std::collections::HashMap::::new(), dir, }, ); @@ -198,6 +217,7 @@ mod tests { crate::issue::IssueHandle::from("3fa5bfd93317ad25772680071d5ac3259cd2384f"), crate::issue::IssueHandle::from("dd79c8cfb8beeacd0460429944b4ecbe95a31561"), ]), + comments: std::collections::HashMap::::new(), dir, }, ); diff --git a/src/lib.rs b/src/lib.rs index cca1c90..e129eff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod comment; pub mod git; pub mod issue; pub mod issues; diff --git a/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description new file mode 100644 index 0000000..f9de678 --- /dev/null +++ b/test/0001/dd79c8cfb8beeacd0460429944b4ecbe95a31561/comments/9055dac36045fe36545bed7ae7b49347/description @@ -0,0 +1,3 @@ +This is a comment on issue dd79c8cfb8beeacd0460429944b4ecbe95a31561 + +It has multiple lines From 9870d42fdcbad2e62de575dc8e403ba5f4889580 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 15:33:43 -0600 Subject: [PATCH 2/5] ent show: include comments --- src/bin/ent/main.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 9c31c60..4a86883 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -90,6 +90,11 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } println!(""); println!("{}", issue.description); + for (uuid, comment) in issue.comments.iter() { + println!(""); + println!("comment: {}", uuid); + println!("{}", comment.description); + } } None => { return Err(anyhow::anyhow!("issue {} not found", issue_id)); From 8ac4ca4c543ccddc15aef154601f753df5f51c42 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 16:15:01 -0600 Subject: [PATCH 3/5] add `ent comment`, to add a comment on an issue --- src/bin/ent/main.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 4a86883..63fba60 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -39,6 +39,12 @@ enum Commands { issue_id: String, new_state: Option, }, + + /// Create a new comment on an issue. + Comment { + issue_id: String, + description: Option, + }, } fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { @@ -128,6 +134,27 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( } } } + + Commands::Comment { + issue_id, + description, + } => { + let mut issues = + entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let Some(issue) = issues.get_mut_issue(issue_id) else { + return Err(anyhow::anyhow!("issue {} not found", issue_id)); + }; + println!("found issue {}", issue.title()); + let mut comment = issue.new_comment()?; + match description { + Some(description) => { + comment.set_description(description)?; + } + None => { + comment.edit_description()?; + } + } + } } Ok(()) From cd4eb8206777335cfabbb771430a497e6dfb5f0d Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 22:33:29 -0600 Subject: [PATCH 4/5] remove a useless debug message --- src/bin/ent/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 63fba60..1c9ddaa 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -144,7 +144,6 @@ fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<( let Some(issue) = issues.get_mut_issue(issue_id) else { return Err(anyhow::anyhow!("issue {} not found", issue_id)); }; - println!("found issue {}", issue.title()); let mut comment = issue.new_comment()?; match description { Some(description) => { From 7d9284bf91141f8e834f03ed60f6d577ca32305f Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Mon, 7 Jul 2025 22:27:24 -0600 Subject: [PATCH 5/5] `ent list` now accepts a filter, default "state=New,Backlog,Blocked,InProgress" --- src/bin/ent/main.rs | 13 ++++++++++--- src/issue.rs | 7 ++++--- src/lib.rs | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/bin/ent/main.rs b/src/bin/ent/main.rs index 1c9ddaa..1982feb 100644 --- a/src/bin/ent/main.rs +++ b/src/bin/ent/main.rs @@ -23,7 +23,11 @@ struct Args { #[derive(clap::Subcommand, Debug)] enum Commands { /// List issues. - List, + List { + /// Filter string, describes issues to include in the list. + #[arg(default_value_t = String::from("state=New,Backlog,Blocked,InProgress"))] + filter: String, + }, /// Create a new issue. New { description: Option }, @@ -49,11 +53,14 @@ enum Commands { fn handle_command(args: &Args, issues_dir: &std::path::Path) -> anyhow::Result<()> { match &args.command { - Commands::List => { + Commands::List { filter } => { let issues = entomologist::issues::Issues::new_from_dir(std::path::Path::new(issues_dir))?; + let filter = entomologist::parse_filter(filter)?; for (uuid, issue) in issues.issues.iter() { - println!("{} {} ({:?})", uuid, issue.title(), issue.state); + if filter.include_states.contains(&issue.state) { + println!("{} {} ({:?})", uuid, issue.title(), issue.state); + } } } diff --git a/src/issue.rs b/src/issue.rs index c762c82..6ce8e46 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -5,7 +5,7 @@ use std::str::FromStr; #[cfg(feature = "log")] use log::debug; -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize)] /// These are the states an issue can be in. pub enum State { New, @@ -38,6 +38,8 @@ pub enum IssueError { CommentError(#[from] crate::comment::CommentError), #[error("Failed to parse issue")] IssueParseError, + #[error("Failed to parse state")] + StateParseError, #[error("Failed to run git")] GitError(#[from] crate::git::GitError), #[error("Failed to run editor")] @@ -61,7 +63,7 @@ impl FromStr for State { } else if s == "wontdo" { Ok(State::WontDo) } else { - Err(IssueError::IssueParseError) + Err(IssueError::StateParseError) } } } @@ -75,7 +77,6 @@ impl fmt::Display for State { State::InProgress => "inprogress", State::Done => "done", State::WontDo => "wontdo", - }; write!(f, "{fmt_str}") } diff --git a/src/lib.rs b/src/lib.rs index e129eff..77a00d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,40 @@ +use std::str::FromStr; + pub mod comment; pub mod git; pub mod issue; pub mod issues; + +#[derive(Debug, thiserror::Error)] +pub enum ParseFilterError { + #[error("Failed to parse filter")] + ParseError, + #[error(transparent)] + IssueParseError(#[from] crate::issue::IssueError), +} + +// FIXME: It's easy to imagine a full dsl for filtering issues, for now +// i'm starting with obvious easy things. Chumsky looks appealing but +// more research is needed. +#[derive(Debug)] +pub struct Filter { + pub include_states: std::collections::HashSet, +} + +// Parses a filter description matching "state=STATE[,STATE*]" +pub fn parse_filter(filter_str: &str) -> Result { + let tokens: Vec<&str> = filter_str.split("=").collect(); + if tokens.len() != 2 { + return Err(ParseFilterError::ParseError); + } + if tokens[0] != "state" { + return Err(ParseFilterError::ParseError); + } + + let mut include_states = std::collections::HashSet::::new(); + for s in tokens[1].split(",") { + include_states.insert(crate::issue::State::from_str(s)?); + } + + Ok(Filter { include_states }) +}